vendor: 339 bytes, lines 1-10
1var __defProp = Object.defineProperty; 2var __getOwnPropNames = Object.getOwnPropertyNames; 3var __esm = (fn, res) => function __init() { 4 return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; 5}; 6var __export = (target, all) => { 7 for (var name in all) 8 __defProp(target, name, { get: all[name], enumerable: true }); 9}; 10
vendor: 334 bytes, lines 11-21
11// node_modules/@rails/actioncable/src/adapters.js 12var adapters_default; 13var init_adapters = __esm({ 14 "node_modules/@rails/actioncable/src/adapters.js"() { 15 adapters_default = { 16 logger: typeof console !== "undefined" ? console : void 0, 17 WebSocket: typeof WebSocket !== "undefined" ? WebSocket : void 0 18 }; 19 } 20}); 21
vendor: 386 bytes, lines 22-37
22// node_modules/@rails/actioncable/src/logger.js 23var logger_default; 24var init_logger = __esm({ 25 "node_modules/@rails/actioncable/src/logger.js"() { 26 init_adapters(); 27 logger_default = { 28 log(...messages) { 29 if (this.enabled) { 30 messages.push(Date.now()); 31 adapters_default.logger.log("[ActionCable]", ...messages); 32 } 33 } 34 }; 35 } 36}); 37
vendor: 4,101 bytes, lines 38-147
38// node_modules/@rails/actioncable/src/connection_monitor.js 39var now, secondsSince, ConnectionMonitor, connection_monitor_default; 40var init_connection_monitor = __esm({ 41 "node_modules/@rails/actioncable/src/connection_monitor.js"() { 42 init_logger(); 43 now = () => (/* @__PURE__ */ new Date()).getTime(); 44 secondsSince = (time) => (now() - time) / 1e3; 45 ConnectionMonitor = class { 46 constructor(connection) { 47 this.visibilityDidChange = this.visibilityDidChange.bind(this); 48 this.connection = connection; 49 this.reconnectAttempts = 0; 50 } 51 start() { 52 if (!this.isRunning()) { 53 this.startedAt = now(); 54 delete this.stoppedAt; 55 this.startPolling(); 56 addEventListener("visibilitychange", this.visibilityDidChange); 57 logger_default.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`); 58 } 59 } 60 stop() { 61 if (this.isRunning()) { 62 this.stoppedAt = now(); 63 this.stopPolling(); 64 removeEventListener("visibilitychange", this.visibilityDidChange); 65 logger_default.log("ConnectionMonitor stopped"); 66 } 67 } 68 isRunning() { 69 return this.startedAt && !this.stoppedAt; 70 } 71 recordMessage() { 72 this.pingedAt = now(); 73 } 74 recordConnect() { 75 this.reconnectAttempts = 0; 76 delete this.disconnectedAt; 77 logger_default.log("ConnectionMonitor recorded connect"); 78 } 79 recordDisconnect() { 80 this.disconnectedAt = now(); 81 logger_default.log("ConnectionMonitor recorded disconnect"); 82 } 83 // Private 84 startPolling() { 85 this.stopPolling(); 86 this.poll(); 87 } 88 stopPolling() { 89 clearTimeout(this.pollTimeout); 90 } 91 poll() { 92 this.pollTimeout = setTimeout( 93 () => { 94 this.reconnectIfStale(); 95 this.poll(); 96 }, 97 this.getPollInterval() 98 ); 99 } 100 getPollInterval() { 101 const { staleThreshold, reconnectionBackoffRate } = this.constructor; 102 const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10)); 103 const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate; 104 const jitter = jitterMax * Math.random(); 105 return staleThreshold * 1e3 * backoff * (1 + jitter); 106 } 107 reconnectIfStale() { 108 if (this.connectionIsStale()) { 109 logger_default.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`); 110 this.reconnectAttempts++; 111 if (this.disconnectedRecently()) { 112 logger_default.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`); 113 } else { 114 logger_default.log("ConnectionMonitor reopening"); 115 this.connection.reopen(); 116 } 117 } 118 } 119 get refreshedAt() { 120 return this.pingedAt ? this.pingedAt : this.startedAt; 121 } 122 connectionIsStale() { 123 return secondsSince(this.refreshedAt) > this.constructor.staleThreshold; 124 } 125 disconnectedRecently() { 126 return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold; 127 } 128 visibilityDidChange() { 129 if (document.visibilityState === "visible") { 130 setTimeout( 131 () => { 132 if (this.connectionIsStale() || !this.connection.isOpen()) { 133 logger_default.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`); 134 this.connection.reopen(); 135 } 136 }, 137 200 138 ); 139 } 140 } 141 }; 142 ConnectionMonitor.staleThreshold = 6; 143 ConnectionMonitor.reconnectionBackoffRate = 0.15; 144 connection_monitor_default = ConnectionMonitor; 145 } 146}); 147
vendor: 741 bytes, lines 148-174
148// node_modules/@rails/actioncable/src/internal.js 149var internal_default; 150var init_internal = __esm({ 151 "node_modules/@rails/actioncable/src/internal.js"() { 152 internal_default = { 153 "message_types": { 154 "welcome": "welcome", 155 "disconnect": "disconnect", 156 "ping": "ping", 157 "confirmation": "confirm_subscription", 158 "rejection": "reject_subscription" 159 }, 160 "disconnect_reasons": { 161 "unauthorized": "unauthorized", 162 "invalid_request": "invalid_request", 163 "server_restart": "server_restart", 164 "remote": "remote" 165 }, 166 "default_mount_path": "/cable", 167 "protocols": [ 168 "actioncable-v1-json", 169 "actioncable-unsupported" 170 ] 171 }; 172 } 173}); 174
vendor: 5,885 bytes, lines 175-343
175// node_modules/@rails/actioncable/src/connection.js 176var message_types, protocols, supportedProtocols, indexOf, Connection, connection_default; 177var init_connection = __esm({ 178 "node_modules/@rails/actioncable/src/connection.js"() { 179 init_adapters(); 180 init_connection_monitor(); 181 init_internal(); 182 init_logger(); 183 ({ message_types, protocols } = internal_default); 184 supportedProtocols = protocols.slice(0, protocols.length - 1); 185 indexOf = [].indexOf; 186 Connection = class { 187 constructor(consumer2) { 188 this.open = this.open.bind(this); 189 this.consumer = consumer2; 190 this.subscriptions = this.consumer.subscriptions; 191 this.monitor = new connection_monitor_default(this); 192 this.disconnected = true; 193 } 194 send(data) { 195 if (this.isOpen()) { 196 this.webSocket.send(JSON.stringify(data)); 197 return true; 198 } else { 199 return false; 200 } 201 } 202 open() { 203 if (this.isActive()) { 204 logger_default.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`); 205 return false; 206 } else { 207 const socketProtocols = [...protocols, ...this.consumer.subprotocols || []]; 208 logger_default.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`); 209 if (this.webSocket) { 210 this.uninstallEventHandlers(); 211 } 212 this.webSocket = new adapters_default.WebSocket(this.consumer.url, socketProtocols); 213 this.installEventHandlers(); 214 this.monitor.start(); 215 return true; 216 } 217 } 218 close({ allowReconnect } = { allowReconnect: true }) { 219 if (!allowReconnect) { 220 this.monitor.stop(); 221 } 222 if (this.isOpen()) { 223 return this.webSocket.close(); 224 } 225 } 226 reopen() { 227 logger_default.log(`Reopening WebSocket, current state is ${this.getState()}`); 228 if (this.isActive()) { 229 try { 230 return this.close(); 231 } catch (error) { 232 logger_default.log("Failed to reopen WebSocket", error); 233 } finally { 234 logger_default.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`); 235 setTimeout(this.open, this.constructor.reopenDelay); 236 } 237 } else { 238 return this.open(); 239 } 240 } 241 getProtocol() { 242 if (this.webSocket) { 243 return this.webSocket.protocol; 244 } 245 } 246 isOpen() { 247 return this.isState("open"); 248 } 249 isActive() { 250 return this.isState("open", "connecting"); 251 } 252 triedToReconnect() { 253 return this.monitor.reconnectAttempts > 0; 254 } 255 // Private 256 isProtocolSupported() { 257 return indexOf.call(supportedProtocols, this.getProtocol()) >= 0; 258 } 259 isState(...states) { 260 return indexOf.call(states, this.getState()) >= 0; 261 } 262 getState() { 263 if (this.webSocket) { 264 for (let state in adapters_default.WebSocket) { 265 if (adapters_default.WebSocket[state] === this.webSocket.readyState) { 266 return state.toLowerCase(); 267 } 268 } 269 } 270 return null; 271 } 272 installEventHandlers() { 273 for (let eventName in this.events) { 274 const handler = this.events[eventName].bind(this); 275 this.webSocket[`on${eventName}`] = handler; 276 } 277 } 278 uninstallEventHandlers() { 279 for (let eventName in this.events) { 280 this.webSocket[`on${eventName}`] = function() { 281 }; 282 } 283 } 284 }; 285 Connection.reopenDelay = 500; 286 Connection.prototype.events = { 287 message(event) { 288 if (!this.isProtocolSupported()) { 289 return; 290 } 291 const { identifier, message, reason, reconnect, type } = JSON.parse(event.data); 292 this.monitor.recordMessage(); 293 switch (type) { 294 case message_types.welcome: 295 if (this.triedToReconnect()) { 296 this.reconnectAttempted = true; 297 } 298 this.monitor.recordConnect(); 299 return this.subscriptions.reload(); 300 case message_types.disconnect: 301 logger_default.log(`Disconnecting. Reason: ${reason}`); 302 return this.close({ allowReconnect: reconnect }); 303 case message_types.ping: 304 return null; 305 case message_types.confirmation: 306 this.subscriptions.confirmSubscription(identifier); 307 if (this.reconnectAttempted) { 308 this.reconnectAttempted = false; 309 return this.subscriptions.notify(identifier, "connected", { reconnected: true }); 310 } else { 311 return this.subscriptions.notify(identifier, "connected", { reconnected: false }); 312 } 313 case message_types.rejection: 314 return this.subscriptions.reject(identifier); 315 default: 316 return this.subscriptions.notify(identifier, "received", message); 317 } 318 }, 319 open() { 320 logger_default.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`); 321 this.disconnected = false; 322 if (!this.isProtocolSupported()) { 323 logger_default.log("Protocol is unsupported. Stopping monitor and disconnecting."); 324 return this.close({ allowReconnect: false }); 325 } 326 }, 327 close(event) { 328 logger_default.log("WebSocket onclose event"); 329 if (this.disconnected) { 330 return; 331 } 332 this.disconnected = true; 333 this.monitor.recordDisconnect(); 334 return this.subscriptions.notifyAll("disconnected", { willAttemptReconnect: this.monitor.isRunning() }); 335 }, 336 error() { 337 logger_default.log("WebSocket onerror event"); 338 } 339 }; 340 connection_default = Connection; 341 } 342}); 343
vendor: 1,036 bytes, lines 344-377
344// node_modules/@rails/actioncable/src/subscription.js 345var extend, Subscription; 346var init_subscription = __esm({ 347 "node_modules/@rails/actioncable/src/subscription.js"() { 348 extend = function(object, properties) { 349 if (properties != null) { 350 for (let key in properties) { 351 const value = properties[key]; 352 object[key] = value; 353 } 354 } 355 return object; 356 }; 357 Subscription = class { 358 constructor(consumer2, params = {}, mixin) { 359 this.consumer = consumer2; 360 this.identifier = JSON.stringify(params); 361 extend(this, mixin); 362 } 363 // Perform a channel action with the optional data passed as an attribute 364 perform(action, data = {}) { 365 data.action = action; 366 return this.send(data); 367 } 368 send(data) { 369 return this.consumer.send({ command: "message", identifier: this.identifier, data: JSON.stringify(data) }); 370 } 371 unsubscribe() { 372 return this.consumer.subscriptions.remove(this); 373 } 374 }; 375 } 376}); 377
vendor: 1,802 bytes, lines 378-425
378// node_modules/@rails/actioncable/src/subscription_guarantor.js 379var SubscriptionGuarantor, subscription_guarantor_default; 380var init_subscription_guarantor = __esm({ 381 "node_modules/@rails/actioncable/src/subscription_guarantor.js"() { 382 init_logger(); 383 SubscriptionGuarantor = class { 384 constructor(subscriptions) { 385 this.subscriptions = subscriptions; 386 this.pendingSubscriptions = []; 387 } 388 guarantee(subscription) { 389 if (this.pendingSubscriptions.indexOf(subscription) == -1) { 390 logger_default.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`); 391 this.pendingSubscriptions.push(subscription); 392 } else { 393 logger_default.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`); 394 } 395 this.startGuaranteeing(); 396 } 397 forget(subscription) { 398 logger_default.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`); 399 this.pendingSubscriptions = this.pendingSubscriptions.filter((s) => s !== subscription); 400 } 401 startGuaranteeing() { 402 this.stopGuaranteeing(); 403 this.retrySubscribing(); 404 } 405 stopGuaranteeing() { 406 clearTimeout(this.retryTimeout); 407 } 408 retrySubscribing() { 409 this.retryTimeout = setTimeout( 410 () => { 411 if (this.subscriptions && typeof this.subscriptions.subscribe === "function") { 412 this.pendingSubscriptions.map((subscription) => { 413 logger_default.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`); 414 this.subscriptions.subscribe(subscription); 415 }); 416 } 417 }, 418 500 419 ); 420 } 421 }; 422 subscription_guarantor_default = SubscriptionGuarantor; 423 } 424}); 425
vendor: 2,968 bytes, lines 426-506
426// node_modules/@rails/actioncable/src/subscriptions.js 427var Subscriptions; 428var init_subscriptions = __esm({ 429 "node_modules/@rails/actioncable/src/subscriptions.js"() { 430 init_subscription(); 431 init_subscription_guarantor(); 432 init_logger(); 433 Subscriptions = class { 434 constructor(consumer2) { 435 this.consumer = consumer2; 436 this.guarantor = new subscription_guarantor_default(this); 437 this.subscriptions = []; 438 } 439 create(channelName, mixin) { 440 const channel = channelName; 441 const params = typeof channel === "object" ? channel : { channel }; 442 const subscription = new Subscription(this.consumer, params, mixin); 443 return this.add(subscription); 444 } 445 // Private 446 add(subscription) { 447 this.subscriptions.push(subscription); 448 this.consumer.ensureActiveConnection(); 449 this.notify(subscription, "initialized"); 450 this.subscribe(subscription); 451 return subscription; 452 } 453 remove(subscription) { 454 this.forget(subscription); 455 if (!this.findAll(subscription.identifier).length) { 456 this.sendCommand(subscription, "unsubscribe"); 457 } 458 return subscription; 459 } 460 reject(identifier) { 461 return this.findAll(identifier).map((subscription) => { 462 this.forget(subscription); 463 this.notify(subscription, "rejected"); 464 return subscription; 465 }); 466 } 467 forget(subscription) { 468 this.guarantor.forget(subscription); 469 this.subscriptions = this.subscriptions.filter((s) => s !== subscription); 470 return subscription; 471 } 472 findAll(identifier) { 473 return this.subscriptions.filter((s) => s.identifier === identifier); 474 } 475 reload() { 476 return this.subscriptions.map((subscription) => this.subscribe(subscription)); 477 } 478 notifyAll(callbackName, ...args) { 479 return this.subscriptions.map((subscription) => this.notify(subscription, callbackName, ...args)); 480 } 481 notify(subscription, callbackName, ...args) { 482 let subscriptions; 483 if (typeof subscription === "string") { 484 subscriptions = this.findAll(subscription); 485 } else { 486 subscriptions = [subscription]; 487 } 488 return subscriptions.map((subscription2) => typeof subscription2[callbackName] === "function" ? subscription2[callbackName](...args) : void 0); 489 } 490 subscribe(subscription) { 491 if (this.sendCommand(subscription, "subscribe")) { 492 this.guarantor.guarantee(subscription); 493 } 494 } 495 confirmSubscription(identifier) { 496 logger_default.log(`Subscription confirmed ${identifier}`); 497 this.findAll(identifier).map((subscription) => this.guarantor.forget(subscription)); 498 } 499 sendCommand(subscription, command) { 500 const { identifier } = subscription; 501 return this.consumer.send({ command, identifier }); 502 } 503 }; 504 } 505}); 506
vendor: 1,295 bytes, lines 507-557
507// node_modules/@rails/actioncable/src/consumer.js 508function createWebSocketURL(url) { 509 if (typeof url === "function") { 510 url = url(); 511 } 512 if (url && !/^wss?:/i.test(url)) { 513 const a = document.createElement("a"); 514 a.href = url; 515 a.href = a.href; 516 a.protocol = a.protocol.replace("http", "ws"); 517 return a.href; 518 } else { 519 return url; 520 } 521} 522var Consumer; 523var init_consumer = __esm({ 524 "node_modules/@rails/actioncable/src/consumer.js"() { 525 init_connection(); 526 init_subscriptions(); 527 Consumer = class { 528 constructor(url) { 529 this._url = url; 530 this.subscriptions = new Subscriptions(this); 531 this.connection = new connection_default(this); 532 this.subprotocols = []; 533 } 534 get url() { 535 return createWebSocketURL(this._url); 536 } 537 send(data) { 538 return this.connection.send(data); 539 } 540 connect() { 541 return this.connection.open(); 542 } 543 disconnect() { 544 return this.connection.close({ allowReconnect: false }); 545 } 546 ensureActiveConnection() { 547 if (!this.connection.isActive()) { 548 return this.connection.open(); 549 } 550 } 551 addSubProtocol(subprotocol) { 552 this.subprotocols = [...this.subprotocols, subprotocol]; 553 } 554 }; 555 } 556}); 557
vendor: 1,183 bytes, lines 558-596
558// node_modules/@rails/actioncable/src/index.js 559var src_exports = {}; 560__export(src_exports, { 561 Connection: () => connection_default, 562 ConnectionMonitor: () => connection_monitor_default, 563 Consumer: () => Consumer, 564 INTERNAL: () => internal_default, 565 Subscription: () => Subscription, 566 SubscriptionGuarantor: () => subscription_guarantor_default, 567 Subscriptions: () => Subscriptions, 568 adapters: () => adapters_default, 569 createConsumer: () => createConsumer, 570 createWebSocketURL: () => createWebSocketURL, 571 getConfig: () => getConfig, 572 logger: () => logger_default 573}); 574function createConsumer(url = getConfig("url") || internal_default.default_mount_path) { 575 return new Consumer(url); 576} 577function getConfig(name) { 578 const element = document.head.querySelector(`meta[name='action-cable-${name}']`); 579 if (element) { 580 return element.getAttribute("content"); 581 } 582} 583var init_src = __esm({ 584 "node_modules/@rails/actioncable/src/index.js"() { 585 init_connection(); 586 init_connection_monitor(); 587 init_consumer(); 588 init_internal(); 589 init_subscription(); 590 init_subscriptions(); 591 init_subscription_guarantor(); 592 init_adapters(); 593 init_logger(); 594 } 595}); 596
vendor: 172,715 bytes, lines 597-5962
597// node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js 598var turbo_es2017_esm_exports = {}; 599__export(turbo_es2017_esm_exports, { 600 FetchEnctype: () => FetchEnctype, 601 FetchMethod: () => FetchMethod, 602 FetchRequest: () => FetchRequest, 603 FetchResponse: () => FetchResponse, 604 FrameElement: () => FrameElement, 605 FrameLoadingStyle: () => FrameLoadingStyle, 606 FrameRenderer: () => FrameRenderer, 607 PageRenderer: () => PageRenderer, 608 PageSnapshot: () => PageSnapshot, 609 StreamActions: () => StreamActions, 610 StreamElement: () => StreamElement, 611 StreamSourceElement: () => StreamSourceElement, 612 cache: () => cache, 613 clearCache: () => clearCache, 614 config: () => config, 615 connectStreamSource: () => connectStreamSource, 616 disconnectStreamSource: () => disconnectStreamSource, 617 fetch: () => fetchWithTurboHeaders, 618 fetchEnctypeFromString: () => fetchEnctypeFromString, 619 fetchMethodFromString: () => fetchMethodFromString, 620 isSafe: () => isSafe, 621 navigator: () => navigator$1, 622 registerAdapter: () => registerAdapter, 623 renderStreamMessage: () => renderStreamMessage, 624 session: () => session, 625 setConfirmMethod: () => setConfirmMethod, 626 setFormMode: () => setFormMode, 627 setProgressBarDelay: () => setProgressBarDelay, 628 start: () => start, 629 visit: () => visit 630}); 631(function(prototype) { 632 if (typeof prototype.requestSubmit == "function") return; 633 prototype.requestSubmit = function(submitter2) { 634 if (submitter2) { 635 validateSubmitter(submitter2, this); 636 submitter2.click(); 637 } else { 638 submitter2 = document.createElement("input"); 639 submitter2.type = "submit"; 640 submitter2.hidden = true; 641 this.appendChild(submitter2); 642 submitter2.click(); 643 this.removeChild(submitter2); 644 } 645 }; 646 function validateSubmitter(submitter2, form) { 647 submitter2 instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'"); 648 submitter2.type == "submit" || raise(TypeError, "The specified element is not a submit button"); 649 submitter2.form == form || raise(DOMException, "The specified element is not owned by this form element", "NotFoundError"); 650 } 651 function raise(errorConstructor, message, name) { 652 throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name); 653 } 654})(HTMLFormElement.prototype); 655var submittersByForm = /* @__PURE__ */ new WeakMap(); 656function findSubmitterFromClickTarget(target) { 657 const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null; 658 const candidate = element ? element.closest("input, button") : null; 659 return candidate?.type == "submit" ? candidate : null; 660} 661function clickCaptured(event) { 662 const submitter2 = findSubmitterFromClickTarget(event.target); 663 if (submitter2 && submitter2.form) { 664 submittersByForm.set(submitter2.form, submitter2); 665 } 666} 667(function() { 668 if ("submitter" in Event.prototype) return; 669 let prototype = window.Event.prototype; 670 if ("SubmitEvent" in window) { 671 const prototypeOfSubmitEvent = window.SubmitEvent.prototype; 672 if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) { 673 prototype = prototypeOfSubmitEvent; 674 } else { 675 return; 676 } 677 } 678 addEventListener("click", clickCaptured, true); 679 Object.defineProperty(prototype, "submitter", { 680 get() { 681 if (this.type == "submit" && this.target instanceof HTMLFormElement) { 682 return submittersByForm.get(this.target); 683 } 684 } 685 }); 686})(); 687var FrameLoadingStyle = { 688 eager: "eager", 689 lazy: "lazy" 690}; 691var FrameElement = class _FrameElement extends HTMLElement { 692 static delegateConstructor = void 0; 693 loaded = Promise.resolve(); 694 static get observedAttributes() { 695 return ["disabled", "loading", "src"]; 696 } 697 constructor() { 698 super(); 699 this.delegate = new _FrameElement.delegateConstructor(this); 700 } 701 connectedCallback() { 702 this.delegate.connect(); 703 } 704 disconnectedCallback() { 705 this.delegate.disconnect(); 706 } 707 reload() { 708 return this.delegate.sourceURLReloaded(); 709 } 710 attributeChangedCallback(name) { 711 if (name == "loading") { 712 this.delegate.loadingStyleChanged(); 713 } else if (name == "src") { 714 this.delegate.sourceURLChanged(); 715 } else if (name == "disabled") { 716 this.delegate.disabledChanged(); 717 } 718 } 719 /** 720 * Gets the URL to lazily load source HTML from 721 */ 722 get src() { 723 return this.getAttribute("src"); 724 } 725 /** 726 * Sets the URL to lazily load source HTML from 727 */ 728 set src(value) { 729 if (value) { 730 this.setAttribute("src", value); 731 } else { 732 this.removeAttribute("src"); 733 } 734 } 735 /** 736 * Gets the refresh mode for the frame. 737 */ 738 get refresh() { 739 return this.getAttribute("refresh"); 740 } 741 /** 742 * Sets the refresh mode for the frame. 743 */ 744 set refresh(value) { 745 if (value) { 746 this.setAttribute("refresh", value); 747 } else { 748 this.removeAttribute("refresh"); 749 } 750 } 751 get shouldReloadWithMorph() { 752 return this.src && this.refresh === "morph"; 753 } 754 /** 755 * Determines if the element is loading 756 */ 757 get loading() { 758 return frameLoadingStyleFromString(this.getAttribute("loading") || ""); 759 } 760 /** 761 * Sets the value of if the element is loading 762 */ 763 set loading(value) { 764 if (value) { 765 this.setAttribute("loading", value); 766 } else { 767 this.removeAttribute("loading"); 768 } 769 } 770 /** 771 * Gets the disabled state of the frame. 772 * 773 * If disabled, no requests will be intercepted by the frame. 774 */ 775 get disabled() { 776 return this.hasAttribute("disabled"); 777 } 778 /** 779 * Sets the disabled state of the frame. 780 * 781 * If disabled, no requests will be intercepted by the frame. 782 */ 783 set disabled(value) { 784 if (value) { 785 this.setAttribute("disabled", ""); 786 } else { 787 this.removeAttribute("disabled"); 788 } 789 } 790 /** 791 * Gets the autoscroll state of the frame. 792 * 793 * If true, the frame will be scrolled into view automatically on update. 794 */ 795 get autoscroll() { 796 return this.hasAttribute("autoscroll"); 797 } 798 /** 799 * Sets the autoscroll state of the frame. 800 * 801 * If true, the frame will be scrolled into view automatically on update. 802 */ 803 set autoscroll(value) { 804 if (value) { 805 this.setAttribute("autoscroll", ""); 806 } else { 807 this.removeAttribute("autoscroll"); 808 } 809 } 810 /** 811 * Determines if the element has finished loading 812 */ 813 get complete() { 814 return !this.delegate.isLoading; 815 } 816 /** 817 * Gets the active state of the frame. 818 * 819 * If inactive, source changes will not be observed. 820 */ 821 get isActive() { 822 return this.ownerDocument === document && !this.isPreview; 823 } 824 /** 825 * Sets the active state of the frame. 826 * 827 * If inactive, source changes will not be observed. 828 */ 829 get isPreview() { 830 return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview"); 831 } 832}; 833function frameLoadingStyleFromString(style) { 834 switch (style.toLowerCase()) { 835 case "lazy": 836 return FrameLoadingStyle.lazy; 837 default: 838 return FrameLoadingStyle.eager; 839 } 840} 841var drive = { 842 enabled: true, 843 progressBarDelay: 500, 844 unvisitableExtensions: /* @__PURE__ */ new Set( 845 [ 846 ".7z", 847 ".aac", 848 ".apk", 849 ".avi", 850 ".bmp", 851 ".bz2", 852 ".css", 853 ".csv", 854 ".deb", 855 ".dmg", 856 ".doc", 857 ".docx", 858 ".exe", 859 ".gif", 860 ".gz", 861 ".heic", 862 ".heif", 863 ".ico", 864 ".iso", 865 ".jpeg", 866 ".jpg", 867 ".js", 868 ".json", 869 ".m4a", 870 ".mkv", 871 ".mov", 872 ".mp3", 873 ".mp4", 874 ".mpeg", 875 ".mpg", 876 ".msi", 877 ".ogg", 878 ".ogv", 879 ".pdf", 880 ".pkg", 881 ".png", 882 ".ppt", 883 ".pptx", 884 ".rar", 885 ".rtf", 886 ".svg", 887 ".tar", 888 ".tif", 889 ".tiff", 890 ".txt", 891 ".wav", 892 ".webm", 893 ".webp", 894 ".wma", 895 ".wmv", 896 ".xls", 897 ".xlsx", 898 ".xml", 899 ".zip" 900 ] 901 ) 902}; 903function activateScriptElement(element) { 904 if (element.getAttribute("data-turbo-eval") == "false") { 905 return element; 906 } else { 907 const createdScriptElement = document.createElement("script"); 908 const cspNonce = getCspNonce(); 909 if (cspNonce) { 910 createdScriptElement.nonce = cspNonce; 911 } 912 createdScriptElement.textContent = element.textContent; 913 createdScriptElement.async = false; 914 copyElementAttributes(createdScriptElement, element); 915 return createdScriptElement; 916 } 917} 918function copyElementAttributes(destinationElement, sourceElement) { 919 for (const { name, value } of sourceElement.attributes) { 920 destinationElement.setAttribute(name, value); 921 } 922} 923function createDocumentFragment(html) { 924 const template = document.createElement("template"); 925 template.innerHTML = html; 926 return template.content; 927} 928function dispatch(eventName, { target, cancelable, detail } = {}) { 929 const event = new CustomEvent(eventName, { 930 cancelable, 931 bubbles: true, 932 composed: true, 933 detail 934 }); 935 if (target && target.isConnected) { 936 target.dispatchEvent(event); 937 } else { 938 document.documentElement.dispatchEvent(event); 939 } 940 return event; 941} 942function cancelEvent(event) { 943 event.preventDefault(); 944 event.stopImmediatePropagation(); 945} 946function nextRepaint() { 947 if (document.visibilityState === "hidden") { 948 return nextEventLoopTick(); 949 } else { 950 return nextAnimationFrame(); 951 } 952} 953function nextAnimationFrame() { 954 return new Promise((resolve) => requestAnimationFrame(() => resolve())); 955} 956function nextEventLoopTick() { 957 return new Promise((resolve) => setTimeout(() => resolve(), 0)); 958} 959function nextMicrotask() { 960 return Promise.resolve(); 961} 962function parseHTMLDocument(html = "") { 963 return new DOMParser().parseFromString(html, "text/html"); 964} 965function unindent(strings, ...values) { 966 const lines = interpolate(strings, values).replace(/^\n/, "").split("\n"); 967 const match = lines[0].match(/^\s+/); 968 const indent = match ? match[0].length : 0; 969 return lines.map((line) => line.slice(indent)).join("\n"); 970} 971function interpolate(strings, values) { 972 return strings.reduce((result, string, i) => { 973 const value = values[i] == void 0 ? "" : values[i]; 974 return result + string + value; 975 }, ""); 976} 977function uuid() { 978 return Array.from({ length: 36 }).map((_, i) => { 979 if (i == 8 || i == 13 || i == 18 || i == 23) { 980 return "-"; 981 } else if (i == 14) { 982 return "4"; 983 } else if (i == 19) { 984 return (Math.floor(Math.random() * 4) + 8).toString(16); 985 } else { 986 return Math.floor(Math.random() * 15).toString(16); 987 } 988 }).join(""); 989} 990function getAttribute(attributeName, ...elements) { 991 for (const value of elements.map((element) => element?.getAttribute(attributeName))) { 992 if (typeof value == "string") return value; 993 } 994 return null; 995} 996function hasAttribute(attributeName, ...elements) { 997 return elements.some((element) => element && element.hasAttribute(attributeName)); 998} 999function markAsBusy(...elements) { 1000 for (const element of elements) { 1001 if (element.localName == "turbo-frame") { 1002 element.setAttribute("busy", ""); 1003 } 1004 element.setAttribute("aria-busy", "true"); 1005 } 1006} 1007function clearBusyState(...elements) { 1008 for (const element of elements) { 1009 if (element.localName == "turbo-frame") { 1010 element.removeAttribute("busy"); 1011 } 1012 element.removeAttribute("aria-busy"); 1013 } 1014} 1015function waitForLoad(element, timeoutInMilliseconds = 2e3) { 1016 return new Promise((resolve) => { 1017 const onComplete = () => { 1018 element.removeEventListener("error", onComplete); 1019 element.removeEventListener("load", onComplete); 1020 resolve(); 1021 }; 1022 element.addEventListener("load", onComplete, { once: true }); 1023 element.addEventListener("error", onComplete, { once: true }); 1024 setTimeout(resolve, timeoutInMilliseconds); 1025 }); 1026} 1027function getHistoryMethodForAction(action) { 1028 switch (action) { 1029 case "replace": 1030 return history.replaceState; 1031 case "advance": 1032 case "restore": 1033 return history.pushState; 1034 } 1035} 1036function isAction(action) { 1037 return action == "advance" || action == "replace" || action == "restore"; 1038} 1039function getVisitAction(...elements) { 1040 const action = getAttribute("data-turbo-action", ...elements); 1041 return isAction(action) ? action : null; 1042} 1043function getMetaElement(name) { 1044 return document.querySelector(`meta[name="${name}"]`); 1045} 1046function getMetaContent(name) { 1047 const element = getMetaElement(name); 1048 return element && element.content; 1049} 1050function getCspNonce() { 1051 const element = getMetaElement("csp-nonce"); 1052 if (element) { 1053 const { nonce, content } = element; 1054 return nonce == "" ? content : nonce; 1055 } 1056} 1057function setMetaContent(name, content) { 1058 let element = getMetaElement(name); 1059 if (!element) { 1060 element = document.createElement("meta"); 1061 element.setAttribute("name", name); 1062 document.head.appendChild(element); 1063 } 1064 element.setAttribute("content", content); 1065 return element; 1066} 1067function findClosestRecursively(element, selector) { 1068 if (element instanceof Element) { 1069 return element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector); 1070 } 1071} 1072function elementIsFocusable(element) { 1073 const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])"; 1074 return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"; 1075} 1076function queryAutofocusableElement(elementOrDocumentFragment) { 1077 return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable); 1078} 1079async function around(callback, reader) { 1080 const before = reader(); 1081 callback(); 1082 await nextAnimationFrame(); 1083 const after = reader(); 1084 return [before, after]; 1085} 1086function doesNotTargetIFrame(name) { 1087 if (name === "_blank") { 1088 return false; 1089 } else if (name) { 1090 for (const element of document.getElementsByName(name)) { 1091 if (element instanceof HTMLIFrameElement) return false; 1092 } 1093 return true; 1094 } else { 1095 return true; 1096 } 1097} 1098function findLinkFromClickTarget(target) { 1099 return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])"); 1100} 1101function getLocationForLink(link) { 1102 return expandURL(link.getAttribute("href") || ""); 1103} 1104function debounce(fn, delay) { 1105 let timeoutId = null; 1106 return (...args) => { 1107 const callback = () => fn.apply(this, args); 1108 clearTimeout(timeoutId); 1109 timeoutId = setTimeout(callback, delay); 1110 }; 1111} 1112var submitter = { 1113 "aria-disabled": { 1114 beforeSubmit: (submitter2) => { 1115 submitter2.setAttribute("aria-disabled", "true"); 1116 submitter2.addEventListener("click", cancelEvent); 1117 }, 1118 afterSubmit: (submitter2) => { 1119 submitter2.removeAttribute("aria-disabled"); 1120 submitter2.removeEventListener("click", cancelEvent); 1121 } 1122 }, 1123 "disabled": { 1124 beforeSubmit: (submitter2) => submitter2.disabled = true, 1125 afterSubmit: (submitter2) => submitter2.disabled = false 1126 } 1127}; 1128var Config = class { 1129 #submitter = null; 1130 constructor(config2) { 1131 Object.assign(this, config2); 1132 } 1133 get submitter() { 1134 return this.#submitter; 1135 } 1136 set submitter(value) { 1137 this.#submitter = submitter[value] || value; 1138 } 1139}; 1140var forms = new Config({ 1141 mode: "on", 1142 submitter: "disabled" 1143}); 1144var config = { 1145 drive, 1146 forms 1147}; 1148function expandURL(locatable) { 1149 return new URL(locatable.toString(), document.baseURI); 1150} 1151function getAnchor(url) { 1152 let anchorMatch; 1153 if (url.hash) { 1154 return url.hash.slice(1); 1155 } else if (anchorMatch = url.href.match(/#(.*)$/)) { 1156 return anchorMatch[1]; 1157 } 1158} 1159function getAction$1(form, submitter2) { 1160 const action = submitter2?.getAttribute("formaction") || form.getAttribute("action") || form.action; 1161 return expandURL(action); 1162} 1163function getExtension(url) { 1164 return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""; 1165} 1166function isPrefixedBy(baseURL, url) { 1167 const prefix = getPrefix(url); 1168 return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix); 1169} 1170function locationIsVisitable(location2, rootLocation) { 1171 return isPrefixedBy(location2, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location2)); 1172} 1173function getRequestURL(url) { 1174 const anchor = getAnchor(url); 1175 return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href; 1176} 1177function toCacheKey(url) { 1178 return getRequestURL(url); 1179} 1180function urlsAreEqual(left, right) { 1181 return expandURL(left).href == expandURL(right).href; 1182} 1183function getPathComponents(url) { 1184 return url.pathname.split("/").slice(1); 1185} 1186function getLastPathComponent(url) { 1187 return getPathComponents(url).slice(-1)[0]; 1188} 1189function getPrefix(url) { 1190 return addTrailingSlash(url.origin + url.pathname); 1191} 1192function addTrailingSlash(value) { 1193 return value.endsWith("/") ? value : value + "/"; 1194} 1195var FetchResponse = class { 1196 constructor(response) { 1197 this.response = response; 1198 } 1199 get succeeded() { 1200 return this.response.ok; 1201 } 1202 get failed() { 1203 return !this.succeeded; 1204 } 1205 get clientError() { 1206 return this.statusCode >= 400 && this.statusCode <= 499; 1207 } 1208 get serverError() { 1209 return this.statusCode >= 500 && this.statusCode <= 599; 1210 } 1211 get redirected() { 1212 return this.response.redirected; 1213 } 1214 get location() { 1215 return expandURL(this.response.url); 1216 } 1217 get isHTML() { 1218 return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/); 1219 } 1220 get statusCode() { 1221 return this.response.status; 1222 } 1223 get contentType() { 1224 return this.header("Content-Type"); 1225 } 1226 get responseText() { 1227 return this.response.clone().text(); 1228 } 1229 get responseHTML() { 1230 if (this.isHTML) { 1231 return this.response.clone().text(); 1232 } else { 1233 return Promise.resolve(void 0); 1234 } 1235 } 1236 header(name) { 1237 return this.response.headers.get(name); 1238 } 1239}; 1240var LimitedSet = class extends Set { 1241 constructor(maxSize) { 1242 super(); 1243 this.maxSize = maxSize; 1244 } 1245 add(value) { 1246 if (this.size >= this.maxSize) { 1247 const iterator = this.values(); 1248 const oldestValue = iterator.next().value; 1249 this.delete(oldestValue); 1250 } 1251 super.add(value); 1252 } 1253}; 1254var recentRequests = new LimitedSet(20); 1255var nativeFetch = window.fetch; 1256function fetchWithTurboHeaders(url, options = {}) { 1257 const modifiedHeaders = new Headers(options.headers || {}); 1258 const requestUID = uuid(); 1259 recentRequests.add(requestUID); 1260 modifiedHeaders.append("X-Turbo-Request-Id", requestUID); 1261 return nativeFetch(url, { 1262 ...options, 1263 headers: modifiedHeaders 1264 }); 1265} 1266function fetchMethodFromString(method) { 1267 switch (method.toLowerCase()) { 1268 case "get": 1269 return FetchMethod.get; 1270 case "post": 1271 return FetchMethod.post; 1272 case "put": 1273 return FetchMethod.put; 1274 case "patch": 1275 return FetchMethod.patch; 1276 case "delete": 1277 return FetchMethod.delete; 1278 } 1279} 1280var FetchMethod = { 1281 get: "get", 1282 post: "post", 1283 put: "put", 1284 patch: "patch", 1285 delete: "delete" 1286}; 1287function fetchEnctypeFromString(encoding) { 1288 switch (encoding.toLowerCase()) { 1289 case FetchEnctype.multipart: 1290 return FetchEnctype.multipart; 1291 case FetchEnctype.plain: 1292 return FetchEnctype.plain; 1293 default: 1294 return FetchEnctype.urlEncoded; 1295 } 1296} 1297var FetchEnctype = { 1298 urlEncoded: "application/x-www-form-urlencoded", 1299 multipart: "multipart/form-data", 1300 plain: "text/plain" 1301}; 1302var FetchRequest = class { 1303 abortController = new AbortController(); 1304 #resolveRequestPromise = (_value) => { 1305 }; 1306 constructor(delegate, method, location2, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) { 1307 const [url, body] = buildResourceAndBody(expandURL(location2), method, requestBody, enctype); 1308 this.delegate = delegate; 1309 this.url = url; 1310 this.target = target; 1311 this.fetchOptions = { 1312 credentials: "same-origin", 1313 redirect: "follow", 1314 method: method.toUpperCase(), 1315 headers: { ...this.defaultHeaders }, 1316 body, 1317 signal: this.abortSignal, 1318 referrer: this.delegate.referrer?.href 1319 }; 1320 this.enctype = enctype; 1321 } 1322 get method() { 1323 return this.fetchOptions.method; 1324 } 1325 set method(value) { 1326 const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData(); 1327 const fetchMethod = fetchMethodFromString(value) || FetchMethod.get; 1328 this.url.search = ""; 1329 const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype); 1330 this.url = url; 1331 this.fetchOptions.body = body; 1332 this.fetchOptions.method = fetchMethod.toUpperCase(); 1333 } 1334 get headers() { 1335 return this.fetchOptions.headers; 1336 } 1337 set headers(value) { 1338 this.fetchOptions.headers = value; 1339 } 1340 get body() { 1341 if (this.isSafe) { 1342 return this.url.searchParams; 1343 } else { 1344 return this.fetchOptions.body; 1345 } 1346 } 1347 set body(value) { 1348 this.fetchOptions.body = value; 1349 } 1350 get location() { 1351 return this.url; 1352 } 1353 get params() { 1354 return this.url.searchParams; 1355 } 1356 get entries() { 1357 return this.body ? Array.from(this.body.entries()) : []; 1358 } 1359 cancel() { 1360 this.abortController.abort(); 1361 } 1362 async perform() { 1363 const { fetchOptions } = this; 1364 this.delegate.prepareRequest(this); 1365 const event = await this.#allowRequestToBeIntercepted(fetchOptions); 1366 try { 1367 this.delegate.requestStarted(this); 1368 if (event.detail.fetchRequest) { 1369 this.response = event.detail.fetchRequest.response; 1370 } else { 1371 this.response = fetchWithTurboHeaders(this.url.href, fetchOptions); 1372 } 1373 const response = await this.response; 1374 return await this.receive(response); 1375 } catch (error) { 1376 if (error.name !== "AbortError") { 1377 if (this.#willDelegateErrorHandling(error)) { 1378 this.delegate.requestErrored(this, error); 1379 } 1380 throw error; 1381 } 1382 } finally { 1383 this.delegate.requestFinished(this); 1384 } 1385 } 1386 async receive(response) { 1387 const fetchResponse = new FetchResponse(response); 1388 const event = dispatch("turbo:before-fetch-response", { 1389 cancelable: true, 1390 detail: { fetchResponse }, 1391 target: this.target 1392 }); 1393 if (event.defaultPrevented) { 1394 this.delegate.requestPreventedHandlingResponse(this, fetchResponse); 1395 } else if (fetchResponse.succeeded) { 1396 this.delegate.requestSucceededWithResponse(this, fetchResponse); 1397 } else { 1398 this.delegate.requestFailedWithResponse(this, fetchResponse); 1399 } 1400 return fetchResponse; 1401 } 1402 get defaultHeaders() { 1403 return { 1404 Accept: "text/html, application/xhtml+xml" 1405 }; 1406 } 1407 get isSafe() { 1408 return isSafe(this.method); 1409 } 1410 get abortSignal() { 1411 return this.abortController.signal; 1412 } 1413 acceptResponseType(mimeType) { 1414 this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", "); 1415 } 1416 async #allowRequestToBeIntercepted(fetchOptions) { 1417 const requestInterception = new Promise((resolve) => this.#resolveRequestPromise = resolve); 1418 const event = dispatch("turbo:before-fetch-request", { 1419 cancelable: true, 1420 detail: { 1421 fetchOptions, 1422 url: this.url, 1423 resume: this.#resolveRequestPromise 1424 }, 1425 target: this.target 1426 }); 1427 this.url = event.detail.url; 1428 if (event.defaultPrevented) await requestInterception; 1429 return event; 1430 } 1431 #willDelegateErrorHandling(error) { 1432 const event = dispatch("turbo:fetch-request-error", { 1433 target: this.target, 1434 cancelable: true, 1435 detail: { request: this, error } 1436 }); 1437 return !event.defaultPrevented; 1438 } 1439}; 1440function isSafe(fetchMethod) { 1441 return fetchMethodFromString(fetchMethod) == FetchMethod.get; 1442} 1443function buildResourceAndBody(resource, method, requestBody, enctype) { 1444 const searchParams = Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams; 1445 if (isSafe(method)) { 1446 return [mergeIntoURLSearchParams(resource, searchParams), null]; 1447 } else if (enctype == FetchEnctype.urlEncoded) { 1448 return [resource, searchParams]; 1449 } else { 1450 return [resource, requestBody]; 1451 } 1452} 1453function entriesExcludingFiles(requestBody) { 1454 const entries = []; 1455 for (const [name, value] of requestBody) { 1456 if (value instanceof File) continue; 1457 else entries.push([name, value]); 1458 } 1459 return entries; 1460} 1461function mergeIntoURLSearchParams(url, requestBody) { 1462 const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody)); 1463 url.search = searchParams.toString(); 1464 return url; 1465} 1466var AppearanceObserver = class { 1467 started = false; 1468 constructor(delegate, element) { 1469 this.delegate = delegate; 1470 this.element = element; 1471 this.intersectionObserver = new IntersectionObserver(this.intersect); 1472 } 1473 start() { 1474 if (!this.started) { 1475 this.started = true; 1476 this.intersectionObserver.observe(this.element); 1477 } 1478 } 1479 stop() { 1480 if (this.started) { 1481 this.started = false; 1482 this.intersectionObserver.unobserve(this.element); 1483 } 1484 } 1485 intersect = (entries) => { 1486 const lastEntry = entries.slice(-1)[0]; 1487 if (lastEntry?.isIntersecting) { 1488 this.delegate.elementAppearedInViewport(this.element); 1489 } 1490 }; 1491}; 1492var StreamMessage = class { 1493 static contentType = "text/vnd.turbo-stream.html"; 1494 static wrap(message) { 1495 if (typeof message == "string") { 1496 return new this(createDocumentFragment(message)); 1497 } else { 1498 return message; 1499 } 1500 } 1501 constructor(fragment) { 1502 this.fragment = importStreamElements(fragment); 1503 } 1504}; 1505function importStreamElements(fragment) { 1506 for (const element of fragment.querySelectorAll("turbo-stream")) { 1507 const streamElement = document.importNode(element, true); 1508 for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) { 1509 inertScriptElement.replaceWith(activateScriptElement(inertScriptElement)); 1510 } 1511 element.replaceWith(streamElement); 1512 } 1513 return fragment; 1514} 1515var PREFETCH_DELAY = 100; 1516var PrefetchCache = class { 1517 #prefetchTimeout = null; 1518 #prefetched = null; 1519 get(url) { 1520 if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) { 1521 return this.#prefetched.request; 1522 } 1523 } 1524 setLater(url, request, ttl) { 1525 this.clear(); 1526 this.#prefetchTimeout = setTimeout(() => { 1527 request.perform(); 1528 this.set(url, request, ttl); 1529 this.#prefetchTimeout = null; 1530 }, PREFETCH_DELAY); 1531 } 1532 set(url, request, ttl) { 1533 this.#prefetched = { url, request, expire: new Date((/* @__PURE__ */ new Date()).getTime() + ttl) }; 1534 } 1535 clear() { 1536 if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout); 1537 this.#prefetched = null; 1538 } 1539}; 1540var cacheTtl = 10 * 1e3; 1541var prefetchCache = new PrefetchCache(); 1542var FormSubmissionState = { 1543 initialized: "initialized", 1544 requesting: "requesting", 1545 waiting: "waiting", 1546 receiving: "receiving", 1547 stopping: "stopping", 1548 stopped: "stopped" 1549}; 1550var FormSubmission = class _FormSubmission { 1551 state = FormSubmissionState.initialized; 1552 static confirmMethod(message) { 1553 return Promise.resolve(confirm(message)); 1554 } 1555 constructor(delegate, formElement, submitter2, mustRedirect = false) { 1556 const method = getMethod(formElement, submitter2); 1557 const action = getAction(getFormAction(formElement, submitter2), method); 1558 const body = buildFormData(formElement, submitter2); 1559 const enctype = getEnctype(formElement, submitter2); 1560 this.delegate = delegate; 1561 this.formElement = formElement; 1562 this.submitter = submitter2; 1563 this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype); 1564 this.mustRedirect = mustRedirect; 1565 } 1566 get method() { 1567 return this.fetchRequest.method; 1568 } 1569 set method(value) { 1570 this.fetchRequest.method = value; 1571 } 1572 get action() { 1573 return this.fetchRequest.url.toString(); 1574 } 1575 set action(value) { 1576 this.fetchRequest.url = expandURL(value); 1577 } 1578 get body() { 1579 return this.fetchRequest.body; 1580 } 1581 get enctype() { 1582 return this.fetchRequest.enctype; 1583 } 1584 get isSafe() { 1585 return this.fetchRequest.isSafe; 1586 } 1587 get location() { 1588 return this.fetchRequest.url; 1589 } 1590 // The submission process 1591 async start() { 1592 const { initialized, requesting } = FormSubmissionState; 1593 const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement); 1594 if (typeof confirmationMessage === "string") { 1595 const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : _FormSubmission.confirmMethod; 1596 const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter); 1597 if (!answer) { 1598 return; 1599 } 1600 } 1601 if (this.state == initialized) { 1602 this.state = requesting; 1603 return this.fetchRequest.perform(); 1604 } 1605 } 1606 stop() { 1607 const { stopping, stopped } = FormSubmissionState; 1608 if (this.state != stopping && this.state != stopped) { 1609 this.state = stopping; 1610 this.fetchRequest.cancel(); 1611 return true; 1612 } 1613 } 1614 // Fetch request delegate 1615 prepareRequest(request) { 1616 if (!request.isSafe) { 1617 const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token"); 1618 if (token) { 1619 request.headers["X-CSRF-Token"] = token; 1620 } 1621 } 1622 if (this.requestAcceptsTurboStreamResponse(request)) { 1623 request.acceptResponseType(StreamMessage.contentType); 1624 } 1625 } 1626 requestStarted(_request) { 1627 this.state = FormSubmissionState.waiting; 1628 if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter); 1629 this.setSubmitsWith(); 1630 markAsBusy(this.formElement); 1631 dispatch("turbo:submit-start", { 1632 target: this.formElement, 1633 detail: { formSubmission: this } 1634 }); 1635 this.delegate.formSubmissionStarted(this); 1636 } 1637 requestPreventedHandlingResponse(request, response) { 1638 prefetchCache.clear(); 1639 this.result = { success: response.succeeded, fetchResponse: response }; 1640 } 1641 requestSucceededWithResponse(request, response) { 1642 if (response.clientError || response.serverError) { 1643 this.delegate.formSubmissionFailedWithResponse(this, response); 1644 return; 1645 } 1646 prefetchCache.clear(); 1647 if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) { 1648 const error = new Error("Form responses must redirect to another location"); 1649 this.delegate.formSubmissionErrored(this, error); 1650 } else { 1651 this.state = FormSubmissionState.receiving; 1652 this.result = { success: true, fetchResponse: response }; 1653 this.delegate.formSubmissionSucceededWithResponse(this, response); 1654 } 1655 } 1656 requestFailedWithResponse(request, response) { 1657 this.result = { success: false, fetchResponse: response }; 1658 this.delegate.formSubmissionFailedWithResponse(this, response); 1659 } 1660 requestErrored(request, error) { 1661 this.result = { success: false, error }; 1662 this.delegate.formSubmissionErrored(this, error); 1663 } 1664 requestFinished(_request) { 1665 this.state = FormSubmissionState.stopped; 1666 if (this.submitter) config.forms.submitter.afterSubmit(this.submitter); 1667 this.resetSubmitterText(); 1668 clearBusyState(this.formElement); 1669 dispatch("turbo:submit-end", { 1670 target: this.formElement, 1671 detail: { formSubmission: this, ...this.result } 1672 }); 1673 this.delegate.formSubmissionFinished(this); 1674 } 1675 // Private 1676 setSubmitsWith() { 1677 if (!this.submitter || !this.submitsWith) return; 1678 if (this.submitter.matches("button")) { 1679 this.originalSubmitText = this.submitter.innerHTML; 1680 this.submitter.innerHTML = this.submitsWith; 1681 } else if (this.submitter.matches("input")) { 1682 const input = this.submitter; 1683 this.originalSubmitText = input.value; 1684 input.value = this.submitsWith; 1685 } 1686 } 1687 resetSubmitterText() { 1688 if (!this.submitter || !this.originalSubmitText) return; 1689 if (this.submitter.matches("button")) { 1690 this.submitter.innerHTML = this.originalSubmitText; 1691 } else if (this.submitter.matches("input")) { 1692 const input = this.submitter; 1693 input.value = this.originalSubmitText; 1694 } 1695 } 1696 requestMustRedirect(request) { 1697 return !request.isSafe && this.mustRedirect; 1698 } 1699 requestAcceptsTurboStreamResponse(request) { 1700 return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement); 1701 } 1702 get submitsWith() { 1703 return this.submitter?.getAttribute("data-turbo-submits-with"); 1704 } 1705}; 1706function buildFormData(formElement, submitter2) { 1707 const formData = new FormData(formElement); 1708 const name = submitter2?.getAttribute("name"); 1709 const value = submitter2?.getAttribute("value"); 1710 if (name) { 1711 formData.append(name, value || ""); 1712 } 1713 return formData; 1714} 1715function getCookieValue(cookieName) { 1716 if (cookieName != null) { 1717 const cookies = document.cookie ? document.cookie.split("; ") : []; 1718 const cookie = cookies.find((cookie2) => cookie2.startsWith(cookieName)); 1719 if (cookie) { 1720 const value = cookie.split("=").slice(1).join("="); 1721 return value ? decodeURIComponent(value) : void 0; 1722 } 1723 } 1724} 1725function responseSucceededWithoutRedirect(response) { 1726 return response.statusCode == 200 && !response.redirected; 1727} 1728function getFormAction(formElement, submitter2) { 1729 const formElementAction = typeof formElement.action === "string" ? formElement.action : null; 1730 if (submitter2?.hasAttribute("formaction")) { 1731 return submitter2.getAttribute("formaction") || ""; 1732 } else { 1733 return formElement.getAttribute("action") || formElementAction || ""; 1734 } 1735} 1736function getAction(formAction, fetchMethod) { 1737 const action = expandURL(formAction); 1738 if (isSafe(fetchMethod)) { 1739 action.search = ""; 1740 } 1741 return action; 1742} 1743function getMethod(formElement, submitter2) { 1744 const method = submitter2?.getAttribute("formmethod") || formElement.getAttribute("method") || ""; 1745 return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get; 1746} 1747function getEnctype(formElement, submitter2) { 1748 return fetchEnctypeFromString(submitter2?.getAttribute("formenctype") || formElement.enctype); 1749} 1750var Snapshot = class { 1751 constructor(element) { 1752 this.element = element; 1753 } 1754 get activeElement() { 1755 return this.element.ownerDocument.activeElement; 1756 } 1757 get children() { 1758 return [...this.element.children]; 1759 } 1760 hasAnchor(anchor) { 1761 return this.getElementForAnchor(anchor) != null; 1762 } 1763 getElementForAnchor(anchor) { 1764 return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null; 1765 } 1766 get isConnected() { 1767 return this.element.isConnected; 1768 } 1769 get firstAutofocusableElement() { 1770 return queryAutofocusableElement(this.element); 1771 } 1772 get permanentElements() { 1773 return queryPermanentElementsAll(this.element); 1774 } 1775 getPermanentElementById(id) { 1776 return getPermanentElementById(this.element, id); 1777 } 1778 getPermanentElementMapForSnapshot(snapshot) { 1779 const permanentElementMap = {}; 1780 for (const currentPermanentElement of this.permanentElements) { 1781 const { id } = currentPermanentElement; 1782 const newPermanentElement = snapshot.getPermanentElementById(id); 1783 if (newPermanentElement) { 1784 permanentElementMap[id] = [currentPermanentElement, newPermanentElement]; 1785 } 1786 } 1787 return permanentElementMap; 1788 } 1789}; 1790function getPermanentElementById(node, id) { 1791 return node.querySelector(`#${id}[data-turbo-permanent]`); 1792} 1793function queryPermanentElementsAll(node) { 1794 return node.querySelectorAll("[id][data-turbo-permanent]"); 1795} 1796var FormSubmitObserver = class { 1797 started = false; 1798 constructor(delegate, eventTarget) { 1799 this.delegate = delegate; 1800 this.eventTarget = eventTarget; 1801 } 1802 start() { 1803 if (!this.started) { 1804 this.eventTarget.addEventListener("submit", this.submitCaptured, true); 1805 this.started = true; 1806 } 1807 } 1808 stop() { 1809 if (this.started) { 1810 this.eventTarget.removeEventListener("submit", this.submitCaptured, true); 1811 this.started = false; 1812 } 1813 } 1814 submitCaptured = () => { 1815 this.eventTarget.removeEventListener("submit", this.submitBubbled, false); 1816 this.eventTarget.addEventListener("submit", this.submitBubbled, false); 1817 }; 1818 submitBubbled = (event) => { 1819 if (!event.defaultPrevented) { 1820 const form = event.target instanceof HTMLFormElement ? event.target : void 0; 1821 const submitter2 = event.submitter || void 0; 1822 if (form && submissionDoesNotDismissDialog(form, submitter2) && submissionDoesNotTargetIFrame(form, submitter2) && this.delegate.willSubmitForm(form, submitter2)) { 1823 event.preventDefault(); 1824 event.stopImmediatePropagation(); 1825 this.delegate.formSubmitted(form, submitter2); 1826 } 1827 } 1828 }; 1829}; 1830function submissionDoesNotDismissDialog(form, submitter2) { 1831 const method = submitter2?.getAttribute("formmethod") || form.getAttribute("method"); 1832 return method != "dialog"; 1833} 1834function submissionDoesNotTargetIFrame(form, submitter2) { 1835 const target = submitter2?.getAttribute("formtarget") || form.getAttribute("target"); 1836 return doesNotTargetIFrame(target); 1837} 1838var View = class { 1839 #resolveRenderPromise = (_value) => { 1840 }; 1841 #resolveInterceptionPromise = (_value) => { 1842 }; 1843 constructor(delegate, element) { 1844 this.delegate = delegate; 1845 this.element = element; 1846 } 1847 // Scrolling 1848 scrollToAnchor(anchor) { 1849 const element = this.snapshot.getElementForAnchor(anchor); 1850 if (element) { 1851 this.scrollToElement(element); 1852 this.focusElement(element); 1853 } else { 1854 this.scrollToPosition({ x: 0, y: 0 }); 1855 } 1856 } 1857 scrollToAnchorFromLocation(location2) { 1858 this.scrollToAnchor(getAnchor(location2)); 1859 } 1860 scrollToElement(element) { 1861 element.scrollIntoView(); 1862 } 1863 focusElement(element) { 1864 if (element instanceof HTMLElement) { 1865 if (element.hasAttribute("tabindex")) { 1866 element.focus(); 1867 } else { 1868 element.setAttribute("tabindex", "-1"); 1869 element.focus(); 1870 element.removeAttribute("tabindex"); 1871 } 1872 } 1873 } 1874 scrollToPosition({ x, y }) { 1875 this.scrollRoot.scrollTo(x, y); 1876 } 1877 scrollToTop() { 1878 this.scrollToPosition({ x: 0, y: 0 }); 1879 } 1880 get scrollRoot() { 1881 return window; 1882 } 1883 // Rendering 1884 async render(renderer) { 1885 const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer; 1886 const shouldInvalidate = willRender; 1887 if (shouldRender) { 1888 try { 1889 this.renderPromise = new Promise((resolve) => this.#resolveRenderPromise = resolve); 1890 this.renderer = renderer; 1891 await this.prepareToRenderSnapshot(renderer); 1892 const renderInterception = new Promise((resolve) => this.#resolveInterceptionPromise = resolve); 1893 const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod }; 1894 const immediateRender = this.delegate.allowsImmediateRender(snapshot, options); 1895 if (!immediateRender) await renderInterception; 1896 await this.renderSnapshot(renderer); 1897 this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod); 1898 this.delegate.preloadOnLoadLinksForView(this.element); 1899 this.finishRenderingSnapshot(renderer); 1900 } finally { 1901 delete this.renderer; 1902 this.#resolveRenderPromise(void 0); 1903 delete this.renderPromise; 1904 } 1905 } else if (shouldInvalidate) { 1906 this.invalidate(renderer.reloadReason); 1907 } 1908 } 1909 invalidate(reason) { 1910 this.delegate.viewInvalidated(reason); 1911 } 1912 async prepareToRenderSnapshot(renderer) { 1913 this.markAsPreview(renderer.isPreview); 1914 await renderer.prepareToRender(); 1915 } 1916 markAsPreview(isPreview) { 1917 if (isPreview) { 1918 this.element.setAttribute("data-turbo-preview", ""); 1919 } else { 1920 this.element.removeAttribute("data-turbo-preview"); 1921 } 1922 } 1923 markVisitDirection(direction) { 1924 this.element.setAttribute("data-turbo-visit-direction", direction); 1925 } 1926 unmarkVisitDirection() { 1927 this.element.removeAttribute("data-turbo-visit-direction"); 1928 } 1929 async renderSnapshot(renderer) { 1930 await renderer.render(); 1931 } 1932 finishRenderingSnapshot(renderer) { 1933 renderer.finishRendering(); 1934 } 1935}; 1936var FrameView = class extends View { 1937 missing() { 1938 this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`; 1939 } 1940 get snapshot() { 1941 return new Snapshot(this.element); 1942 } 1943}; 1944var LinkInterceptor = class { 1945 constructor(delegate, element) { 1946 this.delegate = delegate; 1947 this.element = element; 1948 } 1949 start() { 1950 this.element.addEventListener("click", this.clickBubbled); 1951 document.addEventListener("turbo:click", this.linkClicked); 1952 document.addEventListener("turbo:before-visit", this.willVisit); 1953 } 1954 stop() { 1955 this.element.removeEventListener("click", this.clickBubbled); 1956 document.removeEventListener("turbo:click", this.linkClicked); 1957 document.removeEventListener("turbo:before-visit", this.willVisit); 1958 } 1959 clickBubbled = (event) => { 1960 if (this.clickEventIsSignificant(event)) { 1961 this.clickEvent = event; 1962 } else { 1963 delete this.clickEvent; 1964 } 1965 }; 1966 linkClicked = (event) => { 1967 if (this.clickEvent && this.clickEventIsSignificant(event)) { 1968 if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) { 1969 this.clickEvent.preventDefault(); 1970 event.preventDefault(); 1971 this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent); 1972 } 1973 } 1974 delete this.clickEvent; 1975 }; 1976 willVisit = (_event) => { 1977 delete this.clickEvent; 1978 }; 1979 clickEventIsSignificant(event) { 1980 const target = event.composed ? event.target?.parentElement : event.target; 1981 const element = findLinkFromClickTarget(target) || target; 1982 return element instanceof Element && element.closest("turbo-frame, html") == this.element; 1983 } 1984}; 1985var LinkClickObserver = class { 1986 started = false; 1987 constructor(delegate, eventTarget) { 1988 this.delegate = delegate; 1989 this.eventTarget = eventTarget; 1990 } 1991 start() { 1992 if (!this.started) { 1993 this.eventTarget.addEventListener("click", this.clickCaptured, true); 1994 this.started = true; 1995 } 1996 } 1997 stop() { 1998 if (this.started) { 1999 this.eventTarget.removeEventListener("click", this.clickCaptured, true); 2000 this.started = false; 2001 } 2002 } 2003 clickCaptured = () => { 2004 this.eventTarget.removeEventListener("click", this.clickBubbled, false); 2005 this.eventTarget.addEventListener("click", this.clickBubbled, false); 2006 }; 2007 clickBubbled = (event) => { 2008 if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) { 2009 const target = event.composedPath && event.composedPath()[0] || event.target; 2010 const link = findLinkFromClickTarget(target); 2011 if (link && doesNotTargetIFrame(link.target)) { 2012 const location2 = getLocationForLink(link); 2013 if (this.delegate.willFollowLinkToLocation(link, location2, event)) { 2014 event.preventDefault(); 2015 this.delegate.followedLinkToLocation(link, location2); 2016 } 2017 } 2018 } 2019 }; 2020 clickEventIsSignificant(event) { 2021 return !(event.target && event.target.isContentEditable || event.defaultPrevented || event.which > 1 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey); 2022 } 2023}; 2024var FormLinkClickObserver = class { 2025 constructor(delegate, element) { 2026 this.delegate = delegate; 2027 this.linkInterceptor = new LinkClickObserver(this, element); 2028 } 2029 start() { 2030 this.linkInterceptor.start(); 2031 } 2032 stop() { 2033 this.linkInterceptor.stop(); 2034 } 2035 // Link hover observer delegate 2036 canPrefetchRequestToLocation(link, location2) { 2037 return false; 2038 } 2039 prefetchAndCacheRequestToLocation(link, location2) { 2040 return; 2041 } 2042 // Link click observer delegate 2043 willFollowLinkToLocation(link, location2, originalEvent) { 2044 return this.delegate.willSubmitFormLinkToLocation(link, location2, originalEvent) && (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream")); 2045 } 2046 followedLinkToLocation(link, location2) { 2047 const form = document.createElement("form"); 2048 const type = "hidden"; 2049 for (const [name, value] of location2.searchParams) { 2050 form.append(Object.assign(document.createElement("input"), { type, name, value })); 2051 } 2052 const action = Object.assign(location2, { search: "" }); 2053 form.setAttribute("data-turbo", "true"); 2054 form.setAttribute("action", action.href); 2055 form.setAttribute("hidden", ""); 2056 const method = link.getAttribute("data-turbo-method"); 2057 if (method) form.setAttribute("method", method); 2058 const turboFrame = link.getAttribute("data-turbo-frame"); 2059 if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame); 2060 const turboAction = getVisitAction(link); 2061 if (turboAction) form.setAttribute("data-turbo-action", turboAction); 2062 const turboConfirm = link.getAttribute("data-turbo-confirm"); 2063 if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm); 2064 const turboStream = link.hasAttribute("data-turbo-stream"); 2065 if (turboStream) form.setAttribute("data-turbo-stream", ""); 2066 this.delegate.submittedFormLinkToLocation(link, location2, form); 2067 document.body.appendChild(form); 2068 form.addEventListener("turbo:submit-end", () => form.remove(), { once: true }); 2069 requestAnimationFrame(() => form.requestSubmit()); 2070 } 2071}; 2072var Bardo = class { 2073 static async preservingPermanentElements(delegate, permanentElementMap, callback) { 2074 const bardo = new this(delegate, permanentElementMap); 2075 bardo.enter(); 2076 await callback(); 2077 bardo.leave(); 2078 } 2079 constructor(delegate, permanentElementMap) { 2080 this.delegate = delegate; 2081 this.permanentElementMap = permanentElementMap; 2082 } 2083 enter() { 2084 for (const id in this.permanentElementMap) { 2085 const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id]; 2086 this.delegate.enteringBardo(currentPermanentElement, newPermanentElement); 2087 this.replaceNewPermanentElementWithPlaceholder(newPermanentElement); 2088 } 2089 } 2090 leave() { 2091 for (const id in this.permanentElementMap) { 2092 const [currentPermanentElement] = this.permanentElementMap[id]; 2093 this.replaceCurrentPermanentElementWithClone(currentPermanentElement); 2094 this.replacePlaceholderWithPermanentElement(currentPermanentElement); 2095 this.delegate.leavingBardo(currentPermanentElement); 2096 } 2097 } 2098 replaceNewPermanentElementWithPlaceholder(permanentElement) { 2099 const placeholder = createPlaceholderForPermanentElement(permanentElement); 2100 permanentElement.replaceWith(placeholder); 2101 } 2102 replaceCurrentPermanentElementWithClone(permanentElement) { 2103 const clone = permanentElement.cloneNode(true); 2104 permanentElement.replaceWith(clone); 2105 } 2106 replacePlaceholderWithPermanentElement(permanentElement) { 2107 const placeholder = this.getPlaceholderById(permanentElement.id); 2108 placeholder?.replaceWith(permanentElement); 2109 } 2110 getPlaceholderById(id) { 2111 return this.placeholders.find((element) => element.content == id); 2112 } 2113 get placeholders() { 2114 return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]; 2115 } 2116}; 2117function createPlaceholderForPermanentElement(permanentElement) { 2118 const element = document.createElement("meta"); 2119 element.setAttribute("name", "turbo-permanent-placeholder"); 2120 element.setAttribute("content", permanentElement.id); 2121 return element; 2122} 2123var Renderer = class { 2124 #activeElement = null; 2125 static renderElement(currentElement, newElement) { 2126 } 2127 constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) { 2128 this.currentSnapshot = currentSnapshot; 2129 this.newSnapshot = newSnapshot; 2130 this.isPreview = isPreview; 2131 this.willRender = willRender; 2132 this.renderElement = this.constructor.renderElement; 2133 this.promise = new Promise((resolve, reject) => this.resolvingFunctions = { resolve, reject }); 2134 } 2135 get shouldRender() { 2136 return true; 2137 } 2138 get shouldAutofocus() { 2139 return true; 2140 } 2141 get reloadReason() { 2142 return; 2143 } 2144 prepareToRender() { 2145 return; 2146 } 2147 render() { 2148 } 2149 finishRendering() { 2150 if (this.resolvingFunctions) { 2151 this.resolvingFunctions.resolve(); 2152 delete this.resolvingFunctions; 2153 } 2154 } 2155 async preservingPermanentElements(callback) { 2156 await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback); 2157 } 2158 focusFirstAutofocusableElement() { 2159 if (this.shouldAutofocus) { 2160 const element = this.connectedSnapshot.firstAutofocusableElement; 2161 if (element) { 2162 element.focus(); 2163 } 2164 } 2165 } 2166 // Bardo delegate 2167 enteringBardo(currentPermanentElement) { 2168 if (this.#activeElement) return; 2169 if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) { 2170 this.#activeElement = this.currentSnapshot.activeElement; 2171 } 2172 } 2173 leavingBardo(currentPermanentElement) { 2174 if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) { 2175 this.#activeElement.focus(); 2176 this.#activeElement = null; 2177 } 2178 } 2179 get connectedSnapshot() { 2180 return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot; 2181 } 2182 get currentElement() { 2183 return this.currentSnapshot.element; 2184 } 2185 get newElement() { 2186 return this.newSnapshot.element; 2187 } 2188 get permanentElementMap() { 2189 return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot); 2190 } 2191 get renderMethod() { 2192 return "replace"; 2193 } 2194}; 2195var FrameRenderer = class extends Renderer { 2196 static renderElement(currentElement, newElement) { 2197 const destinationRange = document.createRange(); 2198 destinationRange.selectNodeContents(currentElement); 2199 destinationRange.deleteContents(); 2200 const frameElement = newElement; 2201 const sourceRange = frameElement.ownerDocument?.createRange(); 2202 if (sourceRange) { 2203 sourceRange.selectNodeContents(frameElement); 2204 currentElement.appendChild(sourceRange.extractContents()); 2205 } 2206 } 2207 constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) { 2208 super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender); 2209 this.delegate = delegate; 2210 } 2211 get shouldRender() { 2212 return true; 2213 } 2214 async render() { 2215 await nextRepaint(); 2216 this.preservingPermanentElements(() => { 2217 this.loadFrameElement(); 2218 }); 2219 this.scrollFrameIntoView(); 2220 await nextRepaint(); 2221 this.focusFirstAutofocusableElement(); 2222 await nextRepaint(); 2223 this.activateScriptElements(); 2224 } 2225 loadFrameElement() { 2226 this.delegate.willRenderFrame(this.currentElement, this.newElement); 2227 this.renderElement(this.currentElement, this.newElement); 2228 } 2229 scrollFrameIntoView() { 2230 if (this.currentElement.autoscroll || this.newElement.autoscroll) { 2231 const element = this.currentElement.firstElementChild; 2232 const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end"); 2233 const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto"); 2234 if (element) { 2235 element.scrollIntoView({ block, behavior }); 2236 return true; 2237 } 2238 } 2239 return false; 2240 } 2241 activateScriptElements() { 2242 for (const inertScriptElement of this.newScriptElements) { 2243 const activatedScriptElement = activateScriptElement(inertScriptElement); 2244 inertScriptElement.replaceWith(activatedScriptElement); 2245 } 2246 } 2247 get newScriptElements() { 2248 return this.currentElement.querySelectorAll("script"); 2249 } 2250}; 2251function readScrollLogicalPosition(value, defaultValue) { 2252 if (value == "end" || value == "start" || value == "center" || value == "nearest") { 2253 return value; 2254 } else { 2255 return defaultValue; 2256 } 2257} 2258function readScrollBehavior(value, defaultValue) { 2259 if (value == "auto" || value == "smooth") { 2260 return value; 2261 } else { 2262 return defaultValue; 2263 } 2264} 2265var Idiomorph = (function() { 2266 const noOp = () => { 2267 }; 2268 const defaults = { 2269 morphStyle: "outerHTML", 2270 callbacks: { 2271 beforeNodeAdded: noOp, 2272 afterNodeAdded: noOp, 2273 beforeNodeMorphed: noOp, 2274 afterNodeMorphed: noOp, 2275 beforeNodeRemoved: noOp, 2276 afterNodeRemoved: noOp, 2277 beforeAttributeUpdated: noOp 2278 }, 2279 head: { 2280 style: "merge", 2281 shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", 2282 shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", 2283 shouldRemove: noOp, 2284 afterHeadMorphed: noOp 2285 }, 2286 restoreFocus: true 2287 }; 2288 function morph(oldNode, newContent, config2 = {}) { 2289 oldNode = normalizeElement(oldNode); 2290 const newNode = normalizeParent(newContent); 2291 const ctx = createMorphContext(oldNode, newNode, config2); 2292 const morphedNodes = saveAndRestoreFocus(ctx, () => { 2293 return withHeadBlocking( 2294 ctx, 2295 oldNode, 2296 newNode, 2297 /** @param {MorphContext} ctx */ 2298 (ctx2) => { 2299 if (ctx2.morphStyle === "innerHTML") { 2300 morphChildren2(ctx2, oldNode, newNode); 2301 return Array.from(oldNode.childNodes); 2302 } else { 2303 return morphOuterHTML(ctx2, oldNode, newNode); 2304 } 2305 } 2306 ); 2307 }); 2308 ctx.pantry.remove(); 2309 return morphedNodes; 2310 } 2311 function morphOuterHTML(ctx, oldNode, newNode) { 2312 const oldParent = normalizeParent(oldNode); 2313 let childNodes = Array.from(oldParent.childNodes); 2314 const index = childNodes.indexOf(oldNode); 2315 const rightMargin = childNodes.length - (index + 1); 2316 morphChildren2( 2317 ctx, 2318 oldParent, 2319 newNode, 2320 // these two optional params are the secret sauce 2321 oldNode, 2322 // start point for iteration 2323 oldNode.nextSibling 2324 // end point for iteration 2325 ); 2326 childNodes = Array.from(oldParent.childNodes); 2327 return childNodes.slice(index, childNodes.length - rightMargin); 2328 } 2329 function saveAndRestoreFocus(ctx, fn) { 2330 if (!ctx.config.restoreFocus) return fn(); 2331 let activeElement = ( 2332 /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ 2333 document.activeElement 2334 ); 2335 if (!(activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement)) { 2336 return fn(); 2337 } 2338 const { id: activeElementId, selectionStart, selectionEnd } = activeElement; 2339 const results = fn(); 2340 if (activeElementId && activeElementId !== document.activeElement?.id) { 2341 activeElement = ctx.target.querySelector(`#${activeElementId}`); 2342 activeElement?.focus(); 2343 } 2344 if (activeElement && !activeElement.selectionEnd && selectionEnd) { 2345 activeElement.setSelectionRange(selectionStart, selectionEnd); 2346 } 2347 return results; 2348 } 2349 const morphChildren2 = /* @__PURE__ */ (function() { 2350 function morphChildren3(ctx, oldParent, newParent, insertionPoint = null, endPoint = null) { 2351 if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) { 2352 oldParent = oldParent.content; 2353 newParent = newParent.content; 2354 } 2355 insertionPoint ||= oldParent.firstChild; 2356 for (const newChild of newParent.childNodes) { 2357 if (insertionPoint && insertionPoint != endPoint) { 2358 const bestMatch = findBestMatch( 2359 ctx, 2360 newChild, 2361 insertionPoint, 2362 endPoint 2363 ); 2364 if (bestMatch) { 2365 if (bestMatch !== insertionPoint) { 2366 removeNodesBetween(ctx, insertionPoint, bestMatch); 2367 } 2368 morphNode(bestMatch, newChild, ctx); 2369 insertionPoint = bestMatch.nextSibling; 2370 continue; 2371 } 2372 } 2373 if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) { 2374 const movedChild = moveBeforeById( 2375 oldParent, 2376 newChild.id, 2377 insertionPoint, 2378 ctx 2379 ); 2380 morphNode(movedChild, newChild, ctx); 2381 insertionPoint = movedChild.nextSibling; 2382 continue; 2383 } 2384 const insertedNode = createNode( 2385 oldParent, 2386 newChild, 2387 insertionPoint, 2388 ctx 2389 ); 2390 if (insertedNode) { 2391 insertionPoint = insertedNode.nextSibling; 2392 } 2393 } 2394 while (insertionPoint && insertionPoint != endPoint) { 2395 const tempNode = insertionPoint; 2396 insertionPoint = insertionPoint.nextSibling; 2397 removeNode(ctx, tempNode); 2398 } 2399 } 2400 function createNode(oldParent, newChild, insertionPoint, ctx) { 2401 if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; 2402 if (ctx.idMap.has(newChild)) { 2403 const newEmptyChild = document.createElement( 2404 /** @type {Element} */ 2405 newChild.tagName 2406 ); 2407 oldParent.insertBefore(newEmptyChild, insertionPoint); 2408 morphNode(newEmptyChild, newChild, ctx); 2409 ctx.callbacks.afterNodeAdded(newEmptyChild); 2410 return newEmptyChild; 2411 } else { 2412 const newClonedChild = document.importNode(newChild, true); 2413 oldParent.insertBefore(newClonedChild, insertionPoint); 2414 ctx.callbacks.afterNodeAdded(newClonedChild); 2415 return newClonedChild; 2416 } 2417 } 2418 const findBestMatch = /* @__PURE__ */ (function() { 2419 function findBestMatch2(ctx, node, startPoint, endPoint) { 2420 let softMatch = null; 2421 let nextSibling = node.nextSibling; 2422 let siblingSoftMatchCount = 0; 2423 let cursor = startPoint; 2424 while (cursor && cursor != endPoint) { 2425 if (isSoftMatch(cursor, node)) { 2426 if (isIdSetMatch(ctx, cursor, node)) { 2427 return cursor; 2428 } 2429 if (softMatch === null) { 2430 if (!ctx.idMap.has(cursor)) { 2431 softMatch = cursor; 2432 } 2433 } 2434 } 2435 if (softMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) { 2436 siblingSoftMatchCount++; 2437 nextSibling = nextSibling.nextSibling; 2438 if (siblingSoftMatchCount >= 2) { 2439 softMatch = void 0; 2440 } 2441 } 2442 if (cursor.contains(document.activeElement)) break; 2443 cursor = cursor.nextSibling; 2444 } 2445 return softMatch || null; 2446 } 2447 function isIdSetMatch(ctx, oldNode, newNode) { 2448 let oldSet = ctx.idMap.get(oldNode); 2449 let newSet = ctx.idMap.get(newNode); 2450 if (!newSet || !oldSet) return false; 2451 for (const id of oldSet) { 2452 if (newSet.has(id)) { 2453 return true; 2454 } 2455 } 2456 return false; 2457 } 2458 function isSoftMatch(oldNode, newNode) { 2459 const oldElt = ( 2460 /** @type {Element} */ 2461 oldNode 2462 ); 2463 const newElt = ( 2464 /** @type {Element} */ 2465 newNode 2466 ); 2467 return oldElt.nodeType === newElt.nodeType && oldElt.tagName === newElt.tagName && // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. 2468 // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, 2469 // its not persistent, and new nodes can't have any hidden state. 2470 (!oldElt.id || oldElt.id === newElt.id); 2471 } 2472 return findBestMatch2; 2473 })(); 2474 function removeNode(ctx, node) { 2475 if (ctx.idMap.has(node)) { 2476 moveBefore(ctx.pantry, node, null); 2477 } else { 2478 if (ctx.callbacks.beforeNodeRemoved(node) === false) return; 2479 node.parentNode?.removeChild(node); 2480 ctx.callbacks.afterNodeRemoved(node); 2481 } 2482 } 2483 function removeNodesBetween(ctx, startInclusive, endExclusive) { 2484 let cursor = startInclusive; 2485 while (cursor && cursor !== endExclusive) { 2486 let tempNode = ( 2487 /** @type {Node} */ 2488 cursor 2489 ); 2490 cursor = cursor.nextSibling; 2491 removeNode(ctx, tempNode); 2492 } 2493 return cursor; 2494 } 2495 function moveBeforeById(parentNode, id, after, ctx) { 2496 const target = ( 2497 /** @type {Element} - will always be found */ 2498 ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`) 2499 ); 2500 removeElementFromAncestorsIdMaps(target, ctx); 2501 moveBefore(parentNode, target, after); 2502 return target; 2503 } 2504 function removeElementFromAncestorsIdMaps(element, ctx) { 2505 const id = element.id; 2506 while (element = element.parentNode) { 2507 let idSet = ctx.idMap.get(element); 2508 if (idSet) { 2509 idSet.delete(id); 2510 if (!idSet.size) { 2511 ctx.idMap.delete(element); 2512 } 2513 } 2514 } 2515 } 2516 function moveBefore(parentNode, element, after) { 2517 if (parentNode.moveBefore) { 2518 try { 2519 parentNode.moveBefore(element, after); 2520 } catch (e) { 2521 parentNode.insertBefore(element, after); 2522 } 2523 } else { 2524 parentNode.insertBefore(element, after); 2525 } 2526 } 2527 return morphChildren3; 2528 })(); 2529 const morphNode = /* @__PURE__ */ (function() { 2530 function morphNode2(oldNode, newContent, ctx) { 2531 if (ctx.ignoreActive && oldNode === document.activeElement) { 2532 return null; 2533 } 2534 if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { 2535 return oldNode; 2536 } 2537 if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; 2538 else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") { 2539 handleHeadElement( 2540 oldNode, 2541 /** @type {HTMLHeadElement} */ 2542 newContent, 2543 ctx 2544 ); 2545 } else { 2546 morphAttributes(oldNode, newContent, ctx); 2547 if (!ignoreValueOfActiveElement(oldNode, ctx)) { 2548 morphChildren2(ctx, oldNode, newContent); 2549 } 2550 } 2551 ctx.callbacks.afterNodeMorphed(oldNode, newContent); 2552 return oldNode; 2553 } 2554 function morphAttributes(oldNode, newNode, ctx) { 2555 let type = newNode.nodeType; 2556 if (type === 1) { 2557 const oldElt = ( 2558 /** @type {Element} */ 2559 oldNode 2560 ); 2561 const newElt = ( 2562 /** @type {Element} */ 2563 newNode 2564 ); 2565 const oldAttributes = oldElt.attributes; 2566 const newAttributes = newElt.attributes; 2567 for (const newAttribute of newAttributes) { 2568 if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { 2569 continue; 2570 } 2571 if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { 2572 oldElt.setAttribute(newAttribute.name, newAttribute.value); 2573 } 2574 } 2575 for (let i = oldAttributes.length - 1; 0 <= i; i--) { 2576 const oldAttribute = oldAttributes[i]; 2577 if (!oldAttribute) continue; 2578 if (!newElt.hasAttribute(oldAttribute.name)) { 2579 if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { 2580 continue; 2581 } 2582 oldElt.removeAttribute(oldAttribute.name); 2583 } 2584 } 2585 if (!ignoreValueOfActiveElement(oldElt, ctx)) { 2586 syncInputValue(oldElt, newElt, ctx); 2587 } 2588 } 2589 if (type === 8 || type === 3) { 2590 if (oldNode.nodeValue !== newNode.nodeValue) { 2591 oldNode.nodeValue = newNode.nodeValue; 2592 } 2593 } 2594 } 2595 function syncInputValue(oldElement, newElement, ctx) { 2596 if (oldElement instanceof HTMLInputElement && newElement instanceof HTMLInputElement && newElement.type !== "file") { 2597 let newValue = newElement.value; 2598 let oldValue = oldElement.value; 2599 syncBooleanAttribute(oldElement, newElement, "checked", ctx); 2600 syncBooleanAttribute(oldElement, newElement, "disabled", ctx); 2601 if (!newElement.hasAttribute("value")) { 2602 if (!ignoreAttribute("value", oldElement, "remove", ctx)) { 2603 oldElement.value = ""; 2604 oldElement.removeAttribute("value"); 2605 } 2606 } else if (oldValue !== newValue) { 2607 if (!ignoreAttribute("value", oldElement, "update", ctx)) { 2608 oldElement.setAttribute("value", newValue); 2609 oldElement.value = newValue; 2610 } 2611 } 2612 } else if (oldElement instanceof HTMLOptionElement && newElement instanceof HTMLOptionElement) { 2613 syncBooleanAttribute(oldElement, newElement, "selected", ctx); 2614 } else if (oldElement instanceof HTMLTextAreaElement && newElement instanceof HTMLTextAreaElement) { 2615 let newValue = newElement.value; 2616 let oldValue = oldElement.value; 2617 if (ignoreAttribute("value", oldElement, "update", ctx)) { 2618 return; 2619 } 2620 if (newValue !== oldValue) { 2621 oldElement.value = newValue; 2622 } 2623 if (oldElement.firstChild && oldElement.firstChild.nodeValue !== newValue) { 2624 oldElement.firstChild.nodeValue = newValue; 2625 } 2626 } 2627 } 2628 function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { 2629 const newLiveValue = newElement[attributeName], oldLiveValue = oldElement[attributeName]; 2630 if (newLiveValue !== oldLiveValue) { 2631 const ignoreUpdate = ignoreAttribute( 2632 attributeName, 2633 oldElement, 2634 "update", 2635 ctx 2636 ); 2637 if (!ignoreUpdate) { 2638 oldElement[attributeName] = newElement[attributeName]; 2639 } 2640 if (newLiveValue) { 2641 if (!ignoreUpdate) { 2642 oldElement.setAttribute(attributeName, ""); 2643 } 2644 } else { 2645 if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { 2646 oldElement.removeAttribute(attributeName); 2647 } 2648 } 2649 } 2650 } 2651 function ignoreAttribute(attr, element, updateType, ctx) { 2652 if (attr === "value" && ctx.ignoreActiveValue && element === document.activeElement) { 2653 return true; 2654 } 2655 return ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === false; 2656 } 2657 function ignoreValueOfActiveElement(possibleActiveElement, ctx) { 2658 return !!ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body; 2659 } 2660 return morphNode2; 2661 })(); 2662 function withHeadBlocking(ctx, oldNode, newNode, callback) { 2663 if (ctx.head.block) { 2664 const oldHead = oldNode.querySelector("head"); 2665 const newHead = newNode.querySelector("head"); 2666 if (oldHead && newHead) { 2667 const promises = handleHeadElement(oldHead, newHead, ctx); 2668 return Promise.all(promises).then(() => { 2669 const newCtx = Object.assign(ctx, { 2670 head: { 2671 block: false, 2672 ignore: true 2673 } 2674 }); 2675 return callback(newCtx); 2676 }); 2677 } 2678 } 2679 return callback(ctx); 2680 } 2681 function handleHeadElement(oldHead, newHead, ctx) { 2682 let added = []; 2683 let removed = []; 2684 let preserved = []; 2685 let nodesToAppend = []; 2686 let srcToNewHeadNodes = /* @__PURE__ */ new Map(); 2687 for (const newHeadChild of newHead.children) { 2688 srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); 2689 } 2690 for (const currentHeadElt of oldHead.children) { 2691 let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); 2692 let isReAppended = ctx.head.shouldReAppend(currentHeadElt); 2693 let isPreserved = ctx.head.shouldPreserve(currentHeadElt); 2694 if (inNewContent || isPreserved) { 2695 if (isReAppended) { 2696 removed.push(currentHeadElt); 2697 } else { 2698 srcToNewHeadNodes.delete(currentHeadElt.outerHTML); 2699 preserved.push(currentHeadElt); 2700 } 2701 } else { 2702 if (ctx.head.style === "append") { 2703 if (isReAppended) { 2704 removed.push(currentHeadElt); 2705 nodesToAppend.push(currentHeadElt); 2706 } 2707 } else { 2708 if (ctx.head.shouldRemove(currentHeadElt) !== false) { 2709 removed.push(currentHeadElt); 2710 } 2711 } 2712 } 2713 } 2714 nodesToAppend.push(...srcToNewHeadNodes.values()); 2715 let promises = []; 2716 for (const newNode of nodesToAppend) { 2717 let newElt = ( 2718 /** @type {ChildNode} */ 2719 document.createRange().createContextualFragment(newNode.outerHTML).firstChild 2720 ); 2721 if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { 2722 if ("href" in newElt && newElt.href || "src" in newElt && newElt.src) { 2723 let resolve; 2724 let promise = new Promise(function(_resolve) { 2725 resolve = _resolve; 2726 }); 2727 newElt.addEventListener("load", function() { 2728 resolve(); 2729 }); 2730 promises.push(promise); 2731 } 2732 oldHead.appendChild(newElt); 2733 ctx.callbacks.afterNodeAdded(newElt); 2734 added.push(newElt); 2735 } 2736 } 2737 for (const removedElement of removed) { 2738 if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { 2739 oldHead.removeChild(removedElement); 2740 ctx.callbacks.afterNodeRemoved(removedElement); 2741 } 2742 } 2743 ctx.head.afterHeadMorphed(oldHead, { 2744 added, 2745 kept: preserved, 2746 removed 2747 }); 2748 return promises; 2749 } 2750 const createMorphContext = /* @__PURE__ */ (function() { 2751 function createMorphContext2(oldNode, newContent, config2) { 2752 const { persistentIds, idMap } = createIdMaps(oldNode, newContent); 2753 const mergedConfig = mergeDefaults(config2); 2754 const morphStyle = mergedConfig.morphStyle || "outerHTML"; 2755 if (!["innerHTML", "outerHTML"].includes(morphStyle)) { 2756 throw `Do not understand how to morph style ${morphStyle}`; 2757 } 2758 return { 2759 target: oldNode, 2760 newContent, 2761 config: mergedConfig, 2762 morphStyle, 2763 ignoreActive: mergedConfig.ignoreActive, 2764 ignoreActiveValue: mergedConfig.ignoreActiveValue, 2765 restoreFocus: mergedConfig.restoreFocus, 2766 idMap, 2767 persistentIds, 2768 pantry: createPantry(), 2769 callbacks: mergedConfig.callbacks, 2770 head: mergedConfig.head 2771 }; 2772 } 2773 function mergeDefaults(config2) { 2774 let finalConfig = Object.assign({}, defaults); 2775 Object.assign(finalConfig, config2); 2776 finalConfig.callbacks = Object.assign( 2777 {}, 2778 defaults.callbacks, 2779 config2.callbacks 2780 ); 2781 finalConfig.head = Object.assign({}, defaults.head, config2.head); 2782 return finalConfig; 2783 } 2784 function createPantry() { 2785 const pantry = document.createElement("div"); 2786 pantry.hidden = true; 2787 document.body.insertAdjacentElement("afterend", pantry); 2788 return pantry; 2789 } 2790 function findIdElements(root) { 2791 let elements = Array.from(root.querySelectorAll("[id]")); 2792 if (root.id) { 2793 elements.push(root); 2794 } 2795 return elements; 2796 } 2797 function populateIdMapWithTree(idMap, persistentIds, root, elements) { 2798 for (const elt of elements) { 2799 if (persistentIds.has(elt.id)) { 2800 let current = elt; 2801 while (current) { 2802 let idSet = idMap.get(current); 2803 if (idSet == null) { 2804 idSet = /* @__PURE__ */ new Set(); 2805 idMap.set(current, idSet); 2806 } 2807 idSet.add(elt.id); 2808 if (current === root) break; 2809 current = current.parentElement; 2810 } 2811 } 2812 } 2813 } 2814 function createIdMaps(oldContent, newContent) { 2815 const oldIdElements = findIdElements(oldContent); 2816 const newIdElements = findIdElements(newContent); 2817 const persistentIds = createPersistentIds(oldIdElements, newIdElements); 2818 let idMap = /* @__PURE__ */ new Map(); 2819 populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); 2820 const newRoot = newContent.__idiomorphRoot || newContent; 2821 populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); 2822 return { persistentIds, idMap }; 2823 } 2824 function createPersistentIds(oldIdElements, newIdElements) { 2825 let duplicateIds = /* @__PURE__ */ new Set(); 2826 let oldIdTagNameMap = /* @__PURE__ */ new Map(); 2827 for (const { id, tagName } of oldIdElements) { 2828 if (oldIdTagNameMap.has(id)) { 2829 duplicateIds.add(id); 2830 } else { 2831 oldIdTagNameMap.set(id, tagName); 2832 } 2833 } 2834 let persistentIds = /* @__PURE__ */ new Set(); 2835 for (const { id, tagName } of newIdElements) { 2836 if (persistentIds.has(id)) { 2837 duplicateIds.add(id); 2838 } else if (oldIdTagNameMap.get(id) === tagName) { 2839 persistentIds.add(id); 2840 } 2841 } 2842 for (const id of duplicateIds) { 2843 persistentIds.delete(id); 2844 } 2845 return persistentIds; 2846 } 2847 return createMorphContext2; 2848 })(); 2849 const { normalizeElement, normalizeParent } = /* @__PURE__ */ (function() { 2850 const generatedByIdiomorph = /* @__PURE__ */ new WeakSet(); 2851 function normalizeElement2(content) { 2852 if (content instanceof Document) { 2853 return content.documentElement; 2854 } else { 2855 return content; 2856 } 2857 } 2858 function normalizeParent2(newContent) { 2859 if (newContent == null) { 2860 return document.createElement("div"); 2861 } else if (typeof newContent === "string") { 2862 return normalizeParent2(parseContent(newContent)); 2863 } else if (generatedByIdiomorph.has( 2864 /** @type {Element} */ 2865 newContent 2866 )) { 2867 return ( 2868 /** @type {Element} */ 2869 newContent 2870 ); 2871 } else if (newContent instanceof Node) { 2872 if (newContent.parentNode) { 2873 return createDuckTypedParent(newContent); 2874 } else { 2875 const dummyParent = document.createElement("div"); 2876 dummyParent.append(newContent); 2877 return dummyParent; 2878 } 2879 } else { 2880 const dummyParent = document.createElement("div"); 2881 for (const elt of [...newContent]) { 2882 dummyParent.append(elt); 2883 } 2884 return dummyParent; 2885 } 2886 } 2887 function createDuckTypedParent(newContent) { 2888 return ( 2889 /** @type {Element} */ 2890 /** @type {unknown} */ 2891 { 2892 childNodes: [newContent], 2893 /** @ts-ignore - cover your eyes for a minute, tsc */ 2894 querySelectorAll: (s) => { 2895 const elements = newContent.querySelectorAll(s); 2896 return newContent.matches(s) ? [newContent, ...elements] : elements; 2897 }, 2898 /** @ts-ignore */ 2899 insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r), 2900 /** @ts-ignore */ 2901 moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r), 2902 // for later use with populateIdMapWithTree to halt upwards iteration 2903 get __idiomorphRoot() { 2904 return newContent; 2905 } 2906 } 2907 ); 2908 } 2909 function parseContent(newContent) { 2910 let parser = new DOMParser(); 2911 let contentWithSvgsRemoved = newContent.replace( 2912 /<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, 2913 "" 2914 ); 2915 if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) { 2916 let content = parser.parseFromString(newContent, "text/html"); 2917 if (contentWithSvgsRemoved.match(/<\/html>/)) { 2918 generatedByIdiomorph.add(content); 2919 return content; 2920 } else { 2921 let htmlElement = content.firstChild; 2922 if (htmlElement) { 2923 generatedByIdiomorph.add(htmlElement); 2924 } 2925 return htmlElement; 2926 } 2927 } else { 2928 let responseDoc = parser.parseFromString( 2929 "<body><template>" + newContent + "</template></body>", 2930 "text/html" 2931 ); 2932 let content = ( 2933 /** @type {HTMLTemplateElement} */ 2934 responseDoc.body.querySelector("template").content 2935 ); 2936 generatedByIdiomorph.add(content); 2937 return content; 2938 } 2939 } 2940 return { normalizeElement: normalizeElement2, normalizeParent: normalizeParent2 }; 2941 })(); 2942 return { 2943 morph, 2944 defaults 2945 }; 2946})(); 2947function morphElements(currentElement, newElement, { callbacks, ...options } = {}) { 2948 Idiomorph.morph(currentElement, newElement, { 2949 ...options, 2950 callbacks: new DefaultIdiomorphCallbacks(callbacks) 2951 }); 2952} 2953function morphChildren(currentElement, newElement) { 2954 morphElements(currentElement, newElement.childNodes, { 2955 morphStyle: "innerHTML" 2956 }); 2957} 2958var DefaultIdiomorphCallbacks = class { 2959 #beforeNodeMorphed; 2960 constructor({ beforeNodeMorphed } = {}) { 2961 this.#beforeNodeMorphed = beforeNodeMorphed || (() => true); 2962 } 2963 beforeNodeAdded = (node) => { 2964 return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id)); 2965 }; 2966 beforeNodeMorphed = (currentElement, newElement) => { 2967 if (currentElement instanceof Element) { 2968 if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) { 2969 const event = dispatch("turbo:before-morph-element", { 2970 cancelable: true, 2971 target: currentElement, 2972 detail: { currentElement, newElement } 2973 }); 2974 return !event.defaultPrevented; 2975 } else { 2976 return false; 2977 } 2978 } 2979 }; 2980 beforeAttributeUpdated = (attributeName, target, mutationType) => { 2981 const event = dispatch("turbo:before-morph-attribute", { 2982 cancelable: true, 2983 target, 2984 detail: { attributeName, mutationType } 2985 }); 2986 return !event.defaultPrevented; 2987 }; 2988 beforeNodeRemoved = (node) => { 2989 return this.beforeNodeMorphed(node); 2990 }; 2991 afterNodeMorphed = (currentElement, newElement) => { 2992 if (currentElement instanceof Element) { 2993 dispatch("turbo:morph-element", { 2994 target: currentElement, 2995 detail: { currentElement, newElement } 2996 }); 2997 } 2998 }; 2999}; 3000var MorphingFrameRenderer = class extends FrameRenderer { 3001 static renderElement(currentElement, newElement) { 3002 dispatch("turbo:before-frame-morph", { 3003 target: currentElement, 3004 detail: { currentElement, newElement } 3005 }); 3006 morphChildren(currentElement, newElement); 3007 } 3008 async preservingPermanentElements(callback) { 3009 return await callback(); 3010 } 3011}; 3012var ProgressBar = class _ProgressBar { 3013 static animationDuration = 300; 3014 /*ms*/ 3015 static get defaultCSS() { 3016 return unindent` 3017 .turbo-progress-bar { 3018 position: fixed; 3019 display: block; 3020 top: 0; 3021 left: 0; 3022 height: 3px; 3023 background: #0076ff; 3024 z-index: 2147483647; 3025 transition: 3026 width ${_ProgressBar.animationDuration}ms ease-out, 3027 opacity ${_ProgressBar.animationDuration / 2}ms ${_ProgressBar.animationDuration / 2}ms ease-in; 3028 transform: translate3d(0, 0, 0); 3029 } 3030 `; 3031 } 3032 hiding = false; 3033 value = 0; 3034 visible = false; 3035 constructor() { 3036 this.stylesheetElement = this.createStylesheetElement(); 3037 this.progressElement = this.createProgressElement(); 3038 this.installStylesheetElement(); 3039 this.setValue(0); 3040 } 3041 show() { 3042 if (!this.visible) { 3043 this.visible = true; 3044 this.installProgressElement(); 3045 this.startTrickling(); 3046 } 3047 } 3048 hide() { 3049 if (this.visible && !this.hiding) { 3050 this.hiding = true; 3051 this.fadeProgressElement(() => { 3052 this.uninstallProgressElement(); 3053 this.stopTrickling(); 3054 this.visible = false; 3055 this.hiding = false; 3056 }); 3057 } 3058 } 3059 setValue(value) { 3060 this.value = value; 3061 this.refresh(); 3062 } 3063 // Private 3064 installStylesheetElement() { 3065 document.head.insertBefore(this.stylesheetElement, document.head.firstChild); 3066 } 3067 installProgressElement() { 3068 this.progressElement.style.width = "0"; 3069 this.progressElement.style.opacity = "1"; 3070 document.documentElement.insertBefore(this.progressElement, document.body); 3071 this.refresh(); 3072 } 3073 fadeProgressElement(callback) { 3074 this.progressElement.style.opacity = "0"; 3075 setTimeout(callback, _ProgressBar.animationDuration * 1.5); 3076 } 3077 uninstallProgressElement() { 3078 if (this.progressElement.parentNode) { 3079 document.documentElement.removeChild(this.progressElement); 3080 } 3081 } 3082 startTrickling() { 3083 if (!this.trickleInterval) { 3084 this.trickleInterval = window.setInterval(this.trickle, _ProgressBar.animationDuration); 3085 } 3086 } 3087 stopTrickling() { 3088 window.clearInterval(this.trickleInterval); 3089 delete this.trickleInterval; 3090 } 3091 trickle = () => { 3092 this.setValue(this.value + Math.random() / 100); 3093 }; 3094 refresh() { 3095 requestAnimationFrame(() => { 3096 this.progressElement.style.width = `${10 + this.value * 90}%`; 3097 }); 3098 } 3099 createStylesheetElement() { 3100 const element = document.createElement("style"); 3101 element.type = "text/css"; 3102 element.textContent = _ProgressBar.defaultCSS; 3103 const cspNonce = getCspNonce(); 3104 if (cspNonce) { 3105 element.nonce = cspNonce; 3106 } 3107 return element; 3108 } 3109 createProgressElement() { 3110 const element = document.createElement("div"); 3111 element.className = "turbo-progress-bar"; 3112 return element; 3113 } 3114}; 3115var HeadSnapshot = class extends Snapshot { 3116 detailsByOuterHTML = this.children.filter((element) => !elementIsNoscript(element)).map((element) => elementWithoutNonce(element)).reduce((result, element) => { 3117 const { outerHTML } = element; 3118 const details = outerHTML in result ? result[outerHTML] : { 3119 type: elementType(element), 3120 tracked: elementIsTracked(element), 3121 elements: [] 3122 }; 3123 return { 3124 ...result, 3125 [outerHTML]: { 3126 ...details, 3127 elements: [...details.elements, element] 3128 } 3129 }; 3130 }, {}); 3131 get trackedElementSignature() { 3132 return Object.keys(this.detailsByOuterHTML).filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked).join(""); 3133 } 3134 getScriptElementsNotInSnapshot(snapshot) { 3135 return this.getElementsMatchingTypeNotInSnapshot("script", snapshot); 3136 } 3137 getStylesheetElementsNotInSnapshot(snapshot) { 3138 return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot); 3139 } 3140 getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) { 3141 return Object.keys(this.detailsByOuterHTML).filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML)).map((outerHTML) => this.detailsByOuterHTML[outerHTML]).filter(({ type }) => type == matchedType).map(({ elements: [element] }) => element); 3142 } 3143 get provisionalElements() { 3144 return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { 3145 const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]; 3146 if (type == null && !tracked) { 3147 return [...result, ...elements]; 3148 } else if (elements.length > 1) { 3149 return [...result, ...elements.slice(1)]; 3150 } else { 3151 return result; 3152 } 3153 }, []); 3154 } 3155 getMetaValue(name) { 3156 const element = this.findMetaElementByName(name); 3157 return element ? element.getAttribute("content") : null; 3158 } 3159 findMetaElementByName(name) { 3160 return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { 3161 const { 3162 elements: [element] 3163 } = this.detailsByOuterHTML[outerHTML]; 3164 return elementIsMetaElementWithName(element, name) ? element : result; 3165 }, void 0 | void 0); 3166 } 3167}; 3168function elementType(element) { 3169 if (elementIsScript(element)) { 3170 return "script"; 3171 } else if (elementIsStylesheet(element)) { 3172 return "stylesheet"; 3173 } 3174} 3175function elementIsTracked(element) { 3176 return element.getAttribute("data-turbo-track") == "reload"; 3177} 3178function elementIsScript(element) { 3179 const tagName = element.localName; 3180 return tagName == "script"; 3181} 3182function elementIsNoscript(element) { 3183 const tagName = element.localName; 3184 return tagName == "noscript"; 3185} 3186function elementIsStylesheet(element) { 3187 const tagName = element.localName; 3188 return tagName == "style" || tagName == "link" && element.getAttribute("rel") == "stylesheet"; 3189} 3190function elementIsMetaElementWithName(element, name) { 3191 const tagName = element.localName; 3192 return tagName == "meta" && element.getAttribute("name") == name; 3193} 3194function elementWithoutNonce(element) { 3195 if (element.hasAttribute("nonce")) { 3196 element.setAttribute("nonce", ""); 3197 } 3198 return element; 3199} 3200var PageSnapshot = class _PageSnapshot extends Snapshot { 3201 static fromHTMLString(html = "") { 3202 return this.fromDocument(parseHTMLDocument(html)); 3203 } 3204 static fromElement(element) { 3205 return this.fromDocument(element.ownerDocument); 3206 } 3207 static fromDocument({ documentElement, body, head }) { 3208 return new this(documentElement, body, new HeadSnapshot(head)); 3209 } 3210 constructor(documentElement, body, headSnapshot) { 3211 super(body); 3212 this.documentElement = documentElement; 3213 this.headSnapshot = headSnapshot; 3214 } 3215 clone() { 3216 const clonedElement = this.element.cloneNode(true); 3217 const selectElements = this.element.querySelectorAll("select"); 3218 const clonedSelectElements = clonedElement.querySelectorAll("select"); 3219 for (const [index, source] of selectElements.entries()) { 3220 const clone = clonedSelectElements[index]; 3221 for (const option of clone.selectedOptions) option.selected = false; 3222 for (const option of source.selectedOptions) clone.options[option.index].selected = true; 3223 } 3224 for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) { 3225 clonedPasswordInput.value = ""; 3226 } 3227 return new _PageSnapshot(this.documentElement, clonedElement, this.headSnapshot); 3228 } 3229 get lang() { 3230 return this.documentElement.getAttribute("lang"); 3231 } 3232 get headElement() { 3233 return this.headSnapshot.element; 3234 } 3235 get rootLocation() { 3236 const root = this.getSetting("root") ?? "/"; 3237 return expandURL(root); 3238 } 3239 get cacheControlValue() { 3240 return this.getSetting("cache-control"); 3241 } 3242 get isPreviewable() { 3243 return this.cacheControlValue != "no-preview"; 3244 } 3245 get isCacheable() { 3246 return this.cacheControlValue != "no-cache"; 3247 } 3248 get isVisitable() { 3249 return this.getSetting("visit-control") != "reload"; 3250 } 3251 get prefersViewTransitions() { 3252 return this.headSnapshot.getMetaValue("view-transition") === "same-origin"; 3253 } 3254 get shouldMorphPage() { 3255 return this.getSetting("refresh-method") === "morph"; 3256 } 3257 get shouldPreserveScrollPosition() { 3258 return this.getSetting("refresh-scroll") === "preserve"; 3259 } 3260 // Private 3261 getSetting(name) { 3262 return this.headSnapshot.getMetaValue(`turbo-${name}`); 3263 } 3264}; 3265var ViewTransitioner = class { 3266 #viewTransitionStarted = false; 3267 #lastOperation = Promise.resolve(); 3268 renderChange(useViewTransition, render) { 3269 if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) { 3270 this.#viewTransitionStarted = true; 3271 this.#lastOperation = this.#lastOperation.then(async () => { 3272 await document.startViewTransition(render).finished; 3273 }); 3274 } else { 3275 this.#lastOperation = this.#lastOperation.then(render); 3276 } 3277 return this.#lastOperation; 3278 } 3279 get viewTransitionsAvailable() { 3280 return document.startViewTransition; 3281 } 3282}; 3283var defaultOptions = { 3284 action: "advance", 3285 historyChanged: false, 3286 visitCachedSnapshot: () => { 3287 }, 3288 willRender: true, 3289 updateHistory: true, 3290 shouldCacheSnapshot: true, 3291 acceptsStreamResponse: false 3292}; 3293var TimingMetric = { 3294 visitStart: "visitStart", 3295 requestStart: "requestStart", 3296 requestEnd: "requestEnd", 3297 visitEnd: "visitEnd" 3298}; 3299var VisitState = { 3300 initialized: "initialized", 3301 started: "started", 3302 canceled: "canceled", 3303 failed: "failed", 3304 completed: "completed" 3305}; 3306var SystemStatusCode = { 3307 networkFailure: 0, 3308 timeoutFailure: -1, 3309 contentTypeMismatch: -2 3310}; 3311var Direction = { 3312 advance: "forward", 3313 restore: "back", 3314 replace: "none" 3315}; 3316var Visit = class { 3317 identifier = uuid(); 3318 // Required by turbo-ios 3319 timingMetrics = {}; 3320 followedRedirect = false; 3321 historyChanged = false; 3322 scrolled = false; 3323 shouldCacheSnapshot = true; 3324 acceptsStreamResponse = false; 3325 snapshotCached = false; 3326 state = VisitState.initialized; 3327 viewTransitioner = new ViewTransitioner(); 3328 constructor(delegate, location2, restorationIdentifier, options = {}) { 3329 this.delegate = delegate; 3330 this.location = location2; 3331 this.restorationIdentifier = restorationIdentifier || uuid(); 3332 const { 3333 action, 3334 historyChanged, 3335 referrer, 3336 snapshot, 3337 snapshotHTML, 3338 response, 3339 visitCachedSnapshot, 3340 willRender, 3341 updateHistory, 3342 shouldCacheSnapshot, 3343 acceptsStreamResponse, 3344 direction 3345 } = { 3346 ...defaultOptions, 3347 ...options 3348 }; 3349 this.action = action; 3350 this.historyChanged = historyChanged; 3351 this.referrer = referrer; 3352 this.snapshot = snapshot; 3353 this.snapshotHTML = snapshotHTML; 3354 this.response = response; 3355 this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action); 3356 this.isPageRefresh = this.view.isPageRefresh(this); 3357 this.visitCachedSnapshot = visitCachedSnapshot; 3358 this.willRender = willRender; 3359 this.updateHistory = updateHistory; 3360 this.scrolled = !willRender; 3361 this.shouldCacheSnapshot = shouldCacheSnapshot; 3362 this.acceptsStreamResponse = acceptsStreamResponse; 3363 this.direction = direction || Direction[action]; 3364 } 3365 get adapter() { 3366 return this.delegate.adapter; 3367 } 3368 get view() { 3369 return this.delegate.view; 3370 } 3371 get history() { 3372 return this.delegate.history; 3373 } 3374 get restorationData() { 3375 return this.history.getRestorationDataForIdentifier(this.restorationIdentifier); 3376 } 3377 get silent() { 3378 return this.isSamePage; 3379 } 3380 start() { 3381 if (this.state == VisitState.initialized) { 3382 this.recordTimingMetric(TimingMetric.visitStart); 3383 this.state = VisitState.started; 3384 this.adapter.visitStarted(this); 3385 this.delegate.visitStarted(this); 3386 } 3387 } 3388 cancel() { 3389 if (this.state == VisitState.started) { 3390 if (this.request) { 3391 this.request.cancel(); 3392 } 3393 this.cancelRender(); 3394 this.state = VisitState.canceled; 3395 } 3396 } 3397 complete() { 3398 if (this.state == VisitState.started) { 3399 this.recordTimingMetric(TimingMetric.visitEnd); 3400 this.adapter.visitCompleted(this); 3401 this.state = VisitState.completed; 3402 this.followRedirect(); 3403 if (!this.followedRedirect) { 3404 this.delegate.visitCompleted(this); 3405 } 3406 } 3407 } 3408 fail() { 3409 if (this.state == VisitState.started) { 3410 this.state = VisitState.failed; 3411 this.adapter.visitFailed(this); 3412 this.delegate.visitCompleted(this); 3413 } 3414 } 3415 changeHistory() { 3416 if (!this.historyChanged && this.updateHistory) { 3417 const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action; 3418 const method = getHistoryMethodForAction(actionForHistory); 3419 this.history.update(method, this.location, this.restorationIdentifier); 3420 this.historyChanged = true; 3421 } 3422 } 3423 issueRequest() { 3424 if (this.hasPreloadedResponse()) { 3425 this.simulateRequest(); 3426 } else if (this.shouldIssueRequest() && !this.request) { 3427 this.request = new FetchRequest(this, FetchMethod.get, this.location); 3428 this.request.perform(); 3429 } 3430 } 3431 simulateRequest() { 3432 if (this.response) { 3433 this.startRequest(); 3434 this.recordResponse(); 3435 this.finishRequest(); 3436 } 3437 } 3438 startRequest() { 3439 this.recordTimingMetric(TimingMetric.requestStart); 3440 this.adapter.visitRequestStarted(this); 3441 } 3442 recordResponse(response = this.response) { 3443 this.response = response; 3444 if (response) { 3445 const { statusCode } = response; 3446 if (isSuccessful(statusCode)) { 3447 this.adapter.visitRequestCompleted(this); 3448 } else { 3449 this.adapter.visitRequestFailedWithStatusCode(this, statusCode); 3450 } 3451 } 3452 } 3453 finishRequest() { 3454 this.recordTimingMetric(TimingMetric.requestEnd); 3455 this.adapter.visitRequestFinished(this); 3456 } 3457 loadResponse() { 3458 if (this.response) { 3459 const { statusCode, responseHTML } = this.response; 3460 this.render(async () => { 3461 if (this.shouldCacheSnapshot) this.cacheSnapshot(); 3462 if (this.view.renderPromise) await this.view.renderPromise; 3463 if (isSuccessful(statusCode) && responseHTML != null) { 3464 const snapshot = PageSnapshot.fromHTMLString(responseHTML); 3465 await this.renderPageSnapshot(snapshot, false); 3466 this.adapter.visitRendered(this); 3467 this.complete(); 3468 } else { 3469 await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this); 3470 this.adapter.visitRendered(this); 3471 this.fail(); 3472 } 3473 }); 3474 } 3475 } 3476 getCachedSnapshot() { 3477 const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot(); 3478 if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) { 3479 if (this.action == "restore" || snapshot.isPreviewable) { 3480 return snapshot; 3481 } 3482 } 3483 } 3484 getPreloadedSnapshot() { 3485 if (this.snapshotHTML) { 3486 return PageSnapshot.fromHTMLString(this.snapshotHTML); 3487 } 3488 } 3489 hasCachedSnapshot() { 3490 return this.getCachedSnapshot() != null; 3491 } 3492 loadCachedSnapshot() { 3493 const snapshot = this.getCachedSnapshot(); 3494 if (snapshot) { 3495 const isPreview = this.shouldIssueRequest(); 3496 this.render(async () => { 3497 this.cacheSnapshot(); 3498 if (this.isSamePage || this.isPageRefresh) { 3499 this.adapter.visitRendered(this); 3500 } else { 3501 if (this.view.renderPromise) await this.view.renderPromise; 3502 await this.renderPageSnapshot(snapshot, isPreview); 3503 this.adapter.visitRendered(this); 3504 if (!isPreview) { 3505 this.complete(); 3506 } 3507 } 3508 }); 3509 } 3510 } 3511 followRedirect() { 3512 if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) { 3513 this.adapter.visitProposedToLocation(this.redirectedToLocation, { 3514 action: "replace", 3515 response: this.response, 3516 shouldCacheSnapshot: false, 3517 willRender: false 3518 }); 3519 this.followedRedirect = true; 3520 } 3521 } 3522 goToSamePageAnchor() { 3523 if (this.isSamePage) { 3524 this.render(async () => { 3525 this.cacheSnapshot(); 3526 this.performScroll(); 3527 this.changeHistory(); 3528 this.adapter.visitRendered(this); 3529 }); 3530 } 3531 } 3532 // Fetch request delegate 3533 prepareRequest(request) { 3534 if (this.acceptsStreamResponse) { 3535 request.acceptResponseType(StreamMessage.contentType); 3536 } 3537 } 3538 requestStarted() { 3539 this.startRequest(); 3540 } 3541 requestPreventedHandlingResponse(_request, _response) { 3542 } 3543 async requestSucceededWithResponse(request, response) { 3544 const responseHTML = await response.responseHTML; 3545 const { redirected, statusCode } = response; 3546 if (responseHTML == void 0) { 3547 this.recordResponse({ 3548 statusCode: SystemStatusCode.contentTypeMismatch, 3549 redirected 3550 }); 3551 } else { 3552 this.redirectedToLocation = response.redirected ? response.location : void 0; 3553 this.recordResponse({ statusCode, responseHTML, redirected }); 3554 } 3555 } 3556 async requestFailedWithResponse(request, response) { 3557 const responseHTML = await response.responseHTML; 3558 const { redirected, statusCode } = response; 3559 if (responseHTML == void 0) { 3560 this.recordResponse({ 3561 statusCode: SystemStatusCode.contentTypeMismatch, 3562 redirected 3563 }); 3564 } else { 3565 this.recordResponse({ statusCode, responseHTML, redirected }); 3566 } 3567 } 3568 requestErrored(_request, _error) { 3569 this.recordResponse({ 3570 statusCode: SystemStatusCode.networkFailure, 3571 redirected: false 3572 }); 3573 } 3574 requestFinished() { 3575 this.finishRequest(); 3576 } 3577 // Scrolling 3578 performScroll() { 3579 if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) { 3580 if (this.action == "restore") { 3581 this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop(); 3582 } else { 3583 this.scrollToAnchor() || this.view.scrollToTop(); 3584 } 3585 if (this.isSamePage) { 3586 this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location); 3587 } 3588 this.scrolled = true; 3589 } 3590 } 3591 scrollToRestoredPosition() { 3592 const { scrollPosition } = this.restorationData; 3593 if (scrollPosition) { 3594 this.view.scrollToPosition(scrollPosition); 3595 return true; 3596 } 3597 } 3598 scrollToAnchor() { 3599 const anchor = getAnchor(this.location); 3600 if (anchor != null) { 3601 this.view.scrollToAnchor(anchor); 3602 return true; 3603 } 3604 } 3605 // Instrumentation 3606 recordTimingMetric(metric) { 3607 this.timingMetrics[metric] = (/* @__PURE__ */ new Date()).getTime(); 3608 } 3609 getTimingMetrics() { 3610 return { ...this.timingMetrics }; 3611 } 3612 // Private 3613 hasPreloadedResponse() { 3614 return typeof this.response == "object"; 3615 } 3616 shouldIssueRequest() { 3617 if (this.isSamePage) { 3618 return false; 3619 } else if (this.action == "restore") { 3620 return !this.hasCachedSnapshot(); 3621 } else { 3622 return this.willRender; 3623 } 3624 } 3625 cacheSnapshot() { 3626 if (!this.snapshotCached) { 3627 this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot)); 3628 this.snapshotCached = true; 3629 } 3630 } 3631 async render(callback) { 3632 this.cancelRender(); 3633 await new Promise((resolve) => { 3634 this.frame = document.visibilityState === "hidden" ? setTimeout(() => resolve(), 0) : requestAnimationFrame(() => resolve()); 3635 }); 3636 await callback(); 3637 delete this.frame; 3638 } 3639 async renderPageSnapshot(snapshot, isPreview) { 3640 await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => { 3641 await this.view.renderPage(snapshot, isPreview, this.willRender, this); 3642 this.performScroll(); 3643 }); 3644 } 3645 cancelRender() { 3646 if (this.frame) { 3647 cancelAnimationFrame(this.frame); 3648 delete this.frame; 3649 } 3650 } 3651}; 3652function isSuccessful(statusCode) { 3653 return statusCode >= 200 && statusCode < 300; 3654} 3655var BrowserAdapter = class { 3656 progressBar = new ProgressBar(); 3657 constructor(session2) { 3658 this.session = session2; 3659 } 3660 visitProposedToLocation(location2, options) { 3661 if (locationIsVisitable(location2, this.navigator.rootLocation)) { 3662 this.navigator.startVisit(location2, options?.restorationIdentifier || uuid(), options); 3663 } else { 3664 window.location.href = location2.toString(); 3665 } 3666 } 3667 visitStarted(visit2) { 3668 this.location = visit2.location; 3669 visit2.loadCachedSnapshot(); 3670 visit2.issueRequest(); 3671 visit2.goToSamePageAnchor(); 3672 } 3673 visitRequestStarted(visit2) { 3674 this.progressBar.setValue(0); 3675 if (visit2.hasCachedSnapshot() || visit2.action != "restore") { 3676 this.showVisitProgressBarAfterDelay(); 3677 } else { 3678 this.showProgressBar(); 3679 } 3680 } 3681 visitRequestCompleted(visit2) { 3682 visit2.loadResponse(); 3683 } 3684 visitRequestFailedWithStatusCode(visit2, statusCode) { 3685 switch (statusCode) { 3686 case SystemStatusCode.networkFailure: 3687 case SystemStatusCode.timeoutFailure: 3688 case SystemStatusCode.contentTypeMismatch: 3689 return this.reload({ 3690 reason: "request_failed", 3691 context: { 3692 statusCode 3693 } 3694 }); 3695 default: 3696 return visit2.loadResponse(); 3697 } 3698 } 3699 visitRequestFinished(_visit) { 3700 } 3701 visitCompleted(_visit) { 3702 this.progressBar.setValue(1); 3703 this.hideVisitProgressBar(); 3704 } 3705 pageInvalidated(reason) { 3706 this.reload(reason); 3707 } 3708 visitFailed(_visit) { 3709 this.progressBar.setValue(1); 3710 this.hideVisitProgressBar(); 3711 } 3712 visitRendered(_visit) { 3713 } 3714 // Link prefetching 3715 linkPrefetchingIsEnabledForLocation(location2) { 3716 return true; 3717 } 3718 // Form Submission Delegate 3719 formSubmissionStarted(_formSubmission) { 3720 this.progressBar.setValue(0); 3721 this.showFormProgressBarAfterDelay(); 3722 } 3723 formSubmissionFinished(_formSubmission) { 3724 this.progressBar.setValue(1); 3725 this.hideFormProgressBar(); 3726 } 3727 // Private 3728 showVisitProgressBarAfterDelay() { 3729 this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); 3730 } 3731 hideVisitProgressBar() { 3732 this.progressBar.hide(); 3733 if (this.visitProgressBarTimeout != null) { 3734 window.clearTimeout(this.visitProgressBarTimeout); 3735 delete this.visitProgressBarTimeout; 3736 } 3737 } 3738 showFormProgressBarAfterDelay() { 3739 if (this.formProgressBarTimeout == null) { 3740 this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay); 3741 } 3742 } 3743 hideFormProgressBar() { 3744 this.progressBar.hide(); 3745 if (this.formProgressBarTimeout != null) { 3746 window.clearTimeout(this.formProgressBarTimeout); 3747 delete this.formProgressBarTimeout; 3748 } 3749 } 3750 showProgressBar = () => { 3751 this.progressBar.show(); 3752 }; 3753 reload(reason) { 3754 dispatch("turbo:reload", { detail: reason }); 3755 window.location.href = this.location?.toString() || window.location.href; 3756 } 3757 get navigator() { 3758 return this.session.navigator; 3759 } 3760}; 3761var CacheObserver = class { 3762 selector = "[data-turbo-temporary]"; 3763 deprecatedSelector = "[data-turbo-cache=false]"; 3764 started = false; 3765 start() { 3766 if (!this.started) { 3767 this.started = true; 3768 addEventListener("turbo:before-cache", this.removeTemporaryElements, false); 3769 } 3770 } 3771 stop() { 3772 if (this.started) { 3773 this.started = false; 3774 removeEventListener("turbo:before-cache", this.removeTemporaryElements, false); 3775 } 3776 } 3777 removeTemporaryElements = (_event) => { 3778 for (const element of this.temporaryElements) { 3779 element.remove(); 3780 } 3781 }; 3782 get temporaryElements() { 3783 return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]; 3784 } 3785 get temporaryElementsWithDeprecation() { 3786 const elements = document.querySelectorAll(this.deprecatedSelector); 3787 if (elements.length) { 3788 console.warn( 3789 `The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.` 3790 ); 3791 } 3792 return [...elements]; 3793 } 3794}; 3795var FrameRedirector = class { 3796 constructor(session2, element) { 3797 this.session = session2; 3798 this.element = element; 3799 this.linkInterceptor = new LinkInterceptor(this, element); 3800 this.formSubmitObserver = new FormSubmitObserver(this, element); 3801 } 3802 start() { 3803 this.linkInterceptor.start(); 3804 this.formSubmitObserver.start(); 3805 } 3806 stop() { 3807 this.linkInterceptor.stop(); 3808 this.formSubmitObserver.stop(); 3809 } 3810 // Link interceptor delegate 3811 shouldInterceptLinkClick(element, _location, _event) { 3812 return this.#shouldRedirect(element); 3813 } 3814 linkClickIntercepted(element, url, event) { 3815 const frame = this.#findFrameElement(element); 3816 if (frame) { 3817 frame.delegate.linkClickIntercepted(element, url, event); 3818 } 3819 } 3820 // Form submit observer delegate 3821 willSubmitForm(element, submitter2) { 3822 return element.closest("turbo-frame") == null && this.#shouldSubmit(element, submitter2) && this.#shouldRedirect(element, submitter2); 3823 } 3824 formSubmitted(element, submitter2) { 3825 const frame = this.#findFrameElement(element, submitter2); 3826 if (frame) { 3827 frame.delegate.formSubmitted(element, submitter2); 3828 } 3829 } 3830 #shouldSubmit(form, submitter2) { 3831 const action = getAction$1(form, submitter2); 3832 const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); 3833 const rootLocation = expandURL(meta?.content ?? "/"); 3834 return this.#shouldRedirect(form, submitter2) && locationIsVisitable(action, rootLocation); 3835 } 3836 #shouldRedirect(element, submitter2) { 3837 const isNavigatable = element instanceof HTMLFormElement ? this.session.submissionIsNavigatable(element, submitter2) : this.session.elementIsNavigatable(element); 3838 if (isNavigatable) { 3839 const frame = this.#findFrameElement(element, submitter2); 3840 return frame ? frame != element.closest("turbo-frame") : false; 3841 } else { 3842 return false; 3843 } 3844 } 3845 #findFrameElement(element, submitter2) { 3846 const id = submitter2?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame"); 3847 if (id && id != "_top") { 3848 const frame = this.element.querySelector(`#${id}:not([disabled])`); 3849 if (frame instanceof FrameElement) { 3850 return frame; 3851 } 3852 } 3853 } 3854}; 3855var History = class { 3856 location; 3857 restorationIdentifier = uuid(); 3858 restorationData = {}; 3859 started = false; 3860 pageLoaded = false; 3861 currentIndex = 0; 3862 constructor(delegate) { 3863 this.delegate = delegate; 3864 } 3865 start() { 3866 if (!this.started) { 3867 addEventListener("popstate", this.onPopState, false); 3868 addEventListener("load", this.onPageLoad, false); 3869 this.currentIndex = history.state?.turbo?.restorationIndex || 0; 3870 this.started = true; 3871 this.replace(new URL(window.location.href)); 3872 } 3873 } 3874 stop() { 3875 if (this.started) { 3876 removeEventListener("popstate", this.onPopState, false); 3877 removeEventListener("load", this.onPageLoad, false); 3878 this.started = false; 3879 } 3880 } 3881 push(location2, restorationIdentifier) { 3882 this.update(history.pushState, location2, restorationIdentifier); 3883 } 3884 replace(location2, restorationIdentifier) { 3885 this.update(history.replaceState, location2, restorationIdentifier); 3886 } 3887 update(method, location2, restorationIdentifier = uuid()) { 3888 if (method === history.pushState) ++this.currentIndex; 3889 const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }; 3890 method.call(history, state, "", location2.href); 3891 this.location = location2; 3892 this.restorationIdentifier = restorationIdentifier; 3893 } 3894 // Restoration data 3895 getRestorationDataForIdentifier(restorationIdentifier) { 3896 return this.restorationData[restorationIdentifier] || {}; 3897 } 3898 updateRestorationData(additionalData) { 3899 const { restorationIdentifier } = this; 3900 const restorationData = this.restorationData[restorationIdentifier]; 3901 this.restorationData[restorationIdentifier] = { 3902 ...restorationData, 3903 ...additionalData 3904 }; 3905 } 3906 // Scroll restoration 3907 assumeControlOfScrollRestoration() { 3908 if (!this.previousScrollRestoration) { 3909 this.previousScrollRestoration = history.scrollRestoration ?? "auto"; 3910 history.scrollRestoration = "manual"; 3911 } 3912 } 3913 relinquishControlOfScrollRestoration() { 3914 if (this.previousScrollRestoration) { 3915 history.scrollRestoration = this.previousScrollRestoration; 3916 delete this.previousScrollRestoration; 3917 } 3918 } 3919 // Event handlers 3920 onPopState = (event) => { 3921 if (this.shouldHandlePopState()) { 3922 const { turbo } = event.state || {}; 3923 if (turbo) { 3924 this.location = new URL(window.location.href); 3925 const { restorationIdentifier, restorationIndex } = turbo; 3926 this.restorationIdentifier = restorationIdentifier; 3927 const direction = restorationIndex > this.currentIndex ? "forward" : "back"; 3928 this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction); 3929 this.currentIndex = restorationIndex; 3930 } 3931 } 3932 }; 3933 onPageLoad = async (_event) => { 3934 await nextMicrotask(); 3935 this.pageLoaded = true; 3936 }; 3937 // Private 3938 shouldHandlePopState() { 3939 return this.pageIsLoaded(); 3940 } 3941 pageIsLoaded() { 3942 return this.pageLoaded || document.readyState == "complete"; 3943 } 3944}; 3945var LinkPrefetchObserver = class { 3946 started = false; 3947 #prefetchedLink = null; 3948 constructor(delegate, eventTarget) { 3949 this.delegate = delegate; 3950 this.eventTarget = eventTarget; 3951 } 3952 start() { 3953 if (this.started) return; 3954 if (this.eventTarget.readyState === "loading") { 3955 this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true }); 3956 } else { 3957 this.#enable(); 3958 } 3959 } 3960 stop() { 3961 if (!this.started) return; 3962 this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, { 3963 capture: true, 3964 passive: true 3965 }); 3966 this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, { 3967 capture: true, 3968 passive: true 3969 }); 3970 this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); 3971 this.started = false; 3972 } 3973 #enable = () => { 3974 this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, { 3975 capture: true, 3976 passive: true 3977 }); 3978 this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, { 3979 capture: true, 3980 passive: true 3981 }); 3982 this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true); 3983 this.started = true; 3984 }; 3985 #tryToPrefetchRequest = (event) => { 3986 if (getMetaContent("turbo-prefetch") === "false") return; 3987 const target = event.target; 3988 const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])"); 3989 if (isLink && this.#isPrefetchable(target)) { 3990 const link = target; 3991 const location2 = getLocationForLink(link); 3992 if (this.delegate.canPrefetchRequestToLocation(link, location2)) { 3993 this.#prefetchedLink = link; 3994 const fetchRequest = new FetchRequest( 3995 this, 3996 FetchMethod.get, 3997 location2, 3998 new URLSearchParams(), 3999 target 4000 ); 4001 prefetchCache.setLater(location2.toString(), fetchRequest, this.#cacheTtl); 4002 } 4003 } 4004 }; 4005 #cancelRequestIfObsolete = (event) => { 4006 if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest(); 4007 }; 4008 #cancelPrefetchRequest = () => { 4009 prefetchCache.clear(); 4010 this.#prefetchedLink = null; 4011 }; 4012 #tryToUsePrefetchedRequest = (event) => { 4013 if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") { 4014 const cached = prefetchCache.get(event.detail.url.toString()); 4015 if (cached) { 4016 event.detail.fetchRequest = cached; 4017 } 4018 prefetchCache.clear(); 4019 } 4020 }; 4021 prepareRequest(request) { 4022 const link = request.target; 4023 request.headers["X-Sec-Purpose"] = "prefetch"; 4024 const turboFrame = link.closest("turbo-frame"); 4025 const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id; 4026 if (turboFrameTarget && turboFrameTarget !== "_top") { 4027 request.headers["Turbo-Frame"] = turboFrameTarget; 4028 } 4029 } 4030 // Fetch request interface 4031 requestSucceededWithResponse() { 4032 } 4033 requestStarted(fetchRequest) { 4034 } 4035 requestErrored(fetchRequest) { 4036 } 4037 requestFinished(fetchRequest) { 4038 } 4039 requestPreventedHandlingResponse(fetchRequest, fetchResponse) { 4040 } 4041 requestFailedWithResponse(fetchRequest, fetchResponse) { 4042 } 4043 get #cacheTtl() { 4044 return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl; 4045 } 4046 #isPrefetchable(link) { 4047 const href = link.getAttribute("href"); 4048 if (!href) return false; 4049 if (unfetchableLink(link)) return false; 4050 if (linkToTheSamePage(link)) return false; 4051 if (linkOptsOut(link)) return false; 4052 if (nonSafeLink(link)) return false; 4053 if (eventPrevented(link)) return false; 4054 return true; 4055 } 4056}; 4057var unfetchableLink = (link) => { 4058 return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target"); 4059}; 4060var linkToTheSamePage = (link) => { 4061 return link.pathname + link.search === document.location.pathname + document.location.search || link.href.startsWith("#"); 4062}; 4063var linkOptsOut = (link) => { 4064 if (link.getAttribute("data-turbo-prefetch") === "false") return true; 4065 if (link.getAttribute("data-turbo") === "false") return true; 4066 const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]"); 4067 if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true; 4068 return false; 4069}; 4070var nonSafeLink = (link) => { 4071 const turboMethod = link.getAttribute("data-turbo-method"); 4072 if (turboMethod && turboMethod.toLowerCase() !== "get") return true; 4073 if (isUJS(link)) return true; 4074 if (link.hasAttribute("data-turbo-confirm")) return true; 4075 if (link.hasAttribute("data-turbo-stream")) return true; 4076 return false; 4077}; 4078var isUJS = (link) => { 4079 return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method"); 4080}; 4081var eventPrevented = (link) => { 4082 const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true }); 4083 return event.defaultPrevented; 4084}; 4085var Navigator = class { 4086 constructor(delegate) { 4087 this.delegate = delegate; 4088 } 4089 proposeVisit(location2, options = {}) { 4090 if (this.delegate.allowsVisitingLocationWithAction(location2, options.action)) { 4091 this.delegate.visitProposedToLocation(location2, options); 4092 } 4093 } 4094 startVisit(locatable, restorationIdentifier, options = {}) { 4095 this.stop(); 4096 this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, { 4097 referrer: this.location, 4098 ...options 4099 }); 4100 this.currentVisit.start(); 4101 } 4102 submitForm(form, submitter2) { 4103 this.stop(); 4104 this.formSubmission = new FormSubmission(this, form, submitter2, true); 4105 this.formSubmission.start(); 4106 } 4107 stop() { 4108 if (this.formSubmission) { 4109 this.formSubmission.stop(); 4110 delete this.formSubmission; 4111 } 4112 if (this.currentVisit) { 4113 this.currentVisit.cancel(); 4114 delete this.currentVisit; 4115 } 4116 } 4117 get adapter() { 4118 return this.delegate.adapter; 4119 } 4120 get view() { 4121 return this.delegate.view; 4122 } 4123 get rootLocation() { 4124 return this.view.snapshot.rootLocation; 4125 } 4126 get history() { 4127 return this.delegate.history; 4128 } 4129 // Form submission delegate 4130 formSubmissionStarted(formSubmission) { 4131 if (typeof this.adapter.formSubmissionStarted === "function") { 4132 this.adapter.formSubmissionStarted(formSubmission); 4133 } 4134 } 4135 async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) { 4136 if (formSubmission == this.formSubmission) { 4137 const responseHTML = await fetchResponse.responseHTML; 4138 if (responseHTML) { 4139 const shouldCacheSnapshot = formSubmission.isSafe; 4140 if (!shouldCacheSnapshot) { 4141 this.view.clearSnapshotCache(); 4142 } 4143 const { statusCode, redirected } = fetchResponse; 4144 const action = this.#getActionForFormSubmission(formSubmission, fetchResponse); 4145 const visitOptions = { 4146 action, 4147 shouldCacheSnapshot, 4148 response: { statusCode, responseHTML, redirected } 4149 }; 4150 this.proposeVisit(fetchResponse.location, visitOptions); 4151 } 4152 } 4153 } 4154 async formSubmissionFailedWithResponse(formSubmission, fetchResponse) { 4155 const responseHTML = await fetchResponse.responseHTML; 4156 if (responseHTML) { 4157 const snapshot = PageSnapshot.fromHTMLString(responseHTML); 4158 if (fetchResponse.serverError) { 4159 await this.view.renderError(snapshot, this.currentVisit); 4160 } else { 4161 await this.view.renderPage(snapshot, false, true, this.currentVisit); 4162 } 4163 if (!snapshot.shouldPreserveScrollPosition) { 4164 this.view.scrollToTop(); 4165 } 4166 this.view.clearSnapshotCache(); 4167 } 4168 } 4169 formSubmissionErrored(formSubmission, error) { 4170 console.error(error); 4171 } 4172 formSubmissionFinished(formSubmission) { 4173 if (typeof this.adapter.formSubmissionFinished === "function") { 4174 this.adapter.formSubmissionFinished(formSubmission); 4175 } 4176 } 4177 // Link prefetching 4178 linkPrefetchingIsEnabledForLocation(location2) { 4179 if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") { 4180 return this.adapter.linkPrefetchingIsEnabledForLocation(location2); 4181 } 4182 return true; 4183 } 4184 // Visit delegate 4185 visitStarted(visit2) { 4186 this.delegate.visitStarted(visit2); 4187 } 4188 visitCompleted(visit2) { 4189 this.delegate.visitCompleted(visit2); 4190 delete this.currentVisit; 4191 } 4192 locationWithActionIsSamePage(location2, action) { 4193 const anchor = getAnchor(location2); 4194 const currentAnchor = getAnchor(this.view.lastRenderedLocation); 4195 const isRestorationToTop = action === "restore" && typeof anchor === "undefined"; 4196 return action !== "replace" && getRequestURL(location2) === getRequestURL(this.view.lastRenderedLocation) && (isRestorationToTop || anchor != null && anchor !== currentAnchor); 4197 } 4198 visitScrolledToSamePageLocation(oldURL, newURL) { 4199 this.delegate.visitScrolledToSamePageLocation(oldURL, newURL); 4200 } 4201 // Visits 4202 get location() { 4203 return this.history.location; 4204 } 4205 get restorationIdentifier() { 4206 return this.history.restorationIdentifier; 4207 } 4208 #getActionForFormSubmission(formSubmission, fetchResponse) { 4209 const { submitter: submitter2, formElement } = formSubmission; 4210 return getVisitAction(submitter2, formElement) || this.#getDefaultAction(fetchResponse); 4211 } 4212 #getDefaultAction(fetchResponse) { 4213 const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href; 4214 return sameLocationRedirect ? "replace" : "advance"; 4215 } 4216}; 4217var PageStage = { 4218 initial: 0, 4219 loading: 1, 4220 interactive: 2, 4221 complete: 3 4222}; 4223var PageObserver = class { 4224 stage = PageStage.initial; 4225 started = false; 4226 constructor(delegate) { 4227 this.delegate = delegate; 4228 } 4229 start() { 4230 if (!this.started) { 4231 if (this.stage == PageStage.initial) { 4232 this.stage = PageStage.loading; 4233 } 4234 document.addEventListener("readystatechange", this.interpretReadyState, false); 4235 addEventListener("pagehide", this.pageWillUnload, false); 4236 this.started = true; 4237 } 4238 } 4239 stop() { 4240 if (this.started) { 4241 document.removeEventListener("readystatechange", this.interpretReadyState, false); 4242 removeEventListener("pagehide", this.pageWillUnload, false); 4243 this.started = false; 4244 } 4245 } 4246 interpretReadyState = () => { 4247 const { readyState } = this; 4248 if (readyState == "interactive") { 4249 this.pageIsInteractive(); 4250 } else if (readyState == "complete") { 4251 this.pageIsComplete(); 4252 } 4253 }; 4254 pageIsInteractive() { 4255 if (this.stage == PageStage.loading) { 4256 this.stage = PageStage.interactive; 4257 this.delegate.pageBecameInteractive(); 4258 } 4259 } 4260 pageIsComplete() { 4261 this.pageIsInteractive(); 4262 if (this.stage == PageStage.interactive) { 4263 this.stage = PageStage.complete; 4264 this.delegate.pageLoaded(); 4265 } 4266 } 4267 pageWillUnload = () => { 4268 this.delegate.pageWillUnload(); 4269 }; 4270 get readyState() { 4271 return document.readyState; 4272 } 4273}; 4274var ScrollObserver = class { 4275 started = false; 4276 constructor(delegate) { 4277 this.delegate = delegate; 4278 } 4279 start() { 4280 if (!this.started) { 4281 addEventListener("scroll", this.onScroll, false); 4282 this.onScroll(); 4283 this.started = true; 4284 } 4285 } 4286 stop() { 4287 if (this.started) { 4288 removeEventListener("scroll", this.onScroll, false); 4289 this.started = false; 4290 } 4291 } 4292 onScroll = () => { 4293 this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); 4294 }; 4295 // Private 4296 updatePosition(position) { 4297 this.delegate.scrollPositionChanged(position); 4298 } 4299}; 4300var StreamMessageRenderer = class { 4301 render({ fragment }) { 4302 Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => { 4303 withAutofocusFromFragment(fragment, () => { 4304 withPreservedFocus(() => { 4305 document.documentElement.appendChild(fragment); 4306 }); 4307 }); 4308 }); 4309 } 4310 // Bardo delegate 4311 enteringBardo(currentPermanentElement, newPermanentElement) { 4312 newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true)); 4313 } 4314 leavingBardo() { 4315 } 4316}; 4317function getPermanentElementMapForFragment(fragment) { 4318 const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement); 4319 const permanentElementMap = {}; 4320 for (const permanentElementInDocument of permanentElementsInDocument) { 4321 const { id } = permanentElementInDocument; 4322 for (const streamElement of fragment.querySelectorAll("turbo-stream")) { 4323 const elementInStream = getPermanentElementById(streamElement.templateElement.content, id); 4324 if (elementInStream) { 4325 permanentElementMap[id] = [permanentElementInDocument, elementInStream]; 4326 } 4327 } 4328 } 4329 return permanentElementMap; 4330} 4331async function withAutofocusFromFragment(fragment, callback) { 4332 const generatedID = `turbo-stream-autofocus-${uuid()}`; 4333 const turboStreams = fragment.querySelectorAll("turbo-stream"); 4334 const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams); 4335 let willAutofocusId = null; 4336 if (elementWithAutofocus) { 4337 if (elementWithAutofocus.id) { 4338 willAutofocusId = elementWithAutofocus.id; 4339 } else { 4340 willAutofocusId = generatedID; 4341 } 4342 elementWithAutofocus.id = willAutofocusId; 4343 } 4344 callback(); 4345 await nextRepaint(); 4346 const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body; 4347 if (hasNoActiveElement && willAutofocusId) { 4348 const elementToAutofocus = document.getElementById(willAutofocusId); 4349 if (elementIsFocusable(elementToAutofocus)) { 4350 elementToAutofocus.focus(); 4351 } 4352 if (elementToAutofocus && elementToAutofocus.id == generatedID) { 4353 elementToAutofocus.removeAttribute("id"); 4354 } 4355 } 4356} 4357async function withPreservedFocus(callback) { 4358 const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement); 4359 const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id; 4360 if (restoreFocusTo) { 4361 const elementToFocus = document.getElementById(restoreFocusTo); 4362 if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) { 4363 elementToFocus.focus(); 4364 } 4365 } 4366} 4367function firstAutofocusableElementInStreams(nodeListOfStreamElements) { 4368 for (const streamElement of nodeListOfStreamElements) { 4369 const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content); 4370 if (elementWithAutofocus) return elementWithAutofocus; 4371 } 4372 return null; 4373} 4374var StreamObserver = class { 4375 sources = /* @__PURE__ */ new Set(); 4376 #started = false; 4377 constructor(delegate) { 4378 this.delegate = delegate; 4379 } 4380 start() { 4381 if (!this.#started) { 4382 this.#started = true; 4383 addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); 4384 } 4385 } 4386 stop() { 4387 if (this.#started) { 4388 this.#started = false; 4389 removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false); 4390 } 4391 } 4392 connectStreamSource(source) { 4393 if (!this.streamSourceIsConnected(source)) { 4394 this.sources.add(source); 4395 source.addEventListener("message", this.receiveMessageEvent, false); 4396 } 4397 } 4398 disconnectStreamSource(source) { 4399 if (this.streamSourceIsConnected(source)) { 4400 this.sources.delete(source); 4401 source.removeEventListener("message", this.receiveMessageEvent, false); 4402 } 4403 } 4404 streamSourceIsConnected(source) { 4405 return this.sources.has(source); 4406 } 4407 inspectFetchResponse = (event) => { 4408 const response = fetchResponseFromEvent(event); 4409 if (response && fetchResponseIsStream(response)) { 4410 event.preventDefault(); 4411 this.receiveMessageResponse(response); 4412 } 4413 }; 4414 receiveMessageEvent = (event) => { 4415 if (this.#started && typeof event.data == "string") { 4416 this.receiveMessageHTML(event.data); 4417 } 4418 }; 4419 async receiveMessageResponse(response) { 4420 const html = await response.responseHTML; 4421 if (html) { 4422 this.receiveMessageHTML(html); 4423 } 4424 } 4425 receiveMessageHTML(html) { 4426 this.delegate.receivedMessageFromStream(StreamMessage.wrap(html)); 4427 } 4428}; 4429function fetchResponseFromEvent(event) { 4430 const fetchResponse = event.detail?.fetchResponse; 4431 if (fetchResponse instanceof FetchResponse) { 4432 return fetchResponse; 4433 } 4434} 4435function fetchResponseIsStream(response) { 4436 const contentType = response.contentType ?? ""; 4437 return contentType.startsWith(StreamMessage.contentType); 4438} 4439var ErrorRenderer = class extends Renderer { 4440 static renderElement(currentElement, newElement) { 4441 const { documentElement, body } = document; 4442 documentElement.replaceChild(newElement, body); 4443 } 4444 async render() { 4445 this.replaceHeadAndBody(); 4446 this.activateScriptElements(); 4447 } 4448 replaceHeadAndBody() { 4449 const { documentElement, head } = document; 4450 documentElement.replaceChild(this.newHead, head); 4451 this.renderElement(this.currentElement, this.newElement); 4452 } 4453 activateScriptElements() { 4454 for (const replaceableElement of this.scriptElements) { 4455 const parentNode = replaceableElement.parentNode; 4456 if (parentNode) { 4457 const element = activateScriptElement(replaceableElement); 4458 parentNode.replaceChild(element, replaceableElement); 4459 } 4460 } 4461 } 4462 get newHead() { 4463 return this.newSnapshot.headSnapshot.element; 4464 } 4465 get scriptElements() { 4466 return document.documentElement.querySelectorAll("script"); 4467 } 4468}; 4469var PageRenderer = class extends Renderer { 4470 static renderElement(currentElement, newElement) { 4471 if (document.body && newElement instanceof HTMLBodyElement) { 4472 document.body.replaceWith(newElement); 4473 } else { 4474 document.documentElement.appendChild(newElement); 4475 } 4476 } 4477 get shouldRender() { 4478 return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical; 4479 } 4480 get reloadReason() { 4481 if (!this.newSnapshot.isVisitable) { 4482 return { 4483 reason: "turbo_visit_control_is_reload" 4484 }; 4485 } 4486 if (!this.trackedElementsAreIdentical) { 4487 return { 4488 reason: "tracked_element_mismatch" 4489 }; 4490 } 4491 } 4492 async prepareToRender() { 4493 this.#setLanguage(); 4494 await this.mergeHead(); 4495 } 4496 async render() { 4497 if (this.willRender) { 4498 await this.replaceBody(); 4499 } 4500 } 4501 finishRendering() { 4502 super.finishRendering(); 4503 if (!this.isPreview) { 4504 this.focusFirstAutofocusableElement(); 4505 } 4506 } 4507 get currentHeadSnapshot() { 4508 return this.currentSnapshot.headSnapshot; 4509 } 4510 get newHeadSnapshot() { 4511 return this.newSnapshot.headSnapshot; 4512 } 4513 get newElement() { 4514 return this.newSnapshot.element; 4515 } 4516 #setLanguage() { 4517 const { documentElement } = this.currentSnapshot; 4518 const { lang } = this.newSnapshot; 4519 if (lang) { 4520 documentElement.setAttribute("lang", lang); 4521 } else { 4522 documentElement.removeAttribute("lang"); 4523 } 4524 } 4525 async mergeHead() { 4526 const mergedHeadElements = this.mergeProvisionalElements(); 4527 const newStylesheetElements = this.copyNewHeadStylesheetElements(); 4528 this.copyNewHeadScriptElements(); 4529 await mergedHeadElements; 4530 await newStylesheetElements; 4531 if (this.willRender) { 4532 this.removeUnusedDynamicStylesheetElements(); 4533 } 4534 } 4535 async replaceBody() { 4536 await this.preservingPermanentElements(async () => { 4537 this.activateNewBody(); 4538 await this.assignNewBody(); 4539 }); 4540 } 4541 get trackedElementsAreIdentical() { 4542 return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature; 4543 } 4544 async copyNewHeadStylesheetElements() { 4545 const loadingElements = []; 4546 for (const element of this.newHeadStylesheetElements) { 4547 loadingElements.push(waitForLoad(element)); 4548 document.head.appendChild(element); 4549 } 4550 await Promise.all(loadingElements); 4551 } 4552 copyNewHeadScriptElements() { 4553 for (const element of this.newHeadScriptElements) { 4554 document.head.appendChild(activateScriptElement(element)); 4555 } 4556 } 4557 removeUnusedDynamicStylesheetElements() { 4558 for (const element of this.unusedDynamicStylesheetElements) { 4559 document.head.removeChild(element); 4560 } 4561 } 4562 async mergeProvisionalElements() { 4563 const newHeadElements = [...this.newHeadProvisionalElements]; 4564 for (const element of this.currentHeadProvisionalElements) { 4565 if (!this.isCurrentElementInElementList(element, newHeadElements)) { 4566 document.head.removeChild(element); 4567 } 4568 } 4569 for (const element of newHeadElements) { 4570 document.head.appendChild(element); 4571 } 4572 } 4573 isCurrentElementInElementList(element, elementList) { 4574 for (const [index, newElement] of elementList.entries()) { 4575 if (element.tagName == "TITLE") { 4576 if (newElement.tagName != "TITLE") { 4577 continue; 4578 } 4579 if (element.innerHTML == newElement.innerHTML) { 4580 elementList.splice(index, 1); 4581 return true; 4582 } 4583 } 4584 if (newElement.isEqualNode(element)) { 4585 elementList.splice(index, 1); 4586 return true; 4587 } 4588 } 4589 return false; 4590 } 4591 removeCurrentHeadProvisionalElements() { 4592 for (const element of this.currentHeadProvisionalElements) { 4593 document.head.removeChild(element); 4594 } 4595 } 4596 copyNewHeadProvisionalElements() { 4597 for (const element of this.newHeadProvisionalElements) { 4598 document.head.appendChild(element); 4599 } 4600 } 4601 activateNewBody() { 4602 document.adoptNode(this.newElement); 4603 this.activateNewBodyScriptElements(); 4604 } 4605 activateNewBodyScriptElements() { 4606 for (const inertScriptElement of this.newBodyScriptElements) { 4607 const activatedScriptElement = activateScriptElement(inertScriptElement); 4608 inertScriptElement.replaceWith(activatedScriptElement); 4609 } 4610 } 4611 async assignNewBody() { 4612 await this.renderElement(this.currentElement, this.newElement); 4613 } 4614 get unusedDynamicStylesheetElements() { 4615 return this.oldHeadStylesheetElements.filter((element) => { 4616 return element.getAttribute("data-turbo-track") === "dynamic"; 4617 }); 4618 } 4619 get oldHeadStylesheetElements() { 4620 return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot); 4621 } 4622 get newHeadStylesheetElements() { 4623 return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot); 4624 } 4625 get newHeadScriptElements() { 4626 return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot); 4627 } 4628 get currentHeadProvisionalElements() { 4629 return this.currentHeadSnapshot.provisionalElements; 4630 } 4631 get newHeadProvisionalElements() { 4632 return this.newHeadSnapshot.provisionalElements; 4633 } 4634 get newBodyScriptElements() { 4635 return this.newElement.querySelectorAll("script"); 4636 } 4637}; 4638var MorphingPageRenderer = class extends PageRenderer { 4639 static renderElement(currentElement, newElement) { 4640 morphElements(currentElement, newElement, { 4641 callbacks: { 4642 beforeNodeMorphed: (element) => !canRefreshFrame(element) 4643 } 4644 }); 4645 for (const frame of currentElement.querySelectorAll("turbo-frame")) { 4646 if (canRefreshFrame(frame)) frame.reload(); 4647 } 4648 dispatch("turbo:morph", { detail: { currentElement, newElement } }); 4649 } 4650 async preservingPermanentElements(callback) { 4651 return await callback(); 4652 } 4653 get renderMethod() { 4654 return "morph"; 4655 } 4656 get shouldAutofocus() { 4657 return false; 4658 } 4659}; 4660function canRefreshFrame(frame) { 4661 return frame instanceof FrameElement && frame.src && frame.refresh === "morph" && !frame.closest("[data-turbo-permanent]"); 4662} 4663var SnapshotCache = class { 4664 keys = []; 4665 snapshots = {}; 4666 constructor(size) { 4667 this.size = size; 4668 } 4669 has(location2) { 4670 return toCacheKey(location2) in this.snapshots; 4671 } 4672 get(location2) { 4673 if (this.has(location2)) { 4674 const snapshot = this.read(location2); 4675 this.touch(location2); 4676 return snapshot; 4677 } 4678 } 4679 put(location2, snapshot) { 4680 this.write(location2, snapshot); 4681 this.touch(location2); 4682 return snapshot; 4683 } 4684 clear() { 4685 this.snapshots = {}; 4686 } 4687 // Private 4688 read(location2) { 4689 return this.snapshots[toCacheKey(location2)]; 4690 } 4691 write(location2, snapshot) { 4692 this.snapshots[toCacheKey(location2)] = snapshot; 4693 } 4694 touch(location2) { 4695 const key = toCacheKey(location2); 4696 const index = this.keys.indexOf(key); 4697 if (index > -1) this.keys.splice(index, 1); 4698 this.keys.unshift(key); 4699 this.trim(); 4700 } 4701 trim() { 4702 for (const key of this.keys.splice(this.size)) { 4703 delete this.snapshots[key]; 4704 } 4705 } 4706}; 4707var PageView = class extends View { 4708 snapshotCache = new SnapshotCache(10); 4709 lastRenderedLocation = new URL(location.href); 4710 forceReloaded = false; 4711 shouldTransitionTo(newSnapshot) { 4712 return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions; 4713 } 4714 renderPage(snapshot, isPreview = false, willRender = true, visit2) { 4715 const shouldMorphPage = this.isPageRefresh(visit2) && this.snapshot.shouldMorphPage; 4716 const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer; 4717 const renderer = new rendererClass(this.snapshot, snapshot, isPreview, willRender); 4718 if (!renderer.shouldRender) { 4719 this.forceReloaded = true; 4720 } else { 4721 visit2?.changeHistory(); 4722 } 4723 return this.render(renderer); 4724 } 4725 renderError(snapshot, visit2) { 4726 visit2?.changeHistory(); 4727 const renderer = new ErrorRenderer(this.snapshot, snapshot, false); 4728 return this.render(renderer); 4729 } 4730 clearSnapshotCache() { 4731 this.snapshotCache.clear(); 4732 } 4733 async cacheSnapshot(snapshot = this.snapshot) { 4734 if (snapshot.isCacheable) { 4735 this.delegate.viewWillCacheSnapshot(); 4736 const { lastRenderedLocation: location2 } = this; 4737 await nextEventLoopTick(); 4738 const cachedSnapshot = snapshot.clone(); 4739 this.snapshotCache.put(location2, cachedSnapshot); 4740 return cachedSnapshot; 4741 } 4742 } 4743 getCachedSnapshotForLocation(location2) { 4744 return this.snapshotCache.get(location2); 4745 } 4746 isPageRefresh(visit2) { 4747 return !visit2 || this.lastRenderedLocation.pathname === visit2.location.pathname && visit2.action === "replace"; 4748 } 4749 shouldPreserveScrollPosition(visit2) { 4750 return this.isPageRefresh(visit2) && this.snapshot.shouldPreserveScrollPosition; 4751 } 4752 get snapshot() { 4753 return PageSnapshot.fromElement(this.element); 4754 } 4755}; 4756var Preloader = class { 4757 selector = "a[data-turbo-preload]"; 4758 constructor(delegate, snapshotCache) { 4759 this.delegate = delegate; 4760 this.snapshotCache = snapshotCache; 4761 } 4762 start() { 4763 if (document.readyState === "loading") { 4764 document.addEventListener("DOMContentLoaded", this.#preloadAll); 4765 } else { 4766 this.preloadOnLoadLinksForView(document.body); 4767 } 4768 } 4769 stop() { 4770 document.removeEventListener("DOMContentLoaded", this.#preloadAll); 4771 } 4772 preloadOnLoadLinksForView(element) { 4773 for (const link of element.querySelectorAll(this.selector)) { 4774 if (this.delegate.shouldPreloadLink(link)) { 4775 this.preloadURL(link); 4776 } 4777 } 4778 } 4779 async preloadURL(link) { 4780 const location2 = new URL(link.href); 4781 if (this.snapshotCache.has(location2)) { 4782 return; 4783 } 4784 const fetchRequest = new FetchRequest(this, FetchMethod.get, location2, new URLSearchParams(), link); 4785 await fetchRequest.perform(); 4786 } 4787 // Fetch request delegate 4788 prepareRequest(fetchRequest) { 4789 fetchRequest.headers["X-Sec-Purpose"] = "prefetch"; 4790 } 4791 async requestSucceededWithResponse(fetchRequest, fetchResponse) { 4792 try { 4793 const responseHTML = await fetchResponse.responseHTML; 4794 const snapshot = PageSnapshot.fromHTMLString(responseHTML); 4795 this.snapshotCache.put(fetchRequest.url, snapshot); 4796 } catch (_) { 4797 } 4798 } 4799 requestStarted(fetchRequest) { 4800 } 4801 requestErrored(fetchRequest) { 4802 } 4803 requestFinished(fetchRequest) { 4804 } 4805 requestPreventedHandlingResponse(fetchRequest, fetchResponse) { 4806 } 4807 requestFailedWithResponse(fetchRequest, fetchResponse) { 4808 } 4809 #preloadAll = () => { 4810 this.preloadOnLoadLinksForView(document.body); 4811 }; 4812}; 4813var Cache = class { 4814 constructor(session2) { 4815 this.session = session2; 4816 } 4817 clear() { 4818 this.session.clearCache(); 4819 } 4820 resetCacheControl() { 4821 this.#setCacheControl(""); 4822 } 4823 exemptPageFromCache() { 4824 this.#setCacheControl("no-cache"); 4825 } 4826 exemptPageFromPreview() { 4827 this.#setCacheControl("no-preview"); 4828 } 4829 #setCacheControl(value) { 4830 setMetaContent("turbo-cache-control", value); 4831 } 4832}; 4833var Session = class { 4834 navigator = new Navigator(this); 4835 history = new History(this); 4836 view = new PageView(this, document.documentElement); 4837 adapter = new BrowserAdapter(this); 4838 pageObserver = new PageObserver(this); 4839 cacheObserver = new CacheObserver(); 4840 linkPrefetchObserver = new LinkPrefetchObserver(this, document); 4841 linkClickObserver = new LinkClickObserver(this, window); 4842 formSubmitObserver = new FormSubmitObserver(this, document); 4843 scrollObserver = new ScrollObserver(this); 4844 streamObserver = new StreamObserver(this); 4845 formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement); 4846 frameRedirector = new FrameRedirector(this, document.documentElement); 4847 streamMessageRenderer = new StreamMessageRenderer(); 4848 cache = new Cache(this); 4849 enabled = true; 4850 started = false; 4851 #pageRefreshDebouncePeriod = 150; 4852 constructor(recentRequests2) { 4853 this.recentRequests = recentRequests2; 4854 this.preloader = new Preloader(this, this.view.snapshotCache); 4855 this.debouncedRefresh = this.refresh; 4856 this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod; 4857 } 4858 start() { 4859 if (!this.started) { 4860 this.pageObserver.start(); 4861 this.cacheObserver.start(); 4862 this.linkPrefetchObserver.start(); 4863 this.formLinkClickObserver.start(); 4864 this.linkClickObserver.start(); 4865 this.formSubmitObserver.start(); 4866 this.scrollObserver.start(); 4867 this.streamObserver.start(); 4868 this.frameRedirector.start(); 4869 this.history.start(); 4870 this.preloader.start(); 4871 this.started = true; 4872 this.enabled = true; 4873 } 4874 } 4875 disable() { 4876 this.enabled = false; 4877 } 4878 stop() { 4879 if (this.started) { 4880 this.pageObserver.stop(); 4881 this.cacheObserver.stop(); 4882 this.linkPrefetchObserver.stop(); 4883 this.formLinkClickObserver.stop(); 4884 this.linkClickObserver.stop(); 4885 this.formSubmitObserver.stop(); 4886 this.scrollObserver.stop(); 4887 this.streamObserver.stop(); 4888 this.frameRedirector.stop(); 4889 this.history.stop(); 4890 this.preloader.stop(); 4891 this.started = false; 4892 } 4893 } 4894 registerAdapter(adapter) { 4895 this.adapter = adapter; 4896 } 4897 visit(location2, options = {}) { 4898 const frameElement = options.frame ? document.getElementById(options.frame) : null; 4899 if (frameElement instanceof FrameElement) { 4900 const action = options.action || getVisitAction(frameElement); 4901 frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action); 4902 frameElement.src = location2.toString(); 4903 } else { 4904 this.navigator.proposeVisit(expandURL(location2), options); 4905 } 4906 } 4907 refresh(url, requestId) { 4908 const isRecentRequest = requestId && this.recentRequests.has(requestId); 4909 const isCurrentUrl = url === document.baseURI; 4910 if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) { 4911 this.visit(url, { action: "replace", shouldCacheSnapshot: false }); 4912 } 4913 } 4914 connectStreamSource(source) { 4915 this.streamObserver.connectStreamSource(source); 4916 } 4917 disconnectStreamSource(source) { 4918 this.streamObserver.disconnectStreamSource(source); 4919 } 4920 renderStreamMessage(message) { 4921 this.streamMessageRenderer.render(StreamMessage.wrap(message)); 4922 } 4923 clearCache() { 4924 this.view.clearSnapshotCache(); 4925 } 4926 setProgressBarDelay(delay) { 4927 console.warn( 4928 "Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`" 4929 ); 4930 this.progressBarDelay = delay; 4931 } 4932 set progressBarDelay(delay) { 4933 config.drive.progressBarDelay = delay; 4934 } 4935 get progressBarDelay() { 4936 return config.drive.progressBarDelay; 4937 } 4938 set drive(value) { 4939 config.drive.enabled = value; 4940 } 4941 get drive() { 4942 return config.drive.enabled; 4943 } 4944 set formMode(value) { 4945 config.forms.mode = value; 4946 } 4947 get formMode() { 4948 return config.forms.mode; 4949 } 4950 get location() { 4951 return this.history.location; 4952 } 4953 get restorationIdentifier() { 4954 return this.history.restorationIdentifier; 4955 } 4956 get pageRefreshDebouncePeriod() { 4957 return this.#pageRefreshDebouncePeriod; 4958 } 4959 set pageRefreshDebouncePeriod(value) { 4960 this.refresh = debounce(this.debouncedRefresh.bind(this), value); 4961 this.#pageRefreshDebouncePeriod = value; 4962 } 4963 // Preloader delegate 4964 shouldPreloadLink(element) { 4965 const isUnsafe = element.hasAttribute("data-turbo-method"); 4966 const isStream = element.hasAttribute("data-turbo-stream"); 4967 const frameTarget = element.getAttribute("data-turbo-frame"); 4968 const frame = frameTarget == "_top" ? null : document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])"); 4969 if (isUnsafe || isStream || frame instanceof FrameElement) { 4970 return false; 4971 } else { 4972 const location2 = new URL(element.href); 4973 return this.elementIsNavigatable(element) && locationIsVisitable(location2, this.snapshot.rootLocation); 4974 } 4975 } 4976 // History delegate 4977 historyPoppedToLocationWithRestorationIdentifierAndDirection(location2, restorationIdentifier, direction) { 4978 if (this.enabled) { 4979 this.navigator.startVisit(location2, restorationIdentifier, { 4980 action: "restore", 4981 historyChanged: true, 4982 direction 4983 }); 4984 } else { 4985 this.adapter.pageInvalidated({ 4986 reason: "turbo_disabled" 4987 }); 4988 } 4989 } 4990 // Scroll observer delegate 4991 scrollPositionChanged(position) { 4992 this.history.updateRestorationData({ scrollPosition: position }); 4993 } 4994 // Form click observer delegate 4995 willSubmitFormLinkToLocation(link, location2) { 4996 return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation); 4997 } 4998 submittedFormLinkToLocation() { 4999 } 5000 // Link hover observer delegate 5001 canPrefetchRequestToLocation(link, location2) { 5002 return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation) && this.navigator.linkPrefetchingIsEnabledForLocation(location2); 5003 } 5004 // Link click observer delegate 5005 willFollowLinkToLocation(link, location2, event) { 5006 return this.elementIsNavigatable(link) && locationIsVisitable(location2, this.snapshot.rootLocation) && this.applicationAllowsFollowingLinkToLocation(link, location2, event); 5007 } 5008 followedLinkToLocation(link, location2) { 5009 const action = this.getActionForLink(link); 5010 const acceptsStreamResponse = link.hasAttribute("data-turbo-stream"); 5011 this.visit(location2.href, { action, acceptsStreamResponse }); 5012 } 5013 // Navigator delegate 5014 allowsVisitingLocationWithAction(location2, action) { 5015 return this.locationWithActionIsSamePage(location2, action) || this.applicationAllowsVisitingLocation(location2); 5016 } 5017 visitProposedToLocation(location2, options) { 5018 extendURLWithDeprecatedProperties(location2); 5019 this.adapter.visitProposedToLocation(location2, options); 5020 } 5021 // Visit delegate 5022 visitStarted(visit2) { 5023 if (!visit2.acceptsStreamResponse) { 5024 markAsBusy(document.documentElement); 5025 this.view.markVisitDirection(visit2.direction); 5026 } 5027 extendURLWithDeprecatedProperties(visit2.location); 5028 if (!visit2.silent) { 5029 this.notifyApplicationAfterVisitingLocation(visit2.location, visit2.action); 5030 } 5031 } 5032 visitCompleted(visit2) { 5033 this.view.unmarkVisitDirection(); 5034 clearBusyState(document.documentElement); 5035 this.notifyApplicationAfterPageLoad(visit2.getTimingMetrics()); 5036 } 5037 locationWithActionIsSamePage(location2, action) { 5038 return this.navigator.locationWithActionIsSamePage(location2, action); 5039 } 5040 visitScrolledToSamePageLocation(oldURL, newURL) { 5041 this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL); 5042 } 5043 // Form submit observer delegate 5044 willSubmitForm(form, submitter2) { 5045 const action = getAction$1(form, submitter2); 5046 return this.submissionIsNavigatable(form, submitter2) && locationIsVisitable(expandURL(action), this.snapshot.rootLocation); 5047 } 5048 formSubmitted(form, submitter2) { 5049 this.navigator.submitForm(form, submitter2); 5050 } 5051 // Page observer delegate 5052 pageBecameInteractive() { 5053 this.view.lastRenderedLocation = this.location; 5054 this.notifyApplicationAfterPageLoad(); 5055 } 5056 pageLoaded() { 5057 this.history.assumeControlOfScrollRestoration(); 5058 } 5059 pageWillUnload() { 5060 this.history.relinquishControlOfScrollRestoration(); 5061 } 5062 // Stream observer delegate 5063 receivedMessageFromStream(message) { 5064 this.renderStreamMessage(message); 5065 } 5066 // Page view delegate 5067 viewWillCacheSnapshot() { 5068 if (!this.navigator.currentVisit?.silent) { 5069 this.notifyApplicationBeforeCachingSnapshot(); 5070 } 5071 } 5072 allowsImmediateRender({ element }, options) { 5073 const event = this.notifyApplicationBeforeRender(element, options); 5074 const { 5075 defaultPrevented, 5076 detail: { render } 5077 } = event; 5078 if (this.view.renderer && render) { 5079 this.view.renderer.renderElement = render; 5080 } 5081 return !defaultPrevented; 5082 } 5083 viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) { 5084 this.view.lastRenderedLocation = this.history.location; 5085 this.notifyApplicationAfterRender(renderMethod); 5086 } 5087 preloadOnLoadLinksForView(element) { 5088 this.preloader.preloadOnLoadLinksForView(element); 5089 } 5090 viewInvalidated(reason) { 5091 this.adapter.pageInvalidated(reason); 5092 } 5093 // Frame element 5094 frameLoaded(frame) { 5095 this.notifyApplicationAfterFrameLoad(frame); 5096 } 5097 frameRendered(fetchResponse, frame) { 5098 this.notifyApplicationAfterFrameRender(fetchResponse, frame); 5099 } 5100 // Application events 5101 applicationAllowsFollowingLinkToLocation(link, location2, ev) { 5102 const event = this.notifyApplicationAfterClickingLinkToLocation(link, location2, ev); 5103 return !event.defaultPrevented; 5104 } 5105 applicationAllowsVisitingLocation(location2) { 5106 const event = this.notifyApplicationBeforeVisitingLocation(location2); 5107 return !event.defaultPrevented; 5108 } 5109 notifyApplicationAfterClickingLinkToLocation(link, location2, event) { 5110 return dispatch("turbo:click", { 5111 target: link, 5112 detail: { url: location2.href, originalEvent: event }, 5113 cancelable: true 5114 }); 5115 } 5116 notifyApplicationBeforeVisitingLocation(location2) { 5117 return dispatch("turbo:before-visit", { 5118 detail: { url: location2.href }, 5119 cancelable: true 5120 }); 5121 } 5122 notifyApplicationAfterVisitingLocation(location2, action) { 5123 return dispatch("turbo:visit", { detail: { url: location2.href, action } }); 5124 } 5125 notifyApplicationBeforeCachingSnapshot() { 5126 return dispatch("turbo:before-cache"); 5127 } 5128 notifyApplicationBeforeRender(newBody, options) { 5129 return dispatch("turbo:before-render", { 5130 detail: { newBody, ...options }, 5131 cancelable: true 5132 }); 5133 } 5134 notifyApplicationAfterRender(renderMethod) { 5135 return dispatch("turbo:render", { detail: { renderMethod } }); 5136 } 5137 notifyApplicationAfterPageLoad(timing = {}) { 5138 return dispatch("turbo:load", { 5139 detail: { url: this.location.href, timing } 5140 }); 5141 } 5142 notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) { 5143 dispatchEvent( 5144 new HashChangeEvent("hashchange", { 5145 oldURL: oldURL.toString(), 5146 newURL: newURL.toString() 5147 }) 5148 ); 5149 } 5150 notifyApplicationAfterFrameLoad(frame) { 5151 return dispatch("turbo:frame-load", { target: frame }); 5152 } 5153 notifyApplicationAfterFrameRender(fetchResponse, frame) { 5154 return dispatch("turbo:frame-render", { 5155 detail: { fetchResponse }, 5156 target: frame, 5157 cancelable: true 5158 }); 5159 } 5160 // Helpers 5161 submissionIsNavigatable(form, submitter2) { 5162 if (config.forms.mode == "off") { 5163 return false; 5164 } else { 5165 const submitterIsNavigatable = submitter2 ? this.elementIsNavigatable(submitter2) : true; 5166 if (config.forms.mode == "optin") { 5167 return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null; 5168 } else { 5169 return submitterIsNavigatable && this.elementIsNavigatable(form); 5170 } 5171 } 5172 } 5173 elementIsNavigatable(element) { 5174 const container = findClosestRecursively(element, "[data-turbo]"); 5175 const withinFrame = findClosestRecursively(element, "turbo-frame"); 5176 if (config.drive.enabled || withinFrame) { 5177 if (container) { 5178 return container.getAttribute("data-turbo") != "false"; 5179 } else { 5180 return true; 5181 } 5182 } else { 5183 if (container) { 5184 return container.getAttribute("data-turbo") == "true"; 5185 } else { 5186 return false; 5187 } 5188 } 5189 } 5190 // Private 5191 getActionForLink(link) { 5192 return getVisitAction(link) || "advance"; 5193 } 5194 get snapshot() { 5195 return this.view.snapshot; 5196 } 5197}; 5198function extendURLWithDeprecatedProperties(url) { 5199 Object.defineProperties(url, deprecatedLocationPropertyDescriptors); 5200} 5201var deprecatedLocationPropertyDescriptors = { 5202 absoluteURL: { 5203 get() { 5204 return this.toString(); 5205 } 5206 } 5207}; 5208var session = new Session(recentRequests); 5209var { cache, navigator: navigator$1 } = session; 5210function start() { 5211 session.start(); 5212} 5213function registerAdapter(adapter) { 5214 session.registerAdapter(adapter); 5215} 5216function visit(location2, options) { 5217 session.visit(location2, options); 5218} 5219function connectStreamSource(source) { 5220 session.connectStreamSource(source); 5221} 5222function disconnectStreamSource(source) { 5223 session.disconnectStreamSource(source); 5224} 5225function renderStreamMessage(message) { 5226 session.renderStreamMessage(message); 5227} 5228function clearCache() { 5229 console.warn( 5230 "Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`" 5231 ); 5232 session.clearCache(); 5233} 5234function setProgressBarDelay(delay) { 5235 console.warn( 5236 "Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`" 5237 ); 5238 config.drive.progressBarDelay = delay; 5239} 5240function setConfirmMethod(confirmMethod) { 5241 console.warn( 5242 "Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`" 5243 ); 5244 config.forms.confirm = confirmMethod; 5245} 5246function setFormMode(mode) { 5247 console.warn( 5248 "Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`" 5249 ); 5250 config.forms.mode = mode; 5251} 5252var Turbo2 = /* @__PURE__ */ Object.freeze({ 5253 __proto__: null, 5254 navigator: navigator$1, 5255 session, 5256 cache, 5257 PageRenderer, 5258 PageSnapshot, 5259 FrameRenderer, 5260 fetch: fetchWithTurboHeaders, 5261 config, 5262 start, 5263 registerAdapter, 5264 visit, 5265 connectStreamSource, 5266 disconnectStreamSource, 5267 renderStreamMessage, 5268 clearCache, 5269 setProgressBarDelay, 5270 setConfirmMethod, 5271 setFormMode 5272}); 5273var TurboFrameMissingError = class extends Error { 5274}; 5275var FrameController = class { 5276 fetchResponseLoaded = (_fetchResponse) => Promise.resolve(); 5277 #currentFetchRequest = null; 5278 #resolveVisitPromise = () => { 5279 }; 5280 #connected = false; 5281 #hasBeenLoaded = false; 5282 #ignoredAttributes = /* @__PURE__ */ new Set(); 5283 #shouldMorphFrame = false; 5284 action = null; 5285 constructor(element) { 5286 this.element = element; 5287 this.view = new FrameView(this, this.element); 5288 this.appearanceObserver = new AppearanceObserver(this, this.element); 5289 this.formLinkClickObserver = new FormLinkClickObserver(this, this.element); 5290 this.linkInterceptor = new LinkInterceptor(this, this.element); 5291 this.restorationIdentifier = uuid(); 5292 this.formSubmitObserver = new FormSubmitObserver(this, this.element); 5293 } 5294 // Frame delegate 5295 connect() { 5296 if (!this.#connected) { 5297 this.#connected = true; 5298 if (this.loadingStyle == FrameLoadingStyle.lazy) { 5299 this.appearanceObserver.start(); 5300 } else { 5301 this.#loadSourceURL(); 5302 } 5303 this.formLinkClickObserver.start(); 5304 this.linkInterceptor.start(); 5305 this.formSubmitObserver.start(); 5306 } 5307 } 5308 disconnect() { 5309 if (this.#connected) { 5310 this.#connected = false; 5311 this.appearanceObserver.stop(); 5312 this.formLinkClickObserver.stop(); 5313 this.linkInterceptor.stop(); 5314 this.formSubmitObserver.stop(); 5315 } 5316 } 5317 disabledChanged() { 5318 if (this.loadingStyle == FrameLoadingStyle.eager) { 5319 this.#loadSourceURL(); 5320 } 5321 } 5322 sourceURLChanged() { 5323 if (this.#isIgnoringChangesTo("src")) return; 5324 if (this.element.isConnected) { 5325 this.complete = false; 5326 } 5327 if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) { 5328 this.#loadSourceURL(); 5329 } 5330 } 5331 sourceURLReloaded() { 5332 const { refresh, src } = this.element; 5333 this.#shouldMorphFrame = src && refresh === "morph"; 5334 this.element.removeAttribute("complete"); 5335 this.element.src = null; 5336 this.element.src = src; 5337 return this.element.loaded; 5338 } 5339 loadingStyleChanged() { 5340 if (this.loadingStyle == FrameLoadingStyle.lazy) { 5341 this.appearanceObserver.start(); 5342 } else { 5343 this.appearanceObserver.stop(); 5344 this.#loadSourceURL(); 5345 } 5346 } 5347 async #loadSourceURL() { 5348 if (this.enabled && this.isActive && !this.complete && this.sourceURL) { 5349 this.element.loaded = this.#visit(expandURL(this.sourceURL)); 5350 this.appearanceObserver.stop(); 5351 await this.element.loaded; 5352 this.#hasBeenLoaded = true; 5353 } 5354 } 5355 async loadResponse(fetchResponse) { 5356 if (fetchResponse.redirected || fetchResponse.succeeded && fetchResponse.isHTML) { 5357 this.sourceURL = fetchResponse.response.url; 5358 } 5359 try { 5360 const html = await fetchResponse.responseHTML; 5361 if (html) { 5362 const document2 = parseHTMLDocument(html); 5363 const pageSnapshot = PageSnapshot.fromDocument(document2); 5364 if (pageSnapshot.isVisitable) { 5365 await this.#loadFrameResponse(fetchResponse, document2); 5366 } else { 5367 await this.#handleUnvisitableFrameResponse(fetchResponse); 5368 } 5369 } 5370 } finally { 5371 this.#shouldMorphFrame = false; 5372 this.fetchResponseLoaded = () => Promise.resolve(); 5373 } 5374 } 5375 // Appearance observer delegate 5376 elementAppearedInViewport(element) { 5377 this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element)); 5378 this.#loadSourceURL(); 5379 } 5380 // Form link click observer delegate 5381 willSubmitFormLinkToLocation(link) { 5382 return this.#shouldInterceptNavigation(link); 5383 } 5384 submittedFormLinkToLocation(link, _location, form) { 5385 const frame = this.#findFrameElement(link); 5386 if (frame) form.setAttribute("data-turbo-frame", frame.id); 5387 } 5388 // Link interceptor delegate 5389 shouldInterceptLinkClick(element, _location, _event) { 5390 return this.#shouldInterceptNavigation(element); 5391 } 5392 linkClickIntercepted(element, location2) { 5393 this.#navigateFrame(element, location2); 5394 } 5395 // Form submit observer delegate 5396 willSubmitForm(element, submitter2) { 5397 return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter2); 5398 } 5399 formSubmitted(element, submitter2) { 5400 if (this.formSubmission) { 5401 this.formSubmission.stop(); 5402 } 5403 this.formSubmission = new FormSubmission(this, element, submitter2); 5404 const { fetchRequest } = this.formSubmission; 5405 this.prepareRequest(fetchRequest); 5406 this.formSubmission.start(); 5407 } 5408 // Fetch request delegate 5409 prepareRequest(request) { 5410 request.headers["Turbo-Frame"] = this.id; 5411 if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) { 5412 request.acceptResponseType(StreamMessage.contentType); 5413 } 5414 } 5415 requestStarted(_request) { 5416 markAsBusy(this.element); 5417 } 5418 requestPreventedHandlingResponse(_request, _response) { 5419 this.#resolveVisitPromise(); 5420 } 5421 async requestSucceededWithResponse(request, response) { 5422 await this.loadResponse(response); 5423 this.#resolveVisitPromise(); 5424 } 5425 async requestFailedWithResponse(request, response) { 5426 await this.loadResponse(response); 5427 this.#resolveVisitPromise(); 5428 } 5429 requestErrored(request, error) { 5430 console.error(error); 5431 this.#resolveVisitPromise(); 5432 } 5433 requestFinished(_request) { 5434 clearBusyState(this.element); 5435 } 5436 // Form submission delegate 5437 formSubmissionStarted({ formElement }) { 5438 markAsBusy(formElement, this.#findFrameElement(formElement)); 5439 } 5440 formSubmissionSucceededWithResponse(formSubmission, response) { 5441 const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter); 5442 frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame)); 5443 frame.delegate.loadResponse(response); 5444 if (!formSubmission.isSafe) { 5445 session.clearCache(); 5446 } 5447 } 5448 formSubmissionFailedWithResponse(formSubmission, fetchResponse) { 5449 this.element.delegate.loadResponse(fetchResponse); 5450 session.clearCache(); 5451 } 5452 formSubmissionErrored(formSubmission, error) { 5453 console.error(error); 5454 } 5455 formSubmissionFinished({ formElement }) { 5456 clearBusyState(formElement, this.#findFrameElement(formElement)); 5457 } 5458 // View delegate 5459 allowsImmediateRender({ element: newFrame }, options) { 5460 const event = dispatch("turbo:before-frame-render", { 5461 target: this.element, 5462 detail: { newFrame, ...options }, 5463 cancelable: true 5464 }); 5465 const { 5466 defaultPrevented, 5467 detail: { render } 5468 } = event; 5469 if (this.view.renderer && render) { 5470 this.view.renderer.renderElement = render; 5471 } 5472 return !defaultPrevented; 5473 } 5474 viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) { 5475 } 5476 preloadOnLoadLinksForView(element) { 5477 session.preloadOnLoadLinksForView(element); 5478 } 5479 viewInvalidated() { 5480 } 5481 // Frame renderer delegate 5482 willRenderFrame(currentElement, _newElement) { 5483 this.previousFrameElement = currentElement.cloneNode(true); 5484 } 5485 visitCachedSnapshot = ({ element }) => { 5486 const frame = element.querySelector("#" + this.element.id); 5487 if (frame && this.previousFrameElement) { 5488 frame.replaceChildren(...this.previousFrameElement.children); 5489 } 5490 delete this.previousFrameElement; 5491 }; 5492 // Private 5493 async #loadFrameResponse(fetchResponse, document2) { 5494 const newFrameElement = await this.extractForeignFrameElement(document2.body); 5495 const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer; 5496 if (newFrameElement) { 5497 const snapshot = new Snapshot(newFrameElement); 5498 const renderer = new rendererClass(this, this.view.snapshot, snapshot, false, false); 5499 if (this.view.renderPromise) await this.view.renderPromise; 5500 this.changeHistory(); 5501 await this.view.render(renderer); 5502 this.complete = true; 5503 session.frameRendered(fetchResponse, this.element); 5504 session.frameLoaded(this.element); 5505 await this.fetchResponseLoaded(fetchResponse); 5506 } else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) { 5507 this.#handleFrameMissingFromResponse(fetchResponse); 5508 } 5509 } 5510 async #visit(url) { 5511 const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element); 5512 this.#currentFetchRequest?.cancel(); 5513 this.#currentFetchRequest = request; 5514 return new Promise((resolve) => { 5515 this.#resolveVisitPromise = () => { 5516 this.#resolveVisitPromise = () => { 5517 }; 5518 this.#currentFetchRequest = null; 5519 resolve(); 5520 }; 5521 request.perform(); 5522 }); 5523 } 5524 #navigateFrame(element, url, submitter2) { 5525 const frame = this.#findFrameElement(element, submitter2); 5526 frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter2, element, frame)); 5527 this.#withCurrentNavigationElement(element, () => { 5528 frame.src = url; 5529 }); 5530 } 5531 proposeVisitIfNavigatedWithAction(frame, action = null) { 5532 this.action = action; 5533 if (this.action) { 5534 const pageSnapshot = PageSnapshot.fromElement(frame).clone(); 5535 const { visitCachedSnapshot } = frame.delegate; 5536 frame.delegate.fetchResponseLoaded = async (fetchResponse) => { 5537 if (frame.src) { 5538 const { statusCode, redirected } = fetchResponse; 5539 const responseHTML = await fetchResponse.responseHTML; 5540 const response = { statusCode, redirected, responseHTML }; 5541 const options = { 5542 response, 5543 visitCachedSnapshot, 5544 willRender: false, 5545 updateHistory: false, 5546 restorationIdentifier: this.restorationIdentifier, 5547 snapshot: pageSnapshot 5548 }; 5549 if (this.action) options.action = this.action; 5550 session.visit(frame.src, options); 5551 } 5552 }; 5553 } 5554 } 5555 changeHistory() { 5556 if (this.action) { 5557 const method = getHistoryMethodForAction(this.action); 5558 session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier); 5559 } 5560 } 5561 async #handleUnvisitableFrameResponse(fetchResponse) { 5562 console.warn( 5563 `The response (${fetchResponse.statusCode}) from <turbo-frame id="${this.element.id}"> is performing a full page visit due to turbo-visit-control.` 5564 ); 5565 await this.#visitResponse(fetchResponse.response); 5566 } 5567 #willHandleFrameMissingFromResponse(fetchResponse) { 5568 this.element.setAttribute("complete", ""); 5569 const response = fetchResponse.response; 5570 const visit2 = async (url, options) => { 5571 if (url instanceof Response) { 5572 this.#visitResponse(url); 5573 } else { 5574 session.visit(url, options); 5575 } 5576 }; 5577 const event = dispatch("turbo:frame-missing", { 5578 target: this.element, 5579 detail: { response, visit: visit2 }, 5580 cancelable: true 5581 }); 5582 return !event.defaultPrevented; 5583 } 5584 #handleFrameMissingFromResponse(fetchResponse) { 5585 this.view.missing(); 5586 this.#throwFrameMissingError(fetchResponse); 5587 } 5588 #throwFrameMissingError(fetchResponse) { 5589 const message = `The response (${fetchResponse.statusCode}) did not contain the expected <turbo-frame id="${this.element.id}"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`; 5590 throw new TurboFrameMissingError(message); 5591 } 5592 async #visitResponse(response) { 5593 const wrapped = new FetchResponse(response); 5594 const responseHTML = await wrapped.responseHTML; 5595 const { location: location2, redirected, statusCode } = wrapped; 5596 return session.visit(location2, { response: { redirected, statusCode, responseHTML } }); 5597 } 5598 #findFrameElement(element, submitter2) { 5599 const id = getAttribute("data-turbo-frame", submitter2, element) || this.element.getAttribute("target"); 5600 return getFrameElementById(id) ?? this.element; 5601 } 5602 async extractForeignFrameElement(container) { 5603 let element; 5604 const id = CSS.escape(this.id); 5605 try { 5606 element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL); 5607 if (element) { 5608 return element; 5609 } 5610 element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL); 5611 if (element) { 5612 await element.loaded; 5613 return await this.extractForeignFrameElement(element); 5614 } 5615 } catch (error) { 5616 console.error(error); 5617 return new FrameElement(); 5618 } 5619 return null; 5620 } 5621 #formActionIsVisitable(form, submitter2) { 5622 const action = getAction$1(form, submitter2); 5623 return locationIsVisitable(expandURL(action), this.rootLocation); 5624 } 5625 #shouldInterceptNavigation(element, submitter2) { 5626 const id = getAttribute("data-turbo-frame", submitter2, element) || this.element.getAttribute("target"); 5627 if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter2)) { 5628 return false; 5629 } 5630 if (!this.enabled || id == "_top") { 5631 return false; 5632 } 5633 if (id) { 5634 const frameElement = getFrameElementById(id); 5635 if (frameElement) { 5636 return !frameElement.disabled; 5637 } 5638 } 5639 if (!session.elementIsNavigatable(element)) { 5640 return false; 5641 } 5642 if (submitter2 && !session.elementIsNavigatable(submitter2)) { 5643 return false; 5644 } 5645 return true; 5646 } 5647 // Computed properties 5648 get id() { 5649 return this.element.id; 5650 } 5651 get enabled() { 5652 return !this.element.disabled; 5653 } 5654 get sourceURL() { 5655 if (this.element.src) { 5656 return this.element.src; 5657 } 5658 } 5659 set sourceURL(sourceURL) { 5660 this.#ignoringChangesToAttribute("src", () => { 5661 this.element.src = sourceURL ?? null; 5662 }); 5663 } 5664 get loadingStyle() { 5665 return this.element.loading; 5666 } 5667 get isLoading() { 5668 return this.formSubmission !== void 0 || this.#resolveVisitPromise() !== void 0; 5669 } 5670 get complete() { 5671 return this.element.hasAttribute("complete"); 5672 } 5673 set complete(value) { 5674 if (value) { 5675 this.element.setAttribute("complete", ""); 5676 } else { 5677 this.element.removeAttribute("complete"); 5678 } 5679 } 5680 get isActive() { 5681 return this.element.isActive && this.#connected; 5682 } 5683 get rootLocation() { 5684 const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`); 5685 const root = meta?.content ?? "/"; 5686 return expandURL(root); 5687 } 5688 #isIgnoringChangesTo(attributeName) { 5689 return this.#ignoredAttributes.has(attributeName); 5690 } 5691 #ignoringChangesToAttribute(attributeName, callback) { 5692 this.#ignoredAttributes.add(attributeName); 5693 callback(); 5694 this.#ignoredAttributes.delete(attributeName); 5695 } 5696 #withCurrentNavigationElement(element, callback) { 5697 this.currentNavigationElement = element; 5698 callback(); 5699 delete this.currentNavigationElement; 5700 } 5701}; 5702function getFrameElementById(id) { 5703 if (id != null) { 5704 const element = document.getElementById(id); 5705 if (element instanceof FrameElement) { 5706 return element; 5707 } 5708 } 5709} 5710function activateElement(element, currentURL) { 5711 if (element) { 5712 const src = element.getAttribute("src"); 5713 if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) { 5714 throw new Error(`Matching <turbo-frame id="${element.id}"> element has a source URL which references itself`); 5715 } 5716 if (element.ownerDocument !== document) { 5717 element = document.importNode(element, true); 5718 } 5719 if (element instanceof FrameElement) { 5720 element.connectedCallback(); 5721 element.disconnectedCallback(); 5722 return element; 5723 } 5724 } 5725} 5726var StreamActions = { 5727 after() { 5728 this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling)); 5729 }, 5730 append() { 5731 this.removeDuplicateTargetChildren(); 5732 this.targetElements.forEach((e) => e.append(this.templateContent)); 5733 }, 5734 before() { 5735 this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e)); 5736 }, 5737 prepend() { 5738 this.removeDuplicateTargetChildren(); 5739 this.targetElements.forEach((e) => e.prepend(this.templateContent)); 5740 }, 5741 remove() { 5742 this.targetElements.forEach((e) => e.remove()); 5743 }, 5744 replace() { 5745 const method = this.getAttribute("method"); 5746 this.targetElements.forEach((targetElement) => { 5747 if (method === "morph") { 5748 morphElements(targetElement, this.templateContent); 5749 } else { 5750 targetElement.replaceWith(this.templateContent); 5751 } 5752 }); 5753 }, 5754 update() { 5755 const method = this.getAttribute("method"); 5756 this.targetElements.forEach((targetElement) => { 5757 if (method === "morph") { 5758 morphChildren(targetElement, this.templateContent); 5759 } else { 5760 targetElement.innerHTML = ""; 5761 targetElement.append(this.templateContent); 5762 } 5763 }); 5764 }, 5765 refresh() { 5766 session.refresh(this.baseURI, this.requestId); 5767 } 5768}; 5769var StreamElement = class _StreamElement extends HTMLElement { 5770 static async renderElement(newElement) { 5771 await newElement.performAction(); 5772 } 5773 async connectedCallback() { 5774 try { 5775 await this.render(); 5776 } catch (error) { 5777 console.error(error); 5778 } finally { 5779 this.disconnect(); 5780 } 5781 } 5782 async render() { 5783 return this.renderPromise ??= (async () => { 5784 const event = this.beforeRenderEvent; 5785 if (this.dispatchEvent(event)) { 5786 await nextRepaint(); 5787 await event.detail.render(this); 5788 } 5789 })(); 5790 } 5791 disconnect() { 5792 try { 5793 this.remove(); 5794 } catch { 5795 } 5796 } 5797 /** 5798 * Removes duplicate children (by ID) 5799 */ 5800 removeDuplicateTargetChildren() { 5801 this.duplicateChildren.forEach((c) => c.remove()); 5802 } 5803 /** 5804 * Gets the list of duplicate children (i.e. those with the same ID) 5805 */ 5806 get duplicateChildren() { 5807 const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.getAttribute("id")); 5808 const newChildrenIds = [...this.templateContent?.children || []].filter((c) => !!c.getAttribute("id")).map((c) => c.getAttribute("id")); 5809 return existingChildren.filter((c) => newChildrenIds.includes(c.getAttribute("id"))); 5810 } 5811 /** 5812 * Gets the action function to be performed. 5813 */ 5814 get performAction() { 5815 if (this.action) { 5816 const actionFunction = StreamActions[this.action]; 5817 if (actionFunction) { 5818 return actionFunction; 5819 } 5820 this.#raise("unknown action"); 5821 } 5822 this.#raise("action attribute is missing"); 5823 } 5824 /** 5825 * Gets the target elements which the template will be rendered to. 5826 */ 5827 get targetElements() { 5828 if (this.target) { 5829 return this.targetElementsById; 5830 } else if (this.targets) { 5831 return this.targetElementsByQuery; 5832 } else { 5833 this.#raise("target or targets attribute is missing"); 5834 } 5835 } 5836 /** 5837 * Gets the contents of the main `<template>`. 5838 */ 5839 get templateContent() { 5840 return this.templateElement.content.cloneNode(true); 5841 } 5842 /** 5843 * Gets the main `<template>` used for rendering 5844 */ 5845 get templateElement() { 5846 if (this.firstElementChild === null) { 5847 const template = this.ownerDocument.createElement("template"); 5848 this.appendChild(template); 5849 return template; 5850 } else if (this.firstElementChild instanceof HTMLTemplateElement) { 5851 return this.firstElementChild; 5852 } 5853 this.#raise("first child element must be a <template> element"); 5854 } 5855 /** 5856 * Gets the current action. 5857 */ 5858 get action() { 5859 return this.getAttribute("action"); 5860 } 5861 /** 5862 * Gets the current target (an element ID) to which the result will 5863 * be rendered. 5864 */ 5865 get target() { 5866 return this.getAttribute("target"); 5867 } 5868 /** 5869 * Gets the current "targets" selector (a CSS selector) 5870 */ 5871 get targets() { 5872 return this.getAttribute("targets"); 5873 } 5874 /** 5875 * Reads the request-id attribute 5876 */ 5877 get requestId() { 5878 return this.getAttribute("request-id"); 5879 } 5880 #raise(message) { 5881 throw new Error(`${this.description}: ${message}`); 5882 } 5883 get description() { 5884 return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"; 5885 } 5886 get beforeRenderEvent() { 5887 return new CustomEvent("turbo:before-stream-render", { 5888 bubbles: true, 5889 cancelable: true, 5890 detail: { newStream: this, render: _StreamElement.renderElement } 5891 }); 5892 } 5893 get targetElementsById() { 5894 const element = this.ownerDocument?.getElementById(this.target); 5895 if (element !== null) { 5896 return [element]; 5897 } else { 5898 return []; 5899 } 5900 } 5901 get targetElementsByQuery() { 5902 const elements = this.ownerDocument?.querySelectorAll(this.targets); 5903 if (elements.length !== 0) { 5904 return Array.prototype.slice.call(elements); 5905 } else { 5906 return []; 5907 } 5908 } 5909}; 5910var StreamSourceElement = class extends HTMLElement { 5911 streamSource = null; 5912 connectedCallback() { 5913 this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src); 5914 connectStreamSource(this.streamSource); 5915 } 5916 disconnectedCallback() { 5917 if (this.streamSource) { 5918 this.streamSource.close(); 5919 disconnectStreamSource(this.streamSource); 5920 } 5921 } 5922 get src() { 5923 return this.getAttribute("src") || ""; 5924 } 5925}; 5926FrameElement.delegateConstructor = FrameController; 5927if (customElements.get("turbo-frame") === void 0) { 5928 customElements.define("turbo-frame", FrameElement); 5929} 5930if (customElements.get("turbo-stream") === void 0) { 5931 customElements.define("turbo-stream", StreamElement); 5932} 5933if (customElements.get("turbo-stream-source") === void 0) { 5934 customElements.define("turbo-stream-source", StreamSourceElement); 5935} 5936(() => { 5937 let element = document.currentScript; 5938 if (!element) return; 5939 if (element.hasAttribute("data-turbo-suppress-warning")) return; 5940 element = element.parentElement; 5941 while (element) { 5942 if (element == document.body) { 5943 return console.warn( 5944 unindent` 5945 You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do! 5946 5947 Load your applicationâs JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change. 5948 5949 For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements 5950 5951 ââ 5952 Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s 5953 `, 5954 element.outerHTML 5955 ); 5956 } 5957 element = element.parentElement; 5958 } 5959})(); 5960window.Turbo = { ...Turbo2, StreamActions }; 5961start(); 5962
vendor: 575 bytes, lines 5963-5979
5963// node_modules/@hotwired/turbo-rails/app/javascript/turbo/cable.js 5964var consumer; 5965async function getConsumer() { 5966 return consumer || setConsumer(createConsumer2().then(setConsumer)); 5967} 5968function setConsumer(newConsumer) { 5969 return consumer = newConsumer; 5970} 5971async function createConsumer2() { 5972 const { createConsumer: createConsumer3 } = await Promise.resolve().then(() => (init_src(), src_exports)); 5973 return createConsumer3(); 5974} 5975async function subscribeTo(channel, mixin) { 5976 const { subscriptions } = await getConsumer(); 5977 return subscriptions.create(channel, mixin); 5978} 5979
vendor: 505 bytes, lines 5980-5993
5980// node_modules/@hotwired/turbo-rails/app/javascript/turbo/snakeize.js 5981function walk(obj) { 5982 if (!obj || typeof obj !== "object") return obj; 5983 if (obj instanceof Date || obj instanceof RegExp) return obj; 5984 if (Array.isArray(obj)) return obj.map(walk); 5985 return Object.keys(obj).reduce(function(acc, key) { 5986 var camel = key[0].toLowerCase() + key.slice(1).replace(/([A-Z]+)/g, function(m, x) { 5987 return "_" + x.toLowerCase(); 5988 }); 5989 acc[camel] = walk(obj[key]); 5990 return acc; 5991 }, {}); 5992} 5993
vendor: 1,471 bytes, lines 5994-6035
5994// node_modules/@hotwired/turbo-rails/app/javascript/turbo/cable_stream_source_element.js 5995var TurboCableStreamSourceElement = class extends HTMLElement { 5996 static observedAttributes = ["channel", "signed-stream-name"]; 5997 async connectedCallback() { 5998 connectStreamSource(this); 5999 this.subscription = await subscribeTo(this.channel, { 6000 received: this.dispatchMessageEvent.bind(this), 6001 connected: this.subscriptionConnected.bind(this), 6002 disconnected: this.subscriptionDisconnected.bind(this) 6003 }); 6004 } 6005 disconnectedCallback() { 6006 disconnectStreamSource(this); 6007 if (this.subscription) this.subscription.unsubscribe(); 6008 this.subscriptionDisconnected(); 6009 } 6010 attributeChangedCallback() { 6011 if (this.subscription) { 6012 this.disconnectedCallback(); 6013 this.connectedCallback(); 6014 } 6015 } 6016 dispatchMessageEvent(data) { 6017 const event = new MessageEvent("message", { data }); 6018 return this.dispatchEvent(event); 6019 } 6020 subscriptionConnected() { 6021 this.setAttribute("connected", ""); 6022 } 6023 subscriptionDisconnected() { 6024 this.removeAttribute("connected"); 6025 } 6026 get channel() { 6027 const channel = this.getAttribute("channel"); 6028 const signed_stream_name = this.getAttribute("signed-stream-name"); 6029 return { channel, signed_stream_name, ...walk({ ...this.dataset }) }; 6030 } 6031}; 6032if (customElements.get("turbo-cable-stream-source") === void 0) { 6033 customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement); 6034} 6035
vendor: 1,611 bytes, lines 6036-6082
6036// node_modules/@hotwired/turbo-rails/app/javascript/turbo/fetch_requests.js 6037function encodeMethodIntoRequestBody(event) { 6038 if (event.target instanceof HTMLFormElement) { 6039 const { target: form, detail: { fetchOptions } } = event; 6040 form.addEventListener("turbo:submit-start", ({ detail: { formSubmission: { submitter: submitter2 } } }) => { 6041 const body = isBodyInit(fetchOptions.body) ? fetchOptions.body : new URLSearchParams(); 6042 const method = determineFetchMethod(submitter2, body, form); 6043 if (!/get/i.test(method)) { 6044 if (/post/i.test(method)) { 6045 body.delete("_method"); 6046 } else { 6047 body.set("_method", method); 6048 } 6049 fetchOptions.method = "post"; 6050 } 6051 }, { once: true }); 6052 } 6053} 6054function determineFetchMethod(submitter2, body, form) { 6055 const formMethod = determineFormMethod(submitter2); 6056 const overrideMethod = body.get("_method"); 6057 const method = form.getAttribute("method") || "get"; 6058 if (typeof formMethod == "string") { 6059 return formMethod; 6060 } else if (typeof overrideMethod == "string") { 6061 return overrideMethod; 6062 } else { 6063 return method; 6064 } 6065} 6066function determineFormMethod(submitter2) { 6067 if (submitter2 instanceof HTMLButtonElement || submitter2 instanceof HTMLInputElement) { 6068 if (submitter2.name === "_method") { 6069 return submitter2.value; 6070 } else if (submitter2.hasAttribute("formmethod")) { 6071 return submitter2.formMethod; 6072 } else { 6073 return null; 6074 } 6075 } else { 6076 return null; 6077 } 6078} 6079function isBodyInit(body) { 6080 return body instanceof FormData || body instanceof URLSearchParams; 6081} 6082
vendor: 187 bytes, lines 6083-6086
6083// node_modules/@hotwired/turbo-rails/app/javascript/turbo/index.js 6084window.Turbo = turbo_es2017_esm_exports; 6085addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody); 6086
6087// app/javascript/modal/templates.js 6088function initializeTemplates(modalInstance) { 6089 const modal = document.getElementById("templates-modal"); 6090 if (!modal) return; 6091 let selectedTemplate = null; 6092 let activeCategory = "all"; 6093 const templateInput = document.querySelector('input[name="template"]'); 6094 const currentTemplate = templateInput ? templateInput.value : null; 6095 const templateNodes = modal.querySelectorAll(".template-node"); 6096 if (currentTemplate && currentTemplate !== "none") { 6097 templateNodes.forEach((node) => { 6098 if (node.dataset.template === currentTemplate) { 6099 node.classList.add("ring-2", "ring-active"); 6100 } 6101 }); 6102 } 6103 templateNodes.forEach((node) => { 6104 node.addEventListener("click", () => { 6105 selectedTemplate = node.dataset.template; 6106 const templateData = window.templateData && window.templateData[selectedTemplate]; 6107 const templateName = templateData ? templateData.name : "Template"; 6108 const templateImage = templateData ? templateData.image : ""; 6109 if (window.videoGenerator) { 6110 const templateObj = { 6111 value: selectedTemplate, 6112 name: templateName, 6113 image: templateImage 6114 };
6115 window.videoGenerator.setTemplate(templateObj); 6116 window.videoGenerator.saveFormValues(); 6117 } else if (window.imageGenerator) { 6118 const templateObj = { 6119 value: selectedTemplate, 6120 name: templateName, 6121 image: templateImage 6122 }; 6123 window.imageGenerator.setTemplate(templateObj); 6124 const form = document.getElementById("generate-form"); 6125 if (form) { 6126 const templateInput2 = form.querySelector('input[name="template"]'); 6127 if (templateInput2) { 6128 templateInput2.remove(); 6129 } 6130 } 6131 window.imageGenerator.saveFormValues(); 6132 } 6133 modalInstance.close(); 6134 }); 6135 }); 6136 function getTemplateDisplayName(templateId) { 6137 const templateNames = { 6138 generate: "Generate", 6139 "cinematic-portrait": "Cinematic Portrait", 6140 "landscape-nature": "Nature Landscape", 6141 "square-minimal": "Minimal Square", 6142 "artistic-abstract": "Abstract Art", 6143 "urban-portrait": "Urban Portrait", 6144 "mountain-landscape": "Mountain Vista", 6145 "geometric-square": "Geometric", 6146 "watercolor-artistic": "Watercolor", 6147 "vintage-portrait": "Vintage Portrait" 6148 }; 6149 return templateNames[templateId] || templateId; 6150 } 6151 function getTemplateImageUrl(templateId) { 6152 const templateImages = { 6153 generate: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgdmlld0JveD0iMCAwIDQwMCAzMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxkZWZzPgo8bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPgo8c3RvcCBvZmZzZXQ9IjAlIiBzdHlsZT0ic3RvcC1jb2xvcjojNjM2NmYxO3N0b3Atb3BhY2l0eToxIiAvPgo8c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiM4YjVjZjY7c3RvcC1vcGFjaXR5OjEiIC8+CjwvbGluZWFyR3JhZGllbnQ+CjwvZGVmcz4KPHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSIzMDAiIGZpbGw9InVybCgjZ3JhZCkiLz4KPHN2ZyB4PSIxNzUiIHk9IjEyNSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjUwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMiI+CjxwYXRoIGQ9Im0xMi44NTMgMy4xNDMuNzI1LTIuNjQ0YTEuMTIgMS4xMiAwIDAgMCAwLS45OThhMS4xMiAxLjEyIDAgMCAwLS43NjYtLjUwMWMtLjQ4OC0uMDMtLjk4OS4yNjUtMS4xNjIuNzY0bC0uNzI0IDIuNjQ5QzYuNjg1IDMuNDEyIDIuNjY2IDcuMzg0IDIuNjY2IDEyLjNjMCA1Ljc0OCA0LjY1MiAxMC40IDEwLjQgMTAuNHM5Ljg2Ny00LjY1MiA5Ljg2Ny0xMC40YzAtNC45MTYtMy40ODQtOC44ODgtNy44LTkuODg3elM/CjwvZz4KPC9zdmc+Cjwvc3ZnPgo=", 6154 "cinematic-portrait": "https://images.unsplash.com/photo-1566753323558-f4e0952af115?w=400&h=300&fit=crop", 6155 "landscape-nature": "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop", 6156 "square-minimal": "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&h=400&fit=crop", 6157 "artistic-abstract": "https://images.unsplash.com/photo-1541961017774-22349e4a1262?w=400&h=300&fit=crop", 6158 "urban-portrait": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=300&fit=crop", 6159 "mountain-landscape": "https://images.unsplash.com/photo-1464822759844-d150bfb5b37c?w=400&h=300&fit=crop", 6160 "geometric-square": "https://images.unsplash.com/photo-1557683316-973673baf926?w=400&h=400&fit=crop", 6161 "watercolor-artistic": "https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&h=300&fit=crop", 6162 "vintage-portrait": "https://images.unsplash.com/photo-1494790108755-2616c6d1fd22?w=400&h=300&fit=crop" 6163 }; 6164 return templateImages[templateId] || templateImages["generate"]; 6165 } 6166} 6167
6168// app/javascript/modal/styles.js 6169function initializeStyles(modalInstance) { 6170 const modal = document.getElementById("styles-modal"); 6171 if (!modal) return; 6172 let selectedStyle = null; 6173 let activeCategory = "photography"; 6174 const styleInput = document.querySelector('input[name="style"]'); 6175 const currentStyle = styleInput ? styleInput.value : null; 6176 const filterButtons = modal.querySelectorAll(".category-filter-btn"); 6177 const categorySections = modal.querySelectorAll(".category-styles-section"); 6178 if (currentStyle && currentStyle !== "none") { 6179 const styleNodes2 = modal.querySelectorAll(".style-node"); 6180 styleNodes2.forEach((node) => { 6181 if (node.dataset.style === currentStyle) { 6182 const categorySection = node.closest(".category-styles-section"); 6183 if (categorySection) { 6184 const category = categorySection.dataset.categorySection; 6185 activeCategory = category; 6186 categorySections.forEach((section) => { 6187 if (section.dataset.categorySection === category) { 6188 section.classList.remove("hidden"); 6189 } else { 6190 section.classList.add("hidden"); 6191 } 6192 }); 6193 filterButtons.forEach((filterBtn) => { 6194 if (filterBtn.dataset.category === category) { 6195 filterBtn.classList.add("active"); 6196 } else { 6197 filterBtn.classList.remove("active"); 6198 } 6199 }); 6200 } 6201 const textElement = node.querySelector(".text-gray-400"); 6202 if (textElement) { 6203 textElement.classList.remove("text-gray-400"); 6204 textElement.classList.add("text-white"); 6205 } 6206 } 6207 }); 6208 } 6209 filterButtons.forEach((btn) => { 6210 btn.addEventListener("click", () => { 6211 const category = btn.dataset.category; 6212 filterButtons.forEach((filterBtn) => { 6213 if (filterBtn === btn) { 6214 filterBtn.classList.add("active"); 6215 } else { 6216 filterBtn.classList.remove("active"); 6217 } 6218 }); 6219 categorySections.forEach((section) => { 6220 if (section.dataset.categorySection === category) { 6221 section.classList.remove("hidden"); 6222 } else { 6223 section.classList.add("hidden"); 6224 } 6225 }); 6226 activeCategory = category; 6227 }); 6228 }); 6229 const styleNodes = modal.querySelectorAll(".style-node"); 6230 styleNodes.forEach((node) => { 6231 node.addEventListener("click", () => { 6232 selectedStyle = node.dataset.style; 6233 const styleName = node.dataset.styleName; 6234 const styleImageUrl = node.dataset.styleImage; 6235 const styleButton = document.getElementById("style-button"); 6236 if (styleButton) { 6237 const styleImageContainer = styleButton.querySelector( 6238 ".style-image-container" 6239 ); 6240 const styleImageDiv = styleButton.querySelector(".style-image"); 6241 const styleClearDiv = styleButton.querySelector(".style-clear"); 6242 const styleSpan = styleButton.querySelector("span.flex-1"); 6243 if (styleSpan) { 6244 styleSpan.textContent = styleName; 6245 } 6246 if (selectedStyle !== "none") { 6247 styleButton.classList.remove("text-gray"); 6248 styleButton.classList.add("text-white"); 6249 } else { 6250 styleButton.classList.remove("text-white"); 6251 styleButton.classList.add("text-gray"); 6252 } 6253 if (styleImageContainer) { 6254 console.log("Style selection:", selectedStyle); 6255 if (selectedStyle === "none" || selectedStyle === null) { 6256 console.log("Hiding style container"); 6257 styleImageContainer.classList.add("hidden"); 6258 } else { 6259 console.log("Showing style container"); 6260 styleImageContainer.classList.remove("hidden"); 6261 if (styleImageDiv) { 6262 styleImageDiv.style.backgroundImage = `url('${styleImageUrl}')`; 6263 styleImageDiv.classList.remove("hidden"); 6264 } 6265 if (styleClearDiv) { 6266 styleClearDiv.classList.remove("hidden"); 6267 styleClearDiv.classList.add("flex"); 6268 } 6269 } 6270 } else { 6271 console.log("Style container not found!"); 6272 } 6273 } 6274 const form = document.getElementById("generate-form"); 6275 if (form) { 6276 let styleInput2 = form.querySelector('input[name="style"]'); 6277 if (!styleInput2) { 6278 styleInput2 = document.createElement("input"); 6279 styleInput2.type = "hidden"; 6280 styleInput2.name = "style"; 6281 form.appendChild(styleInput2); 6282 } 6283 styleInput2.value = selectedStyle; 6284 } 6285 if (window.imageGenerator) {
6286 window.imageGenerator.saveFormValues(); 6287 } 6288 modalInstance.close(); 6289 }); 6290 }); 6291} 6292
6293// app/javascript/modal/voices.js 6294function initializeVoices(modalInstance) { 6295 const modal = document.getElementById("voices-modal"); 6296 if (!modal) return; 6297 if (window.voicesModalInstance) { 6298 window.voicesModalInstance.initializeElements(); 6299 } else { 6300 window.voicesModalInstance = new window.VoicesModal(); 6301 window.voicesModalInstance.modal = modal; 6302 window.voicesModalInstance.voicesGrid = document.getElementById("voices-grid"); 6303 window.voicesModalInstance.loadingElement = document.getElementById("voices-loading"); 6304 window.voicesModalInstance.errorElement = document.getElementById("voices-error"); 6305 window.voicesModalInstance.emptyElement = document.getElementById("voices-empty"); 6306 window.voicesModalInstance.paginationElement = document.getElementById("voices-pagination"); 6307 window.voicesModalInstance.setupEventListeners(); 6308 window.voicesModalInstance.loadLanguages(); 6309 window.voicesModalInstance.loadVoices(); 6310 } 6311} 6312
6313// app/javascript/modal/cameras.js 6314function initializeCameras(modalInstance) { 6315 const modal = document.getElementById("cameras-modal"); 6316 if (!modal) return; 6317 let selectedCamera = null; 6318 let activeCategory = "all"; 6319 const cameraInput = document.querySelector('input[name="camera"]'); 6320 const currentCamera = cameraInput ? cameraInput.value : null; 6321 const tabButtons = modal.querySelectorAll(".camera-tab-btn"); 6322 tabButtons.forEach((btn) => { 6323 btn.addEventListener("click", () => { 6324 tabButtons.forEach((tab) => { 6325 if (tab.dataset.category === btn.dataset.category) { 6326 tab.classList.add("active", "border-active", "text-white"); 6327 tab.classList.remove("border-transparent", "text-gray-400"); 6328 } else { 6329 tab.classList.remove("active", "border-active", "text-white"); 6330 tab.classList.add("border-transparent", "text-gray-400"); 6331 } 6332 }); 6333 const cameraNodes2 = modal.querySelectorAll(".camera-node"); 6334 cameraNodes2.forEach((node) => { 6335 const itemCategory = node.dataset.category; 6336 if (btn.dataset.category === "all" || itemCategory === btn.dataset.category) { 6337 node.style.display = "block"; 6338 } else { 6339 node.style.display = "none"; 6340 } 6341 }); 6342 activeCategory = btn.dataset.category; 6343 }); 6344 }); 6345 const cameraNodes = modal.querySelectorAll(".camera-node"); 6346 if (currentCamera && currentCamera !== "none") { 6347 cameraNodes.forEach((node) => { 6348 if (node.dataset.camera === currentCamera) { 6349 node.classList.add("ring-2", "ring-active"); 6350 } 6351 }); 6352 } 6353 cameraNodes.forEach((node) => { 6354 node.addEventListener("click", () => { 6355 selectedCamera = node.dataset.camera; 6356 const cameraData = window.cameraData && window.cameraData[selectedCamera]; 6357 const cameraName = cameraData ? cameraData.name : "Camera"; 6358 const cameraImageUrl = cameraData ? cameraData.image : ""; 6359 const cameraButton = document.getElementById("camera-button"); 6360 if (cameraButton) { 6361 const cameraImageContainer = cameraButton.querySelector( 6362 ".camera-image-container" 6363 ); 6364 const cameraImageDiv = cameraButton.querySelector(".camera-image"); 6365 const cameraClearDiv = cameraButton.querySelector(".camera-clear"); 6366 const cameraSpan = cameraButton.querySelector("span.flex-1"); 6367 if (cameraSpan) { 6368 cameraSpan.textContent = cameraName; 6369 } 6370 if (selectedCamera !== "none") { 6371 cameraButton.classList.remove("text-gray"); 6372 cameraButton.classList.add("text-white"); 6373 } else { 6374 cameraButton.classList.remove("text-white"); 6375 cameraButton.classList.add("text-gray"); 6376 } 6377 if (cameraImageContainer) { 6378 console.log("Camera selection:", selectedCamera); 6379 if (selectedCamera === "none" || selectedCamera === null) { 6380 console.log("Hiding camera container"); 6381 cameraImageContainer.classList.add("hidden"); 6382 } else { 6383 console.log("Showing camera container"); 6384 cameraImageContainer.classList.remove("hidden"); 6385 if (cameraImageDiv) { 6386 cameraImageDiv.style.backgroundImage = `url('${cameraImageUrl}')`; 6387 cameraImageDiv.classList.remove("hidden"); 6388 } 6389 if (cameraClearDiv) { 6390 cameraClearDiv.classList.remove("hidden"); 6391 cameraClearDiv.classList.add("flex"); 6392 } 6393 } 6394 } else { 6395 console.log("Camera container not found!"); 6396 } 6397 } 6398 const form = document.getElementById("generate-form"); 6399 if (form) { 6400 let cameraInput2 = form.querySelector('input[name="camera"]'); 6401 if (!cameraInput2) { 6402 cameraInput2 = document.createElement("input"); 6403 cameraInput2.type = "hidden"; 6404 cameraInput2.name = "camera"; 6405 form.appendChild(cameraInput2); 6406 } 6407 cameraInput2.value = selectedCamera; 6408 } 6409 if (window.videoGenerator) { 6410 window.videoGenerator.saveFormValues(); 6411 } else if (window.imageGenerator) {
6412 window.imageGenerator.saveFormValues(); 6413 } 6414 modalInstance.close(); 6415 }); 6416 }); 6417} 6418
6419// app/javascript/modal/creator.js 6420function initializeCreator(modalInstance) { 6421 const modal = document.getElementById("creator-modal"); 6422 if (!modal) return; 6423 let selectedCreator = null; 6424 const creatorInput = document.querySelector('input[name="creator"]'); 6425 const currentCreator = creatorInput ? creatorInput.value : null; 6426 const showFormBtn = modal.querySelector("#show-creator-form"); 6427 const cancelFormBtn = modal.querySelector("#cancel-creator-form"); 6428 const formContainer = modal.querySelector("#creator-form-container"); 6429 const creatorForm = modal.querySelector("#creator-form"); 6430 const uploadedHashes = /* @__PURE__ */ new Map(); 6431 let editMode = false; 6432 let editingReferenceId = null; 6433 const showForm = () => { 6434 if (formContainer && creatorForm) { 6435 formContainer.classList.remove("hidden"); 6436 formContainer.offsetHeight; 6437 requestAnimationFrame(() => { 6438 formContainer.style.transform = "translateY(0)"; 6439 setTimeout(() => { 6440 creatorForm.style.opacity = "1"; 6441 }, 300); 6442 }); 6443 } 6444 }; 6445 if (showFormBtn) { 6446 showFormBtn.addEventListener("click", (e) => { 6447 e.preventDefault(); 6448 editMode = false; 6449 editingReferenceId = null; 6450 const submitBtn = creatorForm.querySelector('button[type="submit"]'); 6451 if (submitBtn) { 6452 submitBtn.textContent = "CREATE"; 6453 } 6454 const deleteBtn2 = modal.querySelector("#delete-creator-btn"); 6455 if (deleteBtn2) { 6456 deleteBtn2.classList.add("hidden"); 6457 } 6458 showForm(); 6459 }); 6460 } 6461 if (cancelFormBtn) { 6462 cancelFormBtn.addEventListener("click", (e) => { 6463 e.preventDefault(); 6464 if (formContainer && creatorForm) { 6465 creatorForm.style.opacity = "0"; 6466 formContainer.style.transform = "translateY(-10px)"; 6467 setTimeout(() => { 6468 formContainer.classList.add("hidden"); 6469 formContainer.style.transform = "translateY(-10px)"; 6470 creatorForm.style.opacity = "0"; 6471 uploadedHashes.clear(); 6472 const uploadContainers2 = creatorForm.querySelectorAll( 6473 "[data-creator-upload]" 6474 ); 6475 uploadContainers2.forEach((container) => { 6476 const preview = container.querySelector("[data-upload-preview]"); 6477 const placeholder = container.querySelector( 6478 "[data-upload-placeholder]" 6479 ); 6480 const input = container.querySelector("[data-creator-file-input]"); 6481 if (preview) { 6482 preview.style.backgroundImage = ""; 6483 preview.classList.add("hidden"); 6484 } 6485 if (placeholder) { 6486 placeholder.classList.remove("hidden"); 6487 } 6488 if (input) { 6489 input.value = ""; 6490 } 6491 }); 6492 }, 300); 6493 } 6494 }); 6495 } 6496 const uploadContainers = modal.querySelectorAll("[data-creator-upload]"); 6497 uploadContainers.forEach((container) => { 6498 const fileInput = container.querySelector("[data-creator-file-input]"); 6499 const progressContainer = container.querySelector( 6500 "[data-upload-progress-container]" 6501 ); 6502 const progressBar = container.querySelector("[data-upload-progress-bar]"); 6503 const preview = container.querySelector("[data-upload-preview]"); 6504 const placeholder = container.querySelector("[data-upload-placeholder]"); 6505 if (fileInput) { 6506 fileInput.addEventListener("change", async (e) => { 6507 const file = e.target.files[0]; 6508 if (!file) return; 6509 if (progressContainer) { 6510 progressContainer.style.opacity = "1"; 6511 } 6512 if (progressBar) { 6513 progressBar.style.width = "0%"; 6514 } 6515 const uploadType = container.dataset.uploadType || "reference"; 6516 const formData = new FormData(); 6517 formData.append("file", file); 6518 formData.append("type", uploadType); 6519 try { 6520 const xhr = new XMLHttpRequest(); 6521 xhr.upload.addEventListener("progress", (e2) => { 6522 if (e2.lengthComputable && progressBar) { 6523 const percentComplete = e2.loaded / e2.total * 100; 6524 progressBar.style.width = percentComplete + "%"; 6525 } 6526 }); 6527 xhr.addEventListener("load", () => { 6528 if (xhr.status === 200) { 6529 const response = JSON.parse(xhr.responseText); 6530 const mediaHash = response.hash; 6531 uploadedHashes.set(fileInput.id, mediaHash); 6532 if (preview && placeholder) { 6533 preview.style.backgroundImage = `url('${response.url}')`; 6534 preview.classList.remove("hidden"); 6535 placeholder.classList.add("hidden"); 6536 } 6537 if (progressContainer) { 6538 setTimeout(() => { 6539 progressContainer.style.opacity = "0"; 6540 }, 500); 6541 } 6542 } else { 6543 if (window.Toast) { 6544 window.Toast.show("Upload failed", "alert"); 6545 } 6546 if (progressContainer) { 6547 progressContainer.style.opacity = "0"; 6548 } 6549 if (progressBar) { 6550 progressBar.style.width = "0%"; 6551 } 6552 } 6553 }); 6554 xhr.addEventListener("error", () => { 6555 if (window.Toast) { 6556 window.Toast.show("Upload error", "alert"); 6557 } 6558 if (progressContainer) { 6559 progressContainer.style.opacity = "0"; 6560 } 6561 if (progressBar) { 6562 progressBar.style.width = "0%"; 6563 } 6564 }); 6565 xhr.open("POST", "/upload"); 6566 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content"); 6567 if (csrfToken) { 6568 xhr.setRequestHeader("X-CSRF-Token", csrfToken); 6569 } 6570 xhr.send(formData); 6571 } catch (error) { 6572 console.error("Upload error:", error); 6573 if (window.Toast) { 6574 window.Toast.show("Upload failed", "alert"); 6575 } 6576 if (progressContainer) { 6577 progressContainer.style.opacity = "0"; 6578 } 6579 if (progressBar) { 6580 progressBar.style.width = "0%"; 6581 } 6582 } 6583 }); 6584 } 6585 }); 6586 if (creatorForm) { 6587 creatorForm.addEventListener("submit", async (e) => { 6588 e.preventDefault(); 6589 const nameInput = creatorForm.querySelector('input[name="name"]'); 6590 const face1Label = document.getElementById("creator-face-1-label"); 6591 if (face1Label) { 6592 face1Label.classList.remove("border-rose-500"); 6593 face1Label.classList.add("border-gray/20"); 6594 } 6595 if (!nameInput || nameInput.value.trim() === "") { 6596 if (window.Toast) { 6597 window.Toast.show("Please enter a name", "alert"); 6598 } 6599 return; 6600 } 6601 const faceInputIds = [ 6602 "creator-face-1", 6603 "creator-face-2", 6604 "creator-face-3" 6605 ]; 6606 const hasFaceImage = faceInputIds.some((id) => uploadedHashes.has(id)); 6607 if (!hasFaceImage) { 6608 if (face1Label) { 6609 face1Label.classList.remove("border-gray/20"); 6610 face1Label.classList.add("border-rose-500"); 6611 } 6612 if (window.Toast) { 6613 window.Toast.show("Need a photo of face at least", "alert"); 6614 } 6615 return; 6616 } 6617 const mediaHashes = Array.from(uploadedHashes.values()); 6618 const formData = new FormData(); 6619 formData.append("name", nameInput.value);
6620 mediaHashes.forEach((hash) => { 6621 formData.append("media_hashes[]", hash); 6622 }); 6623 try { 6624 const url = editMode ? `/references/${editingReferenceId}` : "/references"; 6625 const method = editMode ? "PATCH" : "POST"; 6626 const response = await fetch(url, { 6627 method, 6628 body: formData, 6629 headers: { 6630 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") 6631 } 6632 }); 6633 const data = await response.json(); 6634 if (response.ok) { 6635 if (window.Toast) { 6636 const message = editMode ? "Creator updated successfully" : "Creator created successfully"; 6637 window.Toast.show(message, "success"); 6638 } 6639 uploadedHashes.clear(); 6640 nameInput.value = "Me"; 6641 const uploadContainers2 = creatorForm.querySelectorAll( 6642 "[data-creator-upload]" 6643 ); 6644 uploadContainers2.forEach((container) => { 6645 const preview = container.querySelector("[data-upload-preview]"); 6646 const placeholder = container.querySelector( 6647 "[data-upload-placeholder]" 6648 ); 6649 const input = container.querySelector("[data-creator-file-input]"); 6650 if (preview) { 6651 preview.style.backgroundImage = ""; 6652 preview.classList.add("hidden"); 6653 } 6654 if (placeholder) { 6655 placeholder.classList.remove("hidden"); 6656 } 6657 if (input) { 6658 input.value = ""; 6659 } 6660 }); 6661 creatorForm.style.opacity = "0"; 6662 formContainer.style.transform = "translateY(-10px)"; 6663 setTimeout(() => { 6664 formContainer.classList.add("hidden"); 6665 formContainer.style.transform = "translateY(-10px)"; 6666 creatorForm.style.opacity = "0"; 6667 }, 300); 6668 setTimeout(() => { 6669 modalInstance.showCreator(); 6670 }, 400); 6671 } else { 6672 if (window.Toast) { 6673 const errorMessage = editMode ? "Failed to update creator" : "Failed to create creator"; 6674 window.Toast.show(data.error || errorMessage, "alert"); 6675 } 6676 } 6677 } catch (error) { 6678 console.error("Creator creation error:", error); 6679 if (window.Toast) { 6680 window.Toast.show("Network error occurred", "alert"); 6681 } 6682 } 6683 }); 6684 } 6685 const editButtons = modal.querySelectorAll("[data-edit-creator]"); 6686 editButtons.forEach((editBtn) => { 6687 editBtn.addEventListener("click", (e) => { 6688 e.preventDefault(); 6689 e.stopPropagation(); 6690 const referenceNode = editBtn.closest(".reference-node"); 6691 if (!referenceNode) return; 6692 const referenceId = referenceNode.dataset.reference; 6693 const referenceName = referenceNode.dataset.referenceName; 6694 const referenceMediaData = referenceNode.dataset.referenceMedia; 6695 editMode = true; 6696 editingReferenceId = referenceId; 6697 let mediaNodes = [];
6698 try { 6699 mediaNodes = JSON.parse(referenceMediaData); 6700 } catch (error) { 6701 console.error("Error parsing media data:", error); 6702 } 6703 const nameInput = creatorForm.querySelector('input[name="name"]'); 6704 if (nameInput) { 6705 nameInput.value = referenceName; 6706 } 6707 uploadedHashes.clear(); 6708 const uploadContainers2 = creatorForm.querySelectorAll( 6709 "[data-creator-upload]" 6710 ); 6711 uploadContainers2.forEach((container) => { 6712 const preview = container.querySelector("[data-upload-preview]"); 6713 const placeholder = container.querySelector( 6714 "[data-upload-placeholder]" 6715 ); 6716 const input = container.querySelector("[data-creator-file-input]"); 6717 if (preview) { 6718 preview.style.backgroundImage = ""; 6719 preview.classList.add("hidden"); 6720 } 6721 if (placeholder) { 6722 placeholder.classList.remove("hidden"); 6723 } 6724 if (input) { 6725 input.value = ""; 6726 } 6727 }); 6728 let faceIndex = 1; 6729 let bodyIndex = 1; 6730 mediaNodes.forEach((media) => { 6731 let inputId; 6732 if (media.upload_type === "face" && faceIndex <= 3) { 6733 inputId = `creator-face-${faceIndex}`; 6734 faceIndex++; 6735 } else if (media.upload_type === "body" && bodyIndex <= 2) { 6736 inputId = `creator-body-${bodyIndex}`; 6737 bodyIndex++; 6738 } else { 6739 return; 6740 } 6741 uploadedHashes.set(inputId, media.hash); 6742 const container = creatorForm.querySelector(`#${inputId}`)?.closest("[data-creator-upload]"); 6743 if (container) { 6744 const preview = container.querySelector("[data-upload-preview]"); 6745 const placeholder = container.querySelector( 6746 "[data-upload-placeholder]" 6747 ); 6748 if (preview && placeholder) { 6749 preview.style.backgroundImage = `url('${media.url}')`; 6750 preview.classList.remove("hidden"); 6751 placeholder.classList.add("hidden"); 6752 } 6753 } 6754 }); 6755 const submitBtn = creatorForm.querySelector('button[type="submit"]'); 6756 if (submitBtn) { 6757 submitBtn.textContent = "UPDATE"; 6758 } 6759 const deleteBtn2 = modal.querySelector("#delete-creator-btn"); 6760 if (deleteBtn2) { 6761 deleteBtn2.classList.remove("hidden"); 6762 } 6763 showForm(); 6764 }); 6765 }); 6766 const deleteBtn = modal.querySelector("[data-creator-delete]"); 6767 if (deleteBtn) { 6768 deleteBtn.addEventListener("click", async (e) => { 6769 e.preventDefault(); 6770 if (!editMode || !editingReferenceId) return; 6771 if (!confirm("Are you sure you want to delete this creator?")) { 6772 return; 6773 } 6774 try { 6775 const response = await fetch(`/references/${editingReferenceId}`, { 6776 method: "DELETE", 6777 headers: { 6778 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content"), 6779 Accept: "application/json" 6780 } 6781 }); 6782 const data = await response.json(); 6783 if (response.ok) { 6784 if (window.Toast) { 6785 window.Toast.show("Creator deleted successfully", "success"); 6786 } 6787 creatorForm.style.opacity = "0"; 6788 formContainer.style.transform = "translateY(-10px)"; 6789 setTimeout(() => { 6790 formContainer.classList.add("hidden"); 6791 formContainer.style.transform = "translateY(-10px)"; 6792 creatorForm.style.opacity = "0"; 6793 }, 300); 6794 setTimeout(() => { 6795 modalInstance.showCreator(); 6796 }, 400); 6797 } else { 6798 if (window.Toast) { 6799 window.Toast.show( 6800 data.error || "Failed to delete creator", 6801 "alert" 6802 ); 6803 } 6804 } 6805 } catch (error) { 6806 console.error("Delete error:", error); 6807 if (window.Toast) { 6808 window.Toast.show("Network error occurred", "alert"); 6809 } 6810 } 6811 }); 6812 } 6813 const referenceNodes = modal.querySelectorAll(".reference-node"); 6814 if (currentCreator && currentCreator !== "none") {
6815 referenceNodes.forEach((node) => { 6816 if (node.dataset.reference === currentCreator) { 6817 const nameElement = node.querySelector(".text-gray-400"); 6818 if (nameElement) { 6819 nameElement.classList.remove("text-gray-400"); 6820 nameElement.classList.add("text-white"); 6821 } 6822 } 6823 }); 6824 } 6825 referenceNodes.forEach((node) => { 6826 node.addEventListener("click", () => { 6827 selectedCreator = node.dataset.reference; 6828 const imgElement = node.querySelector("img"); 6829 const referenceImageUrl = imgElement ? imgElement.src : ""; 6830 const referenceName = node.dataset.referenceName || `Creator #${selectedCreator}`; 6831 const creatorObj = { 6832 id: selectedCreator, 6833 name: referenceName, 6834 image: referenceImageUrl 6835 }; 6836 if (window.carouselGenerator) { 6837 window.carouselGenerator.setCreator(creatorObj); 6838 } else if (window.inspireGenerator) { 6839 window.inspireGenerator.setCreator(creatorObj); 6840 } else { 6841 const creatorButton = document.getElementById("creator-button"); 6842 if (creatorButton) { 6843 const creatorPreview = creatorButton.querySelector("#creator-preview"); 6844 const creatorSpan = creatorButton.querySelector("span"); 6845 if (creatorPreview && referenceImageUrl) { 6846 creatorPreview.style.backgroundImage = `url('${referenceImageUrl}')`; 6847 creatorPreview.classList.remove("hidden"); 6848 } 6849 if (creatorSpan) { 6850 creatorButton.classList.remove("text-gray"); 6851 creatorButton.classList.add("text-white"); 6852 } 6853 } 6854 const form = document.querySelector("[data-creator-form]"); 6855 if (form) { 6856 let creatorInput2 = form.querySelector('input[name="creator"]'); 6857 if (!creatorInput2) { 6858 creatorInput2 = document.createElement("input"); 6859 creatorInput2.type = "hidden"; 6860 creatorInput2.name = "creator"; 6861 form.appendChild(creatorInput2); 6862 } 6863 creatorInput2.value = selectedCreator; 6864 } 6865 } 6866 modalInstance.close(); 6867 }); 6868 }); 6869} 6870
6871// app/javascript/modal/team.js 6872function initializeCreateTeam(modalInstance) { 6873 const form = document.getElementById("create-team-form"); 6874 const submitButton = document.getElementById("create-team-submit"); 6875 const errorDiv = document.getElementById("create-team-error"); 6876 const teamNameInput = document.getElementById("team-name"); 6877 if (!form) return; 6878 teamNameInput?.focus(); 6879 form.addEventListener("submit", async (e) => { 6880 e.preventDefault(); 6881 const teamName = teamNameInput.value.trim(); 6882 if (!teamName) { 6883 showError("Name cannot be empty"); 6884 return; 6885 } 6886 submitButton.disabled = true; 6887 submitButton.textContent = "Creating..."; 6888 try { 6889 const response = await fetch("/teams/create", { 6890 method: "POST", 6891 headers: { 6892 "Content-Type": "application/json", 6893 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") 6894 }, 6895 body: JSON.stringify({ name: teamName }) 6896 }); 6897 const data = await response.json(); 6898 if (response.ok) { 6899 if (window.Toast) { 6900 window.Toast.show( 6901 `Workspace "${data.team.name}" created successfully!`, 6902 "success" 6903 ); 6904 } 6905 modalInstance.close(); 6906 setTimeout(() => { 6907 window.location.reload(); 6908 }, 2e3); 6909 } else { 6910 showError(data.error || "Failed to create team"); 6911 submitButton.disabled = false; 6912 submitButton.textContent = "Create Team"; 6913 } 6914 } catch (error) { 6915 console.error("Error creating team:", error); 6916 showError("Network error occurred"); 6917 submitButton.disabled = false; 6918 submitButton.textContent = "Create Team"; 6919 } 6920 }); 6921 function showError(message) { 6922 if (errorDiv) { 6923 errorDiv.textContent = message; 6924 errorDiv.classList.remove("hidden"); 6925 } 6926 } 6927} 6928
6929// app/javascript/modal/library.js 6930function initializeLibrary(modalInstance) { 6931 const modal = document.getElementById("library-modal"); 6932 if (!modal) return; 6933 const isSingle = modal.dataset.selectMode === "single"; 6934 const selectedRefs = /* @__PURE__ */ new Set(); 6935 const confirmBtn = modal.querySelector("[data-refs-confirm]"); 6936 const selectedCountEl = modal.querySelector(".refs-selected-count"); 6937 const createItemBtn = modal.querySelector("[data-create-item]"); 6938 if (createItemBtn) { 6939 createItemBtn.addEventListener("click", () => { 6940 modalInstance.close(); 6941 window._itemModalFromLibrary = true; 6942 if (window.Modal && window.Modal.loadModal) { 6943 window.Modal.loadModal("/modal/item", "item-modal"); 6944 } 6945 }); 6946 } 6947 const tagFilterBtns = modal.querySelectorAll(".tag-filter-btn"); 6948 const refNodes = modal.querySelectorAll(".ref-node"); 6949 tagFilterBtns.forEach((btn) => { 6950 btn.addEventListener("click", () => { 6951 tagFilterBtns.forEach((b) => { 6952 b.classList.remove("active", "bg-active", "text-white"); 6953 b.classList.add("bg-inside", "text-gray"); 6954 }); 6955 btn.classList.add("active", "bg-active", "text-white"); 6956 btn.classList.remove("bg-inside", "text-gray"); 6957 const selectedTag = btn.dataset.tag; 6958 refNodes.forEach((node) => { 6959 const itemTags = JSON.parse(node.dataset.refTags || "[]"); 6960 if (selectedTag === "all" || itemTags.includes(selectedTag)) { 6961 node.classList.remove("hidden"); 6962 } else { 6963 node.classList.add("hidden"); 6964 } 6965 }); 6966 }); 6967 }); 6968 refNodes.forEach((node) => { 6969 node.addEventListener("click", () => { 6970 const refFileHash = node.dataset.refFileHash; 6971 if (isSingle) { 6972 const selectedData = [{ 6973 file_hash: refFileHash, 6974 ref_id: node.dataset.refId, 6975 name: node.dataset.refName 6976 }]; 6977 document.dispatchEvent( 6978 new CustomEvent("refs:selected", { 6979 detail: { refs: selectedData } 6980 }) 6981 ); 6982 modalInstance.close(); 6983 return; 6984 } 6985 if (selectedRefs.has(refFileHash)) { 6986 selectedRefs.delete(refFileHash); 6987 node.querySelector(".ref-selected-indicator").style.opacity = "0"; 6988 node.querySelector(".ref-checkmark").style.opacity = "0"; 6989 } else { 6990 selectedRefs.add(refFileHash); 6991 node.querySelector(".ref-selected-indicator").style.opacity = "1"; 6992 node.querySelector(".ref-checkmark").style.opacity = "1"; 6993 } 6994 updateSelection(); 6995 }); 6996 }); 6997 function updateSelection() { 6998 const count = selectedRefs.size; 6999 if (selectedCountEl) selectedCountEl.textContent = count; 7000 if (confirmBtn) confirmBtn.disabled = count === 0; 7001 } 7002 if (confirmBtn) { 7003 confirmBtn.addEventListener("click", () => { 7004 const selectedData = Array.from(selectedRefs).map((hash) => { 7005 const node = modal.querySelector(`[data-ref-file-hash="${hash}"]`); 7006 return { 7007 file_hash: hash, 7008 ref_id: node.dataset.refId, 7009 name: node.dataset.refName 7010 }; 7011 }); 7012 document.dispatchEvent( 7013 new CustomEvent("refs:selected", { 7014 detail: { refs: selectedData } 7015 }) 7016 ); 7017 modalInstance.close(); 7018 }); 7019 } 7020} 7021
7022// app/javascript/modal/pins.js 7023function initializePins(modalInstance) { 7024 const modal = document.getElementById("pins-modal"); 7025 if (!modal) return; 7026 const selectedPins = /* @__PURE__ */ new Set(); 7027 function updateUI() { 7028 const countEl = modal.querySelector(".pins-selected-count"); 7029 if (countEl) { 7030 countEl.textContent = selectedPins.size; 7031 } 7032 const confirmBtn2 = modal.querySelector("[data-pins-confirm]"); 7033 if (confirmBtn2) { 7034 confirmBtn2.disabled = selectedPins.size === 0; 7035 } 7036 } 7037 function toggleSelection(node) { 7038 const fileHash = node.dataset.pinFileHash; 7039 const mediaId = node.dataset.pinMediaId; 7040 const name = node.dataset.pinName; 7041 if (selectedPins.has(fileHash)) { 7042 selectedPins.delete(fileHash); 7043 node.querySelector(".pin-selected-indicator").style.opacity = "0"; 7044 node.querySelector(".pin-checkmark").style.opacity = "0"; 7045 delete node.dataset.selected; 7046 } else { 7047 selectedPins.add(fileHash); 7048 node.querySelector(".pin-selected-indicator").style.opacity = "1"; 7049 node.querySelector(".pin-checkmark").style.opacity = "1"; 7050 node.dataset.selected = JSON.stringify({ fileHash, mediaId, name }); 7051 } 7052 updateUI(); 7053 } 7054 function confirm2() { 7055 const selected = []; 7056 modal.querySelectorAll(".pin-node").forEach((node) => { 7057 if (node.dataset.selected) { 7058 const data = JSON.parse(node.dataset.selected); 7059 selected.push({ 7060 file_hash: data.fileHash, 7061 media_id: data.mediaId, 7062 name: data.name 7063 }); 7064 } 7065 }); 7066 document.dispatchEvent(new CustomEvent("pins:selected", { 7067 detail: { pins: selected } 7068 })); 7069 if (modalInstance) { 7070 modalInstance.close(); 7071 } 7072 } 7073 modal.addEventListener("click", (e) => { 7074 const node = e.target.closest(".pin-node"); 7075 if (node) { 7076 toggleSelection(node); 7077 } 7078 }); 7079 const confirmBtn = modal.querySelector("[data-pins-confirm]"); 7080 if (confirmBtn) { 7081 confirmBtn.addEventListener("click", confirm2); 7082 } 7083 updateUI(); 7084} 7085
7086// app/javascript/modal/generated.js 7087function initializeGenerated(modalInstance) { 7088 const modal = document.getElementById("generated-modal"); 7089 if (!modal) return; 7090 const generatedNodes = modal.querySelectorAll(".generated-node"); 7091 generatedNodes.forEach((node) => { 7092 node.addEventListener("click", () => { 7093 const hash = node.dataset.generatedFileHash; 7094 const url = node.dataset.generatedUrl; 7095 const thumbnail = node.dataset.generatedThumbnail; 7096 const event = new CustomEvent("generated-selected", { 7097 detail: { 7098 hash, 7099 url, 7100 thumbnail: thumbnail || url 7101 } 7102 }); 7103 document.dispatchEvent(event); 7104 modalInstance.close(); 7105 }); 7106 }); 7107} 7108
7109// app/javascript/modal/rename.js 7110function initializeRename(modalInstance) { 7111 const form = document.getElementById("rename-form"); 7112 const submitButton = document.getElementById("rename-submit"); 7113 const errorDiv = document.getElementById("rename-error"); 7114 const nameInput = document.getElementById("rename-name"); 7115 if (!form) return; 7116 form.addEventListener("submit", async (e) => { 7117 e.preventDefault(); 7118 const name = nameInput.value.trim(); 7119 if (!name) { 7120 showError("Name is required"); 7121 return; 7122 } 7123 submitButton.disabled = true; 7124 submitButton.textContent = "Saving..."; 7125 try { 7126 const response = await fetch("/settings/rename", { 7127 method: "PATCH", 7128 headers: { 7129 "Content-Type": "application/json", 7130 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") 7131 }, 7132 body: JSON.stringify({ name }) 7133 }); 7134 const data = await response.json(); 7135 if (response.ok) { 7136 if (window.Toast) { 7137 window.Toast.show("Team renamed successfully!", "success"); 7138 } 7139 modalInstance.close(); 7140 setTimeout(() => { 7141 window.location.reload(); 7142 }, 500); 7143 } else { 7144 showError(data.error || "Failed to rename workspace"); 7145 submitButton.disabled = false; 7146 submitButton.textContent = "Rename"; 7147 } 7148 } catch (error) { 7149 console.error("Error renaming workspace:", error); 7150 showError("Network error occurred"); 7151 submitButton.disabled = false; 7152 submitButton.textContent = "Rename"; 7153 } 7154 }); 7155 function showError(message) { 7156 if (errorDiv) { 7157 errorDiv.textContent = message; 7158 errorDiv.classList.remove("hidden"); 7159 } 7160 } 7161} 7162
7163// app/javascript/modal/elements.js 7164function initializeElements(modalInstance) { 7165 const modal = document.getElementById("elements-modal"); 7166 const formModal = document.getElementById("element-form-modal"); 7167 if (!modal || !formModal) return; 7168 const form = document.getElementById("element-form"); 7169 const grid = document.getElementById("elements-grid"); 7170 const csrfToken = document.querySelector('[name="csrf-token"]')?.content; 7171 const formTitle = formModal.querySelector("[data-element-form-title]"); 7172 const formCloseBtn = formModal.querySelector("[data-element-form-close]"); 7173 const submitBtn = formModal.querySelector("[data-element-submit-btn]"); 7174 const uuidInput = formModal.querySelector("[data-element-uuid-input]"); 7175 const nameInput = formModal.querySelector("[data-element-name-input]"); 7176 const slugPreview = formModal.querySelector("[data-slug-preview]"); 7177 let frontUrl = null; 7178 const referenceUrls = [null, null, null]; 7179 let editingUuid = null; 7180 if (nameInput) { 7181 nameInput.addEventListener("input", () => { 7182 const slug = generateSlug(nameInput.value); 7183 if (slugPreview) { 7184 slugPreview.innerHTML = `Slug: <span class="text-active">@${slug || ""}</span>`; 7185 } 7186 }); 7187 } 7188 const createBtn = modal.querySelector("[data-element-create-btn]"); 7189 if (createBtn) { 7190 createBtn.addEventListener("click", () => { 7191 openFormModal(null); 7192 }); 7193 } 7194 if (formCloseBtn) { 7195 formCloseBtn.addEventListener("click", () => { 7196 closeFormModal(); 7197 }); 7198 } 7199 formModal.addEventListener("click", (e) => { 7200 if (e.target === formModal) { 7201 closeFormModal(); 7202 } 7203 }); 7204 formModal.querySelectorAll("[data-element-upload]").forEach((uploadContainer) => { 7205 const fileInput = uploadContainer.querySelector("[data-element-file-input]"); 7206 const removeBtn = uploadContainer.querySelector("[data-upload-remove]"); 7207 if (fileInput) { 7208 fileInput.addEventListener("change", () => handleFileSelect(uploadContainer, fileInput)); 7209 } 7210 if (removeBtn) { 7211 removeBtn.addEventListener("click", (e) => { 7212 e.preventDefault(); 7213 e.stopPropagation(); 7214 removeImage(uploadContainer); 7215 }); 7216 } 7217 }); 7218 if (form) { 7219 form.addEventListener("submit", async (e) => { 7220 e.preventDefault(); 7221 const formData = new FormData(form); 7222 const name = formData.get("name")?.trim(); 7223 if (!name) { 7224 window.Toast?.show("Please enter a name", "alert"); 7225 return; 7226 } 7227 if (!frontUrl) { 7228 window.Toast?.show("Please upload a front image", "alert"); 7229 return; 7230 } 7231 const validReferenceUrls = referenceUrls.filter((url) => url !== null); 7232 if (submitBtn) { 7233 submitBtn.disabled = true; 7234 submitBtn.textContent = editingUuid ? "Saving..." : "Creating..."; 7235 } 7236 try { 7237 const url = editingUuid ? `/studio-element/${editingUuid}` : "/studio-elements"; 7238 const method = editingUuid ? "PATCH" : "POST"; 7239 const response = await fetch(url, { 7240 method, 7241 headers: { 7242 "Content-Type": "application/json", 7243 "X-CSRF-Token": csrfToken 7244 }, 7245 body: JSON.stringify({ 7246 element: { 7247 name, 7248 front_url: frontUrl, 7249 reference_urls: validReferenceUrls 7250 } 7251 }) 7252 }); 7253 if (response.ok) { 7254 const element = await response.json(); 7255 if (editingUuid) { 7256 updateElementInGrid(element); 7257 window.Toast?.show("Element updated", "success"); 7258 } else { 7259 addElementToGrid(element); 7260 window.Toast?.show("Element created", "success"); 7261 if (window.validElementSlugs) { 7262 window.validElementSlugs.add(element.slug.toLowerCase()); 7263 } 7264 } 7265 closeFormModal(); 7266 } else { 7267 const data = await response.json(); 7268 window.Toast?.show(data.error || "Failed to save element", "alert"); 7269 } 7270 } catch (err) { 7271 console.error("Failed to save element:", err); 7272 window.Toast?.show("Failed to save element", "alert"); 7273 } finally { 7274 if (submitBtn) { 7275 submitBtn.disabled = false;
7276 submitBtn.textContent = editingUuid ? "Save" : "Create"; 7277 } 7278 } 7279 }); 7280 } 7281 modal.querySelectorAll(".element-node").forEach((node) => { 7282 setupElementNode(node, modalInstance); 7283 }); 7284 function openFormModal(element) { 7285 resetFormState(); 7286 editingUuid = element?.uuid || null; 7287 if (element) { 7288 formTitle.textContent = "Edit Element"; 7289 submitBtn.textContent = "Save"; 7290 uuidInput.value = element.uuid; 7291 nameInput.value = element.name; 7292 frontUrl = element.frontUrl; 7293 setUploadPreview(formModal.querySelector('[data-upload-type="front"]'), frontUrl); 7294 const refs = element.referenceUrls || []; 7295 refs.forEach((url, i) => { 7296 if (i < 3 && url) { 7297 referenceUrls[i] = url; 7298 const container = formModal.querySelector(`[data-upload-type="reference"][data-upload-index="${i}"]`); 7299 if (container) { 7300 setUploadPreview(container, url); 7301 } 7302 } 7303 }); 7304 const slug = generateSlug(element.name); 7305 if (slugPreview) { 7306 slugPreview.innerHTML = `Slug: <span class="text-active">@${slug}</span>`; 7307 } 7308 } else { 7309 formTitle.textContent = "Create Element"; 7310 submitBtn.textContent = "Create"; 7311 uuidInput.value = ""; 7312 nameInput.value = ""; 7313 if (slugPreview) { 7314 slugPreview.innerHTML = 'Slug: <span class="text-active">@</span>'; 7315 } 7316 } 7317 formModal.classList.remove("hidden"); 7318 nameInput?.focus(); 7319 } 7320 function closeFormModal() { 7321 formModal.classList.add("hidden"); 7322 resetFormState(); 7323 } 7324 function resetFormState() { 7325 frontUrl = null; 7326 referenceUrls.fill(null); 7327 editingUuid = null; 7328 if (form) { 7329 form.reset(); 7330 } 7331 formModal.querySelectorAll("[data-element-upload]").forEach((container) => { 7332 removeImage(container); 7333 }); 7334 if (slugPreview) { 7335 slugPreview.innerHTML = 'Slug: <span class="text-active">@</span>'; 7336 } 7337 } 7338 function setUploadPreview(container, url) { 7339 if (!container || !url) return; 7340 const preview = container.querySelector("[data-upload-preview]"); 7341 const placeholder = container.querySelector("[data-upload-placeholder]"); 7342 const removeBtn = container.querySelector("[data-upload-remove]"); 7343 const label = container.querySelector("label"); 7344 if (preview) { 7345 preview.style.backgroundImage = `url(${url})`; 7346 preview.classList.remove("hidden"); 7347 } 7348 if (placeholder) { 7349 placeholder.classList.add("hidden"); 7350 } 7351 if (removeBtn) { 7352 removeBtn.classList.remove("hidden"); 7353 } 7354 if (label) { 7355 label.classList.remove("border", "border-dashed", "border-gray/20"); 7356 } 7357 } 7358 function generateSlug(name) { 7359 if (!name) return ""; 7360 return name.toLowerCase().replace(/[à áâãäå]/g, "a").replace(/[èéêë]/g, "e").replace(/[ìÃîï]/g, "i").replace(/[òóôõö]/g, "o").replace(/[ùúûü]/g, "u").replace(/[ñ]/g, "n").replace(/[ç]/g, "c").replace(/[^a-z0-9]/g, ""); 7361 } 7362 async function handleFileSelect(uploadContainer, fileInput) { 7363 const file = fileInput.files[0]; 7364 if (!file) return; 7365 const uploadType = uploadContainer.dataset.uploadType; 7366 const uploadIndex = parseInt(uploadContainer.dataset.uploadIndex) || 0; 7367 const progressContainer = uploadContainer.querySelector("[data-upload-progress-container]"); 7368 const progressBar = uploadContainer.querySelector("[data-upload-progress-bar]"); 7369 const preview = uploadContainer.querySelector("[data-upload-preview]"); 7370 const placeholder = uploadContainer.querySelector("[data-upload-placeholder]"); 7371 const removeBtn = uploadContainer.querySelector("[data-upload-remove]"); 7372 const loadingSpinner = uploadContainer.querySelector("[data-upload-loading]"); 7373 if (loadingSpinner) { 7374 loadingSpinner.classList.remove("hidden"); 7375 } 7376 if (placeholder) { 7377 placeholder.classList.add("hidden"); 7378 } 7379 if (progressContainer) { 7380 progressContainer.style.opacity = "1"; 7381 } 7382 if (progressBar) { 7383 progressBar.style.width = "0%"; 7384 } 7385 try { 7386 let uploadFile = file; 7387 if (file.type === "image/webp" || file.type === "image/avif") { 7388 uploadFile = await convertImageToPNG(file); 7389 } 7390 const formData = new FormData(); 7391 formData.append("file", uploadFile); 7392 formData.append("type", "element"); 7393 const data = await new Promise((resolve, reject) => { 7394 const xhr = new XMLHttpRequest(); 7395 xhr.upload.addEventListener("progress", (e) => { 7396 if (e.lengthComputable && progressBar) { 7397 const percentComplete = Math.round(e.loaded / e.total * 100); 7398 progressBar.style.width = `${percentComplete}%`; 7399 } 7400 }); 7401 xhr.addEventListener("load", () => { 7402 if (xhr.status >= 200 && xhr.status < 300) { 7403 try { 7404 const response = JSON.parse(xhr.response); 7405 resolve(response); 7406 } catch { 7407 reject(new Error("Invalid response format")); 7408 } 7409 } else { 7410 try { 7411 const errorData = JSON.parse(xhr.response); 7412 reject(new Error(errorData.error || "Upload failed")); 7413 } catch { 7414 reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`)); 7415 } 7416 } 7417 }); 7418 xhr.addEventListener("error", () => { 7419 reject(new Error("Network error")); 7420 }); 7421 xhr.open("POST", "/upload"); 7422 if (csrfToken) { 7423 xhr.setRequestHeader("X-CSRF-Token", csrfToken); 7424 } 7425 xhr.send(formData); 7426 }); 7427 if (uploadType === "front") { 7428 frontUrl = data.url; 7429 } else if (uploadType === "reference") { 7430 referenceUrls[uploadIndex] = data.url; 7431 } 7432 if (loadingSpinner) { 7433 loadingSpinner.classList.add("hidden"); 7434 } 7435 if (preview) { 7436 preview.style.backgroundImage = `url(${data.url})`; 7437 preview.classList.remove("hidden"); 7438 } 7439 if (placeholder) { 7440 placeholder.classList.add("hidden"); 7441 } 7442 if (removeBtn) { 7443 removeBtn.classList.remove("hidden"); 7444 } 7445 const label = uploadContainer.querySelector("label"); 7446 if (label) { 7447 label.classList.remove("border", "border-dashed", "border-gray/20"); 7448 } 7449 setTimeout(() => { 7450 if (progressContainer) { 7451 progressContainer.style.opacity = "0"; 7452 } 7453 }, 500); 7454 } catch (error) { 7455 console.error("Upload error:", error); 7456 window.Toast?.show(error.message || "Failed to upload image", "alert"); 7457 if (loadingSpinner) { 7458 loadingSpinner.classList.add("hidden"); 7459 } 7460 if (placeholder) { 7461 placeholder.classList.remove("hidden"); 7462 } 7463 if (progressContainer) { 7464 progressContainer.style.opacity = "0"; 7465 } 7466 } 7467 } 7468 async function convertImageToPNG(file) { 7469 return new Promise((resolve, reject) => { 7470 const img = new Image(); 7471 const url = URL.createObjectURL(file); 7472 img.onload = () => { 7473 try { 7474 const canvas = document.createElement("canvas"); 7475 canvas.width = img.width;
7476 canvas.height = img.height; 7477 const ctx = canvas.getContext("2d"); 7478 ctx.drawImage(img, 0, 0); 7479 canvas.toBlob( 7480 (blob) => { 7481 URL.revokeObjectURL(url); 7482 if (blob) { 7483 const pngFile = new File([blob], file.name.replace(/\.(webp|avif)$/i, ".png"), { 7484 type: "image/png", 7485 lastModified: Date.now() 7486 }); 7487 resolve(pngFile); 7488 } else { 7489 reject(new Error("Failed to convert image to PNG")); 7490 } 7491 }, 7492 "image/png", 7493 0.95 7494 ); 7495 } catch (error) { 7496 URL.revokeObjectURL(url); 7497 reject(error); 7498 } 7499 }; 7500 img.onerror = () => { 7501 URL.revokeObjectURL(url); 7502 reject(new Error("Failed to load image")); 7503 }; 7504 img.src = url; 7505 }); 7506 } 7507 function removeImage(uploadContainer) { 7508 const uploadType = uploadContainer.dataset.uploadType; 7509 const uploadIndex = parseInt(uploadContainer.dataset.uploadIndex) || 0; 7510 if (uploadType === "front") { 7511 frontUrl = null; 7512 } else if (uploadType === "reference") { 7513 referenceUrls[uploadIndex] = null; 7514 } 7515 const preview = uploadContainer.querySelector("[data-upload-preview]"); 7516 const placeholder = uploadContainer.querySelector("[data-upload-placeholder]"); 7517 const removeBtn = uploadContainer.querySelector("[data-upload-remove]"); 7518 const fileInput = uploadContainer.querySelector("[data-element-file-input]"); 7519 const label = uploadContainer.querySelector("label"); 7520 if (preview) { 7521 preview.style.backgroundImage = ""; 7522 preview.classList.add("hidden"); 7523 } 7524 if (placeholder) { 7525 placeholder.classList.remove("hidden"); 7526 } 7527 if (removeBtn) { 7528 removeBtn.classList.add("hidden"); 7529 } 7530 if (fileInput) { 7531 fileInput.value = ""; 7532 } 7533 if (label) { 7534 label.classList.add("border", "border-dashed", "border-gray/20"); 7535 } 7536 } 7537 function setupElementNode(node, modalInstance2) { 7538 const getElementData = () => ({ 7539 uuid: node.dataset.elementUuid, 7540 slug: node.dataset.elementSlug, 7541 name: node.dataset.elementName, 7542 frontUrl: node.dataset.elementFrontUrl, 7543 referenceUrls: JSON.parse(node.dataset.elementReferenceUrls || "[]") 7544 }); 7545 node.addEventListener("click", (e) => { 7546 if (e.target.closest(".element-edit-btn") || e.target.closest(".element-delete-btn")) return; 7547 const slug = node.dataset.elementSlug; 7548 insertMention(slug); 7549 if (modalInstance2) { 7550 modalInstance2.close("elements-modal"); 7551 } else { 7552 modal.classList.add("hidden"); 7553 } 7554 }); 7555 const editBtn = node.querySelector(".element-edit-btn"); 7556 if (editBtn) { 7557 editBtn.addEventListener("click", (e) => { 7558 e.stopPropagation(); 7559 openFormModal(getElementData()); 7560 }); 7561 } 7562 const deleteBtn = node.querySelector(".element-delete-btn"); 7563 if (deleteBtn) { 7564 deleteBtn.addEventListener("click", async (e) => { 7565 e.stopPropagation(); 7566 const uuid2 = node.dataset.elementUuid; 7567 const name = node.dataset.elementName; 7568 if (!confirm(`Delete element "${name}"?`)) return; 7569 try { 7570 const response = await fetch(`/studio-element/${uuid2}`, { 7571 method: "DELETE", 7572 headers: { 7573 "X-CSRF-Token": csrfToken 7574 } 7575 }); 7576 if (response.ok) { 7577 const slug = node.dataset.elementSlug; 7578 if (slug && window.validElementSlugs) { 7579 window.validElementSlugs.delete(slug.toLowerCase()); 7580 } 7581 node.remove(); 7582 window.Toast?.show("Element deleted", "success"); 7583 } else { 7584 window.Toast?.show("Failed to delete element", "alert"); 7585 } 7586 } catch (err) { 7587 console.error("Failed to delete element:", err); 7588 window.Toast?.show("Failed to delete element", "alert"); 7589 } 7590 }); 7591 } 7592 } 7593 function addElementToGrid(element) { 7594 if (!grid) return; 7595 const node = document.createElement("div"); 7596 node.className = "element-node group cursor-pointer transition-all duration-300 relative rounded-lg overflow-hidden aspect-[4/3]"; 7597 node.dataset.elementUuid = element.uuid; 7598 node.dataset.elementSlug = element.slug; 7599 node.dataset.elementName = element.name; 7600 node.dataset.elementFrontUrl = element.front_url; 7601 node.dataset.elementReferenceUrls = JSON.stringify(element.reference_urls || []); 7602 node.innerHTML = buildElementCardHtml(element); 7603 const createCard = grid.querySelector(".create-element-card"); 7604 if (createCard && createCard.nextSibling) { 7605 grid.insertBefore(node, createCard.nextSibling); 7606 } else { 7607 grid.appendChild(node); 7608 } 7609 setupElementNode(node, modalInstance); 7610 }
7611 function updateElementInGrid(element) { 7612 const node = grid?.querySelector(`[data-element-uuid="${element.uuid}"]`); 7613 if (!node) return; 7614 node.dataset.elementSlug = element.slug; 7615 node.dataset.elementName = element.name; 7616 node.dataset.elementFrontUrl = element.front_url; 7617 node.dataset.elementReferenceUrls = JSON.stringify(element.reference_urls || []); 7618 node.innerHTML = buildElementCardHtml(element); 7619 setupElementNode(node, modalInstance); 7620 } 7621 function buildElementCardHtml(element) { 7622 const refs = element.reference_urls || []; 7623 const validRefs = refs.filter((url) => url); 7624 const refsHtml = validRefs.slice(0, 3).map((url) => `<div class="w-5 h-5 rounded overflow-hidden border border-white/20"><img src="${url}" class="w-full h-full object-cover" alt="Reference"></div>`).join(""); 7625 return ` 7626 <!-- Background Image --> 7627 <div class="absolute inset-0 bg-inside bg-cover bg-center transition-transform duration-300 group-hover:scale-105" style="background-image: url('${element.front_url}')"></div> 7628 7629 <!-- Gradient Overlay --> 7630 <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-trans
7630parent to-transparent"></div> 7631 7632 <!-- Action Buttons (top right) --> 7633 <div class="absolute top-1.5 right-1.5 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> 7634 <button type="button" class="element-edit-btn w-6 h-6 bg-dark/80 hover:bg-active/80 rounded flex items-center justify-center cursor-pointer transition-colors"> 7635 <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 7636 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path> 7637 </svg> 7638 </button> 7639 <button type="button" class="element-delete-btn w-6 h-6 bg-dark/80 hover:bg-red-500/80 rounded flex items-center justify-center cursor-pointer transition-colors"> 7640 <svg class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 7641 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 7642 </svg> 7643 </button> 7644 </div> 7645 7646 <!-- Name & Slug (bottom left) --> 7647 <div class="absolute bottom-0 left-0 right-0 p-2.5 bg-gradient-to-t from-black/60 to-transparent"> 7648 <div class="text-white text-sm font-medium truncate">${element.name}</div> 7649 <div class="text-active text-[11px] font-medium">@${element.slug}</div> 7650 </div> 7651 7652 ${validRefs.length > 0 ? `<!-- Reference images (bottom right) --><div class="absolute bottom-2.5 right-2.5 flex gap-1">${refsHtml}</div>` : ""} 7653 `; 7654 } 7655 function insertMention(slug) { 7656 const videoPrompt = document.querySelector("[data-studio-video-prompt-input]"); 7657 const imagePrompt = document.querySelector("[data-studio-prompt]"); 7658 const mention = `@${slug}`; 7659 if (videoPrompt && !videoPrompt.closest(".hidden")) { 7660 const text = videoPrompt.innerText || ""; 7661 const needsSpaceBefore = text.length > 0 && !text.endsWith(" ") && !text.endsWith("\n"); 7662 const prefix = needsSpaceBefore ? " " : ""; 7663 const suffix = " "; 7664 videoPrompt.innerText = text + prefix + mention + suffix; 7665 videoPrompt.dispatchEvent(new Event("input", { bubbles: true })); 7666 videoPrompt.focus(); 7667 const range = document.createRange(); 7668 const sel = window.getSelection(); 7669 range.selectNodeContents(videoPrompt); 7670 range.collapse(false); 7671 sel.removeAllRanges(); 7672 sel.addRange(range); 7673 } else if (imagePrompt) { 7674 const start2 = imagePrompt.selectionStart; 7675 const end = imagePrompt.selectionEnd; 7676 const text = imagePrompt.value; 7677 const needsSpaceBefore = start2 > 0 && text[start2 - 1] !== " " && text[start2 - 1] !== "\n"; 7678 const prefix = needsSpaceBefore ? " " : ""; 7679 const suffix = " "; 7680 imagePrompt.value = text.slice(0, start2) + prefix + mention + suffix + text.slice(end); 7681 const newPos = start2 + prefix.length + mention.length + suffix.length; 7682 imagePrompt.selectionStart = imagePrompt.selectionEnd = newPos; 7683 imagePrompt.focus(); 7684 imagePrompt.dispatchEvent(new Event("input", { bubbles: true })); 7685 } 7686 } 7687} 7688
7689// app/javascript/modal.js 7690var Modal = class { 7691 constructor() { 7692 this.activeModal = null; 7693 this.isClosing = false; 7694 this.handlersAttached = false; 7695 this.init(); 7696 } 7697 init() { 7698 if (this.handlersAttached) return; 7699 this.handlersAttached = true; 7700 document.addEventListener("keydown", (e) => { 7701 if (e.key === "Escape") { 7702 const activeModal = document.querySelector("[data-modal]:not(.hidden)"); 7703 if (activeModal) { 7704 this.close(); 7705 } 7706 } 7707 }); 7708 document.addEventListener("turbo:before-cache", () => { 7709 document.querySelectorAll("[data-modal] video").forEach((v) => { 7710 v.pause(); 7711 v.removeAttribute("autoplay"); 7712 }); 7713 document.querySelectorAll("[data-modal]:not([data-modal-static])").forEach((m) => m.remove()); 7714 document.body.classList.remove("modal-open"); 7715 this.activeModal = null; 7716 this.isClosing = false; 7717 }); 7718 this.setupEventListeners(); 7719 } 7720 setupEventListeners() { 7721 document.addEventListener("click", (e) => { 7722 if (e.target.closest("[data-modal-close]")) { 7723 e.preventDefault(); 7724 e.stopPropagation(); 7725 this.close(); 7726 } 7727 }); 7728 document.addEventListener("click", (e) => { 7729 if (e.target.closest("[data-modal-trigger]")) { 7730 e.preventDefault(); 7731 const trigger = e.target.closest("[data-modal-trigger]"); 7732 const modalType = trigger.getAttribute("data-modal-trigger"); 7733 document.querySelectorAll("[data-dropdown]").forEach((d) => d.classList.remove("open")); 7734 if (modalType === "logout") { 7735 this.showLogout(); 7736 } else if (modalType === "delete-team") { 7737 this.showDeleteTeam(); 7738 } else if (modalType === "leave-team") { 7739 this.showLeaveTeam(); 7740 } else if (modalType === "remove-member") { 7741 const memberId = trigger.dataset.memberId; 7742 const memberName = trigger.dataset.memberName; 7743 this.showRemoveMember(memberId, memberName); 7744 } else if (modalType === "cancel-invitation") { 7745 const invitationToken = trigger.dataset.invitationToken; 7746 const invitationEmail = trigger.dataset.invitationEmail; 7747 this.showCancelInvitation(invitationToken, invitationEmail); 7748 } else if (modalType === "templates") { 7749 const category = trigger.getAttribute("data-category"); 7750 this.showTemplates(category); 7751 } else if (modalType === "styles") { 7752 this.showStyles(); 7753 } else if (modalType === "voices") { 7754 this.showVoices(); 7755 } else if (modalType === "cameras") { 7756 const category = trigger.getAttribute("data-category"); 7757 this.showCameras(category); 7758 } else if (modalType === "creator") { 7759 this.showCreator(); 7760 } else if (modalType === "workspace") { 7761 this.showWorkspace(); 7762 } else if (modalType === "create-team") { 7763 this.showCreateTeam(); 7764 } else if (modalType === "feedback") { 7765 this.showFeedback(); 7766 } else if (modalType === "free-credits") { 7767 this.showFreeCredits(); 7768 } else if (modalType === "template") { 7769 const templateId = trigger.getAttribute("data-template-id"); 7770 this.showTemplate(templateId); 7771 } else if (modalType === "rename") { 7772 this.showRename(); 7773 } else if (modalType === "shot") { 7774 this.showShot(); 7775 } else if (modalType === "elements") { 7776 this.showElementsPicker(); 7777 } else if (modalType === "media") { 7778 const mediaId = trigger.getAttribute("data-media-id"); 7779 this.showMedia(mediaId); 7780 } else if (modalType === "video") { 7781 const videoSrc = trigger.getAttribute("data-video-src"); 7782 const videoTitle = trigger.getAttribute("data-video-title") || "Video"; 7783 const videoIcon = trigger.getAttribute("data-video-icon") || "play"; 7784 this.showVideo(videoSrc, videoTitle, videoIcon); 7785 } 7786 } 7787 }); 7788 document.addEventListener("click", (e) => { 7789 if (e.target.matches("[data-modal]")) { 7790 this.close(); 7791 } 7792 }); 7793 } 7794 open(modalId = null) { 7795 const modal = modalId ? document.getElementById(modalId) : document.querySelector("[data-modal]"); 7796 if (!modal) return; 7797 this.activeModal = modal.id; 7798 this.isClosing = false;
7799 modal.classList.remove("hidden"); 7800 document.body.classList.add("modal-open"); 7801 requestAnimationFrame(() => { 7802 modal.classList.add("modal-showing"); 7803 const autofocusEl = modal.querySelector("[autofocus]"); 7804 if (autofocusEl) autofocusEl.focus(); 7805 }); 7806 if (modal.id === "media-modal") { 7807 this.initializeMediaZoom(); 7808 } 7809 return modal; 7810 } 7811 close() { 7812 if (this.isClosing) return; 7813 const modal = this.activeModal ? document.getElementById(this.activeModal) : document.querySelector("[data-modal]:not(.hidden)"); 7814 if (!modal) { 7815 document.body.classList.remove("modal-open"); 7816 this.activeModal = null; 7817 return; 7818 } 7819 this.isClosing = true; 7820 modal.classList.remove("modal-showing"); 7821 setTimeout(() => { 7822 modal.classList.add("hidden"); 7823 document.body.classList.remove("modal-open"); 7824 if (!modal.hasAttribute("data-modal-static")) { 7825 modal.remove(); 7826 } 7827 this.isClosing = false; 7828 this.activeModal = null; 7829 }, 200); 7830 } 7831 showLogout() { 7832 this.loadModal("/modal/logout", "logout-modal"); 7833 } 7834 showShot() { 7835 this.loadModal("/modal/shot", "shot-modal"); 7836 } 7837 showElements() { 7838 this.loadModal("/modal/elements", "elements-modal"); 7839 } 7840 showTemplates(category = null) { 7841 const url = category ? `/modal/templates?category=${category}` : "/modal/templates"; 7842 this.loadModal(url, "templates-modal"); 7843 } 7844 showStyles() { 7845 this.loadModal("/modal/styles", "styles-modal"); 7846 } 7847 showVoices() { 7848 this.loadModal("/modal/voices", "voices-modal"); 7849 } 7850 showCameras(category = null) { 7851 const url = category ? `/modal/cameras/${category}` : "/modal/cameras"; 7852 this.loadModal(url, "cameras-modal"); 7853 } 7854 showCreator() { 7855 this.loadModal("/modal/creator", "creator-modal"); 7856 } 7857 showWorkspace() { 7858 this.loadModal("/modal/workspace", "workspace-modal"); 7859 } 7860 showCreateTeam() { 7861 this.loadModal("/modal/team", "create-team-modal"); 7862 } 7863 showRename() { 7864 this.loadModal("/modal/rename", "rename-modal"); 7865 } 7866 showFeedback() { 7867 this.loadModal("/modal/feedbacks", "feedback-modal"); 7868 } 7869 showFreeCredits() { 7870 this.loadModal("/modal/feedbacks", "free-credits-modal"); 7871 } 7872 showTemplate(templateId) { 7873 this.loadModal(`/tutorial/${templateId}`, "template-modal"); 7874 } 7875 showVideo(videoSrc, videoTitle, videoIcon) { 7876 this.loadModal(`/modal/video?src=${encodeURIComponent(videoSrc)}&title=${encodeURIComponent(videoTitle)}&icon=${encodeURIComponent(videoIcon)}`, "video-modal"); 7877 } 7878 showMedia(mediaId) { 7879 this.loadModal(`/media/${mediaId}`, "media-modal"); 7880 } 7881 showDeleteTeam() { 7882 this.loadModal("/settings/delete", "delete-team-modal"); 7883 } 7884 showLeaveTeam() { 7885 this.loadModal("/settings/leave", "leave-team-modal"); 7886 } 7887 showRemoveMember(memberId, memberName) { 7888 this.currentMemberId = memberId; 7889 this.currentMemberName = memberName; 7890 this.loadModal("/settings/remove", "remove-member-modal"); 7891 } 7892 showCancelInvitation(invitationToken, invitationEmail) { 7893 this.currentInvitationToken = invitationToken; 7894 this.currentInvitationEmail = invitationEmail; 7895 this.loadModal("/settings/cancel", "cancel-invitation-modal"); 7896 } 7897 showLibrary(options = {}) { 7898 const { mediaType, select } = typeof options === "string" ? { mediaType: options } : options; 7899 let url = mediaType ? `/modal/library/${mediaType}s` : "/modal/library"; 7900 if (select) url += `?select=${select}`; 7901 this.loadModal(url, "library-modal"); 7902 } 7903 showPins(flowId) { 7904 this.loadModal(`/modal/pins/${flowId}`, "pins-modal"); 7905 } 7906 showGenerated(sequenceId) { 7907 const url = sequenceId ? `/modal/generated/${sequenceId}` : "/modal/generated/all"; 7908 this.loadModal(url, "generated-modal"); 7909 } 7910 _openPickerShell(modalId, title, maxWidth = "max-w-2xl") { 7911 const prev = document.getElementById(modalId); 7912 if (prev) prev.remove(); 7913 const modal = document.createElement("div"); 7914 modal.id = modalId; 7915 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 7916 modal.style.cssText = "align-items: flex-start; padding-top: 10vh; z-index: 9999;"; 7917 modal.innerHTML = ` 7918 <div class="modal-content bg-box rounded-lg p-6 ${maxWidth} w-full mx-4 shadow-xl max-h-[80vh] overflow-hidden flex flex-col"> 7919 <div class="flex items-start justify-between w-full mb-4"> 7920 <h2 class="text-14 font-bold text-white">${title}</h2> 7921 <button type="button" data-shell-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 7922 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7922<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 7923 </button> 7924 </div> 7925 <div class="flex-1 flex items-center justify-center py-8"> 7926 <svg class="w-5 h-5 text-active animate-spin" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="31.4 31.4" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" dur="0.8s" from="0 12 12" to="360 12 12" repeatCount="indefinite"/></circle></svg> 7927 </div> 7928 </div>`; 7929 document.body.appendChild(modal); 7930 requestAnimationFrame(() => { 7931 requestAnimationFrame(() => { 7932 modal.classList.add("modal-showing"); 7933 }); 7934 }); 7935 const closeShell = () => { 7936 document.removeEventListener("keydown", escHandler); 7937 modal.classList.remove("modal-showing"); 7938 modal.addEventListener("transitionend", () => modal.remove(), { once: true }); 7939 }; 7940 const escHandler = (e) => { 7941 if (e.key === "Escape") closeShell(); 7942 }; 7943 document.addEventListener("keydown", escHandler); 7944 modal.querySelector("[data-shell-close]").addEventListener("click", closeShell); 7945 modal.addEventListener("click", (e) => { 7946 if (e.target === modal) closeShell(); 7947 }); 7948 } 7949 async showElementsPicker() { 7950 this._openPickerShell("elements-picker-modal", "Elements", "max-w-4xl"); 7951 try { 7952 const resp = await fetch("/elements/picker"); 7953 if (!resp.ok) throw new Error("Failed to load elements"); 7954 const data = await resp.json(); 7955 this.buildElementsPickerModal(data.elements); 7956 } catch (err) { 7957 console.error("[Modal] Elements picker error:", err); 7958 const modal = document.getElementById("elements-picker-modal"); 7959 if (modal) modal.remove(); 7960 if (window.Toast) window.Toast.show("Failed to load elements", "alert"); 7961 } 7962 } 7963 buildElementsPickerModal(elements) { 7964 const modal = document.getElementById("elements-picker-modal"); 7965 if (!modal) return; 7966 const isEmpty = !elements || elements.length === 0; 7967 const tabsHTML = ["all", "character", "object", "set"].map((t, i) => { 7968 const label = t === "all" ? "ALL" : t === "character" ? "CASTING" : t === "object" ? "OBJECTS" : "SETS"; 7969 const active = i === 0 ? "text-white border-b border-active" : "text-gray hover:text-white"; 7970 return `<button type="button" data-ep-tab="${t}" class="ep-tab pb-1.5 text-[11px] font-medium cursor-pointer transition duration-300 ${active}">${label}</button>`; 7971 }).join(""); 7972 let bodyHTML = ""; 7973 if (isEmpty) { 7974 bodyHTML = ` 7975 <div class="flex flex-col items-center justify-center py-12 text-center"> 7976 <svg class="w-12 h-12 text-gray mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 7977 <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><circle cx="12" cy="12" r="4"/> 7978 </svg> 7979 <h3 class="text-white font-medium mb-2">No Elements Yet</h3> 7980 <p class="text-gray text-sm max-w-md">Create elements to use their media here.</p> 7981 </div>`; 7982 } else { 7983 const listHTML = elements.map((el, i) => { 7984 const coverImg = el.cover_url ? `<div class="shrink-0 w-9 h-9 rounded overflow-hidden bg-inside"><img src="${el.cover_url}" alt="" class="w-full h-full object-cover" /></div>` : `<div class="shrink-0 w-9 h-9 rounded bg-inside flex items-center justify-center"><svg class="w-4 h-4 text-gray/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></div>`; 7985 const activeClass = i === 0 ? "bg-inside text-white" : "hover:bg-inside/50 text-gray hover:text-white"; 7986 return `<button type="button" data-ep-select="${el.uuid}" data-ep-type="${el.element_type}" class="ep-list-item flex items-center gap-3 rounded-lg p-2.5 cursor-pointer transition duration-300 text-left ${activeClass}"> 7987 ${coverImg} 7988 <div class="min-w-0"> 7989 <div class="text-xs font-medium truncate">${el.name}</div> 7990 <div class="text-[10px] text-active truncate">@${el.slug}</div> 7991 </div> 7992 </button>`; 7993 }).join(""); 7994 const panelsHTML = elements.map((el, i) => { 7995 if (!el.media || el.media.length === 0) { 7996 return `<div data-ep-media="${el.uuid}" class="${i === 0 ? "" : "hidden"}"><div class="text-xs text-gray text-center" style="padding-top:40px">No media</div></div>`; 7997 } 7998 const grid = el.media.map((m) => { 7999 const inner = m.is_video ? `<video src="${m.display_url}" muted playsinline class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"></video>` : `<img src="${m.display_url}" alt="" class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-110" loading="lazy" />`; 8000 const slugLabel = m.slug ? `@${m.slug}` : ""; 8001 return `<button type="button" class="ep-media-node group relative rounded-md overflow-hidden bg-inside cursor-pointer aspect-square" data-media-url="${m.url}" data-media-hash="${m.hash}" data-media-is-video="${m.is_video}">${inner}<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/40 to-transparent p-1.5 pt-3 pointer-events-none text-left"><span class="text-[9px] text-white leading-tight truncate block">${slugLabel}</span></div></button>`; 8002 }).join(""); 8003 return `<div data-ep-media="${el.uuid}" class="${i === 0 ? "" : "hidden"}"><div class="grid grid-cols-3 gap-2">${grid}</div></div>`; 8004 }).join(""); 8005 bodyHTML = ` 8006 <div class="flex gap-4" style="min-height: 300px; max-height: 60vh;"> 8007 <div class="w-[28.5%] overflow-y-auto flex flex-col gap-1 flow-scenes-scroll" data-ep-list>${listHTML}</div> 8008 <div class="w-[71.5%] overflow-y-auto">${panelsHTML}</div> 8009 </div>`; 8010 } 8011 modal.innerHTML = ` 8012 <div class="modal-content bg-box rounded-lg p-6 max-w-4xl w-full mx-4 shadow-xl max-h-[80vh] overflow-hidden flex flex-col"> 8013 <div class="flex items-start justify-between mb-4"> 8014 <div> 8015 <h2 class="text-14 font-bold text-white">Elements</h2> 8016 ${!isEmpty ? `<div class="flex gap-3 mt-2.5">${tabsHTML}</div>` : ""} 8017 </div> 8018 <button type="button" data-ep-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 8019 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
8019<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 8020 </button> 8021 </div> 8022 <div class="flex-1 overflow-hidden">${bodyHTML}</div> 8023 </div>`; 8024 const closeModal = () => { 8025 modal.classList.remove("modal-showing"); 8026 modal.addEventListener("transitionend", () => modal.remove(), { once: true }); 8027 }; 8028 modal.querySelector("[data-ep-close]").addEventListener("click", closeModal); 8029 modal.addEventListener("click", (e) => { 8030 if (e.target === modal) closeModal(); 8031 }); 8032 modal.querySelectorAll("[data-ep-select]").forEach((btn) => { 8033 btn.addEventListener("click", () => { 8034 modal.querySelectorAll(".ep-list-item").forEach((li) => { 8035 li.classList.remove("bg-inside", "text-white"); 8036 li.classList.add("hover:bg-inside/50", "text-gray", "hover:text-white"); 8037 }); 8038 btn.classList.add("bg-inside", "text-white"); 8039 btn.classList.remove("hover:bg-inside/50", "text-gray", "hover:text-white"); 8040 const uuid2 = btn.dataset.epSelect; 8041 modal.querySelectorAll("[data-ep-media]").forEach((p) => { 8042 p.classList.toggle("hidden", p.dataset.epMedia !== uuid2); 8043 }); 8044 }); 8045 }); 8046 const selectFirstVisible = () => { 8047 const first = modal.querySelector(".ep-list-item:not(.hidden)"); 8048 if (first) first.click(); 8049 }; 8050 modal.querySelectorAll("[data-ep-tab]").forEach((tab) => { 8051 tab.addEventListener("click", () => { 8052 modal.querySelectorAll(".ep-tab").forEach((t) => { 8053 t.classList.remove("text-white", "border-b", "border-active"); 8054 t.classList.add("text-gray", "hover:text-white"); 8055 }); 8056 tab.classList.add("text-white", "border-b", "border-active"); 8057 tab.classList.remove("text-gray", "hover:text-white"); 8058 tab.blur(); 8059 const type = tab.dataset.epTab; 8060 modal.querySelectorAll(".ep-list-item").forEach((li) => { 8061 li.classList.toggle("hidden", type !== "all" && li.dataset.epType !== type); 8062 }); 8063 selectFirstVisible(); 8064 }); 8065 }); 8066 modal.querySelectorAll(".ep-media-node").forEach((node) => { 8067 node.addEventListener("click", () => { 8068 const url = node.dataset.mediaUrl; 8069 const hash = node.dataset.mediaHash; 8070 const isVideo = node.dataset.mediaIsVideo === "true"; 8071 closeModal(); 8072 document.dispatchEvent(new CustomEvent("refs:selected", { 8073 detail: { refs: [{ file_hash: hash, url, isVideo }] } 8074 })); 8075 document.dispatchEvent(new CustomEvent("items-selected", { 8076 detail: { hash, url, isVideo } 8077 })); 8078 }); 8079 }); 8080 } 8081 async showFavsPicker() { 8082 this._openPickerShell("favs-picker-modal", "Favs", "max-w-4xl"); 8083 try { 8084 const [resp] = await Promise.all([ 8085 fetch("/favs.json"), 8086 new Promise((r) => setTimeout(r, 500)) 8087 ]); 8088 if (!resp.ok) throw new Error("Failed to load favs"); 8089 const data = await resp.json(); 8090 this.buildFavsPickerModal(data.finals); 8091 } catch (err) { 8092 console.error("[Modal] Favs picker error:", err); 8093 const modal = document.getElementById("favs-picker-modal"); 8094 if (modal) modal.remove(); 8095 if (window.Toast) window.Toast.show("Failed to load favs", "alert"); 8096 } 8097 } 8098 buildFavsPickerModal(finals) { 8099 const modal = document.getElementById("favs-picker-modal"); 8100 if (!modal) return; 8101 const isEmpty = !finals || finals.length === 0; 8102 let bodyHTML = ""; 8103 if (isEmpty) { 8104 bodyHTML = ` 8105 <div class="flex flex-col items-center justify-center py-12 text-center"> 8106 <svg class="w-12 h-12 text-gray mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 8107 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/> 8108 </svg> 8109 <h3 class="text-white font-medium mb-2">No Favs Yet</h3> 8110 <p class="text-gray text-sm max-w-md">Save your best generations to use them as references.</p> 8111 </div>`; 8112 } else { 8113 const playIcon = `<div class="absolute inset-0 flex items-center justify-center pointer-events-none z-10 transition-opacity duration-300 group-hover:opacity-0"><svg class="w-6 h-6 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24">
8113<path d="M8 5v14l11-7z"/></svg></div>`; 8114 const grid = finals.map((f) => { 8115 const inner = f.is_video ? `${playIcon}<video src="${f.file_url}" muted playsinline loop preload="metadata" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"></video>` : `<img src="${f.thumbnail_url}" alt="" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110" loading="lazy" />`; 8116 return `<button type="button" class="fp-media-node group relative rounded-md overflow-hidden bg-inside cursor-pointer aspect-square" data-media-hash="${f.file_hash}" data-media-url="${f.file_url}" data-media-is-video="${f.is_video}">${inner}</button>`; 8117 }).join(""); 8118 bodyHTML = `<div class="overflow-y-auto" style="max-height: 60vh;"><div class="grid grid-cols-5 gap-2">${grid}</div></div>`; 8119 } 8120 modal.innerHTML = ` 8121 <div class="modal-content bg-box rounded-lg p-6 max-w-4xl w-full mx-4 shadow-xl max-h-[80vh] overflow-hidden flex flex-col"> 8122 <div class="flex items-start justify-between mb-4"> 8123 <h2 class="text-14 font-bold text-white">Favs</h2> 8124 <button type="button" data-fp-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 8125 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 8126 </button> 8127 </div> 8128 <div class="flex-1 overflow-hidden">${bodyHTML}</div> 8129 </div>`; 8130 const closeModal = () => { 8131 modal.classList.remove("modal-showing"); 8132 modal.addEventListener("transitionend", () => modal.remove(), { once: true }); 8133 }; 8134 modal.querySelector("[data-fp-close]").addEventListener("click", closeModal); 8135 modal.addEventListener("click", (e) => { 8136 if (e.target === modal) closeModal(); 8137 }); 8138 modal.querySelectorAll(".fp-media-node").forEach((node) => { 8139 node.addEventListener("click", () => { 8140 const hash = node.dataset.mediaHash; 8141 const url = node.dataset.mediaUrl; 8142 closeModal(); 8143 document.dispatchEvent(new CustomEvent("favs:selected", { 8144 detail: { favs: [{ file_hash: hash, url }] } 8145 })); 8146 }); 8147 const video = node.querySelector("video"); 8148 if (video) { 8149 node.addEventListener("mouseenter", () => { 8150 video.currentTime = 0; 8151 video.play(); 8152 }); 8153 node.addEventListener("mouseleave", () => { 8154 video.pause(); 8155 video.currentTime = 0; 8156 }); 8157 } 8158 }); 8159 } 8160 showItem(options = {}) { 8161 const params = new URLSearchParams(); 8162 if (options.mediaHash) params.set("media_hash", options.mediaHash); 8163 if (options.mediaUrl) params.set("media_url", options.mediaUrl); 8164 if (options.isVideo) params.set("is_video", "true"); 8165 const qs = params.toString(); 8166 const url = qs ? `/modal/item?${qs}` : "/modal/item"; 8167 this.loadModal(url, "item-modal"); 8168 } 8169 // Open app modal - redirects to /apps with modal open 8170 showApp(slug, imageUrl = null) { 8171 let url = `/apps?app=${encodeURIComponent(slug)}`; 8172 if (imageUrl) { 8173 url += `&image=${encodeURIComponent(imageUrl)}`; 8174 } 8175 window.location.href = url; 8176 } 8177 initializeDeleteTeam() { 8178 const modal = document.getElementById("delete-team-modal"); 8179 const input = document.getElementById("delete-team-confirmation"); 8180 const submitBtn = document.getElementById("delete-team-submit"); 8181 const expectedText = modal?.dataset?.expectedText; 8182 console.log("initializeDeleteTeam called"); 8183 console.log("modal:", modal); 8184 console.log("input:", input); 8185 console.log("submitBtn:", submitBtn); 8186 console.log("expectedText:", expectedText); 8187 if (input && submitBtn && expectedText) { 8188 input.addEventListener("input", function() { 8189 console.log("input value:", input.value); 8190 console.log("expected:", expectedText); 8191 console.log("match:", input.value === expectedText); 8192 if (input.value === expectedText) { 8193 submitBtn.disabled = false;
8194 submitBtn.classList.remove("opacity-50"); 8195 } else { 8196 submitBtn.disabled = true; 8197 submitBtn.classList.add("opacity-50"); 8198 } 8199 }); 8200 } else { 8201 console.log("Missing elements or expectedText"); 8202 } 8203 } 8204 loadModal(url, modalId) { 8205 const existingModal = document.getElementById(modalId); 8206 if (existingModal) existingModal.remove(); 8207 const shellWidths = { 8208 "generated-modal": "max-w-4xl", 8209 "pins-modal": "max-w-4xl", 8210 "library-modal": "max-w-5xl", 8211 "templates-modal": "max-w-4xl", 8212 "styles-modal": "max-w-4xl", 8213 "voices-modal": "max-w-4xl", 8214 "cameras-modal": "max-w-4xl", 8215 "creator-modal": "max-w-4xl", 8216 "elements-modal": "max-w-4xl", 8217 "media-modal": "max-w-6xl", 8218 "item-modal": "max-w-3xl", 8219 "feedback-modal": "max-w-lg" 8220 }; 8221 const shellMaxW = shellWidths[modalId] || "max-w-2xl"; 8222 const shell = document.createElement("div"); 8223 shell.id = modalId; 8224 shell.setAttribute("data-modal", modalId); 8225 shell.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 8226 shell.style.cssText = "align-items: flex-start; padding-top: 10vh; z-index: 9999;"; 8227 shell.innerHTML = ` 8228 <div class="modal-content bg-box rounded-lg p-6 ${shellMaxW} w-full mx-4 shadow-xl flex flex-col items-center justify-center" style="min-height: 200px;"> 8229 <div class="flex-1 flex items-center justify-center py-8"> 8230 <svg class="w-5 h-5 text-active" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="31.4 31.4" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" dur="0.8s" from="0 12 12" to="360 12 12" repeatCount="indefinite"/></circle></svg> 8231 </div> 8232 </div>`; 8233 document.body.appendChild(shell); 8234 requestAnimationFrame(() => { 8235 requestAnimationFrame(() => { 8236 shell.classList.add("modal-showing"); 8237 }); 8238 }); 8239 const shellEsc = (e) => { 8240 if (e.key === "Escape") { 8241 document.removeEventListener("keydown", shellEsc); 8242 shell.classList.remove("modal-showing"); 8243 shell.addEventListener("transitionend", () => shell.remove(), { once: true }); 8244 } 8245 }; 8246 document.addEventListener("keydown", shellEsc); 8247 shell.addEventListener("click", (e) => { 8248 if (e.target === shell) { 8249 document.removeEventListener("keydown", shellEsc); 8250 shell.classList.remove("modal-showing"); 8251 shell.addEventListener("transitionend", () => shell.remove(), { once: true }); 8252 } 8253 }); 8254 fetch(url, { 8255 headers: { 8256 Accept: "text/html", 8257 "X-Requested-With": "XMLHttpRequest" 8258 } 8259 }).then((response) => response.text()).then((html) => { 8260 document.removeEventListener("keydown", shellEsc); 8261 const currentShell = document.getElementById(modalId); 8262 const hadShell = !!currentShell; 8263 if (currentShell) currentShell.remove(); 8264 document.body.insertAdjacentHTML("beforeend", html); 8265 const modal = document.getElementById(modalId); 8266 if (modal) { 8267 this.activeModal = modalId; 8268 if (modalId === "media-modal") { 8269 this.initializeMediaZoom(); 8270 } 8271 if (hadShell) { 8272 modal.classList.remove("hidden"); 8273 modal.classList.add("modal-showing"); 8274 document.body.classList.add("modal-open"); 8275 this._initModal(modalId); 8276 } else { 8277 modal.classList.add("hidden"); 8278 modal.classList.remove("modal-showing"); 8279 modal.style.opacity = "0"; 8280 modal.offsetHeight; 8281 requestAnimationFrame(() => { 8282 requestAnimationFrame(() => { 8283 modal.style.opacity = ""; 8284 modal.classList.remove("hidden"); 8285 document.body.classList.add("modal-open"); 8286 requestAnimationFrame(() => { 8287 modal.classList.add("modal-showing"); 8288 this._initModal(modalId); 8289 }); 8290 }); 8291 }); 8292 } 8293 } 8294 }).catch((error) => { 8295 console.error(`Error loading ${modalId}:`, error); 8296 }); 8297 } 8298 _initModal(modalId) { 8299 if (modalId === "templates-modal") { 8300 initializeTemplates(this); 8301 } else if (modalId === "styles-modal") { 8302 initializeStyles(this); 8303 } else if (modalId === "voices-modal") { 8304 initializeVoices(this); 8305 } else if (modalId === "cameras-modal") { 8306 initializeCameras(this); 8307 } else if (modalId === "creator-modal") { 8308 initializeCreator(this); 8309 } else if (modalId === "create-team-modal") { 8310 initializeCreateTeam(this); 8311 } else if (modalId === "library-modal") { 8312 initializeLibrary(this); 8313 } else if (modalId === "pins-modal") { 8314 initializePins(this); 8315 } else if (modalId === "generated-modal") { 8316 initializeGenerated(this); 8317 } else if (modalId === "rename-modal") { 8318 initializeRename(this); 8319 } else if (modalId === "elements-modal") { 8320 initializeElements(this); 8321 } else if (modalId === "item-modal") { 8322 this.initializeItemModal(); 8323 } else if (modalId === "delete-team-modal") { 8324 this.initializeDeleteTeam(); 8325 } else if (modalId === "remove-member-modal") { 8326 this.initializeRemoveMember(); 8327 } else if (modalId === "cancel-invitation-modal") { 8328 this.initializeCancelInvitation(); 8329 } 8330 } 8331 initializeCancelInvitation() { 8332 const emailDisplay = document.getElementById("invitation-email-display"); 8333 const confirmBtn = document.getElementById("confirm-cancel-invitation"); 8334 const token = this.currentInvitationToken; 8335 if (emailDisplay && this.currentInvitationEmail) { 8336 emailDisplay.textContent = this.currentInvitationEmail; 8337 } 8338 if (confirmBtn && token) { 8339 confirmBtn.addEventListener("click", async () => { 8340 try { 8341 const response = await fetch(`/invitations/${token}`, { 8342 method: "DELETE", 8343 headers: { 8344 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 8345 } 8346 }); 8347 if (response.ok) { 8348 window.Toast.show("Invitation cancelled", "success"); 8349 this.close(); 8350 location.reload(); 8351 } else { 8352 window.Toast.show("Failed to cancel invitation", "alert"); 8353 } 8354 } catch (error) { 8355 console.error("Error cancelling invitation:", error); 8356 window.Toast.show("Failed to cancel invitation", "alert"); 8357 } 8358 }); 8359 } 8360 } 8361 initializeRemoveMember() { 8362 const memberNameDisplay = document.getElementById("member-name-display"); 8363 const confirmBtn = document.getElementById("confirm-remove-member"); 8364 if (memberNameDisplay && this.currentMemberName) { 8365 memberNameDisplay.textContent = this.currentMemberName; 8366 } 8367 if (confirmBtn && this.currentMemberId) { 8368 confirmBtn.addEventListener("click", async () => { 8369 try { 8370 const response = await fetch( 8371 `/settings/members/${this.currentMemberId}`, 8372 { 8373 method: "DELETE", 8374 headers: { 8375 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 8376 } 8377 } 8378 ); 8379 const data = await response.json(); 8380 if (response.ok) { 8381 window.Toast.show("Member removed successfully", "success"); 8382 this.close(); 8383 document.querySelector(`[data-member-id="${this.currentMemberId}"]`).remove(); 8384 } else { 8385 window.Toast.show(data.error || "Failed to remove member", "alert"); 8386 } 8387 } catch (error) { 8388 console.error("Error removing member:", error); 8389 window.Toast.show("Failed to remove member", "alert"); 8390 } 8391 }); 8392 } 8393 } 8394 initializeMediaZoom() { 8395 const modal = document.getElementById("media-modal"); 8396 const wrapper = document.getElementById("media-zoom-wrapper"); 8397 const img = document.getElementById("media-image"); 8398 const badge = document.getElementById("media-zoom-badge"); 8399 const resetWrap = document.getElementById("media-zoom-reset-wrap"); 8400 window._mediaZoom = { step: () => { 8401 }, reset: () => { 8402 } }; 8403 if (!modal || !wrapper || !img) return; 8404 let scale = 1; 8405 let panX = 0; 8406 let panY = 0; 8407 const minScale = 1; 8408 const maxScale = 5; 8409 const ANIM_DURATION = 300; 8410 let baseW = 0; 8411 let baseH = 0; 8412 let isPanning = false;
8413 let panStartX = 0; 8414 let panStartY = 0; 8415 let sPanX = 0; 8416 let sPanY = 0; 8417 let animFrame = null; 8418 const measureBase = () => { 8419 img.style.position = "absolute"; 8420 img.style.maxWidth = "100%"; 8421 img.style.maxHeight = "100%"; 8422 img.style.width = ""; 8423 img.style.height = ""; 8424 img.style.top = "50%"; 8425 img.style.left = "50%"; 8426 img.style.transform = "translate(-50%, -50%)"; 8427 img.style.userSelect = "none"; 8428 baseW = img.offsetWidth; 8429 baseH = img.offsetHeight; 8430 if ((!baseW || !baseH) && img.isConnected) { 8431 requestAnimationFrame(measureBase); 8432 } 8433 }; 8434 const clampPan = () => { 8435 const w = baseW * scale; 8436 const h = baseH * scale; 8437 const wrapW = wrapper.offsetWidth; 8438 const wrapH = wrapper.offsetHeight; 8439 if (w > wrapW) { 8440 const max = (w - wrapW) / 2; 8441 panX = Math.max(-max, Math.min(max, panX)); 8442 } else { 8443 panX = 0; 8444 } 8445 if (h > wrapH) { 8446 const max = (h - wrapH) / 2; 8447 panY = Math.max(-max, Math.min(max, panY)); 8448 } else { 8449 panY = 0; 8450 } 8451 }; 8452 const apply = () => { 8453 clampPan(); 8454 const w = baseW * scale; 8455 const h = baseH * scale; 8456 const wrapW = wrapper.offsetWidth; 8457 const wrapH = wrapper.offsetHeight; 8458 const left = (wrapW - w) / 2 + panX; 8459 const top = (wrapH - h) / 2 + panY; 8460 img.style.position = "absolute"; 8461 img.style.maxWidth = "none"; 8462 img.style.maxHeight = "none"; 8463 img.style.transform = "none"; 8464 img.style.width = w + "px"; 8465 img.style.height = h + "px"; 8466 img.style.left = left + "px"; 8467 img.style.top = top + "px"; 8468 if (badge) badge.textContent = Math.round(scale * 100) + "%"; 8469 if (resetWrap) resetWrap.classList.toggle("hidden", scale < 1.5); 8470 wrapper.style.cursor = scale > 1 ? "grab" : "default"; 8471 }; 8472 const resetZoom = () => { 8473 animateTo(1, 0, 0); 8474 }; 8475 const animateTo = (targetScale, targetPanX, targetPanY) => { 8476 if (animFrame) cancelAnimationFrame(animFrame); 8477 const startScale = scale; 8478 const startPanX = panX; 8479 const startPanY = panY; 8480 const startTime = Date.now(); 8481 const tick = () => { 8482 const elapsed = Date.now() - startTime; 8483 const progress = Math.min(elapsed / ANIM_DURATION, 1); 8484 const ease = 1 - Math.pow(1 - progress, 3); 8485 scale = startScale + (targetScale - startScale) * ease; 8486 panX = startPanX + (targetPanX - startPanX) * ease; 8487 panY = startPanY + (targetPanY - startPanY) * ease; 8488 apply(); 8489 if (progress < 1) { 8490 animFrame = requestAnimationFrame(tick); 8491 } else { 8492 animFrame = null; 8493 } 8494 }; 8495 tick(); 8496 }; 8497 const zoomAt = (newScale, clientX, clientY) => { 8498 const rect = wrapper.getBoundingClientRect(); 8499 const wrapW = wrapper.offsetWidth; 8500 const wrapH = wrapper.offsetHeight; 8501 const mx = clientX - rect.left; 8502 const my = clientY - rect.top; 8503 const imgLeft = (wrapW - baseW * scale) / 2 + panX; 8504 const imgTop = (wrapH - baseH * scale) / 2 + panY; 8505 const rx = (mx - imgLeft) / (baseW * scale); 8506 const ry = (my - imgTop) / (baseH * scale); 8507 scale = newScale; 8508 const newImgLeft = mx - rx * baseW * scale; 8509 const newImgTop = my - ry * baseH * scale; 8510 panX = newImgLeft - (wrapW - baseW * scale) / 2; 8511 panY = newImgTop - (wrapH - baseH * scale) / 2; 8512 apply(); 8513 }; 8514 const stepZoom = (dir) => { 8515 let target = dir > 0 ? Math.floor(scale) + 1 : Math.ceil(scale) - 1; 8516 target = Math.min(maxScale, Math.max(minScale, target)); 8517 const rect = wrapper.getBoundingClientRect(); 8518 const cx = rect.left + rect.width / 2; 8519 const cy = rect.top + rect.height / 2; 8520 const wrapW = wrapper.offsetWidth; 8521 const wrapH = wrapper.offsetHeight; 8522 const mx = cx - rect.left; 8523 const my = cy - rect.top; 8524 const imgLeft = (wrapW - baseW * scale) / 2 + panX; 8525 const imgTop = (wrapH - baseH * scale) / 2 + panY; 8526 const rx = (mx - imgLeft) / (baseW * scale); 8527 const ry = (my - imgTop) / (baseH * scale); 8528 const newImgLeft = mx - rx * baseW * target;
8529 const newImgTop = my - ry * baseH * target; 8530 const targetPanX = newImgLeft - (wrapW - baseW * target) / 2; 8531 const targetPanY = newImgTop - (wrapH - baseH * target) / 2; 8532 if (target <= minScale) { 8533 animateTo(1, 0, 0); 8534 } else { 8535 const tw = baseW * target; 8536 const th = baseH * target; 8537 let cpx = targetPanX; 8538 let cpy = targetPanY; 8539 if (tw > wrapW) { 8540 const max = (tw - wrapW) / 2; 8541 cpx = Math.max(-max, Math.min(max, cpx)); 8542 } else { 8543 cpx = 0; 8544 } 8545 if (th > wrapH) { 8546 const max = (th - wrapH) / 2; 8547 cpy = Math.max(-max, Math.min(max, cpy)); 8548 } else { 8549 cpy = 0; 8550 } 8551 animateTo(target, cpx, cpy); 8552 } 8553 }; 8554 window._mediaZoom = { step: stepZoom, reset: resetZoom }; 8555 if (img.complete && img.naturalWidth > 0) { 8556 measureBase(); 8557 } else { 8558 img.addEventListener("load", () => measureBase()); 8559 } 8560 modal.addEventListener("wheel", (e) => { 8561 e.preventDefault(); 8562 if (!wrapper.contains(e.target) || !baseW) return; 8563 const factor = e.ctrlKey ? 0.999 ** e.deltaY : e.deltaY < 0 ? 1.05 : 1 / 1.05; 8564 const ns = Math.min(maxScale, Math.max(minScale, scale * factor)); 8565 if (ns <= minScale) { 8566 resetZoom(); 8567 return; 8568 } 8569 zoomAt(ns, e.clientX, e.clientY); 8570 }, { passive: false }); 8571 wrapper.addEventListener("mousedown", (e) => { 8572 if (scale <= 1) return; 8573 e.preventDefault(); 8574 isPanning = true; 8575 panStartX = e.clientX; 8576 panStartY = e.clientY; 8577 sPanX = panX; 8578 sPanY = panY; 8579 wrapper.style.cursor = "grabbing"; 8580 }); 8581 window.addEventListener("mousemove", (e) => { 8582 if (!isPanning) return; 8583 panX = sPanX + (e.clientX - panStartX); 8584 panY = sPanY + (e.clientY - panStartY); 8585 apply(); 8586 }); 8587 window.addEventListener("mouseup", () => { 8588 if (!isPanning) return; 8589 isPanning = false; 8590 wrapper.style.cursor = scale > 1 ? "grab" : "default"; 8591 }); 8592 wrapper.addEventListener("dblclick", () => resetZoom()); 8593 } 8594 initializeItemModal() { 8595 const modal = document.getElementById("item-modal"); 8596 if (modal) { 8597 modal.parentElement.querySelectorAll("script").forEach((script) => { 8598 const newScript = document.createElement("script"); 8599 newScript.textContent = script.textContent; 8600 document.head.appendChild(newScript).remove(); 8601 }); 8602 } 8603 } 8604}; 8605if (!window.Modal) { 8606 window.Modal = new Modal(); 8607} 8608window.switchTemplate = async function(templateId) { 8609 const video = document.querySelector("#template-modal video"); 8610 if (video) video.pause(); 8611 const response = await fetch(`/tutorial/${templateId}`); 8612 if (!response.ok) return; 8613 const html = await response.text(); 8614 const parser = new DOMParser(); 8615 const doc = parser.parseFromString(html, "text/html"); 8616 const newContent = doc.querySelector(".modal-content"); 8617 if (newContent) { 8618 document.querySelector("#template-modal .modal-content").replaceWith(newContent); 8619 } 8620}; 8621window._openMediaItemModal = function(triggerBtn) { 8622 const hash = document.getElementById("media-modal")?.dataset.mediaHash; 8623 if (!hash) { 8624 window.Toast?.show("Media not ready", "notice"); 8625 return; 8626 } 8627 const fileUrl = triggerBtn.dataset.fileUrl || ""; 8628 const isVideo = fileUrl.includes(".mp4"); 8629 if (window.Modal) { 8630 window.Modal.showItem({ mediaHash: hash, mediaUrl: fileUrl, isVideo }); 8631 } 8632}; 8633
8634// app/javascript/flow/canvas.js 8635var CanvasMixin = { 8636 setupCanvas() { 8637 this.container = document.getElementById("flow-canvas-container"); 8638 if (!this.container) return; 8639 const width = this.container.clientWidth; 8640 const height = this.container.clientHeight; 8641 const parent = this.container.parentElement; 8642 const containerMarginLeft = parent ? parseInt(window.getComputedStyle(parent).marginLeft) || 0 : 0; 8643 this.initialCenterX = width / 2 - containerMarginLeft / 2; 8644 this.initialCenterY = height / 2; 8645 this.viewport = document.createElement("div"); 8646 this.viewport.id = "flow-viewport"; 8647 this.viewport.style.cssText = ` 8648 position: absolute; 8649 width: 20000px; 8650 height: 20000px; 8651 top: 0; 8652 left: 0; 8653 transform-origin: 0 0; 8654 will-change: transform; 8655 `; 8656 this.container.appendChild(this.viewport); 8657 this.svgLayer = document.createElementNS( 8658 "http://www.w3.org/2000/svg", 8659 "svg" 8660 ); 8661 this.svgLayer.setAttribute("id", "flow-connections"); 8662 this.svgLayer.setAttribute("width", "20000"); 8663 this.svgLayer.setAttribute("height", "20000"); 8664 this.svgLayer.style.cssText = ` 8665 position: absolute; 8666 inset: 0; 8667 pointer-events: none; 8668 overflow: visible; 8669 `; 8670 this.viewport.appendChild(this.svgLayer); 8671 this.nodesContainer = document.createElement("div"); 8672 this.nodesContainer.id = "flow-nodes"; 8673 this.nodesContainer.style.cssText = ` 8674 position: absolute; 8675 inset: 0; 8676 `; 8677 this.viewport.appendChild(this.nodesContainer); 8678 this.setupPanZoom(); 8679 this.updateTransform(); 8680 this.addListener(window, "resize", () => { 8681 this.updateTransform(); 8682 this.updateTopBarRight(); 8683 }); 8684 }, 8685 setupPanZoom() { 8686 this.addListener(this.container, "mousedown", (e) => { 8687 if (e.button === 1 || e.button === 0 && e.shiftKey) { 8688 this.isPanning = true; 8689 this.lastPosX = e.clientX; 8690 this.lastPosY = e.clientY; 8691 this.container.style.cursor = "grabbing"; 8692 e.preventDefault(); 8693 } 8694 }); 8695 this.addListener(this.container, "mousemove", (e) => { 8696 if (this.isPanning) { 8697 const deltaX = e.clientX - this.lastPosX; 8698 const deltaY = e.clientY - this.lastPosY; 8699 this.panX += deltaX; 8700 this.panY += deltaY; 8701 this.lastPosX = e.clientX; 8702 this.lastPosY = e.clientY; 8703 this.updateTransform(); 8704 } 8705 }); 8706 this.addListener(window, "mouseup", () => { 8707 if (this.isPanning) { 8708 this.isPanning = false; 8709 this.container.style.cursor = "default"; 8710 } 8711 }); 8712 this.addListener(this.container, "wheel", (e) => { 8713 const target = e.target; 8714 const scenesPanel = target.closest("#scenes-list")?.parentElement?.parentElement; 8715 if (scenesPanel) { 8716 if (!e.ctrlKey && !e.metaKey) { 8717 this.panX -= e.deltaX; 8718 this.panY -= e.deltaY; 8719 this.updateTransform(); 8720 return; 8721 } 8722 } 8723 if (e.ctrlKey || e.metaKey) { 8724 e.preventDefault(); 8725 const delta = e.deltaY; 8726 const zoomFactor = 0.999 ** delta;
8727 const newZoom = this.zoom * zoomFactor; 8728 if (newZoom > this.MAX_ZOOM || newZoom < this.MIN_ZOOM) return; 8729 const rect = this.container.getBoundingClientRect(); 8730 const mouseX = e.clientX - rect.left; 8731 const mouseY = e.clientY - rect.top; 8732 this.panX = mouseX - (mouseX - this.panX) * (newZoom / this.zoom); 8733 this.panY = mouseY - (mouseY - this.panY) * (newZoom / this.zoom); 8734 this.zoom = newZoom; 8735 this.updateTransform(); 8736 this.updateZoomDisplay(); 8737 } else { 8738 if ((target.tagName === "TEXTAREA" || target.tagName === "INPUT") && document.activeElement === target) { 8739 if (target.tagName === "TEXTAREA") { 8740 const hasScrollableContent = target.scrollHeight > target.clientHeight; 8741 if (hasScrollableContent) { 8742 return; 8743 } 8744 } else { 8745 return; 8746 } 8747 } 8748 e.preventDefault(); 8749 this.panX -= e.deltaX; 8750 this.panY -= e.deltaY; 8751 this.updateTransform(); 8752 } 8753 }); 8754 }, 8755 updateTransform() { 8756 if (!this.viewport) return; 8757 this.viewport.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`; 8758 this.updateGrid(); 8759 }, 8760 updateGrid() { 8761 if (!this.container) return; 8762 let opacity = 0; 8763 if (this.zoom >= 1) { 8764 opacity = 0.2; 8765 } else if (this.zoom > 0.5) { 8766 opacity = (this.zoom - 0.5) / 0.5 * 0.2; 8767 } 8768 this.container.style.setProperty("--grid-opacity", opacity); 8769 const gridSize = 20 * this.zoom; 8770 this.container.style.setProperty("--grid-size", `${gridSize}px`); 8771 this.container.style.setProperty("--grid-position-x", `${this.panX}px`); 8772 this.container.style.setProperty("--grid-position-y", `${this.panY}px`); 8773 }, 8774 updateZoomDisplay() { 8775 const zoomDisplay = document.querySelector("[data-flow-zoom-display]"); 8776 if (zoomDisplay) { 8777 zoomDisplay.textContent = Math.round(this.zoom * 100) + "%"; 8778 } 8779 }, 8780 // Zoom in (animated, additive 10% steps) 8781 zoomIn() { 8782 let targetZoom = Math.round(this.zoom * 10 + 1) / 10; 8783 if (targetZoom > this.MAX_ZOOM) targetZoom = this.MAX_ZOOM; 8784 const viewportCenterX = this.container.clientWidth / 2; 8785 const viewportCenterY = this.container.clientHeight / 2; 8786 const worldX = (viewportCenterX - this.panX) / this.zoom; 8787 const worldY = (viewportCenterY - this.panY) / this.zoom; 8788 this.animateToZoom(worldX, worldY, targetZoom); 8789 }, 8790 // Zoom out (animated, additive 10% steps) 8791 zoomOut() { 8792 let targetZoom = Math.round(this.zoom * 10 - 1) / 10; 8793 if (targetZoom < this.MIN_ZOOM) targetZoom = this.MIN_ZOOM; 8794 const viewportCenterX = this.container.clientWidth / 2; 8795 const viewportCenterY = this.container.clientHeight / 2; 8796 const worldX = (viewportCenterX - this.panX) / this.zoom; 8797 const worldY = (viewportCenterY - this.panY) / this.zoom; 8798 this.animateToZoom(worldX, worldY, targetZoom); 8799 }, 8800 // Reset zoom to 100% centered on root node (animated) 8801 resetZoom() { 8802 const rootNode = Object.values(this.nodes).find( 8803 (node) => node.data.nodeData && node.data.nodeData.level === 0 8804 ); 8805 let targetX = this.initialCenterX; 8806 let targetY = this.initialCenterY; 8807 if (rootNode && rootNode.position) { 8808 targetX = rootNode.position.left + rootNode.position.width / 2; 8809 targetY = rootNode.position.top + rootNode.position.height / 2; 8810 } 8811 this.animateToZoom(targetX, targetY, 1); 8812 }, 8813 // Reset zoom to 100% centered on root node (no animation) 8814 resetZoomNoAnimation() { 8815 const rootNode = Object.values(this.nodes).find( 8816 (node) => node.data.nodeData && node.data.nodeData.level === 0 8817 ); 8818 let targetX = this.initialCenterX; 8819 let targetY = this.initialCenterY; 8820 if (rootNode && rootNode.position) { 8821 targetX = rootNode.position.left + rootNode.position.width / 2; 8822 targetY = rootNode.position.top + rootNode.position.height / 2; 8823 } 8824 const targetZoom = 1; 8825 const viewportCenterX = this.container.clientWidth / 2; 8826 const viewportCenterY = this.container.clientHeight / 2; 8827 const targetPanX = viewportCenterX - targetX * targetZoom; 8828 const targetPanY = viewportCenterY - targetY * targetZoom; 8829 this.zoom = targetZoom; 8830 this.panX = targetPanX; 8831 this.panY = targetPanY; 8832 this.updateTransform(); 8833 this.updateZoomDisplay(); 8834 if (this.updateConnections) { 8835 this.updateConnections(); 8836 } 8837 }, 8838 // Center viewport on root node (animated) 8839 centerOnRoot() { 8840 const rootNode = Object.values(this.nodes).find( 8841 (node) => node.data.nodeData && node.data.nodeData.level === 0 8842 ); 8843 if (!rootNode || !rootNode.position) { 8844 this.animateToZoom(this.initialCenterX, this.initialCenterY, 1); 8845 return; 8846 } 8847 const centerX = rootNode.position.left + rootNode.position.width / 2; 8848 const centerY = rootNode.position.top + rootNode.position.height / 2; 8849 this.animateToZoom(centerX, centerY, 1); 8850 }, 8851 // Center on a specific node 8852 centerOnNode(node) { 8853 if (!node || !node.position) { 8854 this.animateToZoom(this.initialCenterX, this.initialCenterY, 1); 8855 return; 8856 } 8857 const centerX = node.position.left + node.position.width / 2; 8858 const centerY = node.position.top + node.position.height / 2; 8859 this.animateToZoom(centerX, centerY, 1); 8860 }, 8861 // Fit all content to screen (animated) 8862 fitToScreen() { 8863 const nodePositions = Object.values(this.nodes).map((node) => ({ 8864 left: node.position.left, 8865 top: node.position.top, 8866 width: node.position.width, 8867 height: node.position.height 8868 })); 8869 if (nodePositions.length === 0) { 8870 this.animateToZoom(this.initialCenterX, this.initialCenterY, 1); 8871 return; 8872 } 8873 const minX = Math.min(...nodePositions.map((n) => n.left)); 8874 const maxX = Math.max(...nodePositions.map((n) => n.left + n.width)); 8875 const minY = Math.min(...nodePositions.map((n) => n.top)); 8876 const maxY = Math.max(...nodePositions.map((n) => n.top + n.height)); 8877 const contentWidth = maxX - minX; 8878 const contentHeight = maxY - minY; 8879 const viewportWidth = this.container.clientWidth;
8880 const viewportHeight = this.container.clientHeight; 8881 const paddingX = viewportWidth * 0.1; 8882 const paddingY = viewportHeight * 0.1; 8883 const zoomX = (viewportWidth - paddingX * 2) / contentWidth; 8884 const zoomY = (viewportHeight - paddingY * 2) / contentHeight; 8885 let targetZoom = Math.min(zoomX, zoomY); 8886 targetZoom = Math.max(this.MIN_ZOOM, Math.min(this.MAX_ZOOM, targetZoom)); 8887 const centerX = minX + contentWidth / 2; 8888 const centerY = minY + contentHeight / 2; 8889 this.animateToZoom(centerX, centerY, targetZoom); 8890 }, 8891 fitToScreenNoAnimation() { 8892 const nodePositions = Object.values(this.nodes).map((node) => ({ 8893 left: node.position.left, 8894 top: node.position.top, 8895 width: node.position.width, 8896 height: node.position.height 8897 })); 8898 if (nodePositions.length === 0) { 8899 this.zoom = 1; 8900 this.panX = this.initialCenterX; 8901 this.panY = this.initialCenterY; 8902 this.updateTransform(); 8903 this.updateZoomDisplay(); 8904 return; 8905 } 8906 const minX = Math.min(...nodePositions.map((n) => n.left)); 8907 const maxX = Math.max(...nodePositions.map((n) => n.left + n.width)); 8908 const minY = Math.min(...nodePositions.map((n) => n.top)); 8909 const maxY = Math.max(...nodePositions.map((n) => n.top + n.height)); 8910 const contentWidth = maxX - minX; 8911 const contentHeight = maxY - minY; 8912 const viewportWidth = this.container.clientWidth; 8913 const viewportHeight = this.container.clientHeight; 8914 const paddingX = viewportWidth * 0.1; 8915 const paddingY = viewportHeight * 0.1; 8916 const zoomX = (viewportWidth - paddingX * 2) / contentWidth; 8917 const zoomY = (viewportHeight - paddingY * 2) / contentHeight; 8918 let targetZoom = Math.min(zoomX, zoomY); 8919 targetZoom = Math.max(this.MIN_ZOOM, Math.min(this.MAX_ZOOM, targetZoom)); 8920 const centerX = minX + contentWidth / 2; 8921 const centerY = minY + contentHeight / 2; 8922 const viewportCenterX = this.container.clientWidth / 2; 8923 const viewportCenterY = this.container.clientHeight / 2; 8924 const targetPanX = viewportCenterX - centerX * targetZoom; 8925 const targetPanY = viewportCenterY - centerY * targetZoom; 8926 this.zoom = targetZoom; 8927 this.panX = targetPanX; 8928 this.panY = targetPanY; 8929 this.updateTransform(); 8930 this.updateZoomDisplay(); 8931 }, 8932 // Zoom to specific nodes (animated) 8933 zoomToNodes(nodeIds) { 8934 if (!nodeIds || nodeIds.length === 0) return; 8935 const nodePositions = nodeIds.map((id) => this.nodes[id]).filter((node) => node && node.position).map((node) => ({ 8936 left: node.position.left, 8937 top: node.position.top, 8938 width: node.position.width, 8939 height: node.position.height 8940 })); 8941 if (nodePositions.length === 0) return; 8942 const minX = Math.min(...nodePositions.map((n) => n.left)); 8943 const maxX = Math.max(...nodePositions.map((n) => n.left + n.width)); 8944 const minY = Math.min(...nodePositions.map((n) => n.top)); 8945 const maxY = Math.max(...nodePositions.map((n) => n.top + n.height)); 8946 const contentWidth = maxX - minX; 8947 const contentHeight = maxY - minY; 8948 const viewportWidth = this.container.clientWidth; 8949 const viewportHeight = this.container.clientHeight; 8950 const paddingX = viewportWidth * 0.15; 8951 const paddingY = viewportHeight * 0.15; 8952 const zoomX = (viewportWidth - paddingX * 2) / contentWidth; 8953 const zoomY = (viewportHeight - paddingY * 2) / contentHeight; 8954 let targetZoom = Math.min(zoomX, zoomY); 8955 if (nodePositions.length === 1 && this.zoom >= 0.8) { 8956 targetZoom = this.zoom; 8957 } 8958 targetZoom = Math.max(this.MIN_ZOOM, Math.min(this.MAX_ZOOM, targetZoom)); 8959 const centerX = minX + contentWidth / 2; 8960 const centerY = minY + contentHeight / 2; 8961 this.animateToZoom(centerX, centerY, targetZoom, 0.7); 8962 }, 8963 // Animate pan and zoom to a target position 8964 // horizontalRatio: 0.5 = center (default), 0.7 = 70% from left 8965 animateToZoom(targetWorldX, targetWorldY, targetZoom, horizontalRatio = 0.5) { 8966 const startZoom = this.zoom; 8967 const startPanX = this.panX; 8968 const startPanY = this.panY; 8969 const viewportTargetX = this.container.clientWidth * horizontalRatio; 8970 const viewportCenterY = this.container.clientHeight / 2; 8971 const targetPanX = viewportTargetX - targetWorldX * targetZoom; 8972 const targetPanY = viewportCenterY - targetWorldY * targetZoom; 8973 const startTime = Date.now(); 8974 const animate = () => {
8975 const elapsed = Date.now() - startTime; 8976 const progress = Math.min(elapsed / this.ANIMATION_DURATION, 1); 8977 const easeProgress = 1 - Math.pow(1 - progress, 3); 8978 this.zoom = startZoom + (targetZoom - startZoom) * easeProgress; 8979 this.panX = startPanX + (targetPanX - startPanX) * easeProgress; 8980 this.panY = startPanY + (targetPanY - startPanY) * easeProgress; 8981 this.updateTransform(); 8982 this.updateZoomDisplay(); 8983 if (this.updateConnections) { 8984 this.updateConnections(); 8985 } 8986 if (progress < 1) { 8987 requestAnimationFrame(animate); 8988 } 8989 }; 8990 animate(); 8991 }, 8992 bindZoomControls() { 8993 const zoomInBtn = document.querySelector("[data-flow-zoom-in]"); 8994 const zoomOutBtn = document.querySelector("[data-flow-zoom-out]"); 8995 const resetBtn = document.querySelector("[data-flow-zoom-reset]"); 8996 const fitBtn = document.querySelector("[data-flow-fit-screen]"); 8997 if (zoomInBtn) { 8998 this.addListener(zoomInBtn, "click", () => this.zoomIn()); 8999 } 9000 if (zoomOutBtn) { 9001 this.addListener(zoomOutBtn, "click", () => this.zoomOut()); 9002 } 9003 if (resetBtn) { 9004 this.addListener(resetBtn, "click", () => this.resetZoom()); 9005 } 9006 if (fitBtn) { 9007 this.addListener(fitBtn, "click", () => this.fitToScreen()); 9008 } 9009 this.updateZoomDisplay(); 9010 } 9011}; 9012
9013// app/javascript/flow/nodes.js 9014var NodesMixin = { 9015 // Update badge labels (@image1, @image2, etc.) and bind click handlers 9016 updateBadgeLabels(nodeEl) { 9017 if (!nodeEl) return; 9018 const containers = [ 9019 nodeEl.querySelector("[data-flow-references-root]"), 9020 nodeEl.querySelector("[data-flow-references]") 9021 ].filter((c) => c && c.style.display !== "none"); 9022 let imageIndex = 1; 9023 containers.forEach((container) => { 9024 const badges = container.querySelectorAll("[data-ref-badge-wrapper]"); 9025 badges.forEach((badge) => { 9026 const labelText = badge.querySelector("[data-ref-badge-label-text]"); 9027 if (labelText) { 9028 const label = `@image${imageIndex}`; 9029 labelText.textContent = label; 9030 labelText.dataset.imageLabel = `image${imageIndex}`; 9031 } 9032 const labelEl = badge.querySelector("[data-ref-badge-label]"); 9033 if (labelEl && !labelEl.dataset.labelBound) { 9034 labelEl.dataset.labelBound = "true"; 9035 labelEl.addEventListener("click", (e) => { 9036 e.stopPropagation(); 9037 e.preventDefault(); 9038 const labelTextEl = labelEl.querySelector( 9039 "[data-ref-badge-label-text]" 9040 ); 9041 const imgLabel = labelTextEl?.dataset.imageLabel; 9042 if (imgLabel) { 9043 this.insertMentionAtCursor(nodeEl, imgLabel); 9044 } 9045 }); 9046 } 9047 imageIndex++; 9048 }); 9049 }); 9050 }, 9051 // Insert @mention at cursor position in the editor 9052 insertMentionAtCursor(nodeEl, label) { 9053 const editor = nodeEl.querySelector("[data-flow-prompt]"); 9054 if (!editor) return; 9055 editor.focus(); 9056 const selection = window.getSelection(); 9057 const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; 9058 const mentionSpan = document.createElement("span"); 9059 mentionSpan.className = "mention"; 9060 mentionSpan.contentEditable = "false"; 9061 mentionSpan.dataset.mentionLabel = label; 9062 mentionSpan.textContent = `@${label}`; 9063 const spaceNode = document.createTextNode("\xA0"); 9064 if (range) { 9065 range.deleteContents(); 9066 range.insertNode(spaceNode); 9067 range.insertNode(mentionSpan); 9068 range.setStartAfter(spaceNode); 9069 range.collapse(true); 9070 selection.removeAllRanges(); 9071 selection.addRange(range); 9072 } else { 9073 editor.appendChild(mentionSpan); 9074 editor.appendChild(spaceNode); 9075 } 9076 editor.dispatchEvent(new Event("input", { bubbles: true })); 9077 }, 9078 // Get icon SVG based on node type 9079 getIconForNodeType(nodeType) { 9080 const icons = { 9081 image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>', 9082 video: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"></polygon><rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect></svg>', 9083 upscaler: '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 19H5v-6"/><path d="M13 5h6v6"/><path d="M19 5 5 19"/></svg>', 9084 reference: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 13v8"></path><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path><path d="m8 17 4-4 4 4"></path></svg>' 9085 }; 9086 return icons[nodeType] || ""; 9087 }, 9088 // Get icon SVG based on reference source (upload, reference, pin) 9089 getIconForReferenceSource(source) { 9090 const icons = { 9091 upload: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewB
9091ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 13v8"></path><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path><path d="m8 17 4-4 4 4"></path></svg>', 9092 reference: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><circle cx="12" cy="12" r="4"/></svg>', 9093 pin: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>' 9094 }; 9095 return icons[source] || icons["upload"]; 9096 }, 9097 initNodes() { 9098 const dataElement = document.getElementById("flow-items-data"); 9099 if (!dataElement) { 9100 console.warn("No flow items data found"); 9101 return; 9102 } 9103 try { 9104 const nodesData = JSON.parse(dataElement.textContent); 9105 if (!nodesData || nodesData.length === 0) { 9106 console.log("No nodes to display"); 9107 return; 9108 } 9109 this.loadNodesData(nodesData); 9110 } catch (error) { 9111 console.error("Error loading nodes:", error); 9112 } 9113 }, 9114 loadNodesData(nodesData) { 9115 nodesData.forEach((nodeData) => { 9116 this.createNodeFromData(nodeData); 9117 }); 9118 }, 9119 createNodeFromData(nodeData) { 9120 const x = 0; 9121 const y = 0; 9122 const node = { 9123 id: nodeData.id, 9124 // Backend sends 'id' which contains the UUID 9125 type: this.getNodeType(nodeData), 9126 nodeData 9127 }; 9128 this.createNode(node, x, y); 9129 }, 9130 getNodeType(nodeData) { 9131 if (nodeData.node_type === "reference") { 9132 return "reference"; 9133 } 9134 if (nodeData.node_type === "root") { 9135 return "root"; 9136 } 9137 if (["image", "video", "upscaler"].includes(nodeData.node_type)) { 9138 if (nodeData.status === "ready") { 9139 return "generated"; 9140 } else if (nodeData.status === "processing") { 9141 return "processing"; 9142 } else if (nodeData.status === "failed") { 9143 return "failed"; 9144 } else { 9145 return "root"; 9146 } 9147 } 9148 return "root"; 9149 }, 9150 // Calculate node height based on ratio (width defaults to NODE_SIZE = 400px) 9151 getNodeHeight(ratio, width = null) { 9152 const baseWidth = width || this.NODE_SIZE; 9153 if (!ratio) return baseWidth; 9154 const [w, h] = ratio.split(":").map(Number); 9155 if (!w || !h) return baseWidth; 9156 return Math.round(baseWidth * h / w); 9157 }, 9158 createNode(nodeData, worldX, worldY) { 9159 const snappedX = Math.round(worldX / this.gridSize) * this.gridSize; 9160 const snappedY = Math.round(worldY / this.gridSize) * this.gridSize; 9161 let templateId; 9162 if (nodeData.type === "root") { 9163 templateId = "flow-root-node-template"; 9164 } else if (nodeData.type === "generated") { 9165 templateId = "flow-generated-node-template"; 9166 } else if (nodeData.type === "processing") { 9167 templateId = "flow-processing-node-template"; 9168 } else if (nodeData.type === "failed") { 9169 templateId = "flow-failed-node-template"; 9170 } else { 9171 console.error("Unknown node type:", nodeData.type); 9172 return null; 9173 } 9174 const template = document.getElementById(templateId); 9175 if (!template) { 9176 console.error("Template not found:", templateId); 9177 return null; 9178 } 9179 const templateContent = template.content.cloneNode(true); 9180 let nodeWidth = this.NODE_SIZE; 9181 let nodeHeight = this.NODE_SIZE; 9182 if (nodeData.type === "root") { 9183 const nodeType = nodeData.nodeData?.node_type; 9184 const isInitStatus = nodeType === "image" || nodeType === "video"; 9185 if (isInitStatus) { 9186 nodeWidth = this.NODE_SIZE; 9187 nodeHeight = 200; 9188 } else { 9189 nodeWidth = 500; 9190 nodeHeight = 240; 9191 } 9192 } else { 9193 const ratio = nodeData.nodeData?.ratio; 9194 nodeHeight = this.getNodeHeight(ratio); 9195 if (this.isStudioMode && nodeData.nodeData?.node_type === "video") { 9196 nodeWidth = 620; 9197 nodeHeight = this.getNodeHeight(ratio, 620); 9198 } 9199 } 9200 const nodeEl = document.createElement("div"); 9201 let bgClass = ""; 9202 if (nodeData.type === "processing") { 9203 bgClass = ""; 9204 } else if (["generated", "reference", "failed"].includes(nodeData.type)) { 9205 bgClass = "bg-inside"; 9206 } else { 9207 bgClass = "bg-box"; 9208 } 9209 let ringClass = ""; 9210 if (nodeData.nodeData?.saved) { 9211 ringClass = "ring-3 ring-pink-500"; 9212 } else if (nodeData.nodeData?.pinned) { 9213 ringClass = "ring-3 ring-lime-500"; 9214 } 9215 const magicCardClass = nodeData.type === "processing" ? "magic-card" : ""; 9216 nodeEl.className = `flow-node group ${bgClass} rounded-xl ${ringClass} ${magicCardClass}`; 9217 nodeEl.dataset.nodeId = nodeData.id; 9218 nodeEl.dataset.nodeType = nodeData.nodeData?.node_type || nodeData.type; 9219 if (nodeData.nodeData?.model) { 9220 nodeEl.dataset.model = nodeData.nodeData.model; 9221 } 9222 nodeEl.style.cssText = `
9223 position: absolute; 9224 left: ${snappedX}px; 9225 top: ${snappedY}px; 9226 width: ${nodeWidth}px; 9227 height: ${nodeHeight}px; 9228 transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), height 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), top 0.4s cubic-bezier(0.4, 0, 0.2, 1); 9229 `; 9230 nodeEl.appendChild(templateContent); 9231 if (nodeData.type === "root" && nodeWidth === 500) { 9232 const textarea = nodeEl.querySelector("[data-flow-prompt]"); 9233 if (textarea) { 9234 textarea.style.fontSize = "12px"; 9235 } 9236 } 9237 if (nodeData.nodeData?.references && nodeData.nodeData.references.length > 0 && nodeData.type !== "failed") { 9238 const containerSelector = nodeData.type === "root" ? "[data-flow-references-root]" : "[data-flow-references]"; 9239 const referencesContainer = nodeEl.querySelector(containerSelector); 9240 if (referencesContainer) { 9241 const refBadgeTemplate = document.getElementById( 9242 "flow-reference-badge-template" 9243 ); 9244 nodeData.nodeData.references.forEach((ref) => { 9245 if (ref.media_url) { 9246 const badgeEl = refBadgeTemplate.content.cloneNode(true); 9247 const img = badgeEl.querySelector("[data-ref-badge-img]"); 9248 if (img) { 9249 img.src = ref.thumbnail_url || ref.media_url; 9250 img.alt = ref.label || ref.name || "Reference"; 9251 } 9252 const canEditRef = ref.attached === true; 9253 const wrapper = badgeEl.querySelector("[data-ref-badge-wrapper]"); 9254 const editBtn = badgeEl.querySelector("[data-ref-badge-edit]"); 9255 if (editBtn) { 9256 if (!canEditRef) { 9257 editBtn.parentElement.style.display = "none"; 9258 } else { 9259 editBtn.addEventListener("click", (e) => { 9260 e.stopPropagation(); 9261 e.preventDefault(); 9262 const badgeWrapper = e.target.closest( 9263 "[data-ref-badge-wrapper]" 9264 ); 9265 const imgEl = badgeWrapper?.querySelector( 9266 "[data-ref-badge-img]" 9267 ); 9268 const imgUrl = imgEl?.src || ""; 9269 const currentFileHash = badgeWrapper?.dataset.fileHash || ref.file_hash; 9270 this.openRefEditModal( 9271 nodeEl, 9272 imgUrl, 9273 currentFileHash, 9274 badgeWrapper 9275 ); 9276 }); 9277 } 9278 } 9279 const deleteBtn = badgeEl.querySelector("[data-ref-badge-delete]"); 9280 if (deleteBtn) { 9281 if (!canEditRef) { 9282 } else { 9283 deleteBtn.addEventListener("click", async (e) => { 9284 e.stopPropagation(); 9285 e.preventDefault(); 9286 const badgeWrapper = e.target.closest( 9287 "[data-ref-badge-wrapper]" 9288 ); 9289 const currentFileHash = badgeWrapper?.dataset.fileHash || ref.file_hash; 9290 this.showRefDeleteConfirm(nodeData.id, currentFileHash); 9291 }); 9292 } 9293 } 9294 if (wrapper) { 9295 wrapper.dataset.fileHash = ref.file_hash; 9296 } 9297 referencesContainer.appendChild(badgeEl); 9298 const addedBadge = referencesContainer.lastElementChild; 9299 if (nodeData.type === "generated" && addedBadge) { 9300 addedBadge.classList.remove("border-light"); 9301 addedBadge.classList.add("border-white"); 9302 } 9303 if (addedBadge && ref.media_id) { 9304 addedBadge.style.cursor = "pointer"; 9305 addedBadge.addEventListener("click", async (e) => { 9306 if (e.target.closest("[data-ref-badge-delete]")) return; 9307 e.stopPropagation(); 9308 console.log("[Badge] Opening media viewer for:", ref.media_id); 9309 await this.openMediaViewer(ref.media_id); 9310 }); 9311 } 9312 } 9313 }); 9314 if (referencesContainer.children.length > 0) { 9315 referencesContainer.style.display = "flex"; 9316 this.updateRefBadgesDisplay(nodeEl); 9317 this.updateBadgeLabels(nodeEl); 9318 } 9319 } 9320 } 9321 if (nodeData.type === "reference" && nodeData.nodeData?.media_url) { 9322 const img = nodeEl.querySelector("[data-flow-ref-img]"); 9323 if (img) { 9324 img.src = nodeData.nodeData.thumbnail_url || nodeData.nodeData.media_url; 9325 if (nodeData.nodeData.media_id) { 9326 img.style.cursor = "pointer"; 9327 img.addEventListener("click", async (e) => { 9328 e.stopPropagation(); 9329 await this.openMediaViewer(nodeData.nodeData.media_id, nodeData.id); 9330 }); 9331 } 9332 } 9333 const iconEl = nodeEl.querySelector("[data-flow-ref-icon]"); 9334 const labelEl = nodeEl.querySelector("[data-flow-ref-label]"); 9335 const nameEl = nodeEl.querySelector("[data-flow-ref-name]"); 9336 if (iconEl) { 9337 const source = nodeData.nodeData.source || "upload"; 9338 iconEl.innerHTML = this.getIconForReferenceSource(source); 9339 } 9340 if (labelEl && nodeData.nodeData.label) { 9341 labelEl.textContent = nodeData.nodeData.label; 9342 } 9343 if (nameEl && nodeData.nodeData.name) { 9344 nameEl.textContent = nodeData.nodeData.name; 9345 } 9346 } 9347 if (nodeData.type === "generated" && nodeData.nodeData?.media_url) { 9348 const img = nodeEl.querySelector("[data-flow-generated-img]"); 9349 const isVideo = nodeData.nodeData.node_type === "video" || nodeData.nodeData.node_type === "upscaler" && nodeData.nodeData.upscale_type === "video"; 9350 if (img) { 9351 if (isVideo) { 9352 const video = document.createElement("video"); 9353 video.className = img.className; 9354 video.setAttribute("data-flow-generated-img", ""); 9355 video.autoplay = true; 9356 video.loop = true; 9357 video.muted = true; 9358 video.setAttribute("muted", "");
9359 video.playsInline = true; 9360 video.src = nodeData.nodeData.media_url; 9361 if (nodeData.nodeData.thumbnail_url) { 9362 video.poster = nodeData.nodeData.thumbnail_url; 9363 } 9364 img.parentNode.replaceChild(video, img); 9365 } else { 9366 img.src = nodeData.nodeData.thumbnail_url || nodeData.nodeData.media_url; 9367 } 9368 } 9369 const iconEl = nodeEl.querySelector("[data-flow-generated-icon]"); 9370 const labelEl = nodeEl.querySelector("[data-flow-generated-label]"); 9371 const modelEl = nodeEl.querySelector("[data-flow-generated-model]"); 9372 if (iconEl && nodeData.nodeData.node_type) { 9373 iconEl.innerHTML = this.getIconForNodeType(nodeData.nodeData.node_type); 9374 } 9375 if (labelEl && nodeData.nodeData.label) { 9376 labelEl.textContent = nodeData.nodeData.label; 9377 } 9378 if (modelEl && nodeData.nodeData.model) { 9379 modelEl.textContent = nodeData.nodeData.model; 9380 } 9381 const promptEl = nodeEl.querySelector("[data-flow-generated-prompt]"); 9382 if (promptEl) { 9383 if (nodeData.nodeData.node_type === "upscaler" && !nodeData.nodeData.prompt) { 9384 const parentId = nodeData.ancestors?.[0]?.uuid; 9385 if (parentId && this.nodes[parentId]) { 9386 const parentPrompt = this.nodes[parentId].data.nodeData.prompt; 9387 if (parentPrompt) { 9388 promptEl.textContent = parentPrompt; 9389 } 9390 } 9391 } else if (nodeData.nodeData.model === "last-frame") { 9392 promptEl.textContent = "Extract last frame of the video"; 9393 } else if (nodeData.nodeData.prompt) { 9394 promptEl.textContent = nodeData.nodeData.prompt; 9395 } 9396 } 9397 const dimensionsEl = nodeEl.querySelector( 9398 "[data-flow-generated-dimensions]" 9399 ); 9400 if (dimensionsEl && nodeData.nodeData.media_width && nodeData.nodeData.media_height) { 9401 dimensionsEl.textContent = `${nodeData.nodeData.media_width} \xD7 ${nodeData.nodeData.media_height}`; 9402 } 9403 const mediaEl = nodeEl.querySelector("[data-flow-generated-img]"); 9404 if (mediaEl && nodeData.nodeData.media_id) { 9405 mediaEl.style.cursor = "pointer"; 9406 mediaEl.addEventListener("click", async (e) => { 9407 e.stopPropagation(); 9408 await this.openMediaViewer(nodeData.nodeData.media_id, nodeData.id); 9409 }); 9410 } 9411 const regenerateBottomBtn = nodeEl.querySelector( 9412 "[data-flow-regenerate-bottom]" 9413 ); 9414 if (regenerateBottomBtn) { 9415 regenerateBottomBtn.style.display = "none"; 9416 } 9417 } 9418 if (nodeData.type === "processing") { 9419 const infoContainer = nodeEl.querySelector("[data-flow-processing-info]"); 9420 const iconEl = nodeEl.querySelector("[data-flow-processing-icon]"); 9421 const labelEl = nodeEl.querySelector("[data-flow-processing-label]"); 9422 const nameEl = nodeEl.querySelector("[data-flow-processing-name]"); 9423 if (iconEl) { 9424 iconEl.innerHTML = `<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"></path><path d="M20 3v4"></path><path d="M22 5h-4"></path><path d="M4 17v2"></path><path d="M5 18H3"></path></svg>`; 9425 } 9426 if (nameEl) { 9427 const name = nodeData.nodeData.name || "Generating"; 9428 nameEl.textContent = name; 9429 } 9430 if (infoContainer) { 9431 infoContainer.style.display = "flex"; 9432 } 9433 const progressBar = nodeEl.querySelector("[data-flow-progress-bar]"); 9434 if (progressBar && nodeData.nodeData.request) { 9435 const request = nodeData.nodeData.request; 9436 if (request.created_at && request.estimated_duration) { 9437 progressBar.dataset.createdAt = request.created_at; 9438 progressBar.dataset.estimatedDuration = request.estimated_duration; 9439 if (this.calculateProgress) { 9440 const percentage = this.calculateProgress( 9441 request.created_at, 9442 request.estimated_duration 9443 ); 9444 const fill = progressBar.querySelector("[data-flow-progress-fill]"); 9445 if (fill) fill.style.width = `${percentage}%`; 9446 if (labelEl) { 9447 labelEl.textContent = `${percentage}%`; 9448 } 9449 } 9450 } 9451 } 9452 const wrapper = nodeEl.querySelector(".relative.h-full.w-full"); 9453 if (wrapper) { 9454 for (let i = 1; i <= 7; i++) { 9455 const firefly = document.createElement("div"); 9456 firefly.className = `firefly firefly-${i}`; 9457 firefly.style.opacity = "1"; 9458 wrapper.appendChild(firefly); 9459 } 9460 } 9461 } 9462 if (nodeData.type === "failed") { 9463 const iconEl = nodeEl.querySelector("[data-flow-failed-icon]"); 9464 const labelEl = nodeEl.querySelector("[data-flow-failed-label]"); 9465 const errorEl = nodeEl.querySelector("[data-flow-failed-error]"); 9466 if (iconEl && nodeData.nodeData.node_type) { 9467 iconEl.innerHTML = this.getIconForNodeType(nodeData.nodeData.node_type); 9468 } 9469 if (labelEl && nodeData.nodeData.label) { 9470 labelEl.textContent = nodeData.nodeData.label; 9471 } 9472 if (errorEl && nodeData.nodeData.error_message) { 9473 errorEl.textContent = nodeData.nodeData.error_message; 9474 } 9475 } 9476 if (nodeData.type === "root" && nodeData.nodeData?.prompt) { 9477 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 9478 if (promptEl) { 9479 promptEl.textContent = nodeData.nodeData.prompt; 9480 if (this.parseAndStyleMentions) { 9481 this.parseAndStyleMentions(promptEl); 9482 } 9483 if (this.updateTagButtonStates) { 9484 this.updateTagButtonStates(nodeEl); 9485 } 9486 } 9487 } 9488 if (nodeData.type === "root") { 9489 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 9490 if (promptEl) { 9491 const nodeType = nodeData.nodeData?.node_type; 9492 const level = nodeData.nodeData?.level || 0; 9493 if (level === 0) { 9494 if (nodeType === "image") { 9495 promptEl.dataset.placeholder = "Describe your image\u2026"; 9496 } else if (nodeType === "video") { 9497 promptEl.dataset.placeholder = "Describe your video\u2026"; 9498 } 9499 } else { 9500 if (nodeType === "video") { 9501 promptEl.dataset.placeholder = "How should this animate?"; 9502 } else { 9503 promptEl.dataset.placeholder = "What would you like to change?"; 9504 } 9505 } 9506 } 9507 } 9508 if (this.bindPromptTextarea) { 9509 this.bindPromptTextarea(nodeEl, nodeData); 9510 } 9511 if (this.bindNodeDropdowns) { 9512 this.bindNodeDropdowns(nodeEl, nodeData); 9513 } 9514 if (this.bindUploadButton) { 9515 this.bindUploadButton(nodeEl, nodeData); 9516 } 9517 if (this.bindRefsButton) { 9518 this.bindRefsButton(nodeEl, nodeData); 9519 } 9520 if (this.bindFavsButton) { 9521 this.bindFavsButton(nodeEl, nodeData); 9522 } 9523 if (this.bindElementsButton) { 9524 this.bindElementsButton(nodeEl, nodeData); 9525 } 9526 if (this.bindPinsButton) { 9527 this.bindPinsButton(nodeEl, nodeData); 9528 } 9529 if (this.bindMarkPinButton) { 9530 this.bindMarkPinButton(nodeEl, nodeData); 9531 } 9532 if (this.bindNodeControls) { 9533 this.bindNodeControls(nodeEl, nodeData); 9534 } 9535 const debugEl = nodeEl.querySelector("[data-flow-debug-type]"); 9536 if (debugEl && nodeData.nodeData) { 9537 const nodeType = nodeData.nodeData.node_type || "unknown";
9538 const status = nodeData.nodeData.status || "no-status"; 9539 const ratio = nodeData.nodeData.ratio || "no-ratio"; 9540 debugEl.textContent = `${nodeType} (${status}) - ${ratio}`; 9541 } 9542 this.nodesContainer.appendChild(nodeEl); 9543 this.nodes[nodeData.id] = { 9544 data: nodeData, 9545 element: nodeEl, 9546 position: { 9547 left: snappedX, 9548 top: snappedY, 9549 width: nodeWidth, 9550 height: nodeHeight 9551 } 9552 }; 9553 if (nodeData.type === "processing" && this.checkProcessingNodes) { 9554 setTimeout(() => { 9555 this.checkProcessingNodes(); 9556 }, 50); 9557 } 9558 return nodeEl; 9559 }, 9560 // Get node by ID 9561 getNode(nodeId) { 9562 return this.nodes[nodeId]; 9563 }, 9564 // Update existing node (for status changes with animation) 9565 updateNode(nodeId, newNodeData) { 9566 const existingNode = this.nodes[nodeId]; 9567 if (!existingNode) return false; 9568 const newType = this.getNodeType(newNodeData); 9569 const oldType = existingNode.data.type; 9570 const ratio = newNodeData.ratio; 9571 let newWidth = this.NODE_SIZE; 9572 let newHeight = this.NODE_SIZE; 9573 if (newType === "root") { 9574 const nodeType = newNodeData.node_type; 9575 const isInitStatus = nodeType === "image" || nodeType === "video"; 9576 if (isInitStatus) { 9577 newWidth = this.NODE_SIZE; 9578 newHeight = this.NODE_SIZE; 9579 } else { 9580 newWidth = 500; 9581 newHeight = 240; 9582 } 9583 } else if (newType === "generated" || newType === "processing" || newType === "reference" || newType === "failed") { 9584 newWidth = this.NODE_SIZE; 9585 newHeight = this.getNodeHeight(ratio); 9586 } 9587 existingNode.data.nodeData = newNodeData; 9588 existingNode.data.type = newType; 9589 existingNode.position.width = newWidth; 9590 existingNode.position.height = newHeight; 9591 if (oldType !== newType) { 9592 console.log( 9593 `[Flow] Node ${nodeId} type changed: ${oldType} -> ${newType}`, 9594 newNodeData 9595 ); 9596 const nodeEl = existingNode.element; 9597 if (oldType === "processing" && newType !== "processing") { 9598 nodeEl.classList.remove("magic-card"); 9599 } else if (oldType !== "processing" && newType === "processing") { 9600 nodeEl.classList.add("magic-card"); 9601 } 9602 let oldBgClass = ""; 9603 if (oldType === "processing") { 9604 oldBgClass = ""; 9605 } else if (["generated", "reference", "failed"].includes(oldType)) { 9606 oldBgClass = "bg-inside"; 9607 } else { 9608 oldBgClass = "bg-box"; 9609 } 9610 let newBgClass = ""; 9611 if (newType === "processing") { 9612 newBgClass = ""; 9613 } else if (["generated", "reference", "failed"].includes(newType)) { 9614 newBgClass = "bg-inside"; 9615 } else { 9616 newBgClass = "bg-box"; 9617 } 9618 if (oldBgClass !== newBgClass) { 9619 if (oldBgClass) nodeEl.classList.remove(oldBgClass); 9620 if (newBgClass) nodeEl.classList.add(newBgClass); 9621 } 9622 nodeEl.classList.remove("ring-3", "ring-pink-500", "ring-lime-500"); 9623 if (newNodeData.saved) { 9624 nodeEl.classList.add("ring-3", "ring-pink-500"); 9625 } else if (newNodeData.pinned) { 9626 nodeEl.classList.add("ring-3", "ring-lime-500"); 9627 } 9628 nodeEl.style.width = `${newWidth}px`; 9629 nodeEl.style.height = `${newHeight}px`; 9630 const updateConnectionsDuringTransition = () => { 9631 if (this.updateConnections) { 9632 this.updateConnections(); 9633 } 9634 }; 9635 const transitionDuration = 400; 9636 const frames = 20; 9637 const frameInterval = transitionDuration / frames; 9638 for (let i = 0; i <= frames; i++) { 9639 setTimeout(updateConnectionsDuringTransition, i * frameInterval); 9640 } 9641 let templateId; 9642 if (newType === "root") { 9643 templateId = "flow-root-node-template"; 9644 } else if (newType === "generated") { 9645 templateId = "flow-generated-node-template"; 9646 } else if (newType === "processing") { 9647 templateId = "flow-processing-node-template"; 9648 } else if (newType === "failed") { 9649 templateId = "flow-failed-node-template"; 9650 } 9651 if (templateId) { 9652 const template = document.getElementById(templateId); 9653 if (template) { 9654 while (nodeEl.firstChild) { 9655 nodeEl.removeChild(nodeEl.firstChild); 9656 } 9657 const templateContent = template.content.cloneNode(true); 9658 nodeEl.appendChild(templateContent); 9659 if (newNodeData.references && newNodeData.references.length > 0) { 9660 const referencesContainer = nodeEl.querySelector( 9661 "[data-flow-references]" 9662 ); 9663 if (referencesContainer) { 9664 const refBadgeTemplate = document.getElementById( 9665 "flow-reference-badge-template" 9666 );
9667 newNodeData.references.forEach((ref) => { 9668 if (ref.media_url) { 9669 const badgeEl = refBadgeTemplate.content.cloneNode(true); 9670 const img = badgeEl.querySelector("[data-ref-badge-img]"); 9671 if (img) { 9672 img.src = ref.media_url; 9673 img.alt = ref.label || ref.name || "Reference"; 9674 } 9675 const canEditRef = ref.attached === true; 9676 const wrapper = badgeEl.querySelector( 9677 "[data-ref-badge-wrapper]" 9678 ); 9679 if (wrapper) { 9680 wrapper.dataset.fileHash = ref.file_hash; 9681 } 9682 const editBtn = badgeEl.querySelector( 9683 "[data-ref-badge-edit]" 9684 ); 9685 if (editBtn) { 9686 if (!canEditRef) { 9687 editBtn.parentElement.style.display = "none"; 9688 } else { 9689 editBtn.addEventListener("click", (e) => { 9690 e.stopPropagation(); 9691 e.preventDefault(); 9692 const badgeWrapper = e.target.closest( 9693 "[data-ref-badge-wrapper]" 9694 ); 9695 const imgEl = badgeWrapper?.querySelector( 9696 "[data-ref-badge-img]" 9697 ); 9698 const imgUrl = imgEl?.src || ""; 9699 const currentFileHash = badgeWrapper?.dataset.fileHash || ref.file_hash; 9700 this.openRefEditModal( 9701 nodeEl, 9702 imgUrl, 9703 currentFileHash, 9704 badgeWrapper 9705 ); 9706 }); 9707 } 9708 } 9709 const deleteBtn = badgeEl.querySelector( 9710 "[data-ref-badge-delete]" 9711 ); 9712 if (deleteBtn) { 9713 if (!canEditRef) { 9714 } else { 9715 deleteBtn.addEventListener("click", async (e) => { 9716 e.stopPropagation(); 9717 e.preventDefault(); 9718 const badgeWrapper = e.target.closest( 9719 "[data-ref-badge-wrapper]" 9720 ); 9721 const currentFileHash = badgeWrapper?.dataset.fileHash || ref.file_hash; 9722 this.showRefDeleteConfirm(nodeId, currentFileHash); 9723 }); 9724 } 9725 } 9726 referencesContainer.appendChild(badgeEl); 9727 } 9728 }); 9729 referencesContainer.style.display = "flex"; 9730 this.updateBadgeLabels(nodeEl); 9731 } 9732 } 9733 if (newType === "generated" && newNodeData.media_url) { 9734 const img = nodeEl.querySelector("[data-flow-generated-img]"); 9735 const isVideo = newNodeData.node_type === "video" || newNodeData.node_type === "upscaler" && newNodeData.upscale_type === "video"; 9736 if (img) { 9737 if (isVideo) { 9738 const video = document.createElement("video"); 9739 video.className = img.className; 9740 video.setAttribute("data-flow-generated-img", ""); 9741 video.autoplay = true; 9742 video.loop = true; 9743 video.muted = true; 9744 video.setAttribute("muted", ""); 9745 video.playsInline = true; 9746 video.src = newNodeData.media_url; 9747 if (newNodeData.thumbnail_url) { 9748 video.poster = newNodeData.thumbnail_url; 9749 } 9750 img.parentNode.replaceChild(video, img); 9751 } else { 9752 img.src = newNodeData.thumbnail_url || newNodeData.media_url; 9753 } 9754 } 9755 const iconEl = nodeEl.querySelector("[data-flow-generated-icon]"); 9756 const labelEl = nodeEl.querySelector("[data-flow-generated-label]"); 9757 const modelEl = nodeEl.querySelector("[data-flow-generated-model]"); 9758 const promptEl = nodeEl.querySelector( 9759 "[data-flow-generated-prompt]" 9760 ); 9761 const dimensionsEl = nodeEl.querySelector( 9762 "[data-flow-generated-dimensions]" 9763 ); 9764 if (iconEl && newNodeData.node_type) 9765 iconEl.innerHTML = this.getIconForNodeType(newNodeData.node_type); 9766 if (labelEl && newNodeData.label) 9767 labelEl.textContent = newNodeData.label; 9768 if (modelEl && newNodeData.model) 9769 modelEl.textContent = newNodeData.model; 9770 if (promptEl) { 9771 if (newNodeData.node_type === "upscaler" && !newNodeData.prompt) { 9772 const parentId = newNodeData.ancestors?.[0]?.uuid; 9773 if (parentId && this.nodes[parentId]) { 9774 const parentPrompt = this.nodes[parentId].data.nodeData.prompt; 9775 if (parentPrompt) { 9776 promptEl.textContent = parentPrompt; 9777 } 9778 } 9779 } else if (newNodeData.model === "last-frame") { 9780 promptEl.textContent = "Extract last frame of the video"; 9781 } else if (newNodeData.prompt) { 9782 promptEl.textContent = newNodeData.prompt; 9783 } 9784 } 9785 if (dimensionsEl && newNodeData.media_width && newNodeData.media_height) { 9786 dimensionsEl.textContent = `${newNodeData.media_width} \xD7 ${newNodeData.media_height}`; 9787 } 9788 } else if (newType === "processing") { 9789 const iconEl = nodeEl.querySelector("[data-flow-processing-icon]"); 9790 const labelEl = nodeEl.querySelector( 9791 "[data-flow-processing-label]" 9792 ); 9793 const nameEl = nodeEl.querySelector("[data-flow-processing-name]"); 9794 if (iconEl && newNodeData.node_type) 9795 iconEl.innerHTML = this.getIconForNodeType(newNodeData.node_type); 9796 if (labelEl && newNodeData.label) 9797 labelEl.textContent = newNodeData.label; 9798 if (nameEl && newNodeData.name) 9799 nameEl.textContent = newNodeData.name; 9800 } else if (newType === "failed") { 9801 const iconEl = nodeEl.querySelector("[data-flow-failed-icon]"); 9802 const labelEl = nodeEl.querySelector("[data-flow-failed-label]"); 9803 const errorEl = nodeEl.querySelector("[data-flow-failed-error]"); 9804 if (iconEl && newNodeData.node_type) 9805 iconEl.innerHTML = this.getIconForNodeType(newNodeData.node_type); 9806 if (labelEl && newNodeData.label) 9807 labelEl.textContent = newNodeData.label; 9808 if (errorEl && newNodeData.error_message) 9809 errorEl.textContent = newNodeData.error_message; 9810 } else if (newType === "root" && newNodeData.prompt) { 9811 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 9812 if (promptEl) { 9813 promptEl.textContent = newNodeData.prompt; 9814 } 9815 } 9816 if (this.bindNodeControls) { 9817 this.bindNodeControls(nodeEl, existingNode.data); 9818 } 9819 if (this.bindNodeDropdowns) { 9820 this.bindNodeDropdowns(nodeEl, existingNode.data); 9821 } 9822 if (this.bindUploadButton) { 9823 this.bindUploadButton(nodeEl, existingNode.data); 9824 } 9825 if (this.bindRefsButton) { 9826 this.bindRefsButton(nodeEl, existingNode.data); 9827 } 9828 if (this.bindElementsButton) { 9829 this.bindElementsButton(nodeEl, existingNode.data); 9830 } 9831 if (this.bindPinsButton) { 9832 this.bindPinsButton(nodeEl, existingNode.data); 9833 } 9834 if (this.bindMarkPinButton) { 9835 this.bindMarkPinButton(nodeEl, existingNode.data); 9836 } 9837 if (this.bindImageClickListener) { 9838 this.bindImageClickListener(nodeEl, existingNode.data); 9839 } 9840 if (newType === "generated") { 9841 const regenerateBottomBtn = nodeEl.querySelector( 9842 "[data-flow-regenerate-bottom]" 9843 ); 9844 if (regenerateBottomBtn) { 9845 regenerateBottomBtn.style.display = "none"; 9846 } 9847 } 9848 } 9849 } 9850 } 9851 return true; 9852 }, 9853 // Bind image click listener to open viewer modal 9854 bindImageClickListener(nodeEl, nodeData) { 9855 if (nodeData.nodeData?.node_type && ["image", "video", "upscaler"].includes(nodeData.nodeData.node_type)) { 9856 const img = nodeEl.querySelector("[data-flow-generated-img]"); 9857 if (img && nodeData.nodeData.media_id) { 9858 const newImg = img.cloneNode(true); 9859 img.parentNode.replaceChild(newImg, img); 9860 newImg.style.cursor = "pointer"; 9861 newImg.addEventListener("click", async (e) => { 9862 e.stopPropagation(); 9863 await this.openMediaViewer(nodeData.nodeData.media_id, nodeData.id); 9864 }); 9865 } 9866 } 9867 if (nodeData.nodeData?.node_type === "reference" && nodeData.nodeData.media_url) { 9868 const img = nodeEl.querySelector("[data-flow-ref-img]"); 9869 if (img && nodeData.nodeData.media_id) { 9870 const newImg = img.cloneNode(true); 9871 img.parentNode.replaceChild(newImg, img); 9872 newImg.style.cursor = "pointer"; 9873 newImg.addEventListener("click", async (e) => { 9874 e.stopPropagation(); 9875 await this.openMediaViewer(nodeData.nodeData.media_id, nodeData.id); 9876 }); 9877 } 9878 } 9879 }, 9880 // Remove all nodes 9881 clearAllNodes() {
9882 Object.keys(this.nodes).forEach((nodeId) => { 9883 const node = this.nodes[nodeId]; 9884 if (node.element && node.element.parentNode) { 9885 node.element.parentNode.removeChild(node.element); 9886 } 9887 }); 9888 if (this.nodesContainer) { 9889 while (this.nodesContainer.firstChild) { 9890 this.nodesContainer.removeChild(this.nodesContainer.firstChild); 9891 } 9892 } 9893 if (this.clearConnections) { 9894 this.clearConnections(); 9895 } 9896 this.nodes = {}; 9897 if (this.checkProcessingNodes) { 9898 this.checkProcessingNodes(); 9899 } 9900 }, 9901 // Open media viewer modal 9902 async openMediaViewer(mediaId, nodeId) { 9903 try { 9904 const response = await fetch(`/media/${mediaId}`, { 9905 headers: { 9906 Accept: "text/html", 9907 "X-Requested-With": "XMLHttpRequest" 9908 } 9909 }); 9910 if (!response.ok) { 9911 throw new Error("Failed to load media viewer"); 9912 } 9913 const html = await response.text(); 9914 const existingModal = document.getElementById("media-modal"); 9915 if (existingModal) { 9916 existingModal.remove(); 9917 } 9918 document.body.insertAdjacentHTML("beforeend", html); 9919 const saveBtn = document.querySelector("[data-media-save]"); 9920 if (saveBtn) { 9921 saveBtn.addEventListener("click", async () => { 9922 const mediaId2 = saveBtn.dataset.mediaId; 9923 const nodeId2 = saveBtn.dataset.nodeId; 9924 try { 9925 const csrfToken = document.querySelector('[name="csrf-token"]'); 9926 const response2 = await fetch("/favs", { 9927 method: "POST", 9928 headers: { 9929 "Content-Type": "application/json", 9930 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 9931 }, 9932 body: JSON.stringify({ 9933 media_id: mediaId2, 9934 node_id: nodeId2 9935 }) 9936 }); 9937 const data = await response2.json(); 9938 if (!response2.ok) { 9939 if (window.Toast && data.error) { 9940 window.Toast.show(data.error, "alert"); 9941 } 9942 return; 9943 } 9944 if (window.Toast) { 9945 window.Toast.show("Added to Favs!", "success"); 9946 } 9947 saveBtn.innerHTML = ` 9948 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"> 9949 <path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/> 9950 </svg> 9951 <span>SAVED</span> 9952 `; 9953 saveBtn.disabled = true; 9954 saveBtn.classList.remove("btn-primary"); 9955 saveBtn.classList.add("btn-secondary"); 9956 } catch (error) { 9957 console.error("Save to finals error:", error); 9958 if (window.Toast) { 9959 window.Toast.show("Failed to save", "alert"); 9960 } 9961 } 9962 }); 9963 } 9964 if (window.Modal) { 9965 window.Modal.open("media-modal"); 9966 } 9967 this.initMediaZoom(); 9968 this.setupMultishotSelection(nodeId); 9969 } catch (error) { 9970 console.error("Error loading media viewer:", error); 9971 if (window.Toast) { 9972 window.Toast.show("Failed to load media viewer", "alert"); 9973 } 9974 } 9975 }, 9976 // Initialize media viewer - fade in image 9977 initMediaZoom() { 9978 const mediaImage = document.getElementById("media-image"); 9979 if (!mediaImage) { 9980 return; 9981 } 9982 mediaImage.style.opacity = "0"; 9983 mediaImage.style.transition = "opacity 0.3s ease"; 9984 const fadeIn = () => { 9985 setTimeout(() => { 9986 mediaImage.style.opacity = "1"; 9987 }, 100); 9988 }; 9989 if (mediaImage.complete) { 9990 fadeIn(); 9991 } else { 9992 mediaImage.onload = () => { 9993 fadeIn(); 9994 }; 9995 } 9996 }, 9997 // Setup multishot cell selection for upscaling individual shots as flow child nodes 9998 setupMultishotSelection(nodeId) { 9999 const cells = document.querySelectorAll(".multishot-cell"); 10000 if (cells.length === 0) return; 10001 const upscaleBar = document.getElementById("multishot-upscale-bar"); 10002 const res2k = document.getElementById("multishot-res-2k"); 10003 const res4k = document.getElementById("multishot-res-4k"); 10004 const upscaleBtn = document.getElementById("multishot-upscale-btn"); 10005 const costSpan = document.getElementById("multishot-upscale-cost"); 10006 const selectedCells = /* @__PURE__ */ new Set(); 10007 const COST_2K = 12; 10008 const COST_4K = 16; 10009 let selectedResolution = "2K";
10010 const starSvg = '<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L14.5 8.5L22 11L14.5 13.5L12 21L9.5 13.5L2 11L9.5 8.5L12 1Z"/></svg>'; 10011 const updateCost = () => { 10012 if (selectedCells.size > 0) { 10013 upscaleBar?.classList.remove("hidden"); 10014 const cost = selectedResolution === "4K" ? COST_4K : COST_2K; 10015 costSpan.innerHTML = `${starSvg} ${selectedCells.size * cost}`; 10016 } else { 10017 upscaleBar?.classList.add("hidden"); 10018 costSpan.innerHTML = ""; 10019 } 10020 }; 10021 const setActiveRes = (btn) => { 10022 [res2k, res4k].forEach((b) => { 10023 b.classList.remove("text-white", "bg-active/40"); 10024 b.classList.add("text-gray", "hover:text-white"); 10025 }); 10026 btn.classList.add("text-white", "bg-active/40"); 10027 btn.classList.remove("text-gray", "hover:text-white"); 10028 }; 10029 res2k?.addEventListener("click", () => { 10030 selectedResolution = "2K"; 10031 setActiveRes(res2k); 10032 updateCost(); 10033 }); 10034 res4k?.addEventListener("click", () => { 10035 selectedResolution = "4K"; 10036 setActiveRes(res4k); 10037 updateCost(); 10038 }); 10039 cells.forEach((cell) => { 10040 cell.addEventListener("click", () => { 10041 const cellIndex = cell.dataset.cell; 10042 const checkIcon = cell.querySelector(".multishot-check"); 10043 if (selectedCells.has(cellIndex)) { 10044 selectedCells.delete(cellIndex); 10045 cell.classList.remove("ring-2", "ring-active"); 10046 if (checkIcon) checkIcon.classList.add("opacity-0"); 10047 } else { 10048 selectedCells.add(cellIndex); 10049 cell.classList.add("ring-2", "ring-active"); 10050 if (checkIcon) checkIcon.classList.remove("opacity-0"); 10051 } 10052 updateCost(); 10053 }); 10054 }); 10055 upscaleBtn?.addEventListener("click", async () => { 10056 if (selectedCells.size === 0) return; 10057 const indices = Array.from(selectedCells).map(Number).sort((a, b) => a - b); 10058 const modal = document.getElementById("media-modal"); 10059 const sourceHash = modal?.dataset.mediaHash; 10060 if (!sourceHash) { 10061 window.Toast?.show("Could not find source image", "alert"); 10062 return; 10063 } 10064 upscaleBtn.disabled = true; 10065 upscaleBtn.innerHTML = `<span class="animate-pulse">Upscaling...</span>`; 10066 try { 10067 const response = await fetch("/flows/upscale-multishot", { 10068 method: "POST", 10069 headers: { 10070 "Content-Type": "application/json", 10071 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 10072 }, 10073 body: JSON.stringify({ 10074 flow_id: this.flowId, 10075 parent_id: nodeId, 10076 source_hash: sourceHash, 10077 cell_indices: indices, 10078 resolution: selectedResolution 10079 }) 10080 }); 10081 const data = await response.json(); 10082 if (!response.ok) { 10083 window.Toast?.show(data.error || "Upscale failed", "alert"); 10084 return; 10085 } 10086 window.Toast?.show(`Upscaling ${indices.length} ${indices.length === 1 ? "shot" : "shots"} to ${selectedResolution}...`, "success"); 10087 window.Modal?.close("media-modal"); 10088 if (data.nodes) { 10089 this.clearAllNodes(); 10090 this.loadNodesData(data.nodes); 10091 if (this.repositionTree) this.repositionTree(); 10092 if (this.updateConnections) this.updateConnections(); 10093 if (this.zoomToNewNodes) this.zoomToNewNodes(data.nodes); 10094 if (this.checkProcessingNodes) this.checkProcessingNodes(); 10095 } 10096 if (data.balance !== void 0) { 10097 const balanceEl = document.querySelector("[data-balance]"); 10098 if (balanceEl) balanceEl.textContent = data.balance.toLocaleString(); 10099 } 10100 } catch (error) { 10101 console.error("Upscale error:", error); 10102 window.Toast?.show("Upscale failed", "alert"); 10103 } finally { 10104 upscaleBtn.disabled = false; 10105 upscaleBtn.innerHTML = `<span>Upscale</span><span class="flex items-center gap-1 text-white" id="multishot-upscale-cost"></span>`; 10106 updateCost(); 10107 } 10108 }); 10109 }, 10110 // Delete a reference node (level -1) 10111 async deleteReferenceNode(refNodeId) { 10112 if (!this.flowId || !refNodeId) return; 10113 try { 10114 const csrfToken = document.querySelector('[name="csrf-token"]'); 10115 const response = await fetch( 10116 `/flow/${this.flowId}/node/${refNodeId}/delete`, 10117 { 10118 method: "DELETE", 10119 headers: { 10120 "Content-Type": "application/json", 10121 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 10122 }, 10123 body: JSON.stringify({ 10124 node_id: refNodeId 10125 }) 10126 } 10127 ); 10128 const data = await response.json(); 10129 if (!response.ok) { 10130 if (window.Toast && data.error) { 10131 window.Toast.show(data.error, "alert"); 10132 } 10133 return; 10134 } 10135 if (data.nodes) { 10136 this.clearAllNodes(); 10137 this.loadNodesData(data.nodes); 10138 if (this.repositionTree) { 10139 this.repositionTree(); 10140 } 10141 if (this.updateConnections) { 10142 this.updateConnections(); 10143 } 10144 if (window.Toast) { 10145 window.Toast.show("Reference deleted successfully", "success"); 10146 } 10147 } 10148 } catch (error) { 10149 console.error("Delete reference error:", error); 10150 if (window.Toast) { 10151 window.Toast.show("Failed to delete reference", "alert"); 10152 } 10153 } 10154 }, 10155 // Delete a reference badge (from ref_hashes) 10156 async deleteReferenceBadge(nodeId, fileHash) { 10157 if (!this.flowId || !nodeId || !fileHash) return; 10158 try { 10159 const csrfToken = document.querySelector('[name="csrf-token"]'); 10160 const response = await fetch( 10161 `/flow/${this.flowId}/node/${nodeId}/remove_ref`, 10162 { 10163 method: "PATCH", 10164 headers: { 10165 "Content-Type": "application/json", 10166 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 10167 }, 10168 body: JSON.stringify({ 10169 file_hash: fileHash 10170 }) 10171 } 10172 ); 10173 const data = await response.json(); 10174 if (!response.ok) { 10175 if (window.Toast && data.error) { 10176 window.Toast.show(data.error, "alert"); 10177 } 10178 return; 10179 } 10180 if (data.nodes) { 10181 this.clearAllNodes(); 10182 this.loadNodesData(data.nodes); 10183 if (this.repositionTree) { 10184 this.repositionTree(); 10185 } 10186 if (this.updateConnections) { 10187 this.updateConnections(); 10188 } 10189 if (window.Toast) { 10190 window.Toast.show("Reference removed successfully", "success"); 10191 } 10192 } 10193 } catch (error) { 10194 console.error("Remove reference error:", error); 10195 if (window.Toast) { 10196 window.Toast.show("Failed to remove reference", "alert"); 10197 } 10198 } 10199 }, 10200 // Update ref badges display based on mode (image/video) and video model 10201 updateRefBadgesDisplay(nodeEl) { 10202 let referencesContainer = nodeEl.querySelector( 10203 "[data-flow-references-root]" 10204 ); 10205 if (!referencesContainer) { 10206 referencesContainer = nodeEl.querySelector("[data-flow-references]"); 10207 } 10208 if (!referencesContainer) return; 10209 const isReadyNode = nodeEl.classList.contains("bg-inside") && nodeEl.querySelector("[data-flow-generated-img]"); 10210 if (isReadyNode) { 10211 const badges2 = Array.from( 10212 referencesContainer.querySelectorAll("[data-ref-badge-wrapper]") 10213 ); 10214 badges2.forEach((badge) => this.enableBadge(badge)); 10215 referencesContainer.style.display = badges2.length > 0 ? "flex" : "none"; 10216 return; 10217 } 10218 const videoToggleBtn = nodeEl.querySelector('[data-mode="video"]'); 10219 const isRootNode = !!videoToggleBtn; 10220 const nodeType = nodeEl.dataset.nodeType; 10221 const isVideoEditNode = nodeType === "video"; 10222 const badges = Array.from( 10223 referencesContainer.querySelectorAll("[data-ref-badge-wrapper]") 10224 ); 10225 const isVideoMode = isVideoEditNode || videoToggleBtn && videoToggleBtn.classList.contains("active"); 10226 const refBorder = nodeEl.querySelector("[data-flow-references-border]");
10227 const selectedModel = this.getSelectedVideoModel(nodeEl); 10228 const isVeo3 = selectedModel && (selectedModel.toLowerCase().includes("veo 3") || selectedModel.toLowerCase().includes("veo-3") || selectedModel.toLowerCase().includes("veo3")); 10229 if (badges.length === 0 && isVideoMode) { 10230 this.createOptionalRefBadges(nodeEl, referencesContainer); 10231 if (refBorder) refBorder.style.display = ""; 10232 return; 10233 } 10234 if (badges.length === 1 && isVideoMode && isVeo3) { 10235 this.addEndPlaceholder(nodeEl, referencesContainer); 10236 } else if (badges.length === 1 && isVideoMode && !isVeo3) { 10237 const optionalBadges = referencesContainer.querySelectorAll( 10238 "[data-ref-badge-optional]" 10239 ); 10240 optionalBadges.forEach((badge) => badge.remove()); 10241 const existingSwapBtn = referencesContainer.querySelector( 10242 "[data-ref-swap-button]" 10243 ); 10244 if (existingSwapBtn) existingSwapBtn.remove(); 10245 const parent = referencesContainer.parentNode; 10246 if (parent) { 10247 const labels = parent.querySelectorAll("[data-ref-label-absolute]"); 10248 labels.forEach((label) => label.remove()); 10249 } 10250 } 10251 if (badges.length === 0 && !isVideoMode) { 10252 referencesContainer.style.display = "none"; 10253 if (refBorder) refBorder.style.display = "none"; 10254 const optionalBadges = referencesContainer.querySelectorAll( 10255 "[data-ref-badge-optional]" 10256 ); 10257 optionalBadges.forEach((badge) => badge.remove()); 10258 const parent = referencesContainer.parentNode; 10259 if (parent) { 10260 const labels = parent.querySelectorAll("[data-ref-label-absolute]"); 10261 labels.forEach((label) => label.remove()); 10262 } 10263 return; 10264 } 10265 if (badges.length >= 2) { 10266 const optionalBadges = referencesContainer.querySelectorAll( 10267 "[data-ref-badge-optional]" 10268 ); 10269 optionalBadges.forEach((badge) => badge.remove()); 10270 } 10271 referencesContainer.style.display = "flex"; 10272 if (refBorder) refBorder.style.display = ""; 10273 let swapBtn = referencesContainer.querySelector("[data-ref-swap-button]"); 10274 if (!isVideoMode) { 10275 badges.forEach((badge) => { 10276 this.enableBadge(badge); 10277 }); 10278 const optionalBadges = referencesContainer.querySelectorAll( 10279 "[data-ref-badge-optional]" 10280 ); 10281 optionalBadges.forEach((badge) => badge.remove()); 10282 if (swapBtn) { 10283 swapBtn.remove(); 10284 } 10285 } else { 10286 const selectedModel2 = this.getSelectedVideoModel(nodeEl); 10287 const isSora = selectedModel2 && selectedModel2.toLowerCase().includes("sora"); 10288 const isVeo32 = selectedModel2 && (selectedModel2.toLowerCase().includes("veo 3") || selectedModel2.toLowerCase().includes("veo3")); 10289 let maxActive = 1; 10290 if (isVeo32) { 10291 maxActive = Math.min(2, badges.length); 10292 } 10293 badges.forEach((badge, index) => { 10294 if (index < maxActive) { 10295 this.enableBadge(badge); 10296 } else { 10297 this.disableBadge(badge); 10298 } 10299 }); 10300 const shouldShowSwap = (isRootNode || isVideoEditNode) && isVeo32; 10301 if (shouldShowSwap && !swapBtn) { 10302 const swapBtnTemplate = document.getElementById( 10303 "flow-ref-swap-button-template" 10304 ); 10305 if (swapBtnTemplate) { 10306 const swapBtnEl = swapBtnTemplate.content.cloneNode(true); 10307 swapBtn = swapBtnEl.querySelector("[data-ref-swap-button]"); 10308 if (swapBtn) { 10309 swapBtn.style.transform = "scale(0)"; 10310 swapBtn.style.transition = "transform 0.3s ease"; 10311 const firstItem = referencesContainer.children[0]; 10312 if (firstItem) { 10313 firstItem.insertAdjacentElement("afterend", swapBtn); 10314 swapBtn.addEventListener("click", (e) => { 10315 e.stopPropagation(); 10316 this.swapRefBadges(nodeEl); 10317 }); 10318 requestAnimationFrame(() => { 10319 swapBtn.style.transform = "scale(1)"; 10320 }); 10321 } 10322 } 10323 } 10324 } else if (!shouldShowSwap && swapBtn) { 10325 swapBtn.remove(); 10326 } 10327 } 10328 const modeTooltips = nodeEl.querySelectorAll("[data-mode-tooltip]"); 10329 if (badges.length > 0) { 10330 modeTooltips.forEach((tooltip) => { 10331 tooltip.style.display = "none"; 10332 }); 10333 } else { 10334 modeTooltips.forEach((tooltip) => { 10335 tooltip.style.display = ""; 10336 }); 10337 } 10338 const nodeId = nodeEl.dataset.nodeId; 10339 if (nodeId && this.nodes[nodeId] && this.nodes[nodeId].data.type === "root") { 10340 const textarea = nodeEl.querySelector("[data-flow-prompt]"); 10341 if (textarea) { 10342 requestAnimationFrame(() => { 10343 this.autoResizeEditor(textarea, nodeEl, this.nodes[nodeId].data); 10344 }); 10345 } 10346 } 10347 }, 10348 // Get selected video model name from node
10349 getSelectedVideoModel(nodeEl) { 10350 const modelLabel = nodeEl.querySelector("[data-flow-model-label]"); 10351 if (modelLabel && modelLabel.textContent) { 10352 return modelLabel.textContent; 10353 } 10354 return nodeEl.dataset.model || null; 10355 }, 10356 // Enable badge (show badge) 10357 enableBadge(badge) { 10358 badge.style.display = ""; 10359 badge.style.pointerEvents = "auto"; 10360 }, 10361 // Disable badge (hide badge) 10362 disableBadge(badge) { 10363 badge.style.display = "none"; 10364 badge.style.pointerEvents = "none"; 10365 }, 10366 // Add END placeholder when we have 1 badge in Veo mode 10367 addEndPlaceholder(nodeEl, referencesContainer) { 10368 const parent = referencesContainer.parentNode; 10369 if (!parent) return; 10370 const existingEnd = Array.from( 10371 referencesContainer.querySelectorAll("[data-ref-badge-optional]") 10372 ).find((badge2) => badge2.dataset.placeholderIndex === "1"); 10373 if (existingEnd) return; 10374 referencesContainer.style.display = "flex"; 10375 const optionalTemplate = document.getElementById( 10376 "flow-reference-badge-optional-template" 10377 ); 10378 if (!optionalTemplate) return; 10379 const badgeEl = optionalTemplate.content.cloneNode(true); 10380 const badge = badgeEl.querySelector("[data-ref-badge-optional]"); 10381 if (badge) { 10382 badge.dataset.placeholderIndex = "1"; 10383 const textSpan = badgeEl.querySelector("[data-ref-badge-optional-text]"); 10384 if (textSpan) { 10385 textSpan.textContent = "End\nFrame"; 10386 } 10387 badge.addEventListener("click", (e) => { 10388 e.stopPropagation(); 10389 this.showRefContextMenu(nodeEl, badge, 1); 10390 }); 10391 referencesContainer.appendChild(badge); 10392 } 10393 }, 10394 // Create optional ref badge placeholders for video mode without refs 10395 createOptionalRefBadges(nodeEl, referencesContainer) { 10396 const parent = referencesContainer.parentNode; 10397 if (!parent) return; 10398 referencesContainer.style.display = "flex"; 10399 const selectedModel = this.getSelectedVideoModel(nodeEl); 10400 const isVeo3 = selectedModel && (selectedModel.toLowerCase().includes("veo 3") || selectedModel.toLowerCase().includes("veo3")); 10401 const maxBadges = isVeo3 ? 2 : 1; 10402 const existingOptional = referencesContainer.querySelectorAll( 10403 "[data-ref-badge-optional]" 10404 ); 10405 existingOptional.forEach((badge) => badge.remove()); 10406 const existingSwapBtn = referencesContainer.querySelector( 10407 "[data-ref-swap-button]" 10408 ); 10409 if (existingSwapBtn) { 10410 existingSwapBtn.remove(); 10411 } 10412 const optionalTemplate = document.getElementById( 10413 "flow-reference-badge-optional-template" 10414 ); 10415 if (!optionalTemplate) return; 10416 for (let i = 0; i < maxBadges; i++) { 10417 const badgeEl = optionalTemplate.content.cloneNode(true); 10418 const badge = badgeEl.querySelector("[data-ref-badge-optional]"); 10419 if (badge) { 10420 badge.dataset.placeholderIndex = i; 10421 const textSpan = badgeEl.querySelector( 10422 "[data-ref-badge-optional-text]" 10423 ); 10424 if (textSpan) { 10425 if (isVeo3) { 10426 textSpan.textContent = i === 0 ? "Start\nFrame" : "End\nFrame"; 10427 } else { 10428 textSpan.textContent = "Start\nFrame"; 10429 } 10430 } 10431 badge.addEventListener("click", (e) => { 10432 e.stopPropagation(); 10433 this.showRefContextMenu(nodeEl, badge, i); 10434 }); 10435 referencesContainer.appendChild(badge); 10436 } 10437 } 10438 const optionalBadges = Array.from( 10439 referencesContainer.querySelectorAll("[data-ref-badge-optional]") 10440 ); 10441 if (isVeo3 && optionalBadges.length >= 1) { 10442 let swapBtn = referencesContainer.querySelector("[data-ref-swap-button]"); 10443 if (!swapBtn) { 10444 const swapBtnTemplate = document.getElementById( 10445 "flow-ref-swap-button-template" 10446 ); 10447 if (swapBtnTemplate) { 10448 const swapBtnEl = swapBtnTemplate.content.cloneNode(true); 10449 swapBtn = swapBtnEl.querySelector("[data-ref-swap-button]"); 10450 if (swapBtn) { 10451 swapBtn.style.transform = "scale(0)"; 10452 swapBtn.style.transition = "transform 0.3s ease"; 10453 const firstItem = referencesContainer.children[0]; 10454 if (firstItem) { 10455 firstItem.insertAdjacentElement("afterend", swapBtn); 10456 swapBtn.addEventListener("click", (e) => { 10457 e.stopPropagation(); 10458 this.swapRefBadges(nodeEl); 10459 }); 10460 requestAnimationFrame(() => { 10461 swapBtn.style.transform = "scale(1)"; 10462 }); 10463 } 10464 } 10465 } 10466 } 10467 } 10468 }, 10469 // Swap START and END ref badges 10470 async swapRefBadges(nodeEl) { 10471 let referencesContainer = nodeEl.querySelector( 10472 "[data-flow-references-root]" 10473 ); 10474 if (!referencesContainer || referencesContainer.children.length === 0) { 10475 referencesContainer = nodeEl.querySelector("[data-flow-references]"); 10476 } 10477 if (!referencesContainer) return; 10478 const badges = Array.from( 10479 referencesContainer.querySelectorAll("[data-ref-badge-wrapper]") 10480 ); 10481 if (badges.length < 2) return; 10482 const firstBadge = badges[0]; 10483 const secondBadge = badges[1]; 10484 const swapBtn = referencesContainer.querySelector("[data-ref-swap-button]"); 10485 const firstRect = firstBadge.getBoundingClientRect(); 10486 const secondRect = secondBadge.getBoundingClientRect(); 10487 firstBadge.parentNode.insertBefore(secondBadge, firstBadge); 10488 if (swapBtn) { 10489 const newFirstBadge = referencesContainer.querySelectorAll( 10490 "[data-ref-badge-wrapper]" 10491 )[0]; 10492 if (newFirstBadge) { 10493 newFirstBadge.insertAdjacentElement("afterend", swapBtn); 10494 } 10495 } 10496 const firstNewRect = firstBadge.getBoundingClientRect(); 10497 const secondNewRect = secondBadge.getBoundingClientRect(); 10498 const firstDeltaY = firstRect.top - firstNewRect.top; 10499 const secondDeltaY = secondRect.top - secondNewRect.top; 10500 firstBadge.style.transform = `translateY(${firstDeltaY}px)`; 10501 secondBadge.style.transform = `translateY(${secondDeltaY}px)`; 10502 firstBadge.style.transition = "none"; 10503 secondBadge.style.transition = "none"; 10504 requestAnimationFrame(() => { 10505 firstBadge.style.transition = "transform 0.3s ease"; 10506 secondBadge.style.transition = "transform 0.3s ease"; 10507 firstBadge.style.transform = ""; 10508 secondBadge.style.transform = ""; 10509 }); 10510 await this.saveRefBadgesOrder(nodeEl, referencesContainer); 10511 }, 10512 // Save ref badges order to backend 10513 async saveRefBadgesOrder(nodeEl, referencesContainer) { 10514 const nodeId = nodeEl.dataset.nodeId; 10515 if (!nodeId || !this.flowId) return; 10516 const badges = Array.from( 10517 referencesContainer.querySelectorAll("[data-ref-badge-wrapper]") 10518 ); 10519 const newRefHashes = badges.map((badge) =>
10519 badge.dataset.fileHash).filter((hash) => hash); 10520 if (newRefHashes.length === 0) return; 10521 try { 10522 const csrfToken = document.querySelector('[name="csrf-token"]'); 10523 const response = await fetch(`/flow/${this.flowId}/node`, { 10524 method: "PATCH", 10525 headers: { 10526 "Content-Type": "application/json", 10527 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 10528 }, 10529 body: JSON.stringify({ 10530 node_id: nodeId, 10531 refs: newRefHashes 10532 }) 10533 }); 10534 if (!response.ok) { 10535 console.error("Failed to save ref badges order"); 10536 } 10537 } catch (error) { 10538 console.error("Error saving ref badges order:", error); 10539 } 10540 }, 10541 bindPromptTextarea(nodeEl, nodeData) { 10542 if (nodeData.type !== "root") return; 10543 const textarea = nodeEl.querySelector("[data-flow-prompt]"); 10544 if (!textarea) return; 10545 let initialValue = textarea.textContent || ""; 10546 const autoResize = () => { 10547 this.autoResizeEditor(textarea, nodeEl, nodeData); 10548 }; 10549 textarea.addEventListener("input", autoResize); 10550 requestAnimationFrame(() => { 10551 autoResize(); 10552 }); 10553 textarea.addEventListener("blur", async () => { 10554 const newValue = this.getEditorTextContent ? this.getEditorTextContent(textarea).trim() : textarea.textContent.trim(); 10555 if (newValue === initialValue) return; 10556 initialValue = newValue; 10557 try { 10558 const csrfToken = document.querySelector('[name="csrf-token"]'); 10559 const response = await fetch(`/flow/${this.flowId}/node`, { 10560 method: "PATCH", 10561 headers: { 10562 "Content-Type": "application/json", 10563 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 10564 }, 10565 body: JSON.stringify({ 10566 node_id: nodeData.id, 10567 content: newValue 10568 }) 10569 }); 10570 if (!response.ok) { 10571 throw new Error("Failed to save prompt"); 10572 } 10573 } catch (error) { 10574 console.error("Error saving prompt:", error); 10575 } 10576 }); 10577 textarea.addEventListener("keyup", () => { 10578 if (this.updateTagButtonStates) { 10579 this.updateTagButtonStates(nodeEl); 10580 } 10581 }); 10582 if (this.bindMentions) { 10583 this.bindMentions(textarea, nodeEl); 10584 } 10585 }, 10586 // Auto-resize editor and adjust node height 10587 autoResizeEditor(editor, nodeEl, nodeData) { 10588 if (!editor || !nodeEl) return; 10589 const originalOverflow = editor.style.overflow; 10590 editor.style.overflow = "hidden"; 10591 editor.style.height = "auto"; 10592 const editorScrollHeight = editor.scrollHeight; 10593 editor.style.overflow = originalOverflow; 10594 const padding = 12 + 10; 10595 const bottomControls = nodeEl.querySelector( 10596 ".flex.items-center.justify-between.select-none" 10597 ); 10598 const bottomControlsHeight = bottomControls ? bottomControls.offsetHeight : 40; 10599 const refsContainer = nodeEl.querySelector("[data-flow-references-root]"); 10600 const refsHeight = refsContainer && refsContainer.style.display !== "none" ? refsContainer.offsetHeight + 8 : 0; 10601 const refsBorder = nodeEl.querySelector("[data-flow-references-border]"); 10602 const refsBorderHeight = refsBorder ? refsBorder.offsetHeight + 16 : 0; 10603 const minEditorHeight = 125; 10604 const actualEditorHeight = Math.max(minEditorHeight, editorScrollHeight); 10605 const totalHeight = padding + actualEditorHeight + refsHeight + refsBorderHeight + bottomControlsHeight + 16; 10606 const maxHeight = window.innerHeight * 0.8; 10607 const finalHeight = Math.min(Math.max(totalHeight, 277), maxHeight); 10608 if (totalHeight > maxHeight) { 10609 const availableEditorHeight = maxHeight - padding - refsHeight - refsBorderHeight - bottomControlsHeight - 16; 10610 editor.style.height = `${availableEditorHeight}px`; 10611 } else { 10612 editor.style.height = `${actualEditorHeight}px`; 10613 } 10614 nodeEl.style.height = `${finalHeight}px`; 10615 if (this.nodes[nodeData.id]) { 10616 this.nodes[nodeData.id].position.height = finalHeight; 10617 } 10618 if (this.updateConnections) { 10619 this.updateConnections(); 10620 } 10621 }, 10622 // Show context menu for ref badge placeholders (Upload, Pinned, Library) 10623 showRefContextMenu(nodeEl, badgeEl, positionIndex) { 10624 if (!this.checkPermissions("refs")) return; 10625 this.closeRefContextMenu(); 10626 const menu = document.createElement("div"); 10627 menu.id = "ref-context-menu"; 10628 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px]"; 10629 menu.style.top = "100%"; 10630 menu.style.left = "-4px"; 10631 menu.style.marginTop = "8px"; 10632 const uploadOption = document.createElement("button"); 10633 uploadOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 10634 uploadOption.innerHTML = ` 10635 <div class="flex items-start gap-3"> 10636 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 10637 <path d="M12 13v8"></path> 10638 <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path> 10639 <path d="m8 17 4-4 4 4"></path> 10640 </svg> 10641 <div> 10642 <div class="font-medium text-white">Upload</div> 10643 <div class="text-[10px] text-gray leading-tight mt-0.5">Image from your computer</div> 10644 </div> 10645 </div> 10646 `; 10647 uploadOption.onclick = (e) => { 10648 e.stopPropagation(); 10649 this.closeRefContextMenu(); 10650 const nodeId = nodeEl.dataset.nodeId; 10651 const nodeEntry = this.nodes[nodeId]; 10652 if (nodeEntry && this.handleUploadClick) { 10653 this.handleUploadClick(nodeEl, nodeEntry.data); 10654 } 10655 }; 10656 menu.appendChild(uploadOption); 10657 const pinnedOption = document.createElement("button"); 10658 pinnedOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 10659 pinnedOption.innerHTML = ` 10660 <div class="flex items-start gap-3"> 10661 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 10662 <path d="M12 17v5"></path> 10663 <path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7
10663a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path> 10664 </svg> 10665 <div> 10666 <div class="font-medium text-white">Pinned</div> 10667 <div class="text-[10px] text-gray leading-tight mt-0.5">Images pinned in this Flow</div> 10668 </div> 10669 </div> 10670 `; 10671 pinnedOption.onclick = (e) => { 10672 e.stopPropagation(); 10673 this.closeRefContextMenu(); 10674 this.currentPinsTargetNodeId = nodeEl.dataset.nodeId; 10675 if (window.Modal && window.Modal.showPins) { 10676 window.Modal.showPins(this.flowId); 10677 } 10678 }; 10679 menu.appendChild(pinnedOption); 10680 const isVideoFrame = badgeEl.hasAttribute("data-ref-badge-optional"); 10681 if (!isVideoFrame) { 10682 const libraryOption = document.createElement("button"); 10683 libraryOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 10684 libraryOption.innerHTML = ` 10685 <div class="flex items-start gap-3"> 10686 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 10687 <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path> 10688 <circle cx="12" cy="12" r="4"></circle> 10689 </svg> 10690 <div> 10691 <div class="font-medium text-white">Elements</div> 10692 <div class="text-[10px] text-gray leading-tight mt-0.5">Images from your Elements</div> 10693 </div> 10694 </div> 10695 `; 10696 libraryOption.onclick = (e) => { 10697 e.stopPropagation(); 10698 this.closeRefContextMenu(); 10699 this.currentRefsTargetNodeId = nodeEl.dataset.nodeId; 10700 if (window.Modal) window.Modal.showElementsPicker(); 10701 }; 10702 menu.appendChild(libraryOption); 10703 } 10704 badgeEl.appendChild(menu); 10705 setTimeout(() => { 10706 const closeHandler = (e) => { 10707 if (!menu.contains(e.target) && !badgeEl.contains(e.target)) { 10708 this.closeRefContextMenu(); 10709 document.removeEventListener("click", closeHandler); 10710 } 10711 }; 10712 document.addEventListener("click", closeHandler); 10713 }, 10); 10714 }, 10715 closeRefContextMenu() { 10716 const menu = document.getElementById("ref-context-menu"); 10717 if (menu) { 10718 menu.remove(); 10719 } 10720 }, 10721 // Show confirmation modal before deleting a reference 10722 showRefDeleteConfirm(nodeId, fileHash) { 10723 if (!this.checkPermissions("delete")) return; 10724 const template = document.getElementById( 10725 "flow-ref-delete-confirm-template" 10726 ); 10727 if (!template) { 10728 this.deleteReferenceBadge(nodeId, fileHash); 10729 return; 10730 } 10731 const existingModal = document.getElementById("ref-delete-confirm-modal"); 10732 if (existingModal) { 10733 existingModal.remove(); 10734 } 10735 const modalContent = template.content.cloneNode(true); 10736 document.body.appendChild(modalContent); 10737 const modal = document.getElementById("ref-delete-confirm-modal"); 10738 if (!modal) return; 10739 const closeModal = () => { 10740 modal.classList.remove("modal-showing"); 10741 setTimeout(() => { 10742 modal.classList.add("hidden"); 10743 document.body.classList.remove("modal-open"); 10744 modal.remove(); 10745 }, 300); 10746 }; 10747 const cancelBtns = modal.querySelectorAll("[data-ref-delete-cancel]"); 10748 cancelBtns.forEach((btn) => { 10749 btn.addEventListener("click", closeModal); 10750 }); 10751 modal.addEventListener("click", (e) => { 10752 if (e.target === modal) { 10753 closeModal(); 10754 } 10755 }); 10756 const escHandler = (e) => { 10757 if (e.key === "Escape") { 10758 closeModal(); 10759 document.removeEventListener("keydown", escHandler); 10760 } 10761 }; 10762 document.addEventListener("keydown", escHandler); 10763 const confirmBtn = modal.querySelector("[data-ref-delete-confirm]"); 10764 if (confirmBtn) { 10765 confirmBtn.addEventListener("click", async () => { 10766 closeModal(); 10767 await this.deleteReferenceBadge(nodeId, fileHash); 10768 }); 10769 } 10770 document.body.classList.add("modal-open"); 10771 requestAnimationFrame(() => { 10772 modal.classList.add("modal-showing"); 10773 }); 10774 }, 10775 // Open modal for editing a reference image 10776 openRefEditModal(nodeEl, currentImgUrl, fileHashToReplace = null, badgeElement = null) { 10777 if (!this.checkPermissions("edit")) return; 10778 const template = document.getElementById("flow-ref-edit-modal-template"); 10779 if (!template) { 10780 console.error("Ref edit modal template not found"); 10781 return; 10782 } 10783 const existingModal = document.getElementById("ref-edit-modal"); 10784 if (existingModal) { 10785 existingModal.remove(); 10786 } 10787 const modalContent = template.content.cloneNode(true); 10788 document.body.appendChild(modalContent); 10789 const modal = document.getElementById("ref-edit-modal"); 10790 if (!modal) return;
10791 this.refHashToReplace = fileHashToReplace; 10792 this.refReplaceTargetNodeId = nodeEl.dataset.nodeId; 10793 this.refBadgeToReplace = badgeElement; 10794 const img = modal.querySelector("[data-ref-edit-img]"); 10795 if (img && currentImgUrl) { 10796 img.src = currentImgUrl; 10797 } 10798 const closeModal = () => { 10799 modal.classList.remove("modal-showing"); 10800 setTimeout(() => { 10801 modal.classList.add("hidden"); 10802 document.body.classList.remove("modal-open"); 10803 modal.remove(); 10804 }, 300); 10805 }; 10806 const cancelAndClose = () => { 10807 this.refHashToReplace = null; 10808 this.refReplaceTargetNodeId = null; 10809 this.refBadgeToReplace = null; 10810 closeModal(); 10811 }; 10812 const closeBtn = modal.querySelector("[data-ref-edit-close]"); 10813 if (closeBtn) { 10814 closeBtn.addEventListener("click", cancelAndClose); 10815 } 10816 modal.addEventListener("click", (e) => { 10817 if (e.target === modal) { 10818 cancelAndClose(); 10819 } 10820 }); 10821 const escHandler = (e) => { 10822 if (e.key === "Escape") { 10823 cancelAndClose(); 10824 document.removeEventListener("keydown", escHandler); 10825 } 10826 }; 10827 document.addEventListener("keydown", escHandler); 10828 const uploadBtn = modal.querySelector("[data-ref-edit-upload]"); 10829 if (uploadBtn) { 10830 uploadBtn.addEventListener("click", () => { 10831 closeModal(); 10832 const nodeId = nodeEl.dataset.nodeId; 10833 const nodeEntry = this.nodes[nodeId]; 10834 if (nodeEntry && this.handleUploadClick) { 10835 this.handleUploadClick(nodeEl, nodeEntry.data); 10836 } 10837 }); 10838 } 10839 const pinnedBtn = modal.querySelector("[data-ref-edit-pinned]"); 10840 if (pinnedBtn) { 10841 pinnedBtn.addEventListener("click", () => { 10842 closeModal(); 10843 this.currentPinsTargetNodeId = nodeEl.dataset.nodeId; 10844 if (window.Modal && window.Modal.showPins) { 10845 window.Modal.showPins(this.flowId); 10846 } 10847 }); 10848 } 10849 const libraryBtn = modal.querySelector("[data-ref-edit-library]"); 10850 if (libraryBtn) { 10851 libraryBtn.addEventListener("click", () => { 10852 closeModal(); 10853 this.currentRefsTargetNodeId = nodeEl.dataset.nodeId; 10854 if (window.Modal) window.Modal.showElementsPicker(); 10855 }); 10856 } 10857 document.body.classList.add("modal-open"); 10858 requestAnimationFrame(() => { 10859 modal.classList.add("modal-showing"); 10860 }); 10861 } 10862}; 10863
10864// app/javascript/flow/connections.js 10865var ConnectionsMixin = { 10866 initConnections() { 10867 if (!this.svgLayer) { 10868 console.error("SVG layer not found for connections"); 10869 } 10870 this.updateConnectionsTimer = null; 10871 }, 10872 // Draw a single connection between two points with a bezier curve 10873 drawConnection(startX, startY, endX, endY) { 10874 const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); 10875 const distanceX = Math.abs(endX - startX); 10876 const direction = endX > startX ? 1 : -1; 10877 const cp1X = startX + direction * distanceX * 0.4; 10878 const cp1Y = startY; 10879 const cp2X = endX - direction * distanceX * 0.4; 10880 const cp2Y = endY; 10881 path.setAttribute( 10882 "d", 10883 `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}` 10884 ); 10885 path.setAttribute("stroke", "rgba(99, 102, 241, 0.8)"); 10886 path.setAttribute("stroke-width", "2"); 10887 path.setAttribute("fill", "none"); 10888 this.svgLayer.appendChild(path); 10889 }, 10890 // Update all connections between nodes (with debouncing) 10891 updateConnections() { 10892 if (!this.svgLayer) return; 10893 if (this.updateConnectionsTimer) { 10894 clearTimeout(this.updateConnectionsTimer); 10895 } 10896 this.updateConnectionsTimer = setTimeout(() => { 10897 this.updateConnectionsNow(); 10898 this.updateConnectionsTimer = null; 10899 }, 10); 10900 }, 10901 // Immediately update all connections (no debouncing) 10902 updateConnectionsNow() { 10903 if (!this.svgLayer) return; 10904 while (this.svgLayer.firstChild) { 10905 this.svgLayer.removeChild(this.svgLayer.firstChild); 10906 } 10907 Object.values(this.nodes).forEach((node) => { 10908 if (!node.data.nodeData) return; 10909 const ancestors = node.data.nodeData.ancestors || []; 10910 ancestors.forEach((ancestor) => { 10911 const parentNode = Object.values(this.nodes).find( 10912 (n) => n.data.nodeData && n.data.nodeData.id === ancestor.id 10913 ); 10914 if (parentNode) { 10915 let startX, startY, endX, endY; 10916 if (node.data.nodeData.level === -1) { 10917 startX = node.position.left + node.position.width; 10918 startY = node.position.top + node.position.height / 2; 10919 endX = parentNode.position.left; 10920 endY = parentNode.position.top + parentNode.position.height / 2; 10921 } else if (parentNode.data.nodeData.node_type === "reference" && parentNode.data.nodeData.level > -1) { 10922 startX = parentNode.position.left + parentNode.position.width; 10923 startY = parentNode.position.top + parentNode.position.height / 2; 10924 endX = node.position.left; 10925 endY = node.position.top + node.position.height / 2; 10926 } else { 10927 startX = parentNode.position.left + parentNode.position.width; 10928 startY = parentNode.position.top + parentNode.position.height / 2; 10929 endX = node.position.left; 10930 endY = node.position.top + node.position.height / 2; 10931 } 10932 this.drawConnection(startX, startY, endX, endY); 10933 } 10934 }); 10935 }); 10936 }, 10937 // Clear all connections 10938 clearConnections() { 10939 if (!this.svgLayer) return; 10940 if (this.updateConnectionsTimer) { 10941 clearTimeout(this.updateConnectionsTimer); 10942 this.updateConnectionsTimer = null; 10943 } 10944 while (this.svgLayer.firstChild) { 10945 this.svgLayer.removeChild(this.svgLayer.firstChild); 10946 } 10947 } 10948}; 10949
10950// app/javascript/flow/tree.js 10951var TreeMixin = { 10952 // Get effective height of a node 10953 getEffectiveNodeHeight(node) { 10954 return node.position.height; 10955 }, 10956 // Calculate total height of children group (including spacing) 10957 calculateChildrenHeight(children) { 10958 if (children.length === 0) return 0; 10959 const totalHeight = children.reduce((sum, child) => { 10960 return sum + this.getEffectiveNodeHeight(child); 10961 }, 0); 10962 const totalSpacing = (children.length - 1) * this.verticalSpacing; 10963 return totalHeight + totalSpacing; 10964 }, 10965 // Get children of a node from nodeData 10966 getChildren(parentId) { 10967 const children = []; 10968 Object.values(this.nodes).forEach((node) => { 10969 if (!node.data.nodeData) return; 10970 const ancestors = node.data.nodeData.ancestors || []; 10971 const isChild = ancestors.some((anc) => anc.id === parentId); 10972 if (isChild) { 10973 children.push(node); 10974 } 10975 }); 10976 children.sort((a, b) => { 10977 const orderA = a.data.nodeData?.order_in_level || 0; 10978 const orderB = b.data.nodeData?.order_in_level || 0; 10979 return orderA - orderB; 10980 }); 10981 return children; 10982 }, 10983 // Reposition all nodes using bottom-up block-based layout 10984 repositionTree() { 10985 if (!this.initialCenterX || !this.initialCenterY) { 10986 const container = this.container || document.getElementById("flow-canvas-container"); 10987 if (container) { 10988 this.initialCenterX = container.clientWidth / 2; 10989 this.initialCenterY = container.clientHeight / 2; 10990 } else { 10991 this.initialCenterX = 500; 10992 this.initialCenterY = 400; 10993 } 10994 } 10995 const allNodes = Object.values(this.nodes).filter((n) => n.data.nodeData && n.data.nodeData.node_type !== "reference"); 10996 if (allNodes.length === 0) return; 10997 const nodesByLevel = /* @__PURE__ */ new Map(); 10998 let maxLevel = 0; 10999 allNodes.forEach((node) => { 11000 const level = node.data.nodeData.level; 11001 maxLevel = Math.max(maxLevel, level); 11002 if (!nodesByLevel.has(level)) { 11003 nodesByLevel.set(level, []); 11004 } 11005 nodesByLevel.get(level).push(node); 11006 node._block = { 11007 y: 0, 11008 // Node's own Y position 11009 minY: 0, 11010 // Min Y of entire subtree (including children) 11011 maxY: 0 11012 // Max Y of entire subtree (including children) 11013 }; 11014 }); 11015 const positionedNodes = /* @__PURE__ */ new Set(); 11016 let globalY = 0; 11017 const positionSubtree = (node) => { 11018 const nodeId = node.data.nodeData.id; 11019 if (positionedNodes.has(nodeId)) return; 11020 const children = this.getChildren(nodeId); 11021 const label = node.data.nodeData.letter || nodeId.substring(0, 8); 11022 if (children.length === 0) { 11023 node._block.y = globalY; 11024 node._block.minY = globalY; 11025 node._block.maxY = globalY + this.getEffectiveNodeHeight(node); 11026 globalY = node._block.maxY + this.verticalSpacing; 11027 positionedNodes.add(nodeId); 11028 } else { 11029 children.sort((a, b) => { 11030 const orderA = a.data.nodeData?.order_in_level || 0; 11031 const orderB = b.data.nodeData?.order_in_level || 0; 11032 return orderA - orderB; 11033 }); 11034 children.forEach((child) => positionSubtree(child)); 11035 const childMinY = Math.min(...children.map((c) => c._block.y)); 11036 const childMaxY = Math.max(...children.map((c) => c._block.maxY)); 11037 const childCenter = (childMinY + childMaxY) / 2; 11038 const actualHeight = this.getEffectiveNodeHeight(node); 11039 node._block.y = childCenter - actualHeight / 2; 11040 node._block.minY = Math.min(node._block.y, childMinY); 11041 node._block.maxY = Math.max(node._block.y + actualHeight, childMaxY); 11042 positionedNodes.add(nodeId); 11043 } 11044 }; 11045 const rootNodes = nodesByLevel.get(0) || []; 11046 rootNodes.sort((a, b) => { 11047 const orderA = a.data.nodeData?.order_in_level || 0; 11048 const orderB = b.data.nodeData?.order_in_level || 0; 11049 return orderA - orderB; 11050 }); 11051 rootNodes.forEach((root) => positionSubtree(root)); 11052 const allYs = allNodes.map((n) => n._block.y); 11053 const minY = Math.min(...allYs); 11054 const maxY = Math.max(...allYs.map((y, i) =>
11054 y + this.getEffectiveNodeHeight(allNodes[i]))); 11055 const totalHeight = maxY - minY; 11056 const offsetY = this.initialCenterY - totalHeight / 2; 11057 const rootNode = rootNodes.length > 0 ? rootNodes[0] : null; 11058 const rootNodeWidth = rootNode ? rootNode.position.width : this.NODE_SIZE; 11059 allNodes.forEach((node) => { 11060 const level = node.data.nodeData.level; 11061 let nodeX; 11062 if (level === 0) { 11063 nodeX = this.initialCenterX - rootNodeWidth / 2; 11064 } else if (level === 1) { 11065 nodeX = this.initialCenterX + rootNodeWidth / 2 + this.horizontalSpacing; 11066 } else { 11067 const level1X = this.initialCenterX + rootNodeWidth / 2 + this.horizontalSpacing; 11068 nodeX = level1X + (level - 1) * (this.NODE_SIZE + this.horizontalSpacing); 11069 } 11070 const nodeY = node._block.y + offsetY; 11071 node.position.left = nodeX; 11072 node.position.top = nodeY; 11073 node.element.style.left = `${nodeX}px`; 11074 node.element.style.top = `${nodeY}px`; 11075 }); 11076 if (this.updateConnections) { 11077 this.updateConnections(); 11078 const transitionDuration = 400; 11079 const frames = 20; 11080 const frameInterval = transitionDuration / frames; 11081 for (let i = 1; i <= frames; i++) { 11082 setTimeout(() => { 11083 if (this.updateConnections) this.updateConnections(); 11084 }, i * frameInterval); 11085 } 11086 } 11087 }, 11088 // Shift an entire block (node + all descendants) downward 11089 shiftBlock(node, shift) { 11090 node._block.y += shift; 11091 node._block.minY += shift; 11092 node._block.maxY += shift; 11093 const children = this.getChildren(node.data.nodeData.id); 11094 children.forEach((child) => { 11095 this.shiftBlock(child, shift); 11096 }); 11097 }, 11098 // Position reference nodes (level -1) to the left of root node 11099 positionReferenceNodes(rootNode) { 11100 const refNodes = Object.values(this.nodes).filter( 11101 (node) => node.data.nodeData && node.data.nodeData.level === -1 11102 ); 11103 if (refNodes.length === 0) return; 11104 refNodes.sort((a, b) => { 11105 const orderA = a.data.nodeData?.order_in_level || 0; 11106 const orderB = b.data.nodeData?.order_in_level || 0; 11107 return orderA - orderB; 11108 }); 11109 const refX = rootNode.position.left - this.NODE_SIZE - this.horizontalSpacing; 11110 const allRefChildren = /* @__PURE__ */ new Set(); 11111 refNodes.forEach((refNode) => { 11112 const refId = refNode.data.nodeData.id; 11113 Object.values(this.nodes).forEach((node) => { 11114 if (!node.data.nodeData || !node.data.nodeData.ancestors) return; 11115 if (node.data.nodeData.ancestors.some((anc) => anc.id === refId)) { 11116 allRefChildren.add(node); 11117 } 11118 }); 11119 }); 11120 let centerY; 11121 if (allRefChildren.size > 0) { 11122 const childrenArray = Array.from(allRefChildren); 11123 const minY = Math.min(...childrenArray.map((c) => c.position.top)); 11124 const maxY = Math.max(...childrenArray.map((c) => c.position.top + this.getEffectiveNodeHeight(c))); 11125 centerY = (minY + maxY) / 2; 11126 } else { 11127 centerY = rootNode.position.top + this.getEffectiveNodeHeight(rootNode) / 2; 11128 } 11129 const totalHeight = refNodes.reduce((sum, node, index) => { 11130 return sum + this.getEffectiveNodeHeight(node) + (index < refNodes.length - 1 ? this.verticalSpacing : 0); 11131 }, 0); 11132 let currentY = centerY - totalHeight / 2; 11133 refNodes.forEach((refNode) => { 11134 refNode.position.left = refX; 11135 refNode.position.top = currentY; 11136 refNode.element.style.left = `${refX}px`; 11137 refNode.element.style.top = `${currentY}px`; 11138 currentY += this.getEffectiveNodeHeight(refNode) + this.verticalSpacing; 11139 }); 11140 } 11141}; 11142
11143// app/javascript/flow/pages.js 11144var PagesMixin = { 11145 initScenes() { 11146 this.currentSceneId = null; 11147 this.scenes = []; 11148 this.flowId = null; 11149 const container = document.querySelector("[data-init-flow]"); 11150 if (!container) return; 11151 this.flowId = container.dataset.flowId; 11152 const backendSceneId = container.dataset.sceneId; 11153 this.loadScenes(); 11154 const hashSceneId = window.location.hash.substring(1); 11155 const sceneExists = hashSceneId && this.scenes.some((s) => s.id === hashSceneId); 11156 if (sceneExists) { 11157 this.currentSceneId = hashSceneId; 11158 this.sceneId = hashSceneId; 11159 this.bindSceneEvents(); 11160 this.loadSceneNodes(hashSceneId); 11161 } else if (backendSceneId) { 11162 this.currentSceneId = backendSceneId; 11163 this.sceneId = backendSceneId; 11164 this.bindSceneEvents(); 11165 this.renderScenesList(); 11166 this.updateSceneIndicator(); 11167 this.scheduleAutoGenerateName(backendSceneId); 11168 } else if (this.scenes.length > 0) { 11169 const firstScene = this.scenes.sort((a, b) => a.order - b.order)[0]; 11170 this.currentSceneId = firstScene.id; 11171 this.sceneId = firstScene.id; 11172 this.bindSceneEvents(); 11173 this.updateSceneIndicator(); 11174 this.loadSceneNodes(firstScene.id); 11175 } else { 11176 this.bindSceneEvents(); 11177 } 11178 }, 11179 loadScenes() { 11180 const scenesData = document.getElementById("flow-scenes-data"); 11181 if (!scenesData) { 11182 console.error("Scenes data not found"); 11183 return; 11184 } 11185 try { 11186 this.scenes = JSON.parse(scenesData.textContent); 11187 } catch (e) { 11188 console.error("Error parsing scenes data:", e); 11189 if (this.currentSceneId) { 11190 this.scenes = [{ id: this.currentSceneId, name: "Main", order: 0 }]; 11191 } 11192 } 11193 }, 11194 renderScenesList() { 11195 const scenesList = document.getElementById("scenes-list"); 11196 if (!scenesList) return; 11197 const existingIndicator = scenesList.querySelector("[data-scene-indicator]"); 11198 scenesList.innerHTML = ""; 11199 const wrapper = document.createElement("div"); 11200 wrapper.className = "flex flex-col gap-1 shrink-0"; 11201 const label = document.createElement("div"); 11202 label.className = "pt-[5px] text-[8px] text-gray uppercase whitespace-nowrap px-0.5"; 11203 label.textContent = "Pages"; 11204 wrapper.appendChild(label); 11205 const row = document.createElement("div"); 11206 row.className = "flex items-center gap-1.5"; 11207 const sortedScenes = [...this.scenes].sort((a, b) => a.order - b.order); 11208 sortedScenes.forEach((scene) => { 11209 const sceneEl = this.createSceneElement(scene); 11210 row.appendChild(sceneEl); 11211 }); 11212 if (this.scenes.length < 20 && this.isOwner) { 11213 const addButton = this.createAddSceneButton(); 11214 row.appendChild(addButton); 11215 } 11216 wrapper.appendChild(row); 11217 scenesList.appendChild(wrapper); 11218 if (existingIndicator) { 11219 scenesList.appendChild(existingIndicator); 11220 } 11221 this.updateSceneIndicator(); 11222 }, 11223 createSceneElement(scene) { 11224 const isActive = scene.id === this.currentSceneId; 11225 const sceneEl = document.createElement("div"); 11226 sceneEl.className = "scene-tab group/page relative shrink-0 cursor-pointer"; 11227 sceneEl.dataset.sceneId = scene.id; 11228 const innerBox = document.createElement("div"); 11229 innerBox.className = "bg-inside rounded overflow-hidden flex items-center justify-center transition duration-300 ease relative"; 11230 innerBox.style.height = "34px"; 11231 innerBox.style.width = "56px"; 11232 if (scene.thumbnail_url) { 11233 const img = document.createElement("img"); 11234 img.src = scene.thumbnail_url; 11235 img.alt = ""; 11236 img.className = "w-full h-full object-cover"; 11237 innerBox.appendChild(img); 11238 } else { 11239 const label = document.createElement("span"); 11240 label.className = "text-[9px] text-gray/50 group-hover/page:text-white transition duration-300 ease text-center"; 11241 const sortedScenes = [...this.scenes].sort((a, b) => a.order - b.order); 11242 const pageIndex = sortedScenes.findIndex((s) => s.id === scene.id); 11243 label.textContent = pageIndex + 1; 11244 innerBox.appendChild(label); 11245 } 11246 if (isActive && this.isOwner) { 11247 const menuButton = document.createElement("button"); 11248 menuButton.className = "absolute top-0.5 right-0.5 w-4 h-4 rounded flex items-center justify-center bg-black/50 text-white/70 hover:text-white opacity-0 group-hover/page:opacity-100 transition duration-200 ease cursor-pointer z-10"; 11249 menuButton.innerHTML = ` 11250 <svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 11251 <circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/> 11252 </svg> 11253 `; 11254 menuButton.onclick = (e) => { 11255 e.stopPropagation(); 11256 this.showSceneContextMenu(scene, sceneEl); 11257 }; 11258 innerBox.appendChild(menuButton); 11259 } 11260 sceneEl.appendChild(innerBox); 11261 sceneEl.addEventListener("mouseenter", () => { 11262 this.showPageTooltip(sceneEl, scene.name || "Untitled"); 11263 }); 11264 sceneEl.addEventListener("mouseleave", () => { 11265 this.hidePageTooltip(); 11266 }); 11267 sceneEl.onclick = () => { 11268 if (scene.id !== this.currentSceneId) { 11269 this.switchScene(scene.id); 11270 } 11271 }; 11272 return sceneEl; 11273 }, 11274 createAddSceneButton() { 11275 const button = document.createElement("button"); 11276 button.className = "w-7 bg-inside rounded flex items-center justify-center shrink-0 cursor-pointer text-gray/50 hover:text-white transition duration-300 ease"; 11277 button.style.height = "34px"; 11278 button.innerHTML = ` 11279 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 11280 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> 11281 </svg> 11282 `; 11283 button.onclick = () => this.createScene(); 11284 button.addEventListener("mouseenter", () => { 11285 this.showPageTooltip(button, "New Page"); 11286 }); 11287 button.addEventListener("mouseleave", () => { 11288 this.hidePageTooltip(); 11289 }); 11290 return button; 11291 }, 11292 updateSceneIndicator() { 11293 const scenesList = document.getElementById("scenes-list"); 11294 if (!scenesList) return; 11295 let indicator = scenesList.querySelector("[data-scene-indicator]"); 11296 if (!indicator) { 11297 indicator = document.createElement("div"); 11298 indicator.dataset.sceneIndicator = "true"; 11299 indicator.className = "flow-page-indicator"; 11300 scenesList.appendChild(indicator); 11301 } 11302 const activeEl = scenesList.querySelector( 11303 `[data-scene-id="${this.currentSceneId}"]` 11304 ); 11305 if (!activeEl) { 11306 indicator.style.opacity = "0"; 11307 return; 11308 } 11309 const containerRect = scenesList.getBoundingClientRect(); 11310 const elRect = activeEl.getBoundingClientRect(); 11311 const left = elRect.left - containerRect.left + scenesList.scrollLeft + 2; 11312 const width = elRect.width - 4; 11313 indicator.style.left = `${left}px`; 11314 indicator.style.width = `${width}px`; 11315 indicator.style.opacity = "1"; 11316 }, 11317 scrollSceneIntoView(sceneId) { 11318 const scenesList = document.getElementById("scenes-list"); 11319 if (!scenesList) return; 11320 const el = scenesList.querySelector(`[data-scene-id="${sceneId}"]`); 11321 if (!el) return; 11322 const elRect = el.getBoundingClientRect(); 11323 const containerRect = scenesList.getBoundingClientRect(); 11324 if (elRect.left < containerRect.left || elRect.right > containerRect.right) { 11325 const elCenter = el.offsetLeft - scenesList.offsetLeft + el.offsetWidth / 2; 11326 const scrollTarget = elCenter - scenesList.offsetWidth / 2; 11327 scenesList.scrollTo({ left: scrollTarget, behavior: "smooth" }); 11328 } 11329 }, 11330 showPageTooltip(el, text) { 11331 this.hidePageTooltip(); 11332 const tip = document.createElement("div"); 11333 tip.id = "page-tooltip"; 11334 tip.className = "fixed px-2.5 py-1.5 bg-box rounded shadow-lg text-[10px] text-white whitespace-nowrap pointer-events-none z-[9999] transition-opacity duration-150"; 11335 tip.textContent = text; 11336 document.body.appendChild(tip); 11337 const rect = el.getBoundingClientRect(); 11338 tip.style.top = `${rect.bottom + 11}px`; 11339 tip.style.left = `${rect.left + rect.width / 2 - tip.offsetWidth / 2}px`; 11340 }, 11341 hidePageTooltip() { 11342 const tip = document.getElementById("page-tooltip"); 11343 if (tip) tip.remove(); 11344 }, 11345 async createScene() { 11346 try { 11347 const response = await fetch(`/flow/${this.flowId}/scene`, { 11348 method: "POST", 11349 headers: { 11350 "Content-Type": "application/json", 11351 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 11352 }, 11353 body: JSON.stringify({ 11354 name: `Page ${this.scenes.length + 1}` 11355 }) 11356 }); 11357 const data = await response.json(); 11358 if (!response.ok) { 11359 const errorMessage = data.error || "Failed to create scene"; 11360 throw new Error(errorMessage); 11361 } 11362 const { scene } = data; 11363 const nodes = scene.nodes || []; 11364 this.scenes.push({ 11365 id: scene.id, 11366 name: scene.name, 11367 order: scene.order, 11368 thumbnail_url: scene.thumbnail_url || null 11369 }); 11370 this.renderScenesList(); 11371 this.currentSceneId = scene.id; 11372 this.sceneId = scene.id; 11373 window.history.replaceState(null, "", `#${scene.id}`); 11374 this.clearAllNodes(); 11375 if (nodes.length > 0) { 11376 this.loadNodesData(nodes); 11377 requestAnimationFrame(() => { 11378 if (this.repositionTree) { 11379 this.repositionTree(); 11380 } 11381 if (this.updateConnections) { 11382 this.updateConnections(); 11383 } 11384 if (this.fitToScreenNoAnimation) { 11385 this.fitToScreenNoAnimation(); 11386 } 11387 }); 11388 } 11389 this.renderScenesList(); 11390 if (this.centerOnRoot) { 11391 setTimeout(() => { 11392 this.centerOnRoot(); 11393 }, 100); 11394 } 11395 window.Toast.show("Page created", "success"); 11396 } catch (error) { 11397 console.error("Error creating scene:", error); 11398 window.Toast.show(error.message || "Failed to create page", "alert"); 11399 } 11400 }, 11401 async switchScene(sceneUuid) { 11402 window.location.hash = sceneUuid; 11403 }, 11404 async loadSceneNodes(sceneUuid) { 11405 try { 11406 if (this.viewport) { 11407 this.viewport.style.transition = "opacity 200ms ease-out"; 11408 this.viewport.style.opacity = "0"; 11409 await new Promise((resolve) => setTimeout(resolve, 200)); 11410 } 11411 const response = await fetch( 11412 `/flow/${this.flowId}/scene/${sceneUuid}/nodes` 11413 ); 11414 if (!response.ok) throw new Error("Failed to load scene nodes"); 11415 const data = await response.json(); 11416 this.currentSceneId = sceneUuid; 11417 this.sceneId = sceneUuid; 11418 window.history.replaceState(null, "", `#${sceneUuid}`); 11419 this.clearAllNodes(); 11420 if (data.nodes && data.nodes.length > 0) { 11421 this.loadNodesData(data.nodes); 11422 requestAnimationFrame(() => { 11423 if (this.repositionTree) { 11424 this.repositionTree(); 11425 } 11426 if (this.updateConnections) { 11427 this.updateConnections(); 11428 } 11429 if (this.fitToScreenNoAnimation) { 11430 this.fitToScreenNoAnimation(); 11431 } 11432 }); 11433 } else { 11434 if (data.scene) { 11435 this.panX = data.scene.pan_x || 0; 11436 this.panY = data.scene.pan_y || 0; 11437 this.zoom = data.scene.zoom || 1; 11438 } 11439 if (this.updateTransform) { 11440 this.updateTransform(); 11441 } 11442 if (this.updateZoomDisplay) { 11443 this.updateZoomDisplay(); 11444 } 11445 } 11446 this.renderScenesList(); 11447 this.scrollSceneIntoView(sceneUuid); 11448 this.autoGenerateSceneName(data.scene, data.nodes); 11449 if (this.viewport) { 11450 this.viewport.style.transition = "opacity 200ms ease-in"; 11451 this.viewport.style.opacity = "1"; 11452 } 11453 } catch (error) { 11454 console.error("Error loading scene nodes:", error); 11455 window.Toast.show("Failed to load page", "alert"); 11456 if (this.viewport) { 11457 this.viewport.style.opacity = "1"; 11458 } 11459 } 11460 }, 11461 loadNodesData(nodesData) {
11462 if (!nodesData || nodesData.length === 0) return; 11463 nodesData.forEach((nodeData) => { 11464 this.createNodeFromData(nodeData); 11465 }); 11466 this.repositionTree(); 11467 this.updateConnections(); 11468 }, 11469 scheduleAutoGenerateName(sceneUuid) { 11470 setTimeout(() => { 11471 this.tryAutoGenerateNameFromDOM(sceneUuid); 11472 }, 100); 11473 }, 11474 async tryAutoGenerateNameFromDOM(sceneUuid) { 11475 const scene = this.scenes.find((s) => s.id === sceneUuid); 11476 if (!scene || !scene.name) return; 11477 if (scene.name === "Main") return; 11478 const hasDefaultName = /^(Scene|Page) \d+$/.test(scene.name); 11479 if (!hasDefaultName) return; 11480 const dataElement = document.getElementById("flow-items-data"); 11481 if (!dataElement) return; 11482 try { 11483 const nodesData = JSON.parse(dataElement.textContent); 11484 if (!nodesData || nodesData.length === 0) return; 11485 const rootNode = nodesData.find((node) => node.node_type === "root"); 11486 if (!rootNode || !rootNode.prompt) return; 11487 await this.generateSceneName(sceneUuid); 11488 } catch (error) { 11489 console.error("Error in tryAutoGenerateNameFromDOM:", error); 11490 } 11491 }, 11492 async autoGenerateSceneName(sceneData, nodesData) { 11493 if (!sceneData || !sceneData.name) return; 11494 if (sceneData.name === "Main") return; 11495 const hasDefaultName = /^(Scene|Page) \d+$/.test(sceneData.name); 11496 if (!hasDefaultName) return; 11497 const rootNode = nodesData.find((node) => node.node_type === "root"); 11498 if (!rootNode || !rootNode.prompt) return; 11499 await this.generateSceneName(sceneData.id); 11500 }, 11501 async generateSceneName(sceneUuid) { 11502 try { 11503 const response = await fetch(`/flow/${this.flowId}/scene/${sceneUuid}/name`, { 11504 method: "POST", 11505 headers: { 11506 "Content-Type": "application/json", 11507 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 11508 } 11509 }); 11510 if (!response.ok) return; 11511 const data = await response.json(); 11512 if (data.success && data.name) { 11513 const scene = this.scenes.find((s) => s.id === sceneUuid); 11514 if (scene) { 11515 scene.name = data.name; 11516 this.renderScenesList(); 11517 } 11518 if (data.flow_name) { 11519 const flowNameDisplay = document.getElementById("flow-name-display"); 11520 if (flowNameDisplay) { 11521 flowNameDisplay.textContent = data.flow_name; 11522 } 11523 } 11524 } 11525 } catch (error) { 11526 console.error("Error generating scene name:", error); 11527 } 11528 }, 11529 bindSceneEvents() { 11530 this.hashChangeHandler = () => { 11531 const sceneUuid = window.location.hash.substring(1); 11532 if (sceneUuid && sceneUuid !== this.currentSceneId) { 11533 const sceneExists = this.scenes.some((s) => s.id === sceneUuid); 11534 if (sceneExists) { 11535 this.loadSceneNodes(sceneUuid); 11536 } else { 11537 console.warn("Scene not found:", sceneUuid); 11538 window.history.replaceState(null, "", `#${this.currentSceneId}`); 11539 if (window.Toast) { 11540 window.Toast.show("Page not found", "alert"); 11541 } 11542 } 11543 } 11544 }; 11545 if (this.addListener) { 11546 this.addListener(window, "hashchange", this.hashChangeHandler); 11547 } else { 11548 window.addEventListener("hashchange", this.hashChangeHandler); 11549 } 11550 document.addEventListener("click", () => { 11551 this.closeSceneContextMenu(); 11552 }); 11553 const scenesList = document.getElementById("scenes-list"); 11554 if (scenesList) { 11555 scenesList.addEventListener("scroll", () => { 11556 this.updateSceneIndicator(); 11557 }); 11558 } 11559 }, 11560 showSceneContextMenu(scene, sceneElement) { 11561 this.closeSceneContextMenu(); 11562 const menu = document.createElement("div"); 11563 menu.id = "scene-context-menu"; 11564 menu.className = "w-40 bg-box rounded-sm shadow-lg overflow-hidden"; 11565 menu.style.cssText = "position: fixed; z-index: 9999;"; 11566 const rect = sceneElement.getBoundingClientRect(); 11567 menu.style.top = "67px"; 11568 menu.style.left = `${rect.left - 7}px`; 11569 let items = '<div class="py-1">'; 11570 items += ` 11571 <button data-page-action="rename" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer"> 11572 <svg class="w-3.5 h-3.5 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 11573 <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/> 11574 </svg> 11575 Rename 11576 </button> 11577 `; 11578 items += `
11579 <button data-page-action="duplicate" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer"> 11580 <svg class="w-3.5 h-3.5 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 11581 <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/> 11582 </svg> 11583 Duplicate 11584 </button> 11585 `; 11586 if (this.scenes.length > 1) { 11587 items += '<div class="my-1 border-t border-white/5"></div>'; 11588 items += ` 11589 <button data-page-action="delete" class="flex items-center w-full px-3 py-2 text-xs text-red-400 hover:text-red-300 hover:bg-inside transition duration-300 cursor-pointer"> 11590 <svg class="w-3.5 h-3.5 mr-2 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 11591 <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/> 11592 </svg> 11593 Delete 11594 </button> 11595 `; 11596 } 11597 items += "</div>"; 11598 menu.innerHTML = items; 11599 document.body.appendChild(menu); 11600 menu.querySelector('[data-page-action="rename"]')?.addEventListener("click", (e) => { 11601 e.stopPropagation(); 11602 this.closeSceneContextMenu(); 11603 this.showRenameDialog(scene); 11604 }); 11605 menu.querySelector('[data-page-action="duplicate"]')?.addEventListener("click", (e) => { 11606 e.stopPropagation(); 11607 this.closeSceneContextMenu(); 11608 this.duplicateScene(scene); 11609 }); 11610 menu.querySelector('[data-page-action="delete"]')?.addEventListener("click", (e) => { 11611 e.stopPropagation(); 11612 this.closeSceneContextMenu(); 11613 this.confirmDeleteScene(scene); 11614 }); 11615 menu.addEventListener("click", (e) => e.stopPropagation()); 11616 }, 11617 closeSceneContextMenu() { 11618 const menu = document.getElementById("scene-context-menu"); 11619 if (menu) { 11620 menu.remove(); 11621 } 11622 }, 11623 showRenameDialog(scene) { 11624 this.sceneToRename = scene; 11625 this.loadRenameSceneModal(scene.id); 11626 }, 11627 loadRenameSceneModal(sceneUuid) { 11628 const url = `/flow/${this.flowId}/scene/${sceneUuid}/rename`; 11629 fetch(url, { 11630 headers: { 11631 Accept: "text/html", 11632 "X-Requested-With": "XMLHttpRequest" 11633 } 11634 }).then((response) => response.text()).then((html) => { 11635 const existingModal = document.getElementById("rename-scene-modal"); 11636 if (existingModal) { 11637 existingModal.remove(); 11638 } 11639 document.body.insertAdjacentHTML("beforeend", html); 11640 this.setupRenameSceneModal(); 11641 if (window.Modal) { 11642 window.Modal.open("rename-scene-modal"); 11643 } 11644 }).catch((error) => { 11645 console.error("Error loading rename scene modal:", error); 11646 if (window.Toast) { 11647 window.Toast.show("Failed to load rename modal", "alert"); 11648 } 11649 }); 11650 }, 11651 setupRenameSceneModal() { 11652 const confirmBtn = document.querySelector("[data-rename-scene-confirm]"); 11653 const input = document.getElementById("rename-scene-input"); 11654 if (confirmBtn && input) { 11655 input.addEventListener("keydown", (e) => { 11656 if (e.key === "Enter") { 11657 e.preventDefault(); 11658 confirmBtn.click(); 11659 } 11660 }); 11661 confirmBtn.addEventListener("click", async () => { 11662 if (!this.sceneToRename) return; 11663 const newName = input.value.trim(); 11664 if (!newName) { 11665 if (window.Toast) { 11666 window.Toast.show("Page name cannot be empty", "alert"); 11667 } 11668 return; 11669 } 11670 await this.renameScene(this.sceneToRename.id, newName); 11671 window.Modal?.close(); 11672 }); 11673 } 11674 }, 11675 async renameScene(sceneUuid, newName) { 11676 try { 11677 const response = await fetch( 11678 `/flow/${this.flowId}/scene/${sceneUuid}/rename`, 11679 { 11680 method: "PATCH", 11681 headers: { 11682 "Content-Type": "application/json", 11683 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 11684 }, 11685 body: JSON.stringify({ name: newName }) 11686 } 11687 ); 11688 if (!response.ok) throw new Error("Failed to rename scene"); 11689 const scene = this.scenes.find((s) =>
11689 s.id === sceneUuid); 11690 if (scene) { 11691 scene.name = newName; 11692 this.renderScenesList(); 11693 } 11694 window.Toast.show("Page renamed", "success"); 11695 } catch (error) { 11696 console.error("Error renaming scene:", error); 11697 window.Toast.show("Failed to rename page", "alert"); 11698 } 11699 }, 11700 confirmDeleteScene(scene) { 11701 this.sceneToDelete = scene; 11702 this.loadDeleteSceneModal(scene.id); 11703 }, 11704 loadDeleteSceneModal(sceneUuid) { 11705 const url = `/flow/${this.flowId}/scene/${sceneUuid}/delete`; 11706 fetch(url, { 11707 headers: { 11708 Accept: "text/html", 11709 "X-Requested-With": "XMLHttpRequest" 11710 } 11711 }).then((response) => response.text()).then((html) => { 11712 const existingModal = document.getElementById("delete-scene-modal"); 11713 if (existingModal) { 11714 existingModal.remove(); 11715 } 11716 document.body.insertAdjacentHTML("beforeend", html); 11717 this.setupDeleteSceneModal(); 11718 if (window.Modal) { 11719 window.Modal.open("delete-scene-modal"); 11720 } 11721 }).catch((error) => { 11722 console.error("Error loading delete scene modal:", error); 11723 if (window.Toast) { 11724 window.Toast.show("Failed to load delete modal", "alert"); 11725 } 11726 }); 11727 }, 11728 setupDeleteSceneModal() { 11729 const confirmBtn = document.querySelector("[data-delete-scene-confirm]"); 11730 if (confirmBtn) { 11731 confirmBtn.addEventListener("click", async () => { 11732 if (!this.sceneToDelete) return; 11733 await this.deleteScene(this.sceneToDelete.id); 11734 window.Modal?.close(); 11735 }); 11736 } 11737 }, 11738 async deleteScene(sceneUuid) { 11739 try { 11740 const response = await fetch(`/flow/${this.flowId}/scene/${sceneUuid}`, { 11741 method: "DELETE", 11742 headers: { 11743 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 11744 } 11745 }); 11746 if (!response.ok) throw new Error("Failed to delete scene"); 11747 const index = this.scenes.findIndex((s) => s.id === sceneUuid); 11748 if (index !== -1) { 11749 this.scenes.splice(index, 1); 11750 } 11751 if (sceneUuid === this.currentSceneId && this.scenes.length > 0) { 11752 const firstScene = this.scenes.sort((a, b) => a.order - b.order)[0]; 11753 await this.switchScene(firstScene.id); 11754 } 11755 this.renderScenesList(); 11756 window.Toast.show("Page deleted", "success"); 11757 } catch (error) { 11758 console.error("Error deleting scene:", error); 11759 window.Toast.show("Failed to delete page", "alert"); 11760 } 11761 }, 11762 async duplicateScene(scene) { 11763 if (this.scenes.length >= 20) { 11764 window.Toast.show("Maximum of 20 pages allowed per flow", "alert"); 11765 return; 11766 } 11767 try { 11768 const response = await fetch(`/flow/${this.flowId}/scene/${scene.id}/duplicate`, { 11769 method: "POST", 11770 headers: { 11771 "Content-Type": "application/json", 11772 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 11773 } 11774 }); 11775 const data = await response.json(); 11776 if (!response.ok) { 11777 const errorMessage = data.error || "Failed to duplicate scene"; 11778 throw new Error(errorMessage); 11779 } 11780 const { scene: newScene } = data; 11781 const nodes = newScene.nodes || []; 11782 this.scenes.push({ 11783 id: newScene.id, 11784 name: newScene.name, 11785 order: newScene.order, 11786 thumbnail_url: newScene.thumbnail_url || null 11787 }); 11788 this.renderScenesList(); 11789 this.currentSceneId = newScene.id; 11790 this.sceneId = newScene.id; 11791 window.history.replaceState(null, "", `#${newScene.id}`); 11792 this.clearAllNodes(); 11793 if (nodes.length > 0) { 11794 this.loadNodesData(nodes); 11795 requestAnimationFrame(() => { 11796 if (this.repositionTree) { 11797 this.repositionTree(); 11798 } 11799 if (this.updateConnections) { 11800 this.updateConnections(); 11801 } 11802 if (this.fitToScreenNoAnimation) { 11803 this.fitToScreenNoAnimation(); 11804 } 11805 }); 11806 } 11807 this.renderScenesList(); 11808 window.Toast.show("Page duplicated", "success"); 11809 } catch (error) { 11810 console.error("Error duplicating scene:", error); 11811 window.Toast.show(error.message || "Failed to duplicate page", "alert"); 11812 } 11813 }, 11814 updateSceneThumbnail(sceneUuid, thumbnailUrl) { 11815 if (!sceneUuid || !thumbnailUrl) return; 11816 const scene = this.scenes.find((s) =>
11816 s.id === sceneUuid); 11817 if (!scene) return; 11818 scene.thumbnail_url = thumbnailUrl; 11819 this.renderScenesList(); 11820 } 11821}; 11822
11823// app/javascript/flow/dropdowns.js 11824var aiCreditsIcon = `<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 11825 <path d="M12 1L14.5 8.5L22 11L14.5 13.5L12 21L9.5 13.5L2 11L9.5 8.5L12 1Z"/> 11826</svg>`; 11827function formatCredits(credits) { 11828 return Math.floor(credits).toLocaleString("en-US"); 11829} 11830var DropdownsMixin = { 11831 initDropdowns() { 11832 const modelsData = document.getElementById("flow-models-data"); 11833 const videoModelsData = document.getElementById("flow-video-models-data"); 11834 if (modelsData) { 11835 try { 11836 this.availableModels = JSON.parse(modelsData.textContent); 11837 } catch (e) { 11838 console.error("Error parsing models data:", e); 11839 this.availableModels = []; 11840 } 11841 } 11842 if (videoModelsData) { 11843 try { 11844 this.availableVideoModels = JSON.parse(videoModelsData.textContent); 11845 } catch (e) { 11846 console.error("Error parsing video models data:", e); 11847 this.availableVideoModels = []; 11848 } 11849 } 11850 }, 11851 bindNodeDropdowns(nodeEl, nodeData) { 11852 const isRootNode = nodeData.nodeData?.level === 0 && (nodeData.nodeData?.node_type === "root" || !nodeData.nodeData?.node_type); 11853 if (isRootNode) { 11854 this.bindModeToggle(nodeEl, nodeData); 11855 return; 11856 } 11857 const modeToggle = nodeEl.querySelector("[data-flow-mode-toggle]"); 11858 if (modeToggle) { 11859 modeToggle.style.display = "none"; 11860 } 11861 const isVideoNode = nodeData.nodeData && (nodeData.nodeData.node_type?.includes("animate") || nodeData.nodeData.node_type === "video"); 11862 const isInitNode = !nodeData.nodeData?.status || nodeData.nodeData.status === "init"; 11863 const initialRatio = nodeData.nodeData?.ratio || (isVideoNode ? "16:9" : "4:3"); 11864 let initialModel = nodeData.nodeData?.model; 11865 if (isInitNode && isVideoNode) { 11866 const savedModel = localStorage.getItem("flow-video-model"); 11867 if (savedModel && this.availableVideoModels?.some((m) => m.name === savedModel)) { 11868 initialModel = savedModel; 11869 } else if (this.availableVideoModels?.length > 0) { 11870 initialModel = this.availableVideoModels[0].name; 11871 } 11872 } 11873 this.bindModelDropdown(nodeEl, initialModel, isVideoNode, isInitNode); 11874 if (isVideoNode) { 11875 const ratioContainer = nodeEl.querySelector( 11876 "[data-flow-ratio-container]" 11877 ); 11878 const settingsContainer = nodeEl.querySelector( 11879 "[data-flow-video-settings-container]" 11880 ); 11881 const counter = nodeEl.querySelector("[data-flow-counter]"); 11882 const countEl = nodeEl.querySelector("[data-flow-count]"); 11883 if (ratioContainer) ratioContainer.style.display = "none"; 11884 if (settingsContainer) settingsContainer.style.display = "block"; 11885 if (counter) { 11886 counter.style.removeProperty("visibility"); 11887 } 11888 const initialDuration = nodeData.nodeData?.duration || "4s"; 11889 const initialAudio = nodeData.nodeData?.audio !== false; 11890 this.bindVideoSettingsDropdown( 11891 nodeEl, 11892 initialRatio, 11893 initialDuration, 11894 initialModel, 11895 initialAudio, 11896 isInitNode 11897 ); 11898 if (this.updateRefBadgesDisplay) { 11899 this.updateRefBadgesDisplay(nodeEl); 11900 } 11901 } else { 11902 const ratioContainer = nodeEl.querySelector( 11903 "[data-flow-ratio-container]" 11904 ); 11905 const settingsContainer = nodeEl.querySelector( 11906 "[data-flow-video-settings-container]" 11907 ); 11908 const counter = nodeEl.querySelector("[data-flow-counter]"); 11909 if (ratioContainer) ratioContainer.style.display = "block"; 11910 if (settingsContainer) settingsContainer.style.display = "none"; 11911 if (counter) { 11912 counter.style.removeProperty("visibility"); 11913 } 11914 this.bindRatioDropdown(nodeEl, initialRatio, isVideoNode, false); 11915 } 11916 this.bindStyleDropdown(nodeEl, nodeData); 11917 }, 11918 bindModelDropdown(nodeEl, initialModel, isVideoNode = false, isInitNode = true) { 11919 const modelBtn = nodeEl.querySelector("[data-flow-model-btn]"); 11920 const dropdown = nodeEl.querySelector("[data-flow-model-dropdown]"); 11921 const labelEl = nodeEl.querySelector("[data-flow-model-label]"); 11922 const optionsContainer = nodeEl.querySelector("[data-flow-model-options]"); 11923 if (!modelBtn || !dropdown || !optionsContainer) return; 11924 const models = isVideoNode ? this.availableVideoModels : this.availableModels; 11925 if (!models || models.length === 0) return; 11926 const alreadyBound = modelBtn.dataset.modelBound === "true"; 11927 const currentMode = modelBtn.dataset.modelMode; 11928 const newMode = isVideoNode ? "video" : "image"; 11929 const modeChanged = currentMode && currentMode !== newMode; 11930 let actualDropdown = dropdown; 11931 let actualOptionsContainer = optionsContainer; 11932 let actualModelBtn = modelBtn; 11933 let actualLabelEl = labelEl; 11934 if (modeChanged) { 11935 const dropdownClone = dropdown.cloneNode(true); 11936 dropdown.parentNode.replaceChild(dropdownClone, dropdown); 11937 actualDropdown = dropdownClone; 11938 actualOptionsContainer = nodeEl.querySelector( 11939 "[data-flow-model-options]" 11940 ); 11941 actualLabelEl = nodeEl.querySelector("[data-flow-model-label]"); 11942 actualModelBtn.dataset.modelBound = "false"; 11943 } 11944 actualModelBtn.dataset.modelMode = newMode; 11945 const titleHtml = isVideoNode ? ` 11946 <div class="px-2 pt-2 pb-2"> 11947 <div class="flex items-center justify-between"> 11948 <div class="text-[10px] text-gray uppercase tracking-wider font-medium">Model</div> 11949 <div class="flex gap-7"> 11950 <div class="text-[9px] text-gray text-center min-w-13">No Audio</div> 11951 <div class="text-[9px] text-gray text-center min-w-13">With Audio</div> 11952 </div> 11953 </div> 11954 <div class="border-t border-light/30 mt-2"></div> 11955 </div> 11956 ` : ` 11957 <div class="px-2 pt-2 pb-2"> 11958 <div class="text-[10px] text-gray uppercase tracking-wider font-medium">Model</div> 11959 <div class="border-t border-light/30 mt-2"></div> 11960 </div> 11961 `;
11962 let currentAudio = true; 11963 if (isVideoNode) { 11964 if (isInitNode) { 11965 const savedAudio = localStorage.getItem("flow-video-audio"); 11966 currentAudio = savedAudio !== "false"; 11967 } 11968 if (!nodeEl.dataset.videoAudio) { 11969 nodeEl.dataset.videoAudio = currentAudio.toString(); 11970 } 11971 } 11972 const modelsHtml = models.map((model) => { 11973 const description = model.description || ""; 11974 const cost = parseFloat(model.cost) || 0; 11975 const costWithoutAudio = parseFloat(model.cost_without_audio) || 0; 11976 const supportsAudio = model.supports_audio !== false; 11977 if (isVideoNode && supportsAudio && costWithoutAudio > 0) { 11978 const priceWithAudio = cost > 0 ? `${aiCreditsIcon} ${formatCredits(cost)}/s` : "FREE"; 11979 const priceWithoutAudio = costWithoutAudio > 0 ? `${aiCreditsIcon} ${formatCredits(costWithoutAudio)}/s` : "FREE"; 11980 return ` 11981 <button class="w-full text-left px-4 py-3 text-gray hover:text-white hover:bg-light/50 rounded cursor-pointer transition duration-300 ease" data-model="${model.name}" data-cost="${cost}
11981" data-cost-without-audio="${costWithoutAudio}" data-supports-audio="${supportsAudio}"> 11982 <div class="flex items-center justify-between gap-4"> 11983 <div class="flex-1 min-w-0"> 11984 <div class="text-white text-sm font-medium mb-0.5">${model.display_name || model.name}</div> 11985 ${description ? `<div class="text-[11px] text-gray/70">${description}</div>` : ""} 11986 </div> 11987 <div class="flex gap-3 flex-shrink-0"> 11988 <div class="flex items-center gap-0.5 text-gray text-xs font-medium text-center min-w-13">${priceWithoutAudio}</div> 11989 <div class="flex justify-end items-center gap-0.5 text-indigo-400 text-xs font-medium text-center min-w-13">${priceWithAudio}</div> 11990 </div> 11991 </div> 11992 </button> 11993 `; 11994 } else { 11995 let displayCost = cost; 11996 let pricingText = ""; 11997 if (displayCost === 0) { 11998 pricingText = "FREE"; 11999 } else if (displayCost > 0) { 12000 pricingText = isVideoNode ? `${aiCreditsIcon} ${formatCredits(displayCost)}/s` : `${aiCreditsIcon} ${formatCredits(displayCost)}`; 12001 } 12002 const nameHtml = `<div class="text-white text-sm font-medium mb-0.5">${model.display_name || model.name}</div>`; 12003 const descriptionHtml = description ? `<div class="text-[11px] text-gray/70">${description}</div>` : ""; 12004 return ` 12005 <button class="w-full text-left px-4 py-3 text-gray hover:text-white hover:bg-light/50 rounded cursor-pointer transition duration-300 ease" data-model="${model.name}" data-cost="${cost}
12005" data-cost-without-audio="${costWithoutAudio}" data-supports-audio="${supportsAudio}"> 12006 <div class="flex items-center justify-between gap-4"> 12007 <div class="flex-1 min-w-0"> 12008 ${nameHtml} 12009 ${descriptionHtml} 12010 </div> 12011 <div class="flex-shrink-0"> 12012 ${pricingText ? `<div class="flex justify-end items-center gap-0.5 text-indigo-400 text-xs font-medium text-center min-w-13" data-model-price="${model.name}">${pricingText}</div>` : ""} 12013 </div> 12014 </div> 12015 </button> 12016 `; 12017 } 12018 }).join(""); 12019 actualOptionsContainer.innerHTML = titleHtml + modelsHtml; 12020 let selectedModel; 12021 if (isInitNode) { 12022 const storageKey = isVideoNode ? "flow-video-model" : "flow-image-model"; 12023 const savedModel = localStorage.getItem(storageKey); 12024 if (savedModel && models.some((m) => m.name === savedModel)) { 12025 selectedModel = savedModel; 12026 } else { 12027 selectedModel = models.length > 0 ? models[0].name : null; 12028 } 12029 } else { 12030 selectedModel = initialModel || (models.length > 0 ? models[0].name : null); 12031 } 12032 if (actualModelBtn.dataset.modelBound !== "true") { 12033 actualModelBtn.dataset.modelBound = "true"; 12034 actualModelBtn.addEventListener("click", (e) => { 12035 e.stopPropagation(); 12036 const isHidden = actualDropdown.classList.contains("hidden"); 12037 actualDropdown.classList.toggle("hidden"); 12038 const ratioDropdown = nodeEl.querySelector( 12039 "[data-flow-ratio-dropdown]" 12040 ); 12041 const durationDropdown = nodeEl.querySelector( 12042 "[data-flow-duration-dropdown]" 12043 ); 12044 const videoSettingsDropdown = nodeEl.querySelector( 12045 "[data-flow-video-settings-dropdown]" 12046 ); 12047 if (ratioDropdown) ratioDropdown.classList.add("hidden"); 12048 if (durationDropdown) durationDropdown.classList.add("hidden"); 12049 if (videoSettingsDropdown) 12050 videoSettingsDropdown.classList.add("hidden"); 12051 if (isHidden) { 12052 setTimeout(() => { 12053 const closeHandler = (e2) => { 12054 if (!nodeEl.contains(e2.target)) { 12055 actualDropdown.classList.add("hidden"); 12056 document.removeEventListener("click", closeHandler); 12057 } 12058 }; 12059 document.addEventListener("click", closeHandler); 12060 }, 0); 12061 } 12062 }); 12063 actualOptionsContainer.addEventListener("click", (e) => { 12064 const option = e.target.closest("[data-model]"); 12065 if (!option) return; 12066 e.stopPropagation(); 12067 selectedModel = option.dataset.model; 12068 const modelData = models.find((m) => m.name === selectedModel); 12069 const label = modelData ? modelData.display_name || modelData.name : selectedModel; 12070 const cost = parseFloat(option.dataset.cost) || 0; 12071 const costWithoutAudio = parseFloat(option.dataset.costWithoutAudio) || 0; 12072 const supportsAudio = option.dataset.supportsAudio !== "false"; 12073 const audioEnabled = nodeEl.dataset.videoAudio !== "false"; 12074 let displayCost = cost; 12075 if (isVideoNode && supportsAudio && costWithoutAudio > 0 && !audioEnabled) { 12076 displayCost = costWithoutAudio; 12077 } 12078 actualLabelEl.textContent = label.toUpperCase(); 12079 const pricingEl = nodeEl.querySelector("[data-flow-pricing]"); 12080 if (pricingEl) { 12081 if (displayCost === 0) { 12082 pricingEl.innerHTML = "FREE"; 12083 } else if (isVideoNode && displayCost > 0) { 12084 const durationStr = nodeEl.dataset.selectedDuration || "4s"; 12085 const seconds = parseInt(durationStr.replace("s", "")) || 4; 12086 const totalPrice = displayCost * seconds; 12087 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits( 12088 totalPrice 12089 )}`; 12090 } else if (displayCost > 0) { 12091 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits( 12092 displayCost 12093 )}`; 12094 } 12095 } 12096 nodeEl.dataset.selectedModel = selectedModel; 12097 actualDropdown.classList.add("hidden"); 12098 const storageKey = isVideoNode ? "flow-video-model" : "flow-image-model"; 12099 localStorage.setItem(storageKey, selectedModel); 12100 if (isVideoNode && this.updateRefBadgesDisplay) { 12101 this.updateRefBadgesDisplay(nodeEl); 12102 } 12103 if (isVideoNode) { 12104 const currentRatio = nodeEl.dataset.selectedRatio || "16:9"; 12105 const currentDuration = nodeEl.dataset.selectedDuration || "4s"; 12106 const currentAudio2 = nodeEl.dataset.videoAudio !== "fal
12106se"; 12107 this.bindVideoSettingsDropdown( 12108 nodeEl, 12109 currentRatio, 12110 currentDuration, 12111 selectedModel, 12112 currentAudio2 12113 ); 12114 } else { 12115 this.updateRatioOptionsForModel(nodeEl); 12116 } 12117 }); 12118 } 12119 if (selectedModel) { 12120 const modelData = models.find((m) => m.name === selectedModel); 12121 const label = modelData ? modelData.display_name || modelData.name : selectedModel; 12122 const selectedOption = actualOptionsContainer.querySelector( 12123 `[data-model="${selectedModel}"]` 12124 ); 12125 const cost = selectedOption ? parseFloat(selectedOption.dataset.cost) || 0 : 0; 12126 const costWithoutAudio = selectedOption ? parseFloat(selectedOption.dataset.costWithoutAudio) || 0 : 0; 12127 const supportsAudio = selectedOption ? selectedOption.dataset.supportsAudio !== "false" : true; 12128 const audioEnabled = nodeEl.dataset.videoAudio !== "false"; 12129 let displayCost = cost; 12130 if (isVideoNode && supportsAudio && costWithoutAudio > 0 && !audioEnabled) { 12131 displayCost = costWithoutAudio; 12132 } 12133 actualLabelEl.textContent = label.toUpperCase(); 12134 const pricingEl = nodeEl.querySelector("[data-flow-pricing]"); 12135 if (pricingEl) { 12136 if (displayCost === 0) { 12137 pricingEl.innerHTML = "FREE"; 12138 } else if (isVideoNode && displayCost > 0) { 12139 const durationStr = nodeEl.dataset.selectedDuration || "4s"; 12140 const seconds = parseInt(durationStr.replace("s", "")) || 4; 12141 const totalPrice = displayCost * seconds; 12142 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits(totalPrice)}`; 12143 } else if (displayCost > 0) { 12144 const countEl = nodeEl.querySelector("[data-flow-count]"); 12145 const count = countEl ? parseInt(countEl.textContent) || 1 : 1; 12146 const totalPrice = displayCost * count; 12147 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits(totalPrice)}`; 12148 } 12149 } 12150 nodeEl.dataset.selectedModel = selectedModel; 12151 if (!isVideoNode) { 12152 this.updateRatioOptionsForModel(nodeEl); 12153 } 12154 } 12155 nodeEl.updateModelPricing = () => { 12156 this.updatePricingDisplay(nodeEl); 12157 }; 12158 }, 12159 bindRatioDropdown(nodeEl, initialRatio = "4:3", isVideoNode = false, isInitNode = true) { 12160 const ratioBtn = nodeEl.querySelector("[data-flow-ratio-btn]"); 12161 const dropdown = nodeEl.querySelector("[data-flow-ratio-dropdown]"); 12162 const iconContainer = nodeEl.querySelector("[data-flow-ratio-icon]"); 12163 const labelEl = nodeEl.querySelector("[data-flow-ratio-label]"); 12164 let ratioOptions = nodeEl.querySelectorAll("[data-ratio]"); 12165 if (!ratioBtn || !dropdown) return;
12166 const alreadyBound = ratioBtn.dataset.ratioBound === "true"; 12167 let actualRatioBtn = ratioBtn; 12168 let actualDropdown = dropdown; 12169 let actualIconContainer = iconContainer; 12170 let actualLabelEl = labelEl; 12171 if (alreadyBound) { 12172 const btnClone = ratioBtn.cloneNode(true); 12173 ratioBtn.parentNode.replaceChild(btnClone, ratioBtn); 12174 actualRatioBtn = btnClone; 12175 const dropdownClone = dropdown.cloneNode(true); 12176 dropdown.parentNode.replaceChild(dropdownClone, dropdown); 12177 actualDropdown = dropdownClone; 12178 actualIconContainer = actualRatioBtn.querySelector( 12179 "[data-flow-ratio-icon]" 12180 ); 12181 actualLabelEl = actualRatioBtn.querySelector("[data-flow-ratio-label]"); 12182 ratioOptions = actualDropdown.querySelectorAll("[data-ratio]"); 12183 } 12184 actualRatioBtn.dataset.ratioBound = "true"; 12185 let selectedRatio; 12186 if (isInitNode) { 12187 const storageKey = isVideoNode ? "flow-video-ratio" : "flow-image-ratio"; 12188 const savedRatio = localStorage.getItem(storageKey); 12189 if (savedRatio) { 12190 const validOption = Array.from(ratioOptions).find( 12191 (opt) => opt.dataset.ratio === savedRatio 12192 ); 12193 selectedRatio = validOption ? savedRatio : initialRatio; 12194 } else { 12195 selectedRatio = initialRatio; 12196 } 12197 } else { 12198 selectedRatio = initialRatio; 12199 } 12200 actualRatioBtn.addEventListener("click", (e) => { 12201 e.stopPropagation(); 12202 const isHidden = actualDropdown.classList.contains("hidden"); 12203 actualDropdown.classList.toggle("hidden"); 12204 const modelDropdown = nodeEl.querySelector("[data-flow-model-dropdown]"); 12205 const durationDropdown = nodeEl.querySelector( 12206 "[data-flow-duration-dropdown]" 12207 ); 12208 if (modelDropdown) modelDropdown.classList.add("hidden"); 12209 if (durationDropdown) durationDropdown.classList.add("hidden"); 12210 if (isHidden) { 12211 setTimeout(() => { 12212 const closeHandler = (e2) => { 12213 if (!nodeEl.contains(e2.target)) { 12214 actualDropdown.classList.add("hidden"); 12215 document.removeEventListener("click", closeHandler); 12216 } 12217 }; 12218 document.addEventListener("click", closeHandler); 12219 }, 0); 12220 } 12221 }); 12222 ratioOptions.forEach((option) => { 12223 option.addEventListener("click", (e) => { 12224 e.stopPropagation(); 12225 selectedRatio = option.dataset.ratio; 12226 const label = option.dataset.ratioLabel; 12227 const svg = option.querySelector('svg, [class*="lucide"]'); 12228 if (svg) { 12229 actualIconContainer.innerHTML = svg.outerHTML; 12230 } 12231 actualLabelEl.textContent = label; 12232 nodeEl.dataset.selectedRatio = selectedRatio; 12233 actualDropdown.classList.add("hidden"); 12234 const storageKey = isVideoNode ? "flow-video-ratio" : "flow-image-ratio"; 12235 localStorage.setItem(storageKey, selectedRatio); 12236 }); 12237 }); 12238 const initialOption = Array.from(ratioOptions).find( 12239 (opt) => opt.dataset.ratio === selectedRatio 12240 ); 12241 if (initialOption) { 12242 const svg = initialOption.querySelector('svg, [class*="lucide"]'); 12243 if (svg) { 12244 actualIconContainer.innerHTML = svg.outerHTML; 12245 } 12246 actualLabelEl.textContent = initialOption.dataset.ratioLabel; 12247 } 12248 nodeEl.dataset.selectedRatio = selectedRatio; 12249 }, 12250 bindDurationDropdown(nodeEl, initialDuration = "4s", selectedModel = null) { 12251 const durationContainer = nodeEl.querySelector( 12252 "[data-flow-duration-container]" 12253 ); 12254 const durationBtn = nodeEl.querySelector("[data-flow-duration-btn]"); 12255 const dropdown = nodeEl.querySelector("[data-flow-duration-dropdown]"); 12256 const labelEl = nodeEl.querySelector("[data-flow-duration-label]"); 12257 const optionsContainer = nodeEl.querySelector( 12258 "[data-flow-duration-options]" 12259 ); 12260 if (!durationBtn || !dropdown || !durationContainer) return; 12261 durationContainer.style.display = "block"; 12262 let availableDurations = ["4s", "6s", "8s"]; 12263 if (selectedModel && selectedModel.toLowerCase() === "kling-3") { 12264 availableDurations = ["3s", "5s", "8s", "10s", "15s"]; 12265 } else if (selectedModel && selectedModel.toLowerCase() === "kling-2") { 12266 availableDurations = ["5s", "10s"]; 12267 } else if (selectedModel && selectedModel.toLowerCase().includes("sora")) { 12268 availableDurations = ["4s", "8s", "12s"]; 12269 } else if (selectedModel && selectedModel.toLowerCase().includes("veo")) { 12270 availableDurations = ["4s", "6s", "8s"]; 12271 } 12272 optionsContainer.innerHTML = availableDurations.map( 12273 (duration) => ` 12274 <button class="w-full text-left px-4 py-3 text-gray hover:text-white hover:bg-light/50 rounded cursor-pointer transition duration-300 ease" data-duration="${duration}"> 12275 <div class="flex items-center justify-between gap-4"> 12276 <div class="text-white text-sm font-medium">${duration}</div> 12277 </div> 12278 </button> 12279 ` 12280 ).join(""); 12281 let selectedDuration = initialDuration; 12282 if (initialDuration === "4s") { 12283 const savedDuration = localStorage.getItem("flow-video-duration"); 12284 if (savedDuration && availableDurations.includes(savedDuration)) { 12285 selectedDuration = savedDuration; 12286 } 12287 } 12288 if (!availableDurations.includes(selectedDuration)) { 12289 selectedDuration = availableDurations[0]; 12290 } 12291 durationBtn.addEventListener("click", (e) => { 12292 e.stopPropagation(); 12293 const isHidden = dropdown.classList.contains("hidden"); 12294 dropdown.classList.toggle("hidden"); 12295 const modelDropdown = nodeEl.querySelector("[data-flow-model-dropdown]"); 12296 const ratioDropdown = nodeEl.querySelector("[data-flow-ratio-dropdown]"); 12297 if (modelDropdown) modelDropdown.classList.add("hidden"); 12298 if (ratioDropdown) ratioDropdown.classList.add("hidden");
12299 if (isHidden) { 12300 setTimeout(() => { 12301 const closeHandler = (e2) => { 12302 if (!nodeEl.contains(e2.target)) { 12303 dropdown.classList.add("hidden"); 12304 document.removeEventListener("click", closeHandler); 12305 } 12306 }; 12307 document.addEventListener("click", closeHandler); 12308 }, 0); 12309 } 12310 }); 12311 optionsContainer.addEventListener("click", (e) => { 12312 const option = e.target.closest("[data-duration]"); 12313 if (!option) return; 12314 e.stopPropagation(); 12315 selectedDuration = option.dataset.duration; 12316 labelEl.textContent = selectedDuration; 12317 nodeEl.dataset.selectedDuration = selectedDuration; 12318 dropdown.classList.add("hidden"); 12319 localStorage.setItem("flow-video-duration", selectedDuration); 12320 this.updatePricingDisplay(nodeEl); 12321 }); 12322 labelEl.textContent = selectedDuration; 12323 nodeEl.dataset.selectedDuration = selectedDuration; 12324 }, 12325 bindVideoSettingsDropdown(nodeEl, initialRatio = "16:9", initialDuration = "4s", selectedModel = null, initialAudio = true, isInitNode = true) { 12326 const settingsBtn = nodeEl.querySelector("[data-flow-video-settings-btn]"); 12327 const dropdown = nodeEl.querySelector( 12328 "[data-flow-video-settings-dropdown]" 12329 ); 12330 const durationOptionsContainer = nodeEl.querySelector( 12331 "[data-video-duration-options]" 12332 ); 12333 const audioToggle = nodeEl.querySelector("[data-video-audio-toggle]"); 12334 const audioStatus = nodeEl.querySelector("[data-video-audio-status]"); 12335 const settingsRatioIcon = nodeEl.querySelector( 12336 "[data-video-settings-ratio-icon]" 12337 ); 12338 const settingsDurationLabel = nodeEl.querySelector( 12339 "[data-video-settings-duration-label]" 12340 ); 12341 const settingsAudioIcon = nodeEl.querySelector( 12342 "[data-video-settings-audio-icon]" 12343 ); 12344 if (!settingsBtn || !dropdown) return; 12345 const alreadyBound = settingsBtn.dataset.videoSettingsBound === "true"; 12346 let actualSettingsBtn = settingsBtn; 12347 let actualDropdown = dropdown; 12348 let actualDurationOptionsContainer = durationOptionsContainer; 12349 let actualAudioToggle = audioToggle; 12350 let actualAudioStatus = audioStatus; 12351 let actualSettingsRatioIcon = settingsRatioIcon; 12352 let actualSettingsDurationLabel = settingsDurationLabel; 12353 let actualSettingsAudioIcon = settingsAudioIcon; 12354 if (alreadyBound) { 12355 const btnClone = settingsBtn.cloneNode(true); 12356 settingsBtn.parentNode.replaceChild(btnClone, settingsBtn); 12357 actualSettingsBtn = btnClone; 12358 const dropdownClone = dropdown.cloneNode(true); 12359 dropdown.parentNode.replaceChild(dropdownClone, dropdown); 12360 actualDropdown = dropdownClone; 12361 actualDurationOptionsContainer = actualDropdown.querySelector( 12362 "[data-video-duration-options]" 12363 ); 12364 actualAudioToggle = actualDropdown.querySelector( 12365 "[data-video-audio-toggle]" 12366 ); 12367 actualAudioStatus = actualDropdown.querySelector( 12368 "[data-video-audio-status]" 12369 ); 12370 actualSettingsRatioIcon = actualSettingsBtn.querySelector( 12371 "[data-video-settings-ratio-icon]" 12372 ); 12373 actualSettingsDurationLabel = actualSettingsBtn.querySelector( 12374 "[data-video-settings-duration-label]" 12375 ); 12376 actualSettingsAudioIcon = actualSettingsBtn.querySelector( 12377 "[data-video-settings-audio-icon]" 12378 ); 12379 } 12380 actualSettingsBtn.dataset.videoSettingsBound = "true"; 12381 let selectedRatio, selectedDuration, audioEnabled; 12382 if (isInitNode) { 12383 selectedRatio = localStorage.getItem("flow-video-ratio") || "16:9"; 12384 selectedDuration = localStorage.getItem("flow-video-duration") || "4s"; 12385 const savedAudio = localStorage.getItem("flow-video-audio"); 12386 audioEnabled = savedAudio !== null ? savedAudio === "true" : true; 12387 } else { 12388 selectedRatio = initialRatio; 12389 selectedDuration = initialDuration; 12390 audioEnabled = initialAudio; 12391 } 12392 let availableDurations = ["4s", "6s", "8s"]; 12393 if (selectedModel && selectedModel.toLowerCase() === "kling-3") { 12394 availableDurations = ["3s", "5s", "8s", "10s", "15s"]; 12395 } else if (selectedModel && selectedModel.toLowerCase() === "kling-2") { 12396 availableDurations = ["5s", "10s"]; 12397 } else if (selectedModel && selectedModel.toLowerCase().includes("sora")) { 12398 availableDurations = ["4s", "8s", "12s"]; 12399 } else if (selectedModel && selectedModel.toLowerCase().includes("veo")) { 12400 availableDurations = ["4s", "6s", "8s"]; 12401 } 12402 if (!availableDurations.includes(selectedDuration)) { 12403 selectedDuration = availableDurations[0]; 12404 } 12405 if (actualDurationOptionsContainer) { 12406 const buttonsHtml = availableDurations.map( 12407 (duration) => ` 12408 <button class="flex-1 px-3 py-2 text-gray hover:text-white hover:bg-light/50 rounded cursor-pointer transition duration-300 ease relative" data-video-duration="${duration}"> 12409 <div class="flex items-center justify-start gap-1.5"> 12410 <div class="text-white text-xs font-medium">${duration}</div> 12411 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 text-active" data-duration-check="${duration}" style="display: none;"><path d="M20 6 9 17l-5-5"></path></svg> 12412 </div> 12413 </button> 12414 ` 12415 ).join(""); 12416 actualDurationOptionsContainer.innerHTML = `<div class="flex gap-2 px-2">${buttonsHtml}</div>`; 12417 } 12418 if (actualAudioStatus) { 12419 actualAudioStatus.textContent = audioEnabled ? "ON" : "OFF"; 12420 actualAudioStatus.className = audioEnabled ? "text-xs font-medium text-indigo-400" : "text-xs font-medium text-gray"; 12421 } 12422 nodeEl.dataset.selectedRatio = selectedRatio; 12423 nodeEl.dataset.selectedDuration = selectedDuration; 12424 nodeEl.dataset.videoAudio = audioEnabled.toString(); 12425 const updateCheckIcons = () => { 12426 const currentRatio = nodeEl.dataset.selectedRatio; 12427 const currentDuration = nodeEl.dataset.selectedDuration; 12428 const ratioChecks = actualDropdown.querySelectorAll("[data-ratio-check]"); 12429 ratioChecks.forEach((check) => { 12430 const ratio = check.dataset.ratioCheck; 12431 check.style.display = ratio === currentRatio ? "block" : "none"; 12432 }); 12433 const durationChecks = actualDropdown.querySelectorAll( 12434 "[data-duration-check]" 12435 ); 12436 durationChecks.forEach((check) => { 12437 const duration = check.dataset.durationCheck; 12438 check.style.display = duration === currentDuration ? "block" : "none"; 12439 }); 12440 }; 12441 const updateSettingsButton = () => { 12442 const currentRatio = nodeEl.dataset.selectedRatio; 12443 const currentDuration = nodeEl.dataset.selectedDuration; 12444 const currentAudio = nodeEl.dataset.videoAudio === "true"; 12445 if (actualSettingsRatioIcon) { 12446 const ratioIconSvg = currentRatio === "16:9" ? '<svg class="w-3.5 h-3.5" viewB
12446ox="0 0 16 12" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="14" height="10" rx="1"/></svg>' : '<svg class="w-3.5 h-3.5" viewBox="0 0 12 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="10" height="14" rx="1"/></svg>'; 12447 actualSettingsRatioIcon.innerHTML = ratioIconSvg; 12448 } 12449 if (actualSettingsDurationLabel) { 12450 actualSettingsDurationLabel.textContent = currentDuration; 12451 } 12452 if (actualSettingsAudioIcon) { 12453 const audioIconSvg = currentAudio ? '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z"></path><path d="M16 9a5 5 0 0 1 0 6"></path><path d="M19.364 18.364a9 9 0 0 0 0-12.728"></path></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M16 9a5 5 0 0 1 .95 2.293"></path><path d="M19.364 5.636a9 9 0 0 1 1.889 9.96"></path><path d="m2 2 20 20"></path><path d="m7 7-.587.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298V11"></path><path d="M9.828 4.172A.686.686 0 0 1 11 4.657v.686"></path></svg>'; 12454 actualSettingsAudioIcon.innerHTML = audioIconSvg; 12455 } 12456 }; 12457 updateCheckIcons(); 12458 updateSettingsButton(); 12459 this.updatePricingDisplay(nodeEl); 12460 actualSettingsBtn.addEventListener("click", (e) => { 12461 e.stopPropagation(); 12462 const isHidden = actualDropdown.classList.contains("hidden"); 12463 actualDropdown.classList.toggle("hidden"); 12464 const modelDropdown = nodeEl.querySelector("[data-flow-model-dropdown]"); 12465 if (modelDropdown) modelDropdown.classList.add("hidden"); 12466 if (isHidden) { 12467 setTimeout(() => { 12468 const closeHandler = (e2) => { 12469 if (!nodeEl.contains(e2.target)) { 12470 actualDropdown.classList.add("hidden"); 12471 document.removeEventListener("click", closeHandler); 12472 } 12473 }; 12474 document.addEventListener("click", closeHandler); 12475 }, 0); 12476 } 12477 }); 12478 const ratioOptions = actualDropdown.querySelectorAll("[data-video-ratio]"); 12479 ratioOptions.forEach((option) => { 12480 option.addEventListener("click", (e) => { 12481 e.stopPropagation(); 12482 selectedRatio = option.dataset.videoRatio; 12483 nodeEl.dataset.selectedRatio = selectedRatio; 12484 localStorage.setItem("flow-video-ratio", selectedRatio); 12485 updateCheckIcons(); 12486 updateSettingsButton(); 12487 }); 12488 }); 12489 if (actualDurationOptionsContainer) { 12490 actualDurationOptionsContainer.addEventListener("click", (e) => { 12491 const option = e.target.closest("[data-video-duration]"); 12492 if (!option) return; 12493 e.stopPropagation(); 12494 selectedDuration = option.dataset.videoDuration; 12495 nodeEl.dataset.selectedDuration = selectedDuration; 12496 localStorage.setItem("flow-video-duration", selectedDuration); 12497 updateCheckIcons(); 12498 updateSettingsButton(); 12499 this.updatePricingDisplay(nodeEl); 12500 }); 12501 } 12502 if (actualAudioToggle) { 12503 actualAudioToggle.addEventListener("click", (e) => { 12504 e.stopPropagation(); 12505 audioEnabled = !audioEnabled; 12506 nodeEl.dataset.videoAudio = audioEnabled.toString(); 12507 if (actualAudioStatus) { 12508 actualAudioStatus.textContent = audioEnabled ? "ON" : "OFF"; 12509 actualAudioStatus.className = audioEnabled ? "text-xs font-medium text-indigo-400" : "text-xs font-medium text-gray"; 12510 } 12511 localStorage.setItem("flow-video-audio", audioEnabled.toString()); 12512 updateSettingsButton(); 12513 this.updatePricingDisplay(nodeEl); 12514 }); 12515 } 12516 }, 12517 bindModeToggle(nodeEl, nodeData) { 12518 const toggleContainer = nodeEl.querySelector("[data-flow-mode-toggle]"); 12519 if (!toggleContainer) return; 12520 const imageBtn = toggleContainer.querySelector('[data-mode="image"]'); 12521 const videoBtn = toggleContainer.querySelector('[data-mode="video"]'); 12522 if (!imageBtn || !videoBtn) return; 12523 const isInitNode = !nodeData.nodeData?.status || nodeData.nodeData.status === "init"; 12524 let savedMode; 12525 if (isInitNode) { 12526 savedMode = localStorage.getItem("flow-root-mode") || "image"; 12527 } else if (nodeData.nodeData?.mode) { 12528 savedMode = nodeData.nodeData.mode; 12529 } else { 12530 savedMode = "image"; 12531 } 12532 const isVideoMode = savedMode === "video"; 12533 if (isVideoMode) { 12534 imageBtn.classList.remove("active"); 12535 videoBtn.classList.add("active"); 12536 } else { 12537 videoBtn.classList.remove("active"); 12538 imageBtn.classList.add("active"); 12539 } 12540 const switchMode = (mode, saveToStorage = true) => { 12541 const isVideo = mode === "video"; 12542 if (isVideo) { 12543 imageBtn.classList.remove("active"); 12544 videoBtn.classList.add("active"); 12545 } else { 12546 videoBtn.classList.remove("active"); 12547 imageBtn.classList.add("active"); 12548 } 12549 if (saveToStorage) { 12550 localStorage.setItem("flow-root-mode", mode); 12551 } 12552 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 12553 if (promptEl) { 12554 if (isVideo) { 12555 promptEl.placeholder = "Describe your video\u2026"; 12556 } else { 12557 promptEl.placeholder = "Describe your image\u2026"; 12558 } 12559 } 12560 let savedModel, savedRatio, savedDuration, savedAudio; 12561 if (isInitNode) { 12562 savedModel = localStorage.getItem( 12563 isVideo ? "flow-video-model" : "flow-image-model" 12564 ); 12565 savedRatio = localStorage.getItem( 12566 isVideo ? "flow-video-ratio" : "flow-image-ratio" 12567 ) || (isVideo ? "16:9" : "4:3"); 12568 savedDuration = localStorage.getItem("flow-video-duration") || "4s"; 12569 savedAudio = localStorage.getItem("flow-video-audio") !== "fal
12569se"; 12570 } else { 12571 savedModel = nodeData.nodeData?.model || null; 12572 savedRatio = nodeData.nodeData?.ratio || (isVideo ? "16:9" : "4:3"); 12573 savedDuration = nodeData.nodeData?.duration || "4s"; 12574 savedAudio = nodeData.nodeData?.audio !== false; 12575 } 12576 this.bindModelDropdown(nodeEl, savedModel, isVideo, isInitNode); 12577 const ratioContainer = nodeEl.querySelector( 12578 "[data-flow-ratio-container]" 12579 ); 12580 const settingsContainer = nodeEl.querySelector( 12581 "[data-flow-video-settings-container]" 12582 ); 12583 const counter = nodeEl.querySelector("[data-flow-counter]"); 12584 const countEl = nodeEl.querySelector("[data-flow-count]"); 12585 const underButtons = nodeEl.querySelector("[data-flow-under-buttons]"); 12586 const imageButtons = nodeEl.querySelector("[data-flow-image-buttons]"); 12587 if (isVideo) { 12588 if (ratioContainer) ratioContainer.style.display = "none"; 12589 if (settingsContainer) settingsContainer.style.display = "block"; 12590 if (counter) { 12591 counter.style.removeProperty("visibility"); 12592 } 12593 if (underButtons) { 12594 underButtons.style.opacity = "0"; 12595 underButtons.style.transform = "translateY(-8px) scale(0.9)"; 12596 underButtons.style.pointerEvents = "none"; 12597 } 12598 if (imageButtons) { 12599 imageButtons.style.opacity = "0"; 12600 imageButtons.style.transform = "translateY(-8px) scale(0.9)"; 12601 imageButtons.style.pointerEvents = "none"; 12602 } 12603 this.bindVideoSettingsDropdown( 12604 nodeEl, 12605 savedRatio, 12606 savedDuration, 12607 savedModel, 12608 savedAudio, 12609 isInitNode 12610 ); 12611 } else { 12612 if (ratioContainer) ratioContainer.style.display = "block"; 12613 if (settingsContainer) settingsContainer.style.display = "none"; 12614 if (counter) { 12615 counter.style.removeProperty("visibility"); 12616 } 12617 if (underButtons) { 12618 underButtons.style.opacity = ""; 12619 underButtons.style.transform = ""; 12620 underButtons.style.pointerEvents = ""; 12621 } 12622 if (imageButtons) { 12623 imageButtons.style.opacity = ""; 12624 imageButtons.style.transform = ""; 12625 imageButtons.style.pointerEvents = ""; 12626 } 12627 this.bindRatioDropdown(nodeEl, savedRatio, isVideo, isInitNode); 12628 } 12629 if (this.updateRefBadgesDisplay) { 12630 this.updateRefBadgesDisplay(nodeEl); 12631 } 12632 if (nodeEl.updateModelPricing) { 12633 nodeEl.updateModelPricing(); 12634 } 12635 }; 12636 switchMode(savedMode, false); 12637 this.bindStyleDropdown(nodeEl, nodeData); 12638 if (!isInitNode) { 12639 imageBtn.style.pointerEvents = "none"; 12640 videoBtn.style.pointerEvents = "none"; 12641 imageBtn.style.opacity = "0.5"; 12642 videoBtn.style.opacity = "0.5"; 12643 return; 12644 } 12645 imageBtn.addEventListener("click", (e) => { 12646 e.stopPropagation(); 12647 if (!imageBtn.classList.contains("active")) { 12648 switchMode("image", true); 12649 } 12650 }); 12651 videoBtn.addEventListener("click", (e) => { 12652 e.stopPropagation(); 12653 if (!videoBtn.classList.contains("active")) { 12654 switchMode("video", true); 12655 } 12656 }); 12657 }, 12658 // Helper function to update ratio options visibility based on selected model 12659 updateRatioOptionsForModel(nodeEl) { 12660 const dropdown = nodeEl.querySelector("[data-flow-ratio-dropdown]"); 12661 if (!dropdown) return; 12662 const ratioOptions = dropdown.querySelectorAll("[data-ratio]"); 12663 ratioOptions.forEach((option) => { 12664 option.style.display = ""; 12665 }); 12666 }, 12667 // Helper function to update pricing display (next to generate button only) 12668 updatePricingDisplay(nodeEl) { 12669 const pricingEl = nodeEl.querySelector("[data-flow-pricing]"); 12670 if (!pricingEl) return; 12671 const selectedModel = nodeEl.dataset.selectedModel; 12672 if (!selectedModel) return; 12673 const freshOptionsContainer = nodeEl.querySelector( 12674 "[data-flow-model-options]" 12675 ); 12676 if (!freshOptionsContainer) return; 12677 const selectedOption = freshOptionsContainer.querySelector( 12678 `[data-model="${selectedModel}"]` 12679 ); 12680 if (!selectedOption) return; 12681 const cost = parseFloat(selectedOption.dataset.cost) || 0; 12682 const costWithoutAudio = parseFloat(selectedOption.dataset.costWithoutAudio) || 0; 12683 const supportsAudio = selectedOption.dataset.supportsAudio !== "fal
12683se"; 12684 const audioEnabled = nodeEl.dataset.videoAudio !== "false"; 12685 const videoToggleBtn = nodeEl.querySelector('[data-mode="video"]'); 12686 const videoSettingsContainer = nodeEl.querySelector( 12687 "[data-flow-video-settings-container]" 12688 ); 12689 const isVideoMode = videoToggleBtn && videoToggleBtn.classList.contains("active") || videoSettingsContainer && videoSettingsContainer.style.display !== "none"; 12690 let displayCost = cost; 12691 if (isVideoMode && supportsAudio && costWithoutAudio > 0 && !audioEnabled) { 12692 displayCost = costWithoutAudio; 12693 } 12694 const countEl = nodeEl.querySelector("[data-flow-count]"); 12695 const count = countEl ? parseInt(countEl.textContent) || 1 : 1; 12696 if (displayCost === 0) { 12697 pricingEl.innerHTML = "FREE"; 12698 } else if (displayCost > 0) { 12699 if (isVideoMode) { 12700 const durationStr = nodeEl.dataset.selectedDuration || "4s"; 12701 const seconds = parseInt(durationStr.replace("s", "")) || 4; 12702 const totalPrice = displayCost * seconds * count; 12703 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits(totalPrice)}`; 12704 } else { 12705 const totalPrice = displayCost * count; 12706 pricingEl.innerHTML = `${aiCreditsIcon} ${formatCredits(totalPrice)}`; 12707 } 12708 } 12709 }, 12710 // Style dropdown methods 12711 async loadStyles() { 12712 if (this.stylesLoaded) return; 12713 try { 12714 const response = await fetch("/api/styles"); 12715 const data = await response.json(); 12716 if (data.success) { 12717 this.availableStyles = data.styles; 12718 this.stylesLoaded = true; 12719 } 12720 } catch (error) { 12721 console.error("Error loading styles:", error); 12722 } 12723 }, 12724 bindStyleDropdown(nodeEl, nodeData) { 12725 const styleBtn = nodeEl.querySelector("[data-flow-style-btn]"); 12726 const dropdown = nodeEl.querySelector("[data-flow-style-dropdown]"); 12727 const tooltip = nodeEl.querySelector("[data-flow-style-tooltip]"); 12728 const tabsContainer = nodeEl.querySelector("[data-flow-style-tabs]"); 12729 const grid = nodeEl.querySelector("[data-flow-style-grid]"); 12730 if (!styleBtn || !dropdown || !grid) return; 12731 styleBtn.addEventListener("click", async (e) => { 12732 e.stopPropagation(); 12733 if (tooltip) tooltip.style.display = "none"; 12734 await this.loadStyles(); 12735 if (this.availableStyles && !dropdown.dataset.populated) { 12736 this.populateStyleGrid(grid, "photography"); 12737 dropdown.dataset.populated = "true"; 12738 } 12739 const isHidden = dropdown.classList.contains("hidden"); 12740 document.querySelectorAll("[data-flow-style-dropdown]").forEach((d) => { 12741 if (d !== dropdown) d.classList.add("hidden"); 12742 }); 12743 dropdown.classList.toggle("hidden"); 12744 if (isHidden) { 12745 const closeHandler = (event) => { 12746 if (!dropdown.contains(event.target) && !styleBtn.contains(event.target)) { 12747 dropdown.classList.add("hidden"); 12748 if (tooltip) tooltip.style.display = ""; 12749 document.removeEventListener("click", closeHandler); 12750 } 12751 }; 12752 setTimeout(() => document.addEventListener("click", closeHandler), 0); 12753 } 12754 }); 12755 if (tabsContainer) { 12756 tabsContainer.addEventListener("click", (e) => { 12757 const tab = e.target.closest("[data-style-tab]"); 12758 if (!tab) return; 12759 const category = tab.dataset.styleTab; 12760 tabsContainer.querySelectorAll("[data-style-tab]").forEach((t) => { 12761 t.classList.remove("bg-light", "text-white"); 12762 t.classList.add("text-gray", "hover:text-white"); 12763 }); 12764 tab.classList.add("bg-light", "text-white"); 12765 tab.classList.remove("text-gray", "hover:text-white"); 12766 this.populateStyleGrid(grid, category); 12767 }); 12768 } 12769 }, 12770 populateStyleGrid(grid, category) { 12771 if (!this.availableStyles || !this.availableStyles[category]) { 12772 grid.innerHTML = '<div class="col-span-4 text-center text-gray text-xs py-4">No styles available</div>'; 12773 return; 12774 } 12775 const styles = this.availableStyles[category]; 12776 grid.innerHTML = styles.map( 12777 (style) => ` 12778 <button class="group/item relative aspect-square rounded-md overflow-hidden cursor-pointer border border-transparent hover:border-active transition duration-200" data-style-key="${style.key}" data-style-prompt="${style.prompt || ""}"> 12779 <img src="${style.preview}" alt="${style.name}" class="w-full h-full object-cover" /> 12780 <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-1.5 pt-4"> 12781 <div class="text-white text-[10px] font-medium truncate">${style.name}</div> 12782 </div> 12783 </button> 12784 ` 12785 ).join(""); 12786 grid.querySelectorAll("[data-style-key]").forEach((item) => { 12787 item.addEventListener("click", () => { 12788 const styleKey = item.dataset.styleKey; 12789 const stylePrompt = item.dataset.stylePrompt; 12790 const styleName = item.querySelector(".text-white").textContent; 12791 const nodeEl = grid.closest(".flow-node"); 12792 if (nodeEl) { 12793 nodeEl.dataset.selectedStyle = styleKey; 12794 nodeEl.dataset.selectedStylePrompt = stylePrompt; 12795 const styleBtn = nodeEl.querySelector("[data-flow-style-btn]"); 12796 if (styleBtn) { 12797 styleBtn.classList.remove("text-gray"); 12798 styleBtn.classList.add("text-active"); 12799 } 12800 const tooltip = nodeEl.querySelector("[data-flow-style-tooltip]"); 12801 if (tooltip) { 12802 tooltip.textContent = styleName; 12803 } 12804 const textarea = nodeEl.querySelector("[data-flow-prompt]"); 12805 if (textarea && stylePrompt) { 12806 this.updatePromptTag(textarea, "STYLE", stylePrompt); 12807 } 12808 } 12809 }); 12810 }); 12811 }, 12812 updateTagButtonStates(nodeEl) { 12813 if (!nodeEl) return;
12814 const editor = nodeEl.querySelector("[data-flow-prompt]"); 12815 if (!editor) return; 12816 const styleBtn = nodeEl.querySelector("[data-flow-style-btn]"); 12817 const hasStyleTag = editor.querySelector('.tag-label[data-tag-type="style"]') !== null; 12818 if (styleBtn) { 12819 if (hasStyleTag) { 12820 styleBtn.classList.remove("text-gray"); 12821 styleBtn.classList.add("text-active"); 12822 } else { 12823 styleBtn.classList.remove("text-active"); 12824 styleBtn.classList.add("text-gray"); 12825 } 12826 } 12827 }, 12828 // Helper to add or update a tag like \n\nSTYLE\nvalue in the editor 12829 updatePromptTag(editor, tagName, value) { 12830 let text = this.getEditorTextContent ? this.getEditorTextContent(editor) : editor.textContent; 12831 const tagPattern = new RegExp( 12832 `\\n\\n${tagName}\\n[^]*?(?=\\n\\n(?:STYLE)\\n|$)`, 12833 "g" 12834 ); 12835 const newTag = ` 12836 12837${tagName} 12838${value}`; 12839 if (tagPattern.test(text)) { 12840 text = text.replace(tagPattern, newTag); 12841 } else { 12842 if (text.trim()) { 12843 text = text.trim() + newTag; 12844 } else { 12845 text = `${tagName} 12846${value}`; 12847 } 12848 } 12849 editor.textContent = text; 12850 if (this.parseAndStyleMentions) { 12851 this.parseAndStyleMentions(editor); 12852 } 12853 const nodeEl = editor.closest(".flow-node"); 12854 if (nodeEl) { 12855 this.updateTagButtonStates(nodeEl); 12856 } 12857 editor.dispatchEvent(new Event("input", { bubbles: true })); 12858 } 12859}; 12860
12861// app/javascript/flow/uploads.js 12862var UploadsMixin = { 12863 bindUploadButton(nodeEl, nodeData) { 12864 const uploadBtn = nodeEl.querySelector("[data-flow-upload]"); 12865 if (!uploadBtn) return; 12866 uploadBtn.addEventListener("click", (e) => { 12867 e.stopPropagation(); 12868 this.handleUploadClick(nodeEl, nodeData); 12869 }); 12870 }, 12871 bindRefsButton(nodeEl, nodeData) { 12872 const refsBtn = nodeEl.querySelector("[data-flow-refs]"); 12873 if (!refsBtn) return; 12874 refsBtn.addEventListener("click", (e) => { 12875 e.stopPropagation(); 12876 this.handleRefsClick(nodeEl, nodeData); 12877 }); 12878 }, 12879 bindElementsButton(nodeEl, nodeData) { 12880 const elementsBtn = nodeEl.querySelector("[data-flow-elements]"); 12881 if (!elementsBtn) return; 12882 const nodeType = nodeData.nodeData?.node_type; 12883 const level = nodeData.nodeData?.level || 0; 12884 if (nodeType === "video" && level > 0) { 12885 elementsBtn.style.display = "none"; 12886 return; 12887 } 12888 elementsBtn.addEventListener("click", (e) => { 12889 e.stopPropagation(); 12890 if (!this.checkPermissions("refs")) return; 12891 this.currentRefsTargetNodeId = nodeData.nodeData?.id; 12892 if (window.Modal) window.Modal.showElementsPicker(); 12893 }); 12894 }, 12895 handleRefsClick(nodeEl, nodeData) { 12896 if (!this.checkPermissions("refs")) return; 12897 const maxImages = 6; 12898 const refAncestors = nodeData.ancestors?.filter( 12899 (ancestor) => ancestor.node_type === "reference" 12900 ) || []; 12901 if (refAncestors.length >= maxImages) { 12902 if (window.Toast) { 12903 window.Toast.show("Maximum 6 references reached", "alert"); 12904 } 12905 return; 12906 } 12907 this.currentRefsTargetNodeId = nodeData.nodeData?.id; 12908 if (window.Modal && window.Modal.showLibrary) { 12909 window.Modal.showLibrary({ mediaType: "image" }); 12910 } 12911 }, 12912 setupRefsListener() { 12913 const handleRefsSelected = async (event) => { 12914 const { refs } = event.detail; 12915 if (!refs || refs.length === 0) return; 12916 let nodeId = this.currentRefsTargetNodeId; 12917 if (!nodeId) { 12918 const rootNode = Object.values(this.nodes).find( 12919 (node) => node.data.nodeData && node.data.nodeData.level === 0 12920 ); 12921 if (!rootNode) return; 12922 nodeId = rootNode.data.nodeData.id; 12923 } 12924 const flowId = this.flowId; 12925 const sceneId = this.currentSceneId; 12926 for (const ref of refs) { 12927 const ratio = ref.ratio || null; 12928 const loadingNodeId = this.createLoadingNode(nodeId, ratio); 12929 try { 12930 await this.addRefToNode( 12931 nodeId, 12932 ref.file_hash, 12933 loadingNodeId, 12934 "reference" 12935 ); 12936 } catch (error) { 12937 console.error("Failed to add reference:", error); 12938 this.removeLoadingNode(loadingNodeId); 12939 if (window.Toast) { 12940 window.Toast.show("Failed to add reference", "alert"); 12941 } 12942 } 12943 } 12944 this.currentRefsTargetNodeId = null; 12945 }; 12946 document.removeEventListener( 12947 "refs:selected", 12948 this.boundRefsSelectedHandler 12949 ); 12950 this.boundRefsSelectedHandler = handleRefsSelected.bind(this); 12951 document.addEventListener("refs:selected", this.boundRefsSelectedHandler); 12952 }, 12953 handleUploadClick(nodeEl, nodeData) { 12954 if (!this.checkPermissions("upload")) return; 12955 const fileInput = document.createElement("input"); 12956 fileInput.type = "file"; 12957 fileInput.accept = "image/jpeg,image/jpg,image/png,image/webp,image/avif"; 12958 fileInput.multiple = false; 12959 fileInput.onchange = (e) => { 12960 const file = e.target.files[0]; 12961 if (file) { 12962 this.validateAndUpload(file, nodeEl, nodeData); 12963 } 12964 }; 12965 fileInput.click(); 12966 }, 12967 async validateAndUpload(file, nodeEl, nodeData) { 12968 const validTypes = [ 12969 "image/jpeg", 12970 "image/jpg", 12971 "image/png", 12972 "image/webp", 12973 "image/avif" 12974 ]; 12975 const maxSize = 20 * 1024 * 1024; 12976 if (!validTypes.includes(file.type)) { 12977 if (window.Toast) { 12978 window.Toast.show( 12979 "Only JPG, PNG, WebP, and AVIF images are allowed", 12980 "alert" 12981 ); 12982 } 12983 return; 12984 } 12985 if (file.size > maxSize) { 12986 if (window.Toast) { 12987 window.Toast.show("File must be under 20MB", "alert"); 12988 } 12989 return; 12990 } 12991 let uploadFile = file; 12992 if (file.type === "image/webp" || file.type === "image/avif") {
12993 try { 12994 uploadFile = await this.convertImageToPNG(file); 12995 } catch (error) { 12996 console.error("Conversion error:", error); 12997 if (window.Toast) { 12998 window.Toast.show("Failed to convert image", "alert"); 12999 } 13000 return; 13001 } 13002 } 13003 const imageInfo = await this.extractImageDimensions(uploadFile); 13004 const ratio = imageInfo ? this.calculateRatioFromDimensions(imageInfo.width, imageInfo.height) : null; 13005 const loadingNodeId = this.createLoadingNode(nodeData.id, ratio); 13006 try { 13007 const uploadResult = await this.uploadToServer(uploadFile, loadingNodeId); 13008 if (!uploadResult.success) { 13009 this.removeLoadingNode(loadingNodeId); 13010 if (window.Toast) { 13011 window.Toast.show("Upload failed", "alert"); 13012 } 13013 return; 13014 } 13015 await this.addRefToNode( 13016 nodeData.id, 13017 uploadResult.hash, 13018 loadingNodeId, 13019 "upload" 13020 ); 13021 } catch (error) { 13022 console.error("Upload error:", error); 13023 this.removeLoadingNode(loadingNodeId); 13024 if (window.Toast) { 13025 window.Toast.show("Upload failed", "alert"); 13026 } 13027 } 13028 }, 13029 async convertImageToPNG(file) { 13030 return new Promise((resolve, reject) => { 13031 const img = new Image(); 13032 const url = URL.createObjectURL(file); 13033 img.onload = () => { 13034 try { 13035 const canvas = document.createElement("canvas"); 13036 canvas.width = img.width; 13037 canvas.height = img.height; 13038 const ctx = canvas.getContext("2d"); 13039 ctx.drawImage(img, 0, 0); 13040 canvas.toBlob( 13041 (blob) => { 13042 URL.revokeObjectURL(url); 13043 if (blob) { 13044 const pngFile = new File( 13045 [blob], 13046 file.name.replace(/\.(webp|avif)$/i, ".png"), 13047 { 13048 type: "image/png", 13049 lastModified: Date.now() 13050 } 13051 ); 13052 resolve(pngFile); 13053 } else { 13054 reject(new Error("Failed to convert image to PNG")); 13055 } 13056 }, 13057 "image/png", 13058 0.95 13059 ); 13060 } catch (error) { 13061 URL.revokeObjectURL(url); 13062 reject(error); 13063 } 13064 }; 13065 img.onerror = () => { 13066 URL.revokeObjectURL(url); 13067 reject(new Error("Failed to load image")); 13068 }; 13069 img.src = url; 13070 }); 13071 }, 13072 async extractImageDimensions(file) { 13073 return new Promise((resolve) => { 13074 const img = new Image(); 13075 const url = URL.createObjectURL(file); 13076 img.onload = () => { 13077 URL.revokeObjectURL(url); 13078 resolve({ width: img.width, height: img.height }); 13079 }; 13080 img.onerror = () => { 13081 URL.revokeObjectURL(url); 13082 resolve(null); 13083 }; 13084 img.src = url; 13085 }); 13086 }, 13087 calculateRatioFromDimensions(width, height) { 13088 if (!width || !height) return null; 13089 const gcd = (a, b) => b === 0 ? a : gcd(b, a % b); 13090 const divisor = gcd(width, height); 13091 const ratioW = width / divisor; 13092 const ratioH = height / divisor; 13093 const ratioFloat = width / height; 13094 const commonRatios = [ 13095 { target: 16 / 9, label: "16:9" }, 13096 { target: 9 / 16, label: "9:16" }, 13097 { target: 4 / 3, label: "4:3" }, 13098 { target: 3 / 4, label: "3:4" }, 13099 { target: 1, label: "1:1" } 13100 ]; 13101 for (const { target, label } of commonRatios) { 13102 if (Math.abs(ratioFloat - target) / target < 0.02) { 13103 return label; 13104 } 13105 } 13106 return `${ratioW}:${ratioH}`; 13107 }, 13108 createLoadingNode(parentNodeId, ratio = null) { 13109 const loadingId = `loading-${Date.now()}`; 13110 const parentNode = this.nodes[parentNodeId]; 13111 if (!parentNode) return loadingId; 13112 let referencesContainer = parentNode.element.querySelector( 13113 "[data-flow-references-root]" 13114 ); 13115 if (!referencesContainer) { 13116 referencesContainer = parentNode.element.querySelector( 13117 "[data-flow-references]" 13118 ); 13119 } 13120 if (!referencesContainer) return loadingId; 13121 referencesContainer.style.display = "flex"; 13122 const refBorder = parentNode.element.querySelector("[data-flow-references-border]"); 13123 if (refBorder) refBorder.style.display = ""; 13124 const videoToggleBtn = parentNode.element.querySelector('[data-mode="video"]'); 13125 const isVideoMode = videoToggleBtn && videoToggleBtn.classList.contains("active"); 13126 if (!isVideoMode) { 13127 const swapBtn = referencesContainer.querySelector("[data-ref-swap-button]");
13128 if (swapBtn) swapBtn.remove(); 13129 } 13130 if (this.refHashToReplace && this.refBadgeToReplace) { 13131 const existingBadge = this.refBadgeToReplace; 13132 if (existingBadge && existingBadge.parentNode) { 13133 existingBadge.dataset.loadingBadge = loadingId; 13134 existingBadge.dataset.replacingHash = this.refHashToReplace; 13135 existingBadge.classList.add("overflow-hidden"); 13136 existingBadge.innerHTML = ` 13137 <div class="relative h-full flex items-center justify-center bg-inside"> 13138 <div class="relative flex items-center justify-center"> 13139 <svg width="40" height="40" class="transform -rotate-90"> 13140 <circle cx="20" cy="20" r="16" stroke="rgba(99, 102, 241, 0.2)" stroke-width="2" fill="none" /> 13141 <circle cx="20" cy="20" r="16" stroke="rgba(99, 102, 241, 1)" stroke-width="2" fill="none" 13142 stroke-dasharray="100.53" stroke-dashoffset="100.53" stroke-linecap="round" data-progress-circle /> 13143 </svg> 13144 <div class="absolute inset-0 flex items-center justify-center"> 13145 <span class="text-[8px] font-medium text-indigo-400" data-progress-text>0%</span> 13146 </div> 13147 </div> 13148 </div> 13149 <button class="absolute top-0.5 right-0.5 w-3.5 h-3.5 bg-dark/80 hover:bg-dark rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease cursor-pointer z-10" data-loading-badge-cancel> 13150 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-2 h-2 text-white"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> 13151 </button> 13152 `; 13153 const cancelBtn2 = existingBadge.querySelector("[data-loading-badge-cancel]"); 13154 if (cancelBtn2) { 13155 cancelBtn2.addEventListener("click", (e) => { 13156 e.stopPropagation(); 13157 this.cancelUpload(loadingId); 13158 }); 13159 } 13160 return loadingId; 13161 } 13162 } 13163 const optionalBadges = Array.from( 13164 referencesContainer.querySelectorAll("[data-ref-badge-optional]") 13165 ); 13166 let badgeEl; 13167 if (optionalBadges.length > 0) { 13168 badgeEl = optionalBadges[0]; 13169 badgeEl.dataset.wasPlaceholder = "true"; 13170 badgeEl.dataset.placeholderIndex = badgeEl.dataset.placeholderIndex || "0"; 13171 badgeEl.removeAttribute("data-ref-badge-optional"); 13172 badgeEl.dataset.loadingBadge = loadingId; 13173 badgeEl.className = "w-15 h-15 rounded-md overflow-hidden border border-light flex-shrink-0 relative group"; 13174 badgeEl.innerHTML = ` 13175 <div class="relative h-full flex items-center justify-center bg-inside"> 13176 <div class="relative flex items-center justify-center"> 13177 <svg width="40" height="40" class="transform -rotate-90"> 13178 <circle 13179 cx="20" 13180 cy="20" 13181 r="16" 13182 stroke="rgba(99, 102, 241, 0.2)" 13183 stroke-width="2" 13184 fill="none" 13185 /> 13186 <circle 13187 cx="20" 13188 cy="20" 13189 r="16" 13190 stroke="rgba(99, 102, 241, 1)" 13191 stroke-width="2" 13192 fill="none" 13193 stroke-dasharray="100.53" 13194 stroke-dashoffset="100.53" 13195 stroke-linecap="round" 13196 data-progress-circle 13197 /> 13198 </svg> 13199 <div class="absolute inset-0 flex items-center justify-center"> 13200 <span class="text-[8px] font-medium text-indigo-400" data-progress-text>0%</span> 13201 </div> 13202 </div> 13203 </div> 13204 <button class="absolute top-0.5 right-0.5 w-3.5 h-3.5 bg-dark/80 hover:bg-dark rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease cursor-pointer z-10" data-loading-badge-cancel> 13205 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-2 h-2 text-white"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> 13206 </button> 13207 `; 13208 } else { 13209 badgeEl = document.createElement("div"); 13210 badgeEl.className = "w-15 h-15 rounded-md overflow-hidden border border-light flex-shrink-0 relative group"; 13211 badgeEl.dataset.loadingBadge = loadingId; 13212 badgeEl.innerHTML = ` 13213 <div class="relative h-full flex items-center justify-center bg-inside"> 13214 <div class="relative flex items-center justify-center"> 13215 <svg width="40" height="40" class="transform -rotate-90"> 13216 <circle 13217 cx="20"
13218 cy="20" 13219 r="16" 13220 stroke="rgba(99, 102, 241, 0.2)" 13221 stroke-width="2" 13222 fill="none" 13223 /> 13224 <circle 13225 cx="20" 13226 cy="20" 13227 r="16" 13228 stroke="rgba(99, 102, 241, 1)" 13229 stroke-width="2" 13230 fill="none" 13231 stroke-dasharray="100.53" 13232 stroke-dashoffset="100.53" 13233 stroke-linecap="round" 13234 data-progress-circle 13235 /> 13236 </svg> 13237 <div class="absolute inset-0 flex items-center justify-center"> 13238 <span class="text-[8px] font-medium text-indigo-400" data-progress-text>0%</span> 13239 </div> 13240 </div> 13241 </div> 13242 <button class="absolute top-0.5 right-0.5 w-3.5 h-3.5 bg-dark/80 hover:bg-dark rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease cursor-pointer z-10" data-loading-badge-cancel> 13243 <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-2 h-2 text-white"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> 13244 </button> 13245 `; 13246 referencesContainer.appendChild(badgeEl); 13247 } 13248 const cancelBtn = badgeEl.querySelector("[data-loading-badge-cancel]"); 13249 if (cancelBtn) { 13250 cancelBtn.addEventListener("click", (e) => { 13251 e.stopPropagation(); 13252 this.cancelUpload(loadingId); 13253 }); 13254 } 13255 return loadingId; 13256 }, 13257 updateLoadingProgress(loadingNodeId, percentage) { 13258 const badgeEl = document.querySelector( 13259 `[data-loading-badge="${loadingNodeId}"]` 13260 ); 13261 if (!badgeEl) return; 13262 const circle = badgeEl.querySelector("[data-progress-circle]"); 13263 const text = badgeEl.querySelector("[data-progress-text]"); 13264 if (circle) { 13265 const circumference = 100.53; 13266 const offset = circumference - circumference * percentage / 100; 13267 circle.style.strokeDashoffset = offset; 13268 } 13269 if (text) { 13270 text.textContent = `${Math.round(percentage)}%`; 13271 } 13272 }, 13273 cancelUpload(loadingNodeId) { 13274 this.removeLoadingNode(loadingNodeId); 13275 if (window.Toast) { 13276 window.Toast.show("Upload cancelled", "notice"); 13277 } 13278 }, 13279 removeLoadingNode(loadingNodeId) { 13280 const badgeEl = document.querySelector( 13281 `[data-loading-badge="${loadingNodeId}"]` 13282 ); 13283 if (badgeEl && badgeEl.parentNode) { 13284 badgeEl.parentNode.removeChild(badgeEl); 13285 } 13286 }, 13287 async uploadToServer(file, loadingNodeId) { 13288 const formData = new FormData(); 13289 formData.append("file", file); 13290 formData.append("type", "image"); 13291 const csrfToken = document.querySelector('[name="csrf-token"]'); 13292 return new Promise((resolve, reject) => { 13293 const xhr = new XMLHttpRequest(); 13294 xhr.upload.addEventListener("progress", (e) => { 13295 if (e.lengthComputable && loadingNodeId) { 13296 const percentComplete = Math.round(e.loaded / e.total * 100); 13297 this.updateLoadingProgress(loadingNodeId, percentComplete); 13298 } 13299 }); 13300 xhr.addEventListener("load", () => { 13301 if (xhr.status >= 200 && xhr.status < 300) { 13302 try { 13303 const response = JSON.parse(xhr.response); 13304 resolve(response); 13305 } catch { 13306 reject(new Error("Invalid response format")); 13307 } 13308 } else { 13309 reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`)); 13310 } 13311 }); 13312 xhr.addEventListener("error", () => { 13313 reject(new Error("Network error")); 13314 }); 13315 xhr.open("POST", "/upload"); 13316 if (csrfToken) { 13317 xhr.setRequestHeader("X-CSRF-Token", csrfToken.content); 13318 } 13319 xhr.send(formData); 13320 }); 13321 }, 13322 async addRefToNode(nodeId, fileHash, loadingNodeId, source = "upload") { 13323 const container = document.querySelector("[data-init-flow]"); 13324 if (!container) return; 13325 const flowId = container.dataset.flowId; 13326 const sceneId = this.currentSceneId; 13327 const csrfToken = document.querySelector('[name="csrf-token"]'); 13328 const replaceHash = this.refHashToReplace; 13329 this.refHashToReplace = null; 13330 this.refReplaceTargetNodeId = null; 13331 this.refBadgeToReplace = null; 13332 const requestBody = { 13333 node_id: nodeId, 13334 scene_id: sceneId, 13335 file_hash: fileHash, 13336 from_library: false, 13337 source 13338 }; 13339 if (replaceHash) { 13340 requestBody.replace_hash = replaceHash; 13341 } 13342 const response = await fetch(`/flow/${flowId}/reference`, { 13343 method: "POST", 13344 headers: { 13345 "Content-Type": "application/json", 13346 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13347 }, 13348 body: JSON.stringify(requestBody) 13349 }); 13350 if (!response.ok) { 13351 throw new Error("Failed to add reference"); 13352 } 13353 const data = await response.json(); 13354 if (loadingNodeId) { 13355 this.removeLoadingNode(loadingNodeId); 13356 } 13357 if (data.nodes) { 13358 this.clearAllNodes(); 13359 this.loadNodesData(data.nodes); 13360 if (this.repositionTree) { 13361 this.repositionTree(); 13362 } 13363 if (this.updateConnections) { 13364 this.updateConnections(); 13365 } 13366 if (this.startPolling) { 13367 this.startPolling(); 13368 } 13369 } 13370 } 13371}; 13372
13373// app/javascript/flow/generation.js 13374var loadingSpinnerSvg = `<svg class="w-5 h-5 text-white animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 13375 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"></circle> 13376 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="32" stroke-dashoffset="24"></circle> 13377</svg>`; 13378var chevronSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5 text-white" style="margin-left: 1px;"><path d="m9 18 6-6-6-6"></path></svg>`; 13379var GenerationMixin = { 13380 initGeneration() { 13381 this.generationCount = 1; 13382 }, 13383 // Set generate button to loading state 13384 setGenerateButtonLoading(nodeEl, isLoading) { 13385 const generateBtn = nodeEl.querySelector("[data-flow-generate]"); 13386 if (!generateBtn) return; 13387 if (isLoading) { 13388 generateBtn.innerHTML = loadingSpinnerSvg; 13389 generateBtn.disabled = true; 13390 generateBtn.classList.add("pointer-events-none", "opacity-70"); 13391 } else { 13392 generateBtn.innerHTML = chevronSvg; 13393 generateBtn.disabled = false; 13394 generateBtn.classList.remove("pointer-events-none", "opacity-70"); 13395 } 13396 }, 13397 bindNodeControls(nodeEl, nodeData) { 13398 const generateBtn = nodeEl.querySelector("[data-flow-generate]"); 13399 if (generateBtn) { 13400 generateBtn.addEventListener("click", (e) => { 13401 e.stopPropagation(); 13402 this.handleGenerate(nodeEl, nodeData); 13403 }); 13404 } 13405 const plusBtn = nodeEl.querySelector("[data-flow-plus]"); 13406 if (plusBtn) { 13407 plusBtn.addEventListener("click", (e) => { 13408 e.stopPropagation(); 13409 this.incrementCount(nodeEl); 13410 }); 13411 } 13412 const minusBtn = nodeEl.querySelector("[data-flow-minus]"); 13413 if (minusBtn) { 13414 minusBtn.addEventListener("click", (e) => { 13415 e.stopPropagation(); 13416 this.decrementCount(nodeEl); 13417 }); 13418 } 13419 const counterBadge = nodeEl.querySelector("[data-flow-counter]"); 13420 const badgeText = nodeEl.querySelector("[data-flow-counter-badge]"); 13421 const controls = nodeEl.querySelector("[data-flow-counter-controls]"); 13422 if (counterBadge && badgeText && controls) { 13423 counterBadge.addEventListener("mouseenter", () => { 13424 badgeText.style.display = "none"; 13425 controls.style.display = "flex"; 13426 controls.style.animation = "none"; 13427 setTimeout(() => { 13428 controls.style.animation = "counterControlsSlide 0.3s ease"; 13429 }, 10); 13430 }); 13431 counterBadge.addEventListener("mouseleave", () => { 13432 controls.style.display = "none"; 13433 badgeText.style.display = "block"; 13434 }); 13435 nodeEl.addEventListener("mouseenter", () => { 13436 badgeText.style.animation = "none"; 13437 setTimeout(() => { 13438 badgeText.style.animation = "counterBadgeBump 0.3s ease"; 13439 }, 10); 13440 }); 13441 } 13442 const deleteBtn = nodeEl.querySelector("[data-flow-delete]"); 13443 const overButtons = nodeEl.querySelector("[data-flow-over-buttons]"); 13444 if (deleteBtn) { 13445 deleteBtn.addEventListener("click", (e) => { 13446 e.stopPropagation(); 13447 if (nodeData && nodeData.id) { 13448 this.confirmDeleteNode(nodeData.id); 13449 } 13450 }); 13451 if (nodeData && nodeData.nodeData && nodeData.nodeData.node_type !== "root" && overButtons) { 13452 overButtons.style.display = "flex"; 13453 } 13454 } 13455 const attachBtn = nodeEl.querySelector("[data-flow-attach]"); 13456 if (attachBtn) { 13457 attachBtn.addEventListener("click", (e) => { 13458 e.stopPropagation(); 13459 if (nodeData && nodeData.id) { 13460 this.showAttachMenu(nodeData.id, attachBtn); 13461 } 13462 }); 13463 } 13464 const editBtn = nodeEl.querySelector("[data-flow-edit]"); 13465 if (editBtn) { 13466 editBtn.addEventListener("click", (e) => { 13467 e.stopPropagation(); 13468 if (nodeData && nodeData.id) { 13469 this.handleEdit(nodeData.id); 13470 } 13471 }); 13472 } 13473 const appsBtn = nodeEl.querySelector("[data-flow-apps]"); 13474 if (appsBtn) { 13475 appsBtn.addEventListener("click", (e) => { 13476 e.stopPropagation(); 13477 if (nodeData && nodeData.id) { 13478 this.openAppsModal(nodeData.id); 13479 } 13480 }); 13481 } 13482 const upscaleBtn = nodeEl.querySelector("[data-flow-upscale]"); 13483 if (upscaleBtn) { 13484 upscaleBtn.addEventListener("click", (e) => { 13485 e.stopPropagation(); 13486 if (nodeData && nodeData.id) { 13487 this.handleUpscale(nodeData.id); 13488 } 13489 }); 13490 } 13491 const extractFrameBtn = nodeEl.querySelector("[data-flow-extract-frame]"); 13492 if (extractFrameBtn) { 13493 extractFrameBtn.addEventListener("click", (e) => { 13494 e.stopPropagation(); 13495 if (nodeData && nodeData.id) { 13496 this.handleExtractFrame(nodeData.id); 13497 } 13498 }); 13499 } 13500 const animateBtn = nodeEl.querySelector("[data-flow-animate]"); 13501 if (animateBtn) { 13502 animateBtn.addEventListener("click", (e) => { 13503 e.stopPropagation(); 13504 if (nodeData && nodeData.id) { 13505 this.handleAnimate(nodeData.id); 13506 } 13507 }); 13508 } 13509 const isVideoNode = nodeData?.nodeData?.node_type === "video"; 13510 const isVideoUpscaleNode = nodeData?.nodeData?.node_type === "upscaler" && nodeData?.nodeData?.upscale_type === "video"; 13511 const isImageNode = nodeData?.nodeData?.node_type === "image"; 13512 const isImageUpscaleNode = nodeData?.nodeData?.node_type === "upscaler" && nodeData?.nodeData?.upscale_type !== "video"; 13513 if (isVideoNode) { 13514 const editWrapper = nodeEl.querySelector("[data-flow-edit]")?.closest(".relative"); 13515 const animateWrapper = nodeEl.querySelector("[data-flow-animate]")?.closest(".relative"); 13516 const appsWrapper = nodeEl.querySelector("[data-flow-apps]")?.closest(".relative"); 13517 if (editWrapper) editWrapper.style.display = "none"; 13518 if (animateWrapper) animateWrapper.style.display = "none"; 13519 if (appsWrapper) appsWrapper.style.display = "none"; 13520 const regenerateBtn = nodeEl.querySelector("[data-flow-regenerate]"); 13521 if (regenerateBtn) regenerateBtn.style.display = "none"; 13522 } 13523 if (isImageNode || isImageUpscaleNode) { 13524 const extractFrameWrapper = nodeEl.querySelector("[data-flow-extract-frame]")?.closest(".relative"); 13525 const upscaleWrapper = nodeEl.querySelector("[data-flow-upscale]")?.closest(".relative"); 13526 if (extractFrameWrapper) extractFrameWrapper.style.display = "none"; 13527 if (upscaleWrapper) upscaleWrapper.style.display = "none"; 13528 } 13529 if (isVideoUpscaleNode) { 13530 const editWrapper = nodeEl.querySelector("[data-flow-edit]")?.closest(".relative"); 13531 const animateWrapper = nodeEl.querySelector("[data-flow-animate]")?.closest(".relative"); 13532 const appsWrapper = nodeEl.querySelector("[data-flow-apps]")?.closest(".relative"); 13533 const upscaleWrapper = nodeEl.querySelector("[data-flow-upscale]")?.closest(".relative"); 13534 const downloadWrapper = nodeEl.querySelector("[data-flow-download]")?.closest(".relative"); 13535 const saveWrapper = nodeEl.querySelector("[data-flow-save]")?.closest(".relative"); 13536 const regenerateBtn = nodeEl.querySelector("[data-flow-regenerate]"); 13537 if (editWrapper) editWrapper.style.display = "none"; 13538 if (animateWrapper) animateWrapper.style.display = "none"; 13539 if (appsWrapper) appsWrapper.style.display = "none"; 13540 if (upscaleWrapper) upscaleWrapper.style.display = "none"; 13541 if (downloadWrapper) downloadWrapper.style.display = "none"; 13542 if (saveWrapper) saveWrapper.style.display = "none"; 13543 if (regenerateBtn) regenerateBtn.style.display = "none"; 13544 } 13545 const addToFinalsBtn = nodeEl.querySelector("[data-flow-add-to-finals]"); 13546 if (addToFinalsBtn) { 13547 const isSaved = nodeData.nodeData?.saved || false; 13548 const textSpan = addToFinalsBtn.querySelector("[data-final-text]"); 13549 const iconSpan = addToFinalsBtn.querySelector("[data-final-icon]"); 13550 if (textSpan) { 13551 textSpan.textContent = isSaved ? "REMOVE FROM FAVS" : "ADD TO FAVS"; 13552 } 13553 if (iconSpan) { 13554 if (isSaved) { 13555 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'; 13556 } else { 13557 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'; 13558 } 13559 } 13560 addToFinalsBtn.addEventListener("click", (e) => { 13561 e.stopPropagation(); 13562 if (nodeData && nodeData.nodeData) { 13563 this.handleAddToFinals(nodeData.nodeData, addToFinalsBtn); 13564 } 13565 }); 13566 } 13567 const regenerateBtns = nodeEl.querySelectorAll("[data-flow-regenerate], [data-flow-regenerate-bottom]"); 13568 regenerateBtns.forEach((regenerateBtn) => { 13569 regenerateBtn.addEventListener("click", (e) => { 13570 e.stopPropagation(); 13571 if (nodeData && nodeData.id) { 13572 this.handleRegenerateNode(nodeData.id); 13573 } 13574 }); 13575 }); 13576 const promptTextEl = nodeEl.querySelector("[data-flow-generated-prompt]"); 13577 if (promptTextEl) { 13578 promptTextEl.addEventListener("click", (e) => { 13579 e.stopPropagation(); 13580 if (!this.checkPermissions("edit")) return; 13581 if (nodeData && nodeData.id) { 13582 this.openPromptEditModal(nodeData.id, promptTextEl.textContent); 13583 } 13584 }); 13585 } 13586 if (this.bindCommentsButton && nodeData && nodeData.id) { 13587 this.bindCommentsButton(nodeEl, nodeData.id, nodeData.nodeData?.comments || 0); 13588 } 13589 }, 13590 incrementCount(nodeEl) { 13591 const countEl = nodeEl.querySelector("[data-flow-count]"); 13592 if (!countEl) return;
13593 let count = parseInt(countEl.textContent) || 1; 13594 count = Math.min(count + 1, 4); 13595 countEl.textContent = count; 13596 this.generationCount = count; 13597 const badgeTextEl = nodeEl.querySelector("[data-flow-count-text]"); 13598 if (badgeTextEl) { 13599 badgeTextEl.textContent = count; 13600 } 13601 if (nodeEl.updateModelPricing) { 13602 nodeEl.updateModelPricing(); 13603 } 13604 }, 13605 decrementCount(nodeEl) { 13606 const countEl = nodeEl.querySelector("[data-flow-count]"); 13607 if (!countEl) return; 13608 let count = parseInt(countEl.textContent) || 1; 13609 count = Math.max(count - 1, 1); 13610 countEl.textContent = count; 13611 this.generationCount = count; 13612 const badgeTextEl = nodeEl.querySelector("[data-flow-count-text]"); 13613 if (badgeTextEl) { 13614 badgeTextEl.textContent = count; 13615 } 13616 if (nodeEl.updateModelPricing) { 13617 nodeEl.updateModelPricing(); 13618 } 13619 }, 13620 async handleGenerate(nodeEl, nodeData) { 13621 if (!this.checkPermissions("generate")) return; 13622 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 13623 if (!promptEl) return; 13624 const prompt2 = this.getEditorTextContent ? this.getEditorTextContent(promptEl).trim() : promptEl.textContent.trim(); 13625 if (!prompt2) { 13626 if (window.Toast) { 13627 window.Toast.show("Add a prompt", "alert"); 13628 } 13629 return; 13630 } 13631 this.setGenerateButtonLoading(nodeEl, true); 13632 try { 13633 if (this.flowId) { 13634 await this.generateNodes(nodeEl, nodeData); 13635 } else { 13636 await this.createFlowWithNodes(nodeEl); 13637 } 13638 } finally { 13639 if (this.flowId) { 13640 this.setGenerateButtonLoading(nodeEl, false); 13641 } 13642 } 13643 }, 13644 async generateNodes(nodeEl, nodeData) { 13645 try { 13646 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 13647 const prompt2 = this.getEditorTextContent ? this.getEditorTextContent(promptEl).trim() : promptEl.textContent.trim(); 13648 const countEl = nodeEl.querySelector("[data-flow-count]"); 13649 const count = countEl ? parseInt(countEl.textContent) || 1 : 1; 13650 const selectedRatio = nodeEl.dataset.selectedRatio || "4:3"; 13651 const selectedModel = nodeEl.dataset.selectedModel || this.selectedModel; 13652 const selectedDuration = nodeEl.dataset.selectedDuration || null; 13653 const videoAudio = nodeEl.dataset.videoAudio; 13654 const currentNodeStatus = nodeData.nodeData?.status; 13655 const currentNodeType = nodeData.nodeData?.node_type; 13656 const isInitNode = ["root", "image", "video", "upscaler"].includes(currentNodeType) && currentNodeStatus === "init"; 13657 const requestBody = { 13658 prompt: prompt2, 13659 count, 13660 ratio: selectedRatio, 13661 model: selectedModel, 13662 flow_id: this.flowId 13663 }; 13664 if (isInitNode) { 13665 requestBody.node_id = nodeData.id; 13666 } else { 13667 requestBody.parent_id = nodeData.id; 13668 } 13669 if (selectedDuration) { 13670 requestBody.duration = selectedDuration; 13671 } 13672 if (videoAudio !== void 0) { 13673 requestBody.audio = videoAudio === "true" || videoAudio === true; 13674 } 13675 const csrfToken = document.querySelector('[name="csrf-token"]'); 13676 const response = await fetch("/flows/generate", { 13677 method: "POST", 13678 headers: { 13679 "Content-Type": "application/json", 13680 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13681 }, 13682 body: JSON.stringify(requestBody) 13683 }); 13684 const data = await response.json(); 13685 if (!response.ok) { 13686 if (window.Toast && data.error) { 13687 window.Toast.show(data.error, "alert"); 13688 } 13689 return; 13690 } 13691 if (data.nodes) { 13692 const oldNodeIds = Object.keys(this.nodes); 13693 this.clearAllNodes(); 13694 this.loadNodesData(data.nodes); 13695 if (this.repositionTree) { 13696 this.repositionTree(); 13697 } 13698 if (this.updateConnections) { 13699 this.updateConnections(); 13700 } 13701 const newNodeIds = Object.keys(this.nodes).filter( 13702 (id) => !oldNodeIds.includes(id) 13703 ); 13704 if (newNodeIds.length >
13704 0 && this.zoomToNodes) { 13705 setTimeout(() => { 13706 this.zoomToNodes(newNodeIds); 13707 }, 50); 13708 } 13709 const processingNodes = Object.values(this.nodes).filter( 13710 (node) => node.data?.nodeData?.status === "processing" 13711 ); 13712 if (processingNodes.length > 0 && this.startPolling) { 13713 this.startPolling(); 13714 } 13715 } 13716 if (isInitNode) { 13717 if (this.generateFlowNameIfNeeded) { 13718 await this.generateFlowNameIfNeeded(); 13719 } 13720 if (this.currentSceneId && this.generateSceneName) { 13721 const currentScene = this.scenes?.find( 13722 (s) => s.uuid === this.currentSceneId 13723 ); 13724 if (currentScene && currentScene.name !== "Main") { 13725 await this.generateSceneName(this.currentSceneId); 13726 } 13727 } 13728 } 13729 } catch (error) { 13730 console.error("Generation error:", error); 13731 if (window.Toast) { 13732 window.Toast.show("Generation failed", "alert"); 13733 } 13734 } 13735 }, 13736 async createFlowWithNodes(nodeEl) { 13737 try { 13738 const promptEl = nodeEl.querySelector("[data-flow-prompt]"); 13739 const prompt2 = this.getEditorTextContent ? this.getEditorTextContent(promptEl).trim() : promptEl.textContent.trim(); 13740 if (!prompt2) { 13741 if (window.Toast) { 13742 window.Toast.show("Add a prompt", "alert"); 13743 } 13744 return; 13745 } 13746 const countEl = nodeEl.querySelector("[data-flow-count]"); 13747 const count = countEl ? parseInt(countEl.textContent) || 1 : 1; 13748 const selectedRatio = nodeEl.dataset.selectedRatio || "4:3"; 13749 const selectedModel = nodeEl.dataset.selectedModel || this.selectedModel; 13750 const selectedDuration = nodeEl.dataset.selectedDuration || null; 13751 const requestBody = { 13752 prompt: prompt2, 13753 count, 13754 ratio: selectedRatio, 13755 model: selectedModel 13756 }; 13757 if (selectedDuration) { 13758 requestBody.duration = selectedDuration; 13759 } 13760 const csrfToken = document.querySelector('[name="csrf-token"]'); 13761 const response = await fetch("/flows", { 13762 method: "POST", 13763 headers: { 13764 "Content-Type": "application/json", 13765 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13766 }, 13767 body: JSON.stringify(requestBody) 13768 }); 13769 if (!response.ok) { 13770 throw new Error("Failed to create flow"); 13771 } 13772 const data = await response.json(); 13773 if (data.flow && data.flow.id) { 13774 window.location.href = `/flow/${data.flow.id}`; 13775 } 13776 } catch (error) { 13777 console.error("Create flow error:", error); 13778 if (window.Toast) { 13779 window.Toast.show("Failed to create flow", "alert"); 13780 } 13781 } 13782 }, 13783 confirmDeleteNode(nodeId) { 13784 if (!this.checkPermissions("delete")) return; 13785 const node = this.nodes[nodeId]; 13786 if (node && node.data.nodeData && node.data.nodeData.level === 0) { 13787 if (window.Toast) { 13788 window.Toast.show("Cannot delete root node", "alert"); 13789 } 13790 return; 13791 } 13792 this.nodeIdToDelete = nodeId; 13793 this.loadDeleteNodeModal(nodeId); 13794 }, 13795 loadDeleteNodeModal(nodeId) { 13796 const url = `/flow/${this.flowId}/node/${nodeId}/delete`; 13797 fetch(url, { 13798 headers: { 13799 Accept: "text/html", 13800 "X-Requested-With": "XMLHttpRequest" 13801 } 13802 }).then((response) => response.text()).then((html) => { 13803 const existingModal = document.getElementById("delete-node-modal"); 13804 if (existingModal) { 13805 existingModal.remove(); 13806 } 13807 document.body.insertAdjacentHTML("beforeend", html); 13808 this.setupDeleteNodeModal(); 13809 if (window.Modal) { 13810 window.Modal.open("delete-node-modal"); 13811 } 13812 }).catch((error) => { 13813 console.error("Error loading delete node modal:", error); 13814 if (window.Toast) { 13815 window.Toast.show("Failed to load delete modal", "alert"); 13816 } 13817 }); 13818 }, 13819 setupDeleteNodeModal() { 13820 const confirmBtn = document.querySelector("[data-delete-node-confirm]"); 13821 if (confirmBtn) { 13822 confirmBtn.addEventListener("click", async () => { 13823 if (!this.nodeIdToDelete || !this.flowId) return; 13824 try { 13825 const csrfToken = document.querySelector('[name="csrf-token"]'); 13826 const response = await fetch( 13827 `/flow/${this.flowId}/node/${this.nodeIdToDelete}/delete`, 13828 { 13829 method: "DELETE", 13830 headers: { 13831 "Content-Type": "application/json", 13832 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13833 }, 13834 body: JSON.stringify({ 13835 node_id: this.nodeIdToDelete 13836 }) 13837 } 13838 ); 13839 const data = await response.json(); 13840 if (!response.ok) { 13841 if (window.Toast && data.error) { 13842 window.Toast.show(data.error, "alert"); 13843 }
13844 window.Modal?.close(); 13845 return; 13846 } 13847 window.Modal?.close(); 13848 if (data.nodes) { 13849 console.log("Delete: Received nodes:", data.nodes.length); 13850 this.clearAllNodes(); 13851 this.loadNodesData(data.nodes); 13852 console.log( 13853 "Delete: Loaded nodes:", 13854 Object.keys(this.nodes).length 13855 ); 13856 if (this.repositionTree) { 13857 this.repositionTree(); 13858 } 13859 if (this.updateConnections) { 13860 this.updateConnections(); 13861 } 13862 if (window.Toast) { 13863 window.Toast.show("Node deleted successfully", "success"); 13864 } 13865 } else { 13866 console.error("Delete: No nodes in response"); 13867 } 13868 this.nodeIdToDelete = null; 13869 } catch (error) { 13870 console.error("Delete node error:", error); 13871 window.Modal?.close(); 13872 if (window.Toast) { 13873 window.Toast.show("Failed to delete node", "alert"); 13874 } 13875 } 13876 }); 13877 } 13878 }, 13879 async handleEdit(nodeId) { 13880 if (!this.checkPermissions("edit")) return; 13881 if (!this.flowId) return; 13882 try { 13883 const parentNode = this.nodes[nodeId]; 13884 const parentRatio = parentNode?.data?.nodeData?.ratio; 13885 const csrfToken = document.querySelector('[name="csrf-token"]'); 13886 const requestBody = { 13887 parent_id: nodeId, 13888 node_type: "image" 13889 // Status will default to 'init' in backend 13890 }; 13891 if (parentRatio) { 13892 requestBody.ratio = parentRatio; 13893 } 13894 const response = await fetch(`/flow/${this.flowId}/add`, { 13895 method: "POST", 13896 headers: { 13897 "Content-Type": "application/json", 13898 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13899 }, 13900 body: JSON.stringify(requestBody) 13901 }); 13902 const data = await response.json(); 13903 if (!response.ok) { 13904 if (window.Toast && data.error) { 13905 window.Toast.show(data.error, "alert"); 13906 } 13907 return; 13908 } 13909 if (data.nodes) { 13910 const oldNodeIds = Object.keys(this.nodes); 13911 this.clearAllNodes(); 13912 this.loadNodesData(data.nodes); 13913 if (this.repositionTree) { 13914 this.repositionTree(); 13915 } 13916 if (this.updateConnections) { 13917 this.updateConnections(); 13918 } 13919 const newNodeIds = Object.keys(this.nodes).filter( 13920 (id) => !oldNodeIds.includes(id) 13921 ); 13922 if (newNodeIds.length >
13922 0 && this.zoomToNodes) { 13923 setTimeout(() => { 13924 this.zoomToNodes(newNodeIds); 13925 }, 50); 13926 } 13927 } 13928 } catch (error) { 13929 console.error("Edit error:", error); 13930 if (window.Toast) { 13931 window.Toast.show("Failed to create edit node", "alert"); 13932 } 13933 } 13934 }, 13935 async handleUpscale(nodeId) { 13936 if (!this.checkPermissions("upscale")) return; 13937 if (!this.flowId) return; 13938 try { 13939 const parentNode = this.nodes[nodeId]; 13940 const parentNodeType = parentNode?.data?.nodeData?.node_type; 13941 const isVideoNode = parentNodeType === "video"; 13942 const isVideoUpscaleNode = parentNodeType === "upscaler" && parentNode?.data?.nodeData?.upscale_type === "video"; 13943 if (isVideoNode || isVideoUpscaleNode) { 13944 await this.openUpscaleModal(nodeId); 13945 return; 13946 } 13947 const upscaleModel = "seedvr2"; 13948 const csrfToken = document.querySelector('[name="csrf-token"]'); 13949 const response = await fetch("/flows/generate", { 13950 method: "POST", 13951 headers: { 13952 "Content-Type": "application/json", 13953 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 13954 }, 13955 body: JSON.stringify({ 13956 parent_id: nodeId, 13957 flow_id: this.flowId, 13958 upscale: true, 13959 model: upscaleModel, 13960 prompt: "" 13961 // Empty prompt - upscaler will display parent's prompt 13962 }) 13963 }); 13964 const data = await response.json(); 13965 if (!response.ok) { 13966 if (window.Toast && data.error) { 13967 window.Toast.show(data.error, "alert"); 13968 } 13969 return; 13970 } 13971 if (data.nodes) { 13972 const oldNodeIds = Object.keys(this.nodes); 13973 this.clearAllNodes(); 13974 this.loadNodesData(data.nodes); 13975 if (this.repositionTree) { 13976 this.repositionTree(); 13977 } 13978 if (this.updateConnections) { 13979 this.updateConnections(); 13980 } 13981 const newNodeIds = Object.keys(this.nodes).filter( 13982 (id) => !oldNodeIds.includes(id) 13983 ); 13984 if (newNodeIds.length >
13984 0 && this.zoomToNodes) { 13985 setTimeout(() => { 13986 this.zoomToNodes(newNodeIds); 13987 }, 50); 13988 } 13989 if (this.startPolling) { 13990 this.startPolling(); 13991 } 13992 } 13993 } catch (error) { 13994 console.error("Upscale error:", error); 13995 if (window.Toast) { 13996 window.Toast.show("Upscaling failed", "alert"); 13997 } 13998 } 13999 }, 14000 async handleExtractFrame(nodeId) { 14001 if (!this.checkPermissions("extract frame")) return; 14002 if (!this.flowId) return; 14003 try { 14004 const csrfToken = document.querySelector('[name="csrf-token"]'); 14005 const response = await fetch("/flows/generate", { 14006 method: "POST", 14007 headers: { 14008 "Content-Type": "application/json", 14009 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14010 }, 14011 body: JSON.stringify({ 14012 parent_id: nodeId, 14013 flow_id: this.flowId, 14014 extract_frame: true, 14015 prompt: "" 14016 }) 14017 }); 14018 const data = await response.json(); 14019 if (!response.ok) { 14020 if (window.Toast && data.error) { 14021 window.Toast.show(data.error, "alert"); 14022 } 14023 return; 14024 } 14025 if (data.nodes) { 14026 const oldNodeIds = Object.keys(this.nodes); 14027 this.clearAllNodes(); 14028 this.loadNodesData(data.nodes); 14029 if (this.repositionTree) { 14030 this.repositionTree(); 14031 } 14032 if (this.updateConnections) { 14033 this.updateConnections(); 14034 } 14035 const newNodeIds = Object.keys(this.nodes).filter( 14036 (id) => !oldNodeIds.includes(id) 14037 ); 14038 if (newNodeIds.length >
14038 0 && this.zoomToNodes) { 14039 setTimeout(() => { 14040 this.zoomToNodes(newNodeIds); 14041 }, 50); 14042 } 14043 if (this.startPolling) { 14044 this.startPolling(); 14045 } 14046 } 14047 } catch (error) { 14048 console.error("Extract frame error:", error); 14049 if (window.Toast) { 14050 window.Toast.show("Failed to extract frame", "alert"); 14051 } 14052 } 14053 }, 14054 async openUpscaleModal(nodeId) { 14055 try { 14056 const response = await fetch("/modal/upscale"); 14057 const html = await response.text(); 14058 const existingModal = document.getElementById("upscale-modal"); 14059 if (existingModal) { 14060 existingModal.remove(); 14061 } 14062 document.body.insertAdjacentHTML("beforeend", html); 14063 const modal = document.getElementById("upscale-modal"); 14064 if (!modal) { 14065 console.error("[Upscale Modal] Modal element not found after injection"); 14066 return; 14067 } 14068 const parentNode = this.nodes[nodeId]; 14069 const nodeData = parentNode?.data?.nodeData; 14070 const durationSeconds = nodeData?.media_duration || 8; 14071 const costPerSecond = parseFloat(modal.dataset.costPerSecond) || 10; 14072 const initialCost = Math.floor(durationSeconds * costPerSecond); 14073 const durationEl = modal.querySelector("[data-upscale-duration]"); 14074 const costEl = modal.querySelector("[data-upscale-cost]"); 14075 if (durationEl) durationEl.textContent = durationSeconds; 14076 if (costEl) costEl.textContent = initialCost.toLocaleString("en-US"); 14077 modal.dataset.nodeId = nodeId; 14078 modal.dataset.duration = durationSeconds; 14079 const factorButtons = modal.querySelectorAll("[data-upscale-factor]"); 14080 factorButtons.forEach((btn) => { 14081 btn.addEventListener("click", () => { 14082 factorButtons.forEach((b) => b.classList.remove("active")); 14083 btn.classList.add("active"); 14084 const cost = Math.floor(durationSeconds * costPerSecond); 14085 const costEl2 = modal.querySelector("[data-upscale-cost]"); 14086 if (costEl2) costEl2.textContent = cost.toLocaleString("en-US"); 14087 }); 14088 }); 14089 const confirmBtn = modal.querySelector("[data-upscale-confirm]"); 14090 confirmBtn.addEventListener("click", async () => { 14091 const selectedFactor = modal.querySelector("[data-upscale-factor].active")?.dataset.upscaleFactor || "2"; 14092 modal.classList.add("hidden"); 14093 modal.remove(); 14094 await this.confirmUpscale(nodeId, selectedFactor); 14095 }); 14096 const closeButtons = modal.querySelectorAll("[data-modal-close]"); 14097 closeButtons.forEach((btn) => { 14098 btn.addEventListener("click", () => { 14099 modal.classList.add("hidden"); 14100 modal.remove(); 14101 }); 14102 }); 14103 modal.classList.add("hidden"); 14104 modal.classList.remove("modal-showing"); 14105 modal.style.opacity = "0"; 14106 modal.offsetHeight; 14107 requestAnimationFrame(() => { 14108 requestAnimationFrame(() => { 14109 modal.style.opacity = ""; 14110 modal.classList.remove("hidden"); 14111 document.body.classList.add("modal-open"); 14112 requestAnimationFrame(() => { 14113 modal.classList.add("modal-showing"); 14114 }); 14115 }); 14116 }); 14117 } catch (error) { 14118 console.error("[Upscale Modal] Error opening modal:", error); 14119 if (window.Toast) { 14120 window.Toast.show("Failed to open upscale modal", "alert"); 14121 } 14122 } 14123 }, 14124 async confirmUpscale(nodeId, upscaleFactor) { 14125 if (!this.flowId) return; 14126 try { 14127 const csrfToken = document.querySelector('[name="csrf-token"]'); 14128 const payload = { 14129 parent_id: nodeId, 14130 flow_id: this.flowId, 14131 upscale: true, 14132 model: "topaz", 14133 prompt: "", 14134 upscale_factor: parseInt(upscaleFactor) 14135 }; 14136 const response = await fetch("/flows/generate", { 14137 method: "POST", 14138 headers: { 14139 "Content-Type": "application/json", 14140 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14141 }, 14142 body: JSON.stringify(payload) 14143 }); 14144 const data = await response.json(); 14145 if (!response.ok) { 14146 if (window.Toast && data.error) { 14147 window.Toast.show(data.error, "alert"); 14148 } 14149 return; 14150 } 14151 if (data.nodes) { 14152 const oldNodeIds = Object.keys(this.nodes); 14153 this.clearAllNodes(); 14154 this.loadNodesData(data.nodes); 14155 if (this.repositionTree) { 14156 this.repositionTree(); 14157 } 14158 if (this.updateConnections) { 14159 this.updateConnections(); 14160 } 14161 const newNodeIds = Object.keys(this.nodes).filter( 14162 (id) => !oldNodeIds.includes(id) 14163 ); 14164 if (newNodeIds.length >
14164 0 && this.zoomToNodes) { 14165 setTimeout(() => { 14166 this.zoomToNodes(newNodeIds); 14167 }, 50); 14168 } 14169 if (this.startPolling) { 14170 this.startPolling(); 14171 } 14172 } 14173 } catch (error) { 14174 console.error("Upscale error:", error); 14175 if (window.Toast) { 14176 window.Toast.show("Upscaling failed", "alert"); 14177 } 14178 } 14179 }, 14180 async openAppsModal(nodeId) { 14181 try { 14182 const node = this.nodes[nodeId]; 14183 const nodeData = node?.data?.nodeData; 14184 const imageUrl = nodeData?.media_url; 14185 if (!imageUrl) { 14186 if (window.Toast) window.Toast.show("No image available", "alert"); 14187 return; 14188 } 14189 const response = await fetch("/modal/apps"); 14190 const html = await response.text(); 14191 const existingModal = document.getElementById("apps-modal"); 14192 if (existingModal) existingModal.remove(); 14193 document.body.insertAdjacentHTML("beforeend", html); 14194 const modal = document.getElementById("apps-modal"); 14195 if (!modal) return; 14196 const closeModal = () => { 14197 modal.classList.remove("modal-showing"); 14198 setTimeout(() => { 14199 modal.remove(); 14200 document.body.classList.remove("modal-open"); 14201 }, 200); 14202 }; 14203 modal.querySelector("[data-modal-close]")?.addEventListener("click", closeModal); 14204 modal.addEventListener("click", (e) => { 14205 if (e.target === modal) closeModal(); 14206 }); 14207 modal.querySelectorAll("[data-app-slug]").forEach((card) => { 14208 card.addEventListener("click", () => { 14209 const slug = card.dataset.appSlug; 14210 closeModal(); 14211 setTimeout(() => this.openFlowAppModal(slug, imageUrl, nodeId), 250); 14212 }); 14213 }); 14214 modal.classList.remove("hidden"); 14215 document.body.classList.add("modal-open"); 14216 requestAnimationFrame(() => { 14217 modal.classList.add("modal-showing"); 14218 }); 14219 } catch (error) { 14220 console.error("[Apps Modal] Error:", error); 14221 if (window.Toast) window.Toast.show("Failed to open apps modal", "alert"); 14222 } 14223 }, 14224 async openFlowAppModal(slug, imageUrl, nodeId) { 14225 try { 14226 const response = await fetch(`/modal/app?slug=${slug}`); 14227 if (!response.ok) { 14228 window.Toast?.show("Failed to load app", "alert"); 14229 return; 14230 } 14231 const html = await response.text(); 14232 const existingModal = document.getElementById("flow-app-modal"); 14233 if (existingModal) existingModal.remove(); 14234 document.body.insertAdjacentHTML("beforeend", html); 14235 const modal = document.getElementById("flow-app-modal"); 14236 if (!modal) return; 14237 const imgEl = modal.querySelector("#flow-app-image"); 14238 if (imgEl) imgEl.src = imageUrl; 14239 const closeModal = () => { 14240 modal.classList.remove("modal-showing"); 14241 setTimeout(() => { 14242 modal.remove(); 14243 document.body.classList.remove("modal-open"); 14244 }, 200); 14245 }; 14246 modal.querySelector("[data-modal-close]")?.addEventListener("click", closeModal); 14247 modal.addEventListener("click", (e) => { 14248 if (e.target === modal) closeModal(); 14249 }); 14250 const generateBtn = modal.querySelector("#flow-app-generate-btn"); 14251 modal.querySelectorAll(".flow-app-model-btn").forEach((btn) => { 14252 btn.addEventListener("click", () => { 14253 modal.querySelectorAll(".flow-app-model-btn").forEach((b) => { 14254 b.classList.remove("border-active", "bg-active/10"); 14255 b.classList.add("border-light/40"); 14256 }); 14257 btn.classList.add("border-active", "bg-active/10"); 14258 btn.classList.remove("border-light/40"); 14259 if (generateBtn) { 14260 generateBtn.dataset.model = btn.dataset.model; 14261 const costEl = modal.querySelector("#flow-app-cost"); 14262 if (costEl) costEl.textContent = btn.dataset.cost; 14263 } 14264 }); 14265 }); 14266 modal.querySelectorAll(".flow-app-option-btn").forEach((btn) => { 14267 btn.addEventListener("click", () => { 14268 const container = btn.closest("[data-param]"); 14269 if (!container) return; 14270 const input = modal.querySelector(`input[name="${container.dataset.param}"]`); 14271 if (input) input.value = btn.dataset.value; 14272 container.querySelectorAll("button").forEach((b) => { 14273 b.classList.remove("border-active", "bg-active/10", "text-white"); 14274 b.classList.add("border-light/40", "text-gray"); 14275 }); 14276 btn.classList.add("border-active", "bg-active/10", "text-white"); 14277 btn.classList.remove("border-light/40", "text-gray"); 14278 }); 14279 }); 14280 const randomGridContainer = modal.querySelector(".locations-grid-container"); 14281 if (randomGridContainer) { 14282 const randomUrl = randomGridContainer.dataset.randomUrl || "/locations/random"; 14283 const hidden = randomGridContainer.previousElementSibling; 14284 const updateHidden = () => { 14285 const values = [...randomGridContainer.querySelectorAll(".locations-input")].map((i) => i.value); 14286 if (hidden) hidden.value = JSON.stringify(values); 14287 }; 14288 randomGridContainer.querySelectorAll(".locations-input").forEach((input) => { 14289 input.addEventListener("input", updateHidden); 14290 }); 14291 randomGridContainer.querySelectorAll(".locations-dice").forEach((btn) => { 14292 btn.addEventListener("click", async () => { 14293 const idx = parseInt(btn.dataset.index); 14294 const input = randomGridContainer.querySelector(`.locations-input[data-index="${idx}"]`); 14295 const current = [...randomGridContainer.querySelectorAll(".locations-input")].map((i) => i.value); 14296 btn.classList.add("animate-spin"); 14297 try { 14298 const res = await fetch(`${randomUrl}?exclude=${encodeURIComponent(JSON.stringify(current))}`); 14299 const data = await res.json(); 14300 if (data.location) { 14301 input.value = data.location; 14302 updateHidden(); 14303 } 14304 } catch (e) { 14305 console.error("Failed to fetch random item:", e); 14306 } 14307 btn.classList.remove("animate-spin"); 14308 }); 14309 }); 14310 } 14311 const perspGrid = modal.querySelector(".perspectives-grid"); 14312 if (perspGrid) { 14313 const perspHidden = perspGrid.previousElementSibling; 14314 const perspCount = modal.querySelector(".perspectives-count"); 14315 const maxPersp = parseInt(perspGrid.dataset.max || "9"); 14316 const checkSvg = '<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>'; 14317 const getSelected = () => [...perspGrid.querySelectorAll(".perspective-checkbox.checked")].map((l) => l.dataset.perspective); 14318 perspGrid.querySelectorAll(".perspective-checkbox").forEach((label) => { 14319 label.addEventListener("click", (e) => { 14320 e.preventDefault(); 14321 const box = label.querySelector(".perspective-box"); 14322 const txt = label.querySelector(".perspective-label"); 14323 const isChecked = label.classList.contains("checked"); 14324 if (isChecked) { 14325 label.classList.remove("checked"); 14326 box.classList.remove("bg-active", "border-active"); 14327 box.classList.add("border-light/40"); 14328 box.innerHTML = ""; 14329 txt.classList.remove("text-white"); 14330 txt.classList.add("text-gray"); 14331 } else { 14332 if (getSelected().length >= maxPersp) { 14333 window.Toast?.show(`Maximum ${maxPersp} perspectives`, "alert"); 14334 return; 14335 } 14336 label.classList.add("checked"); 14337 box.classList.add("bg-active", "border-active"); 14338 box.classList.remove("border-light/40"); 14339 box.innerHTML = checkSvg; 14340 txt.classList.add("text-white"); 14341 txt.classList.remove("text-gray"); 14342 } 14343 const selected = getSelected(); 14344 if (perspHidden) perspHidden.value = JSON.stringify(selected); 14345 if (perspCount) perspCount.textContent = `${selected.length}
14345/${maxPersp} selected`; 14346 }); 14347 }); 14348 } 14349 if (generateBtn) { 14350 generateBtn.addEventListener("click", () => { 14351 this.generateFromFlowApp(modal, slug, generateBtn.dataset.model, imageUrl, nodeId); 14352 }); 14353 } 14354 modal.classList.remove("hidden"); 14355 document.body.classList.add("modal-open"); 14356 requestAnimationFrame(() => { 14357 modal.classList.add("modal-showing"); 14358 }); 14359 } catch (error) { 14360 console.error("[Flow App Modal] Error:", error); 14361 window.Toast?.show("Failed to open app", "alert"); 14362 } 14363 }, 14364 async generateFromFlowApp(modal, slug, model, imageUrl, nodeId) { 14365 const params = {}; 14366 modal.querySelectorAll(".flow-app-param").forEach((input) => { 14367 if (input.name) params[input.name] = input.value; 14368 }); 14369 modal.classList.remove("modal-showing"); 14370 setTimeout(() => { 14371 modal.remove(); 14372 document.body.classList.remove("modal-open"); 14373 }, 200); 14374 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; 14375 try { 14376 const response = await fetch("/flows/generate-app", { 14377 method: "POST", 14378 headers: { 14379 "Content-Type": "application/json", 14380 "X-CSRF-Token": csrfToken || "" 14381 }, 14382 body: JSON.stringify({ 14383 flow_id: this.flowId, 14384 parent_id: nodeId, 14385 app_slug: slug, 14386 model, 14387 ...params 14388 }) 14389 }); 14390 const data = await response.json(); 14391 if (!response.ok) { 14392 window.Toast?.show(data.error || "Generation failed", "alert"); 14393 return; 14394 } 14395 if (data.nodes) { 14396 const oldNodeIds = Object.keys(this.nodes); 14397 this.clearAllNodes(); 14398 this.loadNodesData(data.nodes); 14399 if (this.repositionTree) this.repositionTree(); 14400 if (this.updateConnections) this.updateConnections(); 14401 const newNodeIds = Object.keys(this.nodes).filter((id) => !oldNodeIds.includes(id)); 14402 if (newNodeIds.length >
14402 0 && this.zoomToNodes) { 14403 setTimeout(() => this.zoomToNodes(newNodeIds), 50); 14404 } 14405 if (this.startPolling) this.startPolling(); 14406 } 14407 } catch (error) { 14408 console.error("[Flow App] Generate error:", error); 14409 window.Toast?.show("Generation failed", "alert"); 14410 } 14411 }, 14412 async handleAnimate(nodeId) { 14413 if (!this.checkPermissions("animate")) return; 14414 if (!this.flowId) return; 14415 try { 14416 const parentNode = this.nodes[nodeId]; 14417 const parentRatio = parentNode?.data?.nodeData?.ratio; 14418 const csrfToken = document.querySelector('[name="csrf-token"]'); 14419 const requestBody = { 14420 parent_id: nodeId, 14421 node_type: "video" 14422 // Status will default to 'init' in backend 14423 }; 14424 if (parentRatio) { 14425 requestBody.ratio = parentRatio; 14426 } 14427 const response = await fetch(`/flow/${this.flowId}/add`, { 14428 method: "POST", 14429 headers: { 14430 "Content-Type": "application/json", 14431 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14432 }, 14433 body: JSON.stringify(requestBody) 14434 }); 14435 const data = await response.json(); 14436 if (!response.ok) { 14437 if (window.Toast && data.error) { 14438 window.Toast.show(data.error, "alert"); 14439 } 14440 return; 14441 } 14442 if (data.nodes) { 14443 const oldNodeIds = Object.keys(this.nodes); 14444 this.clearAllNodes(); 14445 this.loadNodesData(data.nodes); 14446 if (this.repositionTree) { 14447 this.repositionTree(); 14448 } 14449 if (this.updateConnections) { 14450 this.updateConnections(); 14451 } 14452 const newNodeIds = Object.keys(this.nodes).filter( 14453 (id) => !oldNodeIds.includes(id) 14454 ); 14455 if (newNodeIds.length >
14455 0 && this.zoomToNodes) { 14456 setTimeout(() => { 14457 this.zoomToNodes(newNodeIds); 14458 }, 50); 14459 } 14460 } 14461 } catch (error) { 14462 console.error("Animate error:", error); 14463 if (window.Toast) { 14464 window.Toast.show("Failed to create animate node", "alert"); 14465 } 14466 } 14467 }, 14468 async handleAddToFinals(nodeData, buttonEl) { 14469 if (!this.checkPermissions("save")) return; 14470 if (!nodeData.media_id) { 14471 if (window.Toast) { 14472 window.Toast.show("No media to save", "alert"); 14473 } 14474 return; 14475 } 14476 try { 14477 const csrfToken = document.querySelector('[name="csrf-token"]'); 14478 const isSaved = nodeData.saved; 14479 let response; 14480 if (isSaved) { 14481 response = await fetch(`/favs/unsave`, { 14482 method: "DELETE", 14483 headers: { 14484 "Content-Type": "application/json", 14485 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14486 }, 14487 body: JSON.stringify({ 14488 media_id: nodeData.media_id 14489 }) 14490 }); 14491 } else { 14492 response = await fetch("/favs", { 14493 method: "POST", 14494 headers: { 14495 "Content-Type": "application/json", 14496 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14497 }, 14498 body: JSON.stringify({ 14499 media_id: nodeData.media_id, 14500 node_id: nodeData.id 14501 }) 14502 }); 14503 } 14504 const data = await response.json(); 14505 if (!response.ok) { 14506 if (window.Toast && data.error) { 14507 window.Toast.show(data.error, "alert"); 14508 } 14509 return; 14510 } 14511 nodeData.saved = !isSaved; 14512 const textSpan = buttonEl.querySelector("[data-final-text]"); 14513 const iconSpan = buttonEl.querySelector("[data-final-icon]"); 14514 if (textSpan) { 14515 textSpan.textContent = nodeData.saved ? "REMOVE FROM FAVS" : "ADD TO FAVS"; 14516 } 14517 if (iconSpan) { 14518 if (nodeData.saved) { 14519 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'; 14520 } else { 14521 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'; 14522 } 14523 } 14524 const nodeEl = buttonEl.closest(".flow-node"); 14525 if (nodeEl) { 14526 if (nodeData.saved) { 14527 nodeEl.classList.remove("ring-lime-500", "ring-bump-lime"); 14528 nodeEl.classList.add("ring-3", "ring-pink-500"); 14529 nodeEl.classList.remove("ring-bump-pink"); 14530 void nodeEl.offsetWidth; 14531 nodeEl.classList.add("ring-bump-pink"); 14532 nodeEl.addEventListener("animationend", () => { 14533 nodeEl.classList.remove("ring-bump-pink"); 14534 }, { once: true }); 14535 if (window.Toast) { 14536 window.Toast.show("Added to Favs!", "success"); 14537 } 14538 } else { 14539 nodeEl.classList.remove("ring-bump-pink"); 14540 nodeEl.classList.add("ring-shrink-pink"); 14541 nodeEl.addEventListener("animationend", () => { 14542 nodeEl.classList.remove("ring-3", "ring-pink-500", "ring-shrink-pink"); 14543 if (nodeData.pinned) { 14544 nodeEl.classList.add("ring-3", "ring-lime-500"); 14545 } 14546 }, { once: true }); 14547 if (window.Toast) { 14548 window.Toast.show("Removed from Favs", "success"); 14549 } 14550 } 14551 } 14552 } catch (error) { 14553 console.error("Add to Finals error:", error); 14554 if (window.Toast) { 14555 window.Toast.show("Failed to update", "alert"); 14556 } 14557 } 14558 }, 14559 async handleRegenerateNode(nodeId, newPrompt = null) { 14560 if (!this.checkPermissions("regenerate")) return; 14561 if (!this.flowId) return; 14562 try { 14563 const csrfToken = document.querySelector('[name="csrf-token"]'); 14564 const requestBody = { 14565 node_id: nodeId, 14566 flow_id: this.flowId 14567 }; 14568 if (newPrompt !== null) { 14569 requestBody.prompt = newPrompt; 14570 } 14571 const response = await fetch("/flows/regenerate", { 14572 method: "POST", 14573 headers: { 14574 "Content-Type": "application/json", 14575 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14576 }, 14577 body: JSON.stringify(requestBody) 14578 }); 14579 const data = await response.json(); 14580 if (!response.ok) { 14581 if (window.Toast && data.error) { 14582 window.Toast.show(data.error, "alert"); 14583 } 14584 return; 14585 } 14586 if (data.nodes) { 14587 this.clearAllNodes(); 14588 this.loadNodesData(data.nodes); 14589 if (this.repositionTree) { 14590 this.repositionTree(); 14591 } 14592 if (this.updateConnections) { 14593 this.updateConnections(); 14594 } 14595 if (this.startPolling) { 14596 this.startPolling(); 14597 } 14598 } 14599 } catch (error) { 14600 console.error("Regenerate error:", error); 14601 if (window.Toast) { 14602 window.Toast.show("Regeneration failed", "alert"); 14603 } 14604 } 14605 }, 14606 openPromptEditModal(nodeId, currentPrompt) { 14607 const template = document.getElementById("flow-prompt-edit-modal-template"); 14608 if (!template) { 14609 console.error("Prompt edit modal template not found"); 14610 return; 14611 } 14612 const existingModal = document.getElementById("prompt-edit-modal"); 14613 if (existingModal) { 14614 existingModal.remove(); 14615 } 14616 const modalContent = template.content.cloneNode(true); 14617 document.body.appendChild(modalContent); 14618 const modal = document.getElementById("prompt-edit-modal"); 14619 if (!modal) return; 14620 const textarea = modal.querySelector("[data-prompt-edit-textarea]"); 14621 const cancelBtns = modal.querySelectorAll("[data-prompt-edit-cancel]"); 14622 const saveBtn = modal.querySelector("[data-prompt-edit-save]"); 14623 if (textarea && currentPrompt) { 14624 textarea.value = currentPrompt; 14625 } 14626 const closeModal = () => { 14627 modal.classList.remove("modal-showing"); 14628 setTimeout(() => { 14629 modal.classList.add("hidden"); 14630 document.body.classList.remove("modal-open"); 14631 modal.remove(); 14632 }, 300); 14633 }; 14634 cancelBtns.forEach((btn) => { 14635 btn.addEventListener("click", closeModal); 14636 }); 14637 modal.addEventListener("click", (e) => { 14638 if (e.target === modal) { 14639 closeModal(); 14640 } 14641 }); 14642 const escHandler = (e) => { 14643 if (e.key === "Escape") { 14644 closeModal(); 14645 document.removeEventListener("keydown", escHandler); 14646 } 14647 }; 14648 document.addEventListener("keydown", escHandler); 14649 if (saveBtn) { 14650 saveBtn.addEventListener("click", async () => { 14651 const newPrompt = textarea.value.trim(); 14652 if (!newPrompt) { 14653 if (window.Toast) { 14654 window.Toast.show("Prompt cannot be empty", "alert"); 14655 } 14656 return; 14657 } 14658 await this.saveNodePrompt(nodeId, newPrompt); 14659 closeModal(); 14660 }); 14661 } 14662 document.body.classList.add("modal-open"); 14663 requestAnimationFrame(() => { 14664 modal.classList.add("modal-showing"); 14665 }); 14666 }, 14667 async saveNodePrompt(nodeId, newPrompt) { 14668 if (!this.flowId || !nodeId) return; 14669 try { 14670 const csrfToken = document.querySelector('[name="csrf-token"]'); 14671 const response = await fetch(`/flow/${this.flowId}/node`, { 14672 method: "PATCH", 14673 headers: { 14674 "Content-Type": "application/json", 14675 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 14676 }, 14677 body: JSON.stringify({ 14678 node_id: nodeId, 14679 content: newPrompt 14680 }) 14681 }); 14682 if (!response.ok) { 14683 throw new Error("Failed to save prompt"); 14684 } 14685 const nodeEl = this.nodes[nodeId]?.element; 14686 if (nodeEl) { 14687 const promptEl = nodeEl.querySelector("[data-flow-generated-prompt]"); 14688 if (promptEl) { 14689 promptEl.textContent = newPrompt; 14690 } 14691 } 14692 if (this.nodes[nodeId]?.data?.nodeData) { 14693 this.nodes[nodeId].data.nodeData.prompt = newPrompt; 14694 } 14695 if (window.Toast) { 14696 window.Toast.show("Prompt saved", "success"); 14697 } 14698 } catch (error) { 14699 console.error("Save prompt error:", error); 14700 if (window.Toast) { 14701 window.Toast.show("Failed to save prompt", "alert"); 14702 } 14703 } 14704 }, 14705 async handleDownloadNode(nodeData) { 14706 if (!nodeData.media_url) { 14707 if (window.Toast) { 14708 window.Toast.show("No media to download", "alert"); 14709 } 14710 return; 14711 } 14712 try { 14713 const isVideo = nodeData.media_url.includes(".mp4"); 14714 const downloadUrl = `/media/download?url=${encodeURIComponent( 14715 nodeData.media_url 14716 )}&is_video=${isVideo}`;
14717 if (window.Toast) { 14718 window.Toast.show("Downloading...", "success"); 14719 } 14720 const response = await fetch(downloadUrl); 14721 if (!response.ok) throw new Error("Download failed"); 14722 const blob = await response.blob(); 14723 const contentDisposition = response.headers.get("Content-Disposition"); 14724 let filename = "reflet-download"; 14725 if (contentDisposition) { 14726 const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i); 14727 if (filenameMatch) { 14728 filename = filenameMatch[1]; 14729 } 14730 } else { 14731 const extension = isVideo ? "mp4" : "jpg"; 14732 filename = `reflet-${Date.now()}.${extension}`; 14733 } 14734 const blobUrl = window.URL.createObjectURL(blob); 14735 const link = document.createElement("a"); 14736 link.href = blobUrl; 14737 link.download = filename; 14738 link.style.display = "none"; 14739 document.body.appendChild(link); 14740 link.click(); 14741 document.body.removeChild(link); 14742 window.URL.revokeObjectURL(blobUrl); 14743 } catch (error) { 14744 console.error("Download error:", error); 14745 if (window.Toast) { 14746 window.Toast.show("Download failed", "alert"); 14747 } 14748 } 14749 }, 14750 // Polling system to check for node status updates 14751 startPolling() { 14752 if (this.pollTimeout) return; 14753 this.pollActive = true; 14754 this.schedulePoll(0); 14755 }, 14756 schedulePoll(delay) { 14757 if (!this.pollActive) return; 14758 this.pollTimeout = setTimeout(async () => { 14759 await this.checkNodesStatus(); 14760 if (this.pollActive) { 14761 this.schedulePoll(3e3); 14762 } 14763 }, delay); 14764 }, 14765 stopPolling() { 14766 this.pollActive = false; 14767 if (this.pollTimeout) { 14768 clearTimeout(this.pollTimeout); 14769 this.pollTimeout = null; 14770 } 14771 }, 14772 async checkNodesStatus() { 14773 if (!this.flowId) { 14774 return; 14775 } 14776 try { 14777 const response = await fetch(`/flow/${this.flowId}/${this.sceneId}/nodes`); 14778 if (!response.ok) { 14779 return; 14780 } 14781 const data = await response.json(); 14782 if (data.balance !== void 0) { 14783 this.updateBalanceDisplay(data.balance); 14784 } 14785 let hasChanges = false; 14786 let firstCompletedNode = null; 14787 if (data.nodes && Array.isArray(data.nodes)) { 14788 data.nodes.forEach((nodeUpdate) => { 14789 const existingNode = this.nodes[nodeUpdate.id || nodeUpdate.uuid]; 14790 if (existingNode && existingNode.data && existingNode.data.nodeData) { 14791 const currentStatus = existingNode.data.nodeData.status; 14792 const newStatus = nodeUpdate.status; 14793 if (currentStatus === "processing" && (newStatus === "ready" || newStatus === "completed")) { 14794 console.log(`[Flow] Node status changed: processing -> ${newStatus}`, nodeUpdate); 14795 hasChanges = true; 14796 if (!firstCompletedNode && nodeUpdate.media_url) { 14797 firstCompletedNode = nodeUpdate; 14798 } 14799 } 14800 if (currentStatus === "processing" && newStatus === "failed") { 14801 hasChanges = true; 14802 window.Toast?.show(nodeUpdate.error_message || "Generation failed", "alert"); 14803 } 14804 } else if (!existingNode) { 14805 hasChanges = true; 14806 } 14807 }); 14808 const existingNodeIds = Object.keys(this.nodes); 14809 const newNodeIds = data.nodes.map((n) => n.id || n.uuid).filter(Boolean); 14810 const removedNodes = existingNodeIds.filter( 14811 (id) => !newNodeIds.includes(id) 14812 ); 14813 if (removedNodes.length > 0) { 14814 hasChanges = true; 14815 } 14816 } 14817 if (hasChanges) { 14818 await this.updateNodesFromData(data.nodes); 14819 if (firstCompletedNode && this.currentSceneId && this.updateSceneThumbnail) { 14820 const thumbnailUrl = firstCompletedNode.thumb_url || (firstCompletedNode.node_type === "video" && firstCompletedNode.thumbnail_url ? firstCompletedNode.thumbnail_url : firstCompletedNode.media_url); 14821 this.updateSceneThumbnail(this.currentSceneId, thumbnailUrl); 14822 } 14823 } 14824 if (this.currentSceneId && this.updateSceneThumbnail) { 14825 const currentScene = this.scenes?.find( 14826 (s) => s.uuid === this.currentSceneId 14827 ); 14828 if (currentScene && !currentScene.thumbnail_url) { 14829 const completedNodeWithMedia = data.nodes?.find( 14830 (node) => (node.status === "ready" || node.status === "completed") && node.media_url 14831 ); 14832 if (completedNodeWithMedia) { 14833 const thumbnailUrl = completedNodeWithMedia.thumb_url || (completedNodeWithMedia.node_type === "video" && completedNodeWithMedia.thumbnail_url ? completedNodeWithMedia.thumbnail_url : completedNodeWithMedia.media_url); 14834 this.updateSceneThumbnail(this.currentSceneId, thumbnailUrl); 14835 } 14836 } 14837 } 14838 const allNodesData = Object.values(this.nodes).map( 14839 (n) => n.data.nodeData 14840 ); 14841 const hasProcessing = allNodesData.some((n) => n.status === "processing"); 14842 if (!hasProcessing) { 14843 this.stopPolling(); 14844 } 14845 } catch (error) { 14846 console.error("Error checking nodes status:", error); 14847 } 14848 }, 14849 // Update nodes from data (used by polling to avoid extra fetch) 14850 async updateNodesFromData(nodesData) {
14851 if (!nodesData) return; 14852 const newNodesMap = {}; 14853 nodesData.forEach((nodeData) => { 14854 newNodesMap[nodeData.id] = nodeData; 14855 }); 14856 const existingNodeIds = Object.keys(this.nodes); 14857 const newNodeIds = Object.keys(newNodesMap); 14858 newNodeIds.forEach((nodeId) => { 14859 const newNodeData = newNodesMap[nodeId]; 14860 if (this.nodes[nodeId]) { 14861 if (this.updateNode) { 14862 this.updateNode(nodeId, newNodeData); 14863 } 14864 } else { 14865 this.createNodeFromData(newNodeData); 14866 } 14867 }); 14868 existingNodeIds.forEach((nodeId) => { 14869 if (!newNodesMap[nodeId]) { 14870 const node = this.nodes[nodeId]; 14871 if (node && node.element && node.element.parentNode) { 14872 node.element.parentNode.removeChild(node.element); 14873 } 14874 delete this.nodes[nodeId]; 14875 } 14876 }); 14877 if (this.repositionTree) { 14878 this.repositionTree(); 14879 } 14880 if (this.updateConnections) { 14881 this.updateConnections(); 14882 } 14883 }, 14884 async reloadAllNodes() { 14885 try { 14886 const response = await fetch(`/flow/${this.flowId}/${this.sceneId}/nodes`); 14887 if (!response.ok) return; 14888 const data = await response.json(); 14889 if (!data.nodes) return; 14890 await this.updateNodesFromData(data.nodes); 14891 } catch (error) { 14892 console.error("Error reloading nodes:", error); 14893 } 14894 }, 14895 // Update balance display in the UI 14896 updateBalanceDisplay(balance) { 14897 const balanceSpan = document.querySelector('[data-dropdown-trigger="balance"] span.text-indigo-400'); 14898 if (balanceSpan) { 14899 const formattedCredits = balance.toLocaleString("en-US"); 14900 const svg = balanceSpan.querySelector("svg"); 14901 if (svg) { 14902 balanceSpan.innerHTML = ""; 14903 balanceSpan.appendChild(svg); 14904 balanceSpan.appendChild(document.createTextNode(formattedCredits)); 14905 } 14906 } 14907 } 14908}; 14909
14910// app/javascript/flow/info.js 14911var FlowInfoMixin = { 14912 initFlowInfo() { 14913 this.nameGenerationAttempted = false; 14914 this.bindFlowStatusDropdown(); 14915 }, 14916 bindFlowStatusDropdown() { 14917 const statusBtn = document.getElementById("flow-status-btn"); 14918 const statusDropdown = document.getElementById("flow-status-dropdown"); 14919 const statusOptions = document.querySelectorAll(".flow-status-option"); 14920 if (!statusBtn || !statusDropdown) return; 14921 this.addListener(statusBtn, "click", (e) => { 14922 e.stopPropagation(); 14923 statusDropdown.classList.toggle("hidden"); 14924 }); 14925 const closeDropdown = (e) => { 14926 if (!statusDropdown.classList.contains("hidden")) { 14927 if (!e.target.closest("#flow-status-btn") && !e.target.closest("#flow-status-dropdown")) { 14928 statusDropdown.classList.add("hidden"); 14929 } 14930 } 14931 }; 14932 this.addListener(document, "click", closeDropdown); 14933 statusOptions.forEach((option) => { 14934 this.addListener(option, "click", async () => { 14935 const newStatus = option.dataset.status; 14936 const currentStatus = statusBtn.dataset.currentStatus; 14937 if (newStatus === currentStatus) { 14938 statusDropdown.classList.add("hidden"); 14939 return; 14940 } 14941 try { 14942 const response = await fetch(`/flow/${this.flowId}/status`, { 14943 method: "PATCH", 14944 headers: { 14945 "Content-Type": "application/json", 14946 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 14947 }, 14948 body: JSON.stringify({ status: newStatus }) 14949 }); 14950 if (response.ok) { 14951 this.updateStatusUI(newStatus); 14952 if (window.Toast) { 14953 window.Toast.show("Flow status updated", "success"); 14954 } 14955 } else { 14956 if (window.Toast) { 14957 window.Toast.show("Failed to update flow status", "alert"); 14958 } 14959 } 14960 } catch (error) { 14961 console.error("Error updating flow status:", error); 14962 if (window.Toast) { 14963 window.Toast.show("Error updating flow status", "alert"); 14964 } 14965 } 14966 statusDropdown.classList.add("hidden"); 14967 }); 14968 }); 14969 }, 14970 updateStatusUI(status) { 14971 const statusBtn = document.getElementById("flow-status-btn"); 14972 if (!statusBtn) return; 14973 const statusConfig = { 14974 draft: { 14975 icon: "lock", 14976 iconClass: "w-2.5 h-2.5 text-gray", 14977 label: "Private", 14978 labelClass: "text-gray-400" 14979 }, 14980 personal: { 14981 icon: "lock", 14982 iconClass: "w-2.5 h-2.5 text-gray", 14983 label: "Private", 14984 labelClass: "text-gray-400" 14985 }, 14986 team: { 14987 icon: "users", 14988 iconClass: "w-2.5 h-2.5 text-indigo-400", 14989 label: "Team", 14990 labelClass: "text-indigo-400" 14991 }, 14992 published: { 14993 icon: "globe", 14994 iconClass: "w-2.5 h-2.5 text-teal-400", 14995 label: "Public", 14996 labelClass: "text-teal-400" 14997 } 14998 }; 14999 const config2 = statusConfig[status]; 15000 if (!config2) return; 15001 const iconSvg = this.createLucideIcon(config2.icon, config2.iconClass); 15002 statusBtn.innerHTML = ` 15003 <span class="inline-flex items-center gap-1.5"> 15004 ${iconSvg} 15005 <span class="${config2.labelClass}">${config2.label}</span> 15006 </span> 15007 ${this.createLucideIcon("chevron-up", "w-3 h-3 text-gray group-hover/status:text-white transition-colors")} 15008 `; 15009 statusBtn.dataset.currentStatus = status; 15010 }, 15011 createLucideIcon(name, className) { 15012 const icons = { 15013 "lock": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11h14a2 2 0 012 2v7a2 2 0 01-2 2H5a2 2 0 01-2-2v-7a2 2 0 012-2zm0 0V7a7 7 0 0114 0v4" />', 15014 "users": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2M9 11a4 4 0 100-8 4 4 0 000 8zM23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />', 15015 "globe": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />', 15016 "chevron-down": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />', 15017 "chevron-up": '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />' 15018 }; 15019 return `<svg class="${className}" fill="none" stroke="currentColor" viewBox="0 0 24 24">${icons[name] || ""}</svg>`; 15020 }, 15021 async generateFlowNameIfNeeded() { 15022 if (this.nameGenerationAttempted) return; 15023 const nameDisplay = document.getElementById("flow-name-display"); 15024 if (!nameDisplay) return; 15025 const currentName = nameDisplay.textContent.trim(); 15026 if (currentName && currentName !== "Untitled") return; 15027 if (!this.flowId) return; 15028 try { 15029 const response = await fetch(`/flow/${this.flowId}/name`, { 15030 method: "POST", 15031 headers: { 15032 "Content-Type": "application/json", 15033 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 15034 } 15035 }); 15036 if (!response.ok) { 15037 console.log("Failed to generate flow name"); 15038 return; 15039 } 15040 const data = await response.json(); 15041 if (data.success && data.name) { 15042 this.nameGenerationAttempted = true; 15043 nameDisplay.textContent = data.name; 15044 console.log("Flow name generated:", data.name); 15045 } 15046 } catch (error) { 15047 console.error("Error generating flow name:", error); 15048 } 15049 } 15050}; 15051
15052// app/javascript/flow/pins.js 15053var PinsMixin = { 15054 initPins() { 15055 this.setupPinsListener(); 15056 }, 15057 bindPinsButton(nodeEl, nodeData) { 15058 const pinsBtn = nodeEl.querySelector("[data-flow-pins]"); 15059 if (!pinsBtn) return; 15060 pinsBtn.addEventListener("click", (e) => { 15061 e.stopPropagation(); 15062 this.handlePinsClick(nodeEl, nodeData); 15063 }); 15064 }, 15065 bindMarkPinButton(nodeEl, nodeData) { 15066 const markBtn = nodeEl.querySelector("[data-flow-mark-pin]"); 15067 if (!markBtn) return; 15068 const isPinned = nodeData.nodeData?.pinned || false; 15069 markBtn.dataset.pinMarked = isPinned ? "true" : "false"; 15070 const textSpan = markBtn.querySelector("[data-pin-text]"); 15071 const iconSpan = markBtn.querySelector("[data-pin-icon]"); 15072 if (textSpan) { 15073 textSpan.textContent = isPinned ? "UNPIN" : "PIN FOR REUSE"; 15074 } 15075 if (iconSpan) { 15076 if (isPinned) { 15077 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>'; 15078 } else { 15079 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>'; 15080 } 15081 } 15082 markBtn.addEventListener("click", async (e) => { 15083 e.stopPropagation(); 15084 await this.handleMarkPin(nodeEl, nodeData, markBtn); 15085 }); 15086 }, 15087 handlePinsClick(nodeEl, nodeData) { 15088 if (!this.checkPermissions("pins")) return; 15089 this.currentPinsTargetNodeId = nodeData.nodeData?.id; 15090 if (window.Modal && window.Modal.showPins) { 15091 window.Modal.showPins(this.flowId); 15092 } 15093 }, 15094 async handleMarkPin(nodeEl, nodeData, markBtn) { 15095 if (!this.checkPermissions("pin")) return; 15096 const mediaId = nodeData.nodeData?.media_id; 15097 if (!mediaId) { 15098 if (window.Toast) { 15099 window.Toast.show("No media found for this node", "alert"); 15100 } 15101 return; 15102 } 15103 const isMarked = markBtn.dataset.pinMarked === "true"; 15104 try { 15105 if (isMarked) { 15106 await this.unmarkPin(mediaId); 15107 markBtn.dataset.pinMarked = "false"; 15108 const textSpan = markBtn.querySelector("[data-pin-text]"); 15109 const iconSpan = markBtn.querySelector("[data-pin-icon]"); 15110 if (textSpan) { 15111 textSpan.textContent = "PIN FOR REUSE"; 15112 } 15113 if (iconSpan) { 15114 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>'; 15115 } 15116 if (window.Toast) { 15117 window.Toast.show("Unpinned", "success"); 15118 } 15119 if (!nodeEl.classList.contains("ring-pink-500")) { 15120 nodeEl.classList.remove("ring-bump-lime"); 15121 nodeEl.classList.add("ring-shrink-lime"); 15122 nodeEl.addEventListener( 15123 "animationend", 15124 () => { 15125 nodeEl.classList.remove( 15126 "ring-3", 15127 "ring-lime-500", 15128 "ring-shrink-lime" 15129 ); 15130 }, 15131 { once: true } 15132 ); 15133 } 15134 } else { 15135 await this.markPin(mediaId); 15136 markBtn.dataset.pinMarked = "true"; 15137 const textSpan = markBtn.querySelector("[data-pin-text]"); 15138 const iconSpan = markBtn.querySelector("[data-pin-icon]"); 15139 if (textSpan) { 15140 textSpan.textContent = "UNPIN"; 15141 } 15142 if (iconSpan) { 15143 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7
15143a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>'; 15144 } 15145 if (window.Toast) { 15146 window.Toast.show("Pinned for reuse!", "success"); 15147 } 15148 if (!nodeEl.classList.contains("ring-pink-500")) { 15149 nodeEl.classList.add("ring-3", "ring-lime-500"); 15150 nodeEl.classList.remove("ring-bump-lime"); 15151 void nodeEl.offsetWidth; 15152 nodeEl.classList.add("ring-bump-lime"); 15153 nodeEl.addEventListener( 15154 "animationend", 15155 () => { 15156 nodeEl.classList.remove("ring-bump-lime"); 15157 }, 15158 { once: true } 15159 ); 15160 } 15161 } 15162 } catch (error) { 15163 console.error("Error toggling pin:", error); 15164 if (window.Toast) { 15165 window.Toast.show("Failed to update pin", "alert"); 15166 } 15167 } 15168 }, 15169 async markPin(mediaId) { 15170 const csrfToken = document.querySelector('[name="csrf-token"]'); 15171 const response = await fetch(`/flow/${this.flowId}/pin`, { 15172 method: "POST", 15173 headers: { 15174 "Content-Type": "application/json", 15175 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15176 }, 15177 body: JSON.stringify({ 15178 media_id: mediaId, 15179 scene_id: this.sceneId 15180 }) 15181 }); 15182 if (!response.ok) { 15183 throw new Error("Failed to mark as pin"); 15184 } 15185 return await response.json(); 15186 }, 15187 async unmarkPin(mediaId) { 15188 const csrfToken = document.querySelector('[name="csrf-token"]'); 15189 const response = await fetch( 15190 `/flow/${this.flowId}/pin?media_id=${mediaId}`, 15191 { 15192 method: "DELETE", 15193 headers: { 15194 "Content-Type": "application/json", 15195 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15196 } 15197 } 15198 ); 15199 if (!response.ok) { 15200 throw new Error("Failed to unmark pin"); 15201 } 15202 return await response.json(); 15203 }, 15204 async checkPinStatus(nodeData, markBtn) { 15205 const mediaId = nodeData.nodeData?.media_id; 15206 if (!mediaId) return; 15207 try { 15208 const csrfToken = document.querySelector('[name="csrf-token"]'); 15209 const response = await fetch( 15210 `/flow/${this.flowId}/pin/check?media_id=${mediaId}`, 15211 { 15212 method: "GET", 15213 headers: { 15214 "Content-Type": "application/json", 15215 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15216 } 15217 } 15218 ); 15219 if (response.ok) { 15220 const data = await response.json(); 15221 markBtn.dataset.pinMarked = data.is_pinned ? "true" : "false"; 15222 const textSpan = markBtn.querySelector("span"); 15223 if (textSpan) { 15224 textSpan.textContent = data.is_pinned ? "UNMARK" : "MARK"; 15225 } 15226 } 15227 } catch (error) { 15228 console.error("Error checking pin status:", error); 15229 } 15230 }, 15231 setupPinsListener() { 15232 const handlePinsSelected = async (event) => { 15233 const { pins } = event.detail; 15234 if (!pins || pins.length === 0) return; 15235 let nodeId = this.currentPinsTargetNodeId; 15236 if (!nodeId) { 15237 const rootNode = Object.values(this.nodes).find( 15238 (node) => node.data.nodeData && node.data.nodeData.level === 0 15239 ); 15240 if (!rootNode) return; 15241 nodeId = rootNode.data.nodeData.id; 15242 } 15243 for (const pin of pins) { 15244 const ratio = pin.ratio || null; 15245 const loadingNodeId = this.createLoadingNode(nodeId, ratio); 15246 try { 15247 await this.addRefToNode(nodeId, pin.file_hash, loadingNodeId, "pin"); 15248 } catch (error) { 15249 console.error("Failed to attach media:", error); 15250 this.removeLoadingNode(loadingNodeId); 15251 if (window.Toast) { 15252 window.Toast.show("Failed to attach media", "alert"); 15253 } 15254 } 15255 } 15256 this.currentPinsTargetNodeId = null; 15257 }; 15258 document.removeEventListener( 15259 "pins:selected", 15260 this.boundPinsSelectedHandler 15261 ); 15262 this.boundPinsSelectedHandler = handlePinsSelected.bind(this); 15263 document.addEventListener("pins:selected", this.boundPinsSelectedHandler); 15264 } 15265}; 15266
15267// app/javascript/flow/favs.js 15268var FavsMixin = { 15269 initFavs() { 15270 this.setupFavsListener(); 15271 }, 15272 bindFavsButton(nodeEl, nodeData) { 15273 const favsBtn = nodeEl.querySelector("[data-flow-favs]"); 15274 if (!favsBtn) return; 15275 favsBtn.addEventListener("click", (e) => { 15276 e.stopPropagation(); 15277 if (!this.checkPermissions("refs")) return; 15278 this.currentFavsTargetNodeId = nodeData.nodeData?.id; 15279 if (window.Modal) window.Modal.showFavsPicker(); 15280 }); 15281 }, 15282 setupFavsListener() { 15283 const handleFavsSelected = async (event) => { 15284 const { favs } = event.detail; 15285 if (!favs || favs.length === 0) return; 15286 let nodeId = this.currentFavsTargetNodeId; 15287 if (!nodeId) { 15288 const rootNode = Object.values(this.nodes).find( 15289 (node) => node.data.nodeData && node.data.nodeData.level === 0 15290 ); 15291 if (!rootNode) return; 15292 nodeId = rootNode.data.nodeData.id; 15293 } 15294 for (const fav of favs) { 15295 const ratio = fav.ratio || null; 15296 const loadingNodeId = this.createLoadingNode(nodeId, ratio); 15297 try { 15298 await this.addRefToNode(nodeId, fav.file_hash, loadingNodeId, "fav"); 15299 } catch (error) { 15300 console.error("Failed to add fav reference:", error); 15301 this.removeLoadingNode(loadingNodeId); 15302 if (window.Toast) { 15303 window.Toast.show("Failed to add reference", "alert"); 15304 } 15305 } 15306 } 15307 this.currentFavsTargetNodeId = null; 15308 }; 15309 document.removeEventListener("favs:selected", this.boundFavsSelectedHandler); 15310 this.boundFavsSelectedHandler = handleFavsSelected.bind(this); 15311 document.addEventListener("favs:selected", this.boundFavsSelectedHandler); 15312 } 15313}; 15314
15315// app/javascript/flow/selection.js 15316var SelectionMixin = { 15317 initSelection() { 15318 this.selectionBox = null; 15319 this.isSelecting = false; 15320 this.selectionStart = { x: 0, y: 0 }; 15321 this.selectedNodes = /* @__PURE__ */ new Set(); 15322 this.createSelectionMenu(); 15323 this.bindSelectionEvents(); 15324 }, 15325 createSelectionMenu() { 15326 this.selectionMenu = document.createElement("div"); 15327 this.selectionMenu.className = "selection-menu"; 15328 this.selectionMenu.style.cssText = ` 15329 position: fixed; 15330 bottom: 24px; 15331 left: 50%; 15332 transform: translateX(-50%); 15333 z-index: 10000; 15334 display: none; 15335 align-items: center; 15336 gap: 12px; 15337 background: rgba(30, 30, 40, 0.95); 15338 backdrop-filter: blur(12px); 15339 border: 1px solid rgba(255,255,255,0.1); 15340 border-radius: 12px; 15341 padding: 8px 16px; 15342 box-shadow: 0 8px 32px rgba(0,0,0,0.4); 15343 `; 15344 const countText = document.createElement("span"); 15345 countText.className = "selection-count"; 15346 countText.style.cssText = "font-size: 13px; color: #a78bfa; font-weight: 500; user-select: none;"; 15347 countText.textContent = "Selection: 0 nodes"; 15348 this.selectionMenu.appendChild(countText); 15349 const buttonsContainer = document.createElement("div"); 15350 buttonsContainer.className = "flex items-center gap-2"; 15351 const deleteBtn = document.createElement("button"); 15352 deleteBtn.className = "selection-btn-delete w-8 h-8 flex items-center justify-center rounded hover:bg-white/20 transition duration-300 ease cursor-pointer relative selection-tooltip-trigger"; 15353 deleteBtn.setAttribute("data-tooltip", "Delete"); 15354 deleteBtn.innerHTML = ` 15355 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 15356 <path d="M3 6h18"></path> 15357 <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path> 15358 <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path> 15359 </svg> 15360 <span class="selection-tooltip">Delete</span> 15361 `; 15362 deleteBtn.addEventListener("click", (e) => { 15363 e.stopPropagation(); 15364 if (!this.checkPermissions("delete")) return; 15365 this.deleteSelectedNodes(); 15366 }); 15367 buttonsContainer.appendChild(deleteBtn); 15368 const regenerateBtn = document.createElement("button"); 15369 regenerateBtn.className = "selection-btn-regenerate w-8 h-8 flex items-center justify-center rounded hover:bg-white/20 transition duration-300 ease cursor-pointer relative selection-tooltip-trigger"; 15370 regenerateBtn.setAttribute("data-tooltip", "Regenerate"); 15371 regenerateBtn.innerHTML = ` 15372 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 15373 <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path> 15374 <path d="M3 3v5h5"></path> 15375 <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path> 15376 <path d="M16 16h5v5"></path> 15377 </svg> 15378 <span class="selection-tooltip">Regenerate</span> 15379 `; 15380 regenerateBtn.addEventListener("click", (e) => { 15381 e.stopPropagation(); 15382 if (!this.checkPermissions("regenerate")) return; 15383 this.regenerateSelectedNodes(); 15384 }); 15385 buttonsContainer.appendChild(regenerateBtn); 15386 const upscaleBtn = document.createElement("button"); 15387 upscaleBtn.className = "selection-btn-upscale w-8 h-8 flex items-center justify-center rounded hover:bg-white/20 transition duration-300 ease cursor-pointer relative selection-tooltip-trigger"; 15388 upscaleBtn.setAttribute("data-tooltip", "Upscale"); 15389 upscaleBtn.innerHTML = ` 15390 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 15391 <polyline points="15 3 21 3 21 9"></polyline> 15392 <polyline points="9 21 3 21 3 15"></polyline> 15393 <line x1="21" y1="3" x2="14" y2="10"></line> 15394 <line x1="3" y1="21" x2="10" y2="14"></line> 15395 </svg>
15396 <span class="selection-tooltip">Upscale</span> 15397 `; 15398 upscaleBtn.addEventListener("click", (e) => { 15399 e.stopPropagation(); 15400 if (!this.checkPermissions("upscale")) return; 15401 this.upscaleSelectedNodes(); 15402 }); 15403 buttonsContainer.appendChild(upscaleBtn); 15404 const downloadBtn = document.createElement("button"); 15405 downloadBtn.className = "selection-btn-download w-8 h-8 flex items-center justify-center rounded hover:bg-white/20 transition duration-300 ease cursor-pointer relative selection-tooltip-trigger"; 15406 downloadBtn.setAttribute("data-tooltip", "Download"); 15407 downloadBtn.innerHTML = ` 15408 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 15409 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 15410 <polyline points="7 10 12 15 17 10"></polyline> 15411 <line x1="12" y1="15" x2="12" y2="3"></line> 15412 </svg> 15413 <span class="selection-tooltip">Download</span> 15414 `; 15415 downloadBtn.addEventListener("click", (e) => { 15416 e.stopPropagation(); 15417 if (!this.checkPermissions("download")) return; 15418 this.downloadSelectedNodes(); 15419 }); 15420 buttonsContainer.appendChild(downloadBtn); 15421 this.selectionMenu.appendChild(buttonsContainer); 15422 document.body.appendChild(this.selectionMenu); 15423 }, 15424 updateSelectionMenu() { 15425 const count = this.selectedNodes.size; 15426 const countText = this.selectionMenu.querySelector(".selection-count"); 15427 if (count > 0) { 15428 countText.textContent = `Selection: ${count} node${count > 1 ? "s" : ""}`; 15429 this.selectionMenu.style.display = "flex"; 15430 } else { 15431 this.selectionMenu.style.display = "none"; 15432 } 15433 }, 15434 deleteSelectedNodes() { 15435 const nodeIds = Array.from(this.selectedNodes); 15436 const count = nodeIds.length; 15437 if (count === 0) return; 15438 const modal = document.createElement("div"); 15439 modal.id = "delete-nodes-modal"; 15440 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 15441 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10000;"; 15442 modal.innerHTML = ` 15443 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 15444 <div class="flex items-start justify-between mb-3"> 15445 <div class="text-center"> 15446 <h2 class="text-14 font-bold text-white">Delete Nodes</h2> 15447 </div> 15448 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 15449 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 15450 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 15451 </svg> 15452 </button> 15453 </div> 15454 15455 <div> 15456 <p class="text-neutral-300 mb-6 text-sm"> 15457 Are you sure you want to delete ${count} node${count > 1 ? "s" : ""}? 15458 <br> 15459 All descendant nodes will also be deleted. 15460 <br> 15461 This action cannot be undone. 15462 </p> 15463 <div class="flex gap-3 justify-end"> 15464 <button data-modal-close class="btn-secondary"> 15465 Cancel 15466 </button> 15467 <button data-delete-confirm class="btn-danger"> 15468 Delete 15469 </button> 15470 </div> 15471 </div> 15472 </div> 15473 `; 15474 document.body.appendChild(modal); 15475 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 15476 btn.addEventListener("click", () => { 15477 modal.remove(); 15478 }); 15479 }); 15480 const confirmBtn = modal.querySelector("[data-delete-confirm]"); 15481 confirmBtn.addEventListener("click", async () => { 15482 modal.remove(); 15483 this.selectionMenu.style.display = "none"; 15484 const csrfToken = document.querySelector('[name="csrf-token"]'); 15485 for (const nodeId of nodeIds) { 15486 try { 15487 const response = await fetch( 15488 `/flow/${this.flowId}/node/${nodeId}/delete`, 15489 { 15490 method: "DELETE", 15491 headers: { 15492 "Content-Type": "application/json", 15493 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15494 }, 15495 body: JSON.stringify({ 15496 node_id: nodeId 15497 }) 15498 } 15499 ); 15500 if (!response.ok) { 15501 throw new Error("Failed to delete node"); 15502 } 15503 const data = await response.json(); 15504 if (data.nodes) { 15505 this.clearAllNodes(); 15506 this.loadNodesData(data.nodes); 15507 if (this.repositionTree) { 15508 this.repositionTree(); 15509 } 15510 if (this.updateConnections) { 15511 this.updateConnections(); 15512 } 15513 } 15514 } catch (error) { 15515 if (window.Toast) { 15516 window.Toast.show("Failed to delete node", "alert"); 15517 } 15518 } 15519 } 15520 this.clearSelection(); 15521 if (window.Toast) { 15522 window.Toast.show( 15523 `${count} node${count > 1 ? "s" : ""} deleted`, 15524 "success" 15525 ); 15526 } 15527 }); 15528 modal.addEventListener("click", (e) => { 15529 if (e.target === modal) { 15530 modal.remove(); 15531 } 15532 }); 15533 }, 15534 regenerateSelectedNodes() { 15535 const nodeIds = Array.from(this.selectedNodes); 15536 const count = nodeIds.length; 15537 if (count === 0) return; 15538 const modal = document.createElement("div"); 15539 modal.id = "regenerate-nodes-modal"; 15540 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 15541 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10000;"; 15542 modal.innerHTML = ` 15543 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 15544 <div class="flex items-start justify-between mb-3"> 15545 <div class="text-center"> 15546 <h2 class="text-14 font-bold text-white">Regenerate Nodes</h2> 15547 </div> 15548 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 15549 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 15550 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 15551 </svg> 15552 </button> 15553 </div> 15554 15555 <div> 15556 <p class="text-neutral-300 mb-6 text-sm"> 15557 Are you sure you want to regenerate ${count} node${count > 1 ? "s" : ""}? 15558 <br> 15559 This will create new generations with the same parameters. 15560 </p> 15561 <div class="flex gap-3 justify-end"> 15562 <button data-modal-close class="btn-secondary"> 15563 Cancel 15564 </button> 15565 <button data-regenerate-confirm class="btn-primary"> 15566 Regenerate 15567 </button> 15568 </div> 15569 </div> 15570 </div> 15571 `; 15572 document.body.appendChild(modal); 15573 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 15574 btn.addEventListener("click", () => { 15575 modal.remove(); 15576 }); 15577 }); 15578 const confirmBtn = modal.querySelector("[data-regenerate-confirm]"); 15579 confirmBtn.addEventListener("click", async () => { 15580 modal.remove(); 15581 this.selectionMenu.style.display = "none"; 15582 const csrfToken = document.querySelector('[name="csrf-token"]'); 15583 for (const nodeId of nodeIds) { 15584 try { 15585 const response = await fetch("/flows/regenerate", { 15586 method: "POST", 15587 headers: { 15588 "Content-Type": "application/json", 15589 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15590 }, 15591 body: JSON.stringify({ 15592 node_id: nodeId, 15593 flow_id: this.flowId 15594 }) 15595 }); 15596 if (!response.ok) { 15597 throw new Error("Failed to regenerate node"); 15598 } 15599 const data = await response.json(); 15600 if (data.nodes) { 15601 this.clearAllNodes(); 15602 this.loadNodesData(data.nodes); 15603 if (this.repositionTree) { 15604 this.repositionTree(); 15605 } 15606 if (this.updateConnections) { 15607 this.updateConnections(); 15608 } 15609 } 15610 } catch (error) { 15611 if (window.Toast) { 15612 window.Toast.show("Failed to regenerate node", "alert"); 15613 } 15614 } 15615 } 15616 if (this.startPolling) { 15617 this.startPolling(); 15618 } 15619 this.clearSelection(); 15620 if (window.Toast) { 15621 window.Toast.show( 15622 `${count} node${count > 1 ? "s" : ""} regenerating...`, 15623 "success" 15624 ); 15625 } 15626 }); 15627 modal.addEventListener("click", (e) => { 15628 if (e.target === modal) { 15629 modal.remove(); 15630 } 15631 }); 15632 }, 15633 async downloadSelectedNodes() { 15634 const nodeIds = Array.from(this.selectedNodes); 15635 const count = nodeIds.length; 15636 if (count === 0) return; 15637 this.selectionMenu.style.display = "none"; 15638 if (window.Toast) { 15639 window.Toast.show( 15640 `Downloading ${count} file${count > 1 ? "s" : ""}...`, 15641 "notice" 15642 ); 15643 } 15644 let downloadedCount = 0; 15645 for (const nodeId of nodeIds) { 15646 const node = this.nodes[nodeId]; 15647 const nodeData = node?.data?.nodeData; 15648 if (!nodeData || !nodeData.media_url) { 15649 continue; 15650 }
15651 try { 15652 const isVideo = nodeData.media_url.includes(".mp4"); 15653 const downloadUrl = `/media/download?url=${encodeURIComponent( 15654 nodeData.media_url 15655 )}&is_video=${isVideo}`; 15656 const response = await fetch(downloadUrl); 15657 if (!response.ok) throw new Error("Download failed"); 15658 const blob = await response.blob(); 15659 const url = window.URL.createObjectURL(blob); 15660 const a = document.createElement("a"); 15661 a.style.display = "none"; 15662 a.href = url; 15663 a.download = `reflet_${nodeId}.${isVideo ? "mp4" : "png"}`; 15664 document.body.appendChild(a); 15665 a.click(); 15666 window.URL.revokeObjectURL(url); 15667 document.body.removeChild(a); 15668 downloadedCount++; 15669 if (nodeIds.length > 1) { 15670 await new Promise((resolve) => setTimeout(resolve, 200)); 15671 } 15672 } catch (error) { 15673 if (window.Toast) { 15674 window.Toast.show("Failed to download some files", "alert"); 15675 } 15676 } 15677 } 15678 this.clearSelection(); 15679 if (window.Toast && downloadedCount > 0) { 15680 window.Toast.show( 15681 `${downloadedCount} file${downloadedCount > 1 ? "s" : ""} downloaded`, 15682 "success" 15683 ); 15684 } 15685 }, 15686 upscaleSelectedNodes() { 15687 const nodeIds = Array.from(this.selectedNodes); 15688 const count = nodeIds.length; 15689 if (count === 0) return; 15690 const modal = document.createElement("div"); 15691 modal.id = "upscale-nodes-modal"; 15692 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 15693 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10000;"; 15694 modal.innerHTML = ` 15695 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 15696 <div class="flex items-start justify-between mb-3"> 15697 <div class="text-center"> 15698 <h2 class="text-14 font-bold text-white">Upscale Nodes</h2> 15699 </div> 15700 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 15701 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 15702 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 15703 </svg> 15704 </button> 15705 </div> 15706 15707 <div> 15708 <p class="text-neutral-300 mb-6 text-sm"> 15709 Are you sure you want to upscale ${count} node${count > 1 ? "s" : ""}? 15710 <br> 15711 This will create high-resolution versions of your images. 15712 </p> 15713 <div class="flex gap-3 justify-end"> 15714 <button data-modal-close class="btn-secondary"> 15715 Cancel 15716 </button> 15717 <button data-upscale-confirm class="btn-primary"> 15718 Upscale 15719 </button> 15720 </div> 15721 </div> 15722 </div> 15723 `; 15724 document.body.appendChild(modal); 15725 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 15726 btn.addEventListener("click", () => { 15727 modal.remove(); 15728 }); 15729 }); 15730 const confirmBtn = modal.querySelector("[data-upscale-confirm]"); 15731 confirmBtn.addEventListener("click", async () => { 15732 modal.remove(); 15733 this.selectionMenu.style.display = "none"; 15734 const csrfToken = document.querySelector('[name="csrf-token"]'); 15735 for (const nodeId of nodeIds) { 15736 try { 15737 const response = await fetch("/flows/generate", { 15738 method: "POST", 15739 headers: { 15740 "Content-Type": "application/json", 15741 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 15742 }, 15743 body: JSON.stringify({ 15744 parent_id: nodeId, 15745 flow_id: this.flowId, 15746 upscale: true, 15747 model: "seedvr2", 15748 prompt: "" 15749 }) 15750 }); 15751 if (!response.ok) { 15752 throw new Error("Failed to upscale node"); 15753 } 15754 const data = await response.json(); 15755 if (data.nodes) { 15756 this.clearAllNodes(); 15757 this.loadNodesData(data.nodes); 15758 if (this.repositionTree) { 15759 this.repositionTree(); 15760 } 15761 if (this.updateConnections) { 15762 this.updateConnections(); 15763 } 15764 } 15765 } catch (error) { 15766 if (window.Toast) { 15767 window.Toast.show("Failed to upscale node", "alert"); 15768 } 15769 } 15770 } 15771 if (this.startPolling) { 15772 this.startPolling(); 15773 } 15774 this.clearSelection(); 15775 if (window.Toast) { 15776 window.Toast.show( 15777 `${count} node${count > 1 ? "s" : ""} upscaling...`, 15778 "success" 15779 ); 15780 } 15781 }); 15782 modal.addEventListener("click", (e) => { 15783 if (e.target === modal) { 15784 modal.remove(); 15785 } 15786 }); 15787 }, 15788 bindSelectionEvents() { 15789 this.container.addEventListener("mousedown", (e) => { 15790 if (e.button !== 0) return; 15791 if (e.target === this.container || e.target === this.viewport || e.target === this.nodesContainer || e.target === this.svgLayer) { 15792 this.startSelection(e); 15793 } 15794 }); 15795 document.addEventListener("mousemove", (e) => { 15796 if (this.isSelecting) { 15797 this.updateSelection(e); 15798 } 15799 }); 15800 document.addEventListener("mouseup", (e) => { 15801 if (this.isSelecting) { 15802 this.endSelection(e); 15803 } 15804 }); 15805 }, 15806 startSelection(e) { 15807 this.isSelecting = true; 15808 this.container.style.cursor = "crosshair"; 15809 const rect = this.container.getBoundingClientRect(); 15810 const mouseX = e.clientX - rect.left; 15811 const mouseY = e.clientY - rect.top; 15812 this.selectionStart = { 15813 x: (mouseX - this.panX) / this.zoom, 15814 y: (mouseY - this.panY) / this.zoom 15815 }; 15816 this.selectionBox = document.createElement("div"); 15817 this.selectionBox.className = "flow-selection-box"; 15818 this.selectionBox.style.cssText = `
15819 position: absolute; 15820 left: ${this.selectionStart.x}px; 15821 top: ${this.selectionStart.y}px; 15822 width: 0px; 15823 height: 0px; 15824 border: 2px dashed #a78bfa; 15825 border-radius: 6px; 15826 background: rgba(99, 102, 241, 0.08); 15827 pointer-events: none; 15828 z-index: 9999; 15829 `; 15830 this.nodesContainer.appendChild(this.selectionBox); 15831 }, 15832 updateSelection(e) { 15833 if (!this.selectionBox) return; 15834 const rect = this.container.getBoundingClientRect(); 15835 const mouseX = e.clientX - rect.left; 15836 const mouseY = e.clientY - rect.top; 15837 const currentX = (mouseX - this.panX) / this.zoom; 15838 const currentY = (mouseY - this.panY) / this.zoom; 15839 const left = Math.min(this.selectionStart.x, currentX); 15840 const top = Math.min(this.selectionStart.y, currentY); 15841 const width = Math.abs(currentX - this.selectionStart.x); 15842 const height = Math.abs(currentY - this.selectionStart.y); 15843 this.selectionBox.style.left = `${left}px`; 15844 this.selectionBox.style.top = `${top}px`; 15845 this.selectionBox.style.width = `${width}px`; 15846 this.selectionBox.style.height = `${height}px`; 15847 const selectionRect = { 15848 left, 15849 top, 15850 right: left + width, 15851 bottom: top + height 15852 }; 15853 Object.values(this.nodes).forEach((node) => { 15854 if (!node.position || !node.data.nodeData) return; 15855 if (node.data.nodeData.level === 0) return; 15856 const nodeRect = { 15857 left: node.position.left, 15858 top: node.position.top, 15859 right: node.position.left + node.position.width, 15860 bottom: node.position.top + node.position.height 15861 }; 15862 if (this.rectanglesIntersect(selectionRect, nodeRect)) { 15863 node.element.style.outline = "2px solid #a78bfa"; 15864 node.element.style.outlineOffset = "4px"; 15865 } else { 15866 node.element.style.outline = ""; 15867 node.element.style.outlineOffset = ""; 15868 } 15869 }); 15870 }, 15871 endSelection(e) { 15872 if (!this.selectionBox) return; 15873 const rect = this.container.getBoundingClientRect(); 15874 const mouseX = e.clientX - rect.left; 15875 const mouseY = e.clientY - rect.top; 15876 const currentX = (mouseX - this.panX) / this.zoom; 15877 const currentY = (mouseY - this.panY) / this.zoom; 15878 const selectionRect = { 15879 left: Math.min(this.selectionStart.x, currentX), 15880 top: Math.min(this.selectionStart.y, currentY), 15881 right: Math.max(this.selectionStart.x, currentX), 15882 bottom: Math.max(this.selectionStart.y, currentY) 15883 }; 15884 this.selectionBox.remove(); 15885 this.selectionBox = null; 15886 this.isSelecting = false; 15887 this.container.style.cursor = ""; 15888 const width = selectionRect.right - selectionRect.left; 15889 const height = selectionRect.bottom - selectionRect.top; 15890 if (width <= 10 || height <= 10) { 15891 this.clearSelection(); 15892 return; 15893 } 15894 this.selectedNodes.clear(); 15895 Object.values(this.nodes).forEach((node) => { 15896 if (!node.position || !node.data.nodeData) return; 15897 if (node.data.nodeData.level === 0) { 15898 node.element.style.outline = ""; 15899 node.element.style.outlineOffset = ""; 15900 return; 15901 } 15902 const nodeRect = { 15903 left: node.position.left, 15904 top: node.position.top, 15905 right: node.position.left + node.position.width, 15906 bottom: node.position.top + node.position.height 15907 }; 15908 if (this.rectanglesIntersect(selectionRect, nodeRect)) { 15909 this.selectedNodes.add(node.data.nodeData.id); 15910 node.element.style.outline = "2px solid #a78bfa"; 15911 node.element.style.outlineOffset = "4px"; 15912 } else { 15913 node.element.style.outline = ""; 15914 node.element.style.outlineOffset = ""; 15915 } 15916 }); 15917 this.updateSelectionMenu(); 15918 }, 15919 rectanglesIntersect(rect1, rect2) { 15920 return !(rect2.left > rect1.right || rect2.right < rect1.left || rect2.top > rect1.bottom || rect2.bottom < rect1.top); 15921 }, 15922 clearSelection() { 15923 this.selectedNodes.clear(); 15924 Object.values(this.nodes).forEach((node) => { 15925 if (node.element) { 15926 node.element.style.outline = ""; 15927 node.element.style.outlineOffset = ""; 15928 } 15929 }); 15930 this.updateSelectionMenu(); 15931 }, 15932 cleanupSelection() { 15933 if (this.selectionMenu && this.selectionMenu.parentNode) { 15934 this.selectionMenu.parentNode.removeChild(this.selectionMenu); 15935 } 15936 } 15937}; 15938
15939// app/javascript/flow/progress.js 15940var ProgressMixin = { 15941 // Calculate progress percentage based on elapsed time vs estimated duration 15942 calculateProgress(createdAt, estimatedDuration) { 15943 if (!createdAt || !estimatedDuration) { 15944 return 0; 15945 } 15946 const createdAtDate = new Date(createdAt); 15947 const now2 = /* @__PURE__ */ new Date(); 15948 const elapsedSeconds = (now2 - createdAtDate) / 1e3; 15949 const estimatedSeconds = parseFloat(estimatedDuration); 15950 if (isNaN(elapsedSeconds) || isNaN(estimatedSeconds) || estimatedSeconds === 0) { 15951 return 0; 15952 } 15953 const percentage = Math.floor(elapsedSeconds / estimatedSeconds * 100); 15954 return Math.min(percentage, 98); 15955 }, 15956 // Update all progress bars in the DOM 15957 updateAllProgressBars() { 15958 const progressBars = document.querySelectorAll("[data-flow-progress-bar]"); 15959 progressBars.forEach((bar) => { 15960 const createdAt = bar.dataset.createdAt; 15961 const estimatedDuration = parseFloat(bar.dataset.estimatedDuration); 15962 if (!createdAt || !estimatedDuration || estimatedDuration === 0) { 15963 return; 15964 } 15965 const percentage = this.calculateProgress(createdAt, estimatedDuration); 15966 const fill = bar.querySelector("[data-flow-progress-fill]"); 15967 if (fill) { 15968 fill.style.width = `${percentage}%`; 15969 } 15970 const nodeEl = bar.closest(".flow-node"); 15971 if (nodeEl) { 15972 const labelEl = nodeEl.querySelector("[data-flow-processing-label]"); 15973 if (labelEl) { 15974 labelEl.textContent = `${percentage}%`; 15975 } 15976 } 15977 }); 15978 }, 15979 // Start automatic progress updates 15980 startProgressUpdates() { 15981 if (this.progressUpdateInterval) { 15982 return; 15983 } 15984 this.progressUpdateInterval = setInterval(() => { 15985 this.updateAllProgressBars(); 15986 }, 100); 15987 }, 15988 // Stop automatic progress updates 15989 stopProgressUpdates() { 15990 if (this.progressUpdateInterval) { 15991 clearInterval(this.progressUpdateInterval); 15992 this.progressUpdateInterval = null; 15993 } 15994 }, 15995 // Check if there are any processing nodes and start/stop updates accordingly 15996 checkProcessingNodes() { 15997 const progressBars = document.querySelectorAll("[data-flow-progress-bar]"); 15998 const hasProcessingNodes = progressBars.length > 0; 15999 if (hasProcessingNodes && !this.progressUpdateInterval) { 16000 this.startProgressUpdates(); 16001 } else if (!hasProcessingNodes && this.progressUpdateInterval) { 16002 this.stopProgressUpdates(); 16003 } 16004 } 16005}; 16006
16007// app/javascript/flow/comments.js 16008var CommentsMixin = { 16009 initComments() { 16010 this.commentsPanel = document.getElementById("comments-panel"); 16011 this.commentsList = this.commentsPanel?.querySelector("[data-comments-list]"); 16012 this.commentsInput = this.commentsPanel?.querySelector("[data-comments-input]"); 16013 this.commentsSendBtn = this.commentsPanel?.querySelector("[data-comments-send]"); 16014 this.commentsCloseBtn = this.commentsPanel?.querySelector("[data-comments-close]"); 16015 this.currentCommentNodeId = null; 16016 if (this.commentsSendBtn) { 16017 this.commentsSendBtn.addEventListener("click", () => this.sendComment()); 16018 } 16019 if (this.commentsInput) { 16020 this.commentsInput.addEventListener("keypress", (e) => { 16021 if (e.key === "Enter") { 16022 this.sendComment(); 16023 } 16024 }); 16025 } 16026 if (this.commentsCloseBtn) { 16027 this.commentsCloseBtn.addEventListener("click", () => this.closeCommentsPanel()); 16028 } 16029 }, 16030 bindCommentsButton(nodeElement, nodeId, commentsCount = 0) { 16031 if (this.flowStatus === "published") { 16032 const topBarRight = nodeElement.querySelector("[data-flow-top-bar-right]"); 16033 const commentsWrapper = nodeElement.querySelector("[data-flow-comments-wrapper]"); 16034 if (commentsWrapper) { 16035 commentsWrapper.style.display = "none"; 16036 } 16037 return; 16038 } 16039 const commentsBtn = nodeElement.querySelector("[data-flow-comments]"); 16040 if (commentsBtn) { 16041 commentsBtn.addEventListener("click", (e) => { 16042 e.stopPropagation(); 16043 this.openCommentsPanel(nodeId); 16044 }); 16045 } 16046 if (commentsCount > 0) { 16047 const topBarRight = nodeElement.querySelector("[data-flow-top-bar-right]"); 16048 const commentsCountEl = nodeElement.querySelector("[data-flow-comments-count]"); 16049 const commentsBtn2 = nodeElement.querySelector("[data-flow-comments]"); 16050 const deleteBtn = topBarRight?.querySelector("[data-flow-delete]"); 16051 if (commentsCountEl) { 16052 commentsCountEl.textContent = commentsCount; 16053 commentsCountEl.classList.remove("hidden"); 16054 } 16055 if (commentsBtn2) { 16056 commentsBtn2.classList.remove("text-gray/50"); 16057 commentsBtn2.classList.add("text-gray"); 16058 } 16059 if (topBarRight) { 16060 topBarRight.classList.remove("opacity-0", "translate-y-2", "-translate-x-3", "scale-90", "pointer-events-none"); 16061 topBarRight.classList.remove("group-hover:opacity-100", "group-hover:translate-y-0", "group-hover:translate-x-0", "group-hover:scale-100", "group-hover:pointer-events-auto"); 16062 topBarRight.classList.add("opacity-100", "pointer-events-auto"); 16063 } 16064 if (deleteBtn) { 16065 deleteBtn.classList.add("opacity-0", "group-hover:opacity-100", "transition-opacity", "duration-300"); 16066 } 16067 } 16068 }, 16069 async openCommentsPanel(nodeId) { 16070 if (!this.commentsPanel) return; 16071 this.currentCommentNodeId = nodeId; 16072 const node = this.nodes[nodeId]; 16073 if (node) { 16074 this.centerOnNode(node); 16075 } 16076 this.commentsPanel.classList.remove("translate-x-4", "opacity-0", "pointer-events-none"); 16077 this.commentsPanel.classList.add("translate-x-0", "opacity-100", "pointer-events-auto"); 16078 this.dimNodesExcept(nodeId); 16079 await this.loadComments(nodeId); 16080 }, 16081 closeCommentsPanel() { 16082 if (this.commentsPanel) { 16083 this.commentsPanel.classList.remove("translate-x-0", "opacity-100", "pointer-events-auto"); 16084 this.commentsPanel.classList.add("translate-x-4", "opacity-0", "pointer-events-none"); 16085 this.restoreNodesOpacity(); 16086 this.currentCommentNodeId = null; 16087 } 16088 }, 16089 dimNodesExcept(nodeId) { 16090 Object.entries(this.nodes).forEach(([id, node]) => { 16091 if (node.element) { 16092 if (id === nodeId) { 16093 node.element.style.opacity = "1"; 16094 } else { 16095 node.element.style.opacity = "0.1"; 16096 } 16097 node.element.style.transition = "opacity 0.3s ease"; 16098 } 16099 }); 16100 if (this.svgLayer) { 16101 this.svgLayer.style.opacity = "0.1"; 16102 this.svgLayer.style.transition = "opacity 0.3s ease"; 16103 } 16104 this.commentsClickHandler = (e) => { 16105 if (this.commentsPanel && this.commentsPanel.contains(e.target)) return; 16106 if (e.target.closest("[data-flow-comments]")) return; 16107 this.closeCommentsPanel(); 16108 }; 16109 setTimeout(() => { 16110 document.addEventListener("click", this.commentsClickHandler); 16111 }, 100); 16112 }, 16113 restoreNodesOpacity() {
16114 Object.values(this.nodes).forEach((node) => { 16115 if (node.element) { 16116 node.element.style.opacity = "1"; 16117 } 16118 }); 16119 if (this.svgLayer) { 16120 this.svgLayer.style.opacity = "1"; 16121 } 16122 if (this.commentsClickHandler) { 16123 document.removeEventListener("click", this.commentsClickHandler); 16124 this.commentsClickHandler = null; 16125 } 16126 }, 16127 async loadComments(nodeId) { 16128 if (!this.commentsList) return; 16129 try { 16130 const response = await fetch(`/node/${nodeId}/comments`); 16131 if (!response.ok) throw new Error("Failed to load comments"); 16132 const comments = await response.json(); 16133 this.renderComments(comments); 16134 } catch (error) { 16135 console.error("Error loading comments:", error); 16136 this.commentsList.innerHTML = '<div class="text-gray text-xs text-center">Failed to load comments</div>'; 16137 } 16138 }, 16139 renderComments(comments) { 16140 if (!this.commentsList) return; 16141 if (comments.length === 0) { 16142 this.commentsList.innerHTML = '<div class="text-gray text-xs text-center">No comments yet</div>'; 16143 return; 16144 } 16145 this.commentsList.innerHTML = comments.map((comment) => this.renderComment(comment)).join(""); 16146 }, 16147 renderComment(comment) { 16148 const date = new Date(comment.created_at); 16149 const timeAgo = this.timeAgo(date); 16150 return ` 16151 <div class="mb-4"> 16152 <div class="flex items-start gap-2"> 16153 <div class="w-6 h-6 rounded-full bg-inside flex-shrink-0 overflow-hidden"> 16154 ${comment.user.photo ? `<img src="${comment.user.photo}" class="w-full h-full object-cover" />` : `<div class="w-full h-full flex items-center justify-center text-xs text-gray">${comment.user.name.charAt(0).toUpperCase()}</div>`} 16155 </div> 16156 <div class="flex-1 min-w-0"> 16157 <div class="flex items-center gap-2"> 16158 <span class="text-xs font-medium text-white">${comment.user.name}</span> 16159 <span class="text-[10px] text-gray">${timeAgo}</span> 16160 </div> 16161 <p class="text-xs text-gray-300 mt-1">${this.formatCommentContent(comment.content)}</p> 16162 </div> 16163 </div> 16164 </div> 16165 `; 16166 }, 16167 formatCommentContent(content) { 16168 return content.replace(/@(\w+)/g, '<span class="text-active">@$1</span>'); 16169 }, 16170 async sendComment() { 16171 if (!this.commentsInput || !this.currentCommentNodeId) return; 16172 const content = this.commentsInput.value.trim(); 16173 if (!content) return; 16174 try { 16175 const csrfToken = document.querySelector('[name="csrf-token"]'); 16176 const response = await fetch(`/node/${this.currentCommentNodeId}/comments`, { 16177 method: "POST", 16178 headers: { 16179 "Content-Type": "application/json", 16180 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 16181 }, 16182 body: JSON.stringify({ content }) 16183 }); 16184 if (!response.ok) throw new Error("Failed to send comment"); 16185 this.commentsInput.value = ""; 16186 await this.loadComments(this.currentCommentNodeId); 16187 this.updateNodeCommentCount(this.currentCommentNodeId); 16188 } catch (error) { 16189 console.error("Error sending comment:", error); 16190 if (window.Toast) { 16191 window.Toast.show("Failed to send comment", "alert"); 16192 } 16193 } 16194 }, 16195 async updateNodeCommentCount(nodeId) { 16196 const node = this.nodes[nodeId]; 16197 if (!node || !node.element) return; 16198 const topBarRight = node.element.querySelector("[data-flow-top-bar-right]"); 16199 const commentsCountEl = node.element.querySelector("[data-flow-comments-count]"); 16200 const commentsBtn = node.element.querySelector("[data-flow-comments]"); 16201 const deleteBtn = topBarRight?.querySelector("[data-flow-delete]"); 16202 try { 16203 const response = await fetch(`/node/${nodeId}/comments`); 16204 const comments = await response.json(); 16205 if (comments.length > 0 && commentsCountEl) { 16206 commentsCountEl.textContent = comments.length; 16207 commentsCountEl.classList.remove("hidden"); 16208 if (commentsBtn) { 16209 commentsBtn.classList.remove("text-gray/50"); 16210 commentsBtn.classList.add("text-gray"); 16211 } 16212 if (topBarRight) { 16213 topBarRight.classList.remove("opacity-0", "translate-y-2", "-translate-x-3", "scale-90", "pointer-events-none"); 16214 topBarRight.classList.remove("group-hover:opacity-100", "group-hover:translate-y-0", "group-hover:translate-x-0", "group-hover:scale-100", "group-hover:pointer-events-auto"); 16215 topBarRight.classList.add("opacity-100", "pointer-events-auto"); 16216 } 16217 if (deleteBtn) { 16218 deleteBtn.classList.add("opacity-0", "group-hover:opacity-100", "transition-opacity", "duration-300"); 16219 } 16220 } 16221 } catch (error) { 16222 console.error("Error updating comment count:", error); 16223 } 16224 }, 16225 timeAgo(date) { 16226 const seconds = Math.floor((/* @__PURE__ */ new Date() - date) / 1e3); 16227 if (seconds < 60) return "just now"; 16228 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 16229 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 16230 if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 16231 return date.toLocaleDateString(); 16232 } 16233}; 16234
16235// app/javascript/flow/mentions.js 16236var MentionsMixin = { 16237 initMentions() { 16238 this.mentionPopup = null; 16239 this.mentionSearchText = ""; 16240 this.mentionStartOffset = 0; 16241 this.mentionSelectedIndex = 0; 16242 this.currentEditor = null; 16243 this.currentNodeEl = null; 16244 }, 16245 // Bind mention detection to an editor element 16246 bindMentions(editor, nodeEl) { 16247 if (!editor) return; 16248 editor.addEventListener("input", (e) => { 16249 this.handleMentionInput(editor, nodeEl); 16250 }); 16251 editor.addEventListener("keydown", (e) => { 16252 if (this.mentionPopup && !this.mentionPopup.classList.contains("hidden")) { 16253 this.handleMentionKeydown(e, editor); 16254 } 16255 }); 16256 editor.addEventListener("blur", () => { 16257 setTimeout(() => { 16258 this.closeMentionPopup(); 16259 }, 200); 16260 }); 16261 }, 16262 // Handle input to detect @ mentions 16263 handleMentionInput(editor, nodeEl) { 16264 const selection = window.getSelection(); 16265 if (!selection.rangeCount) return; 16266 const range = selection.getRangeAt(0); 16267 const text = editor.textContent || ""; 16268 const cursorPos = this.getCursorPosition(editor); 16269 const textBeforeCursor = text.substring(0, cursorPos); 16270 const lastAtIndex = textBeforeCursor.lastIndexOf("@"); 16271 if (lastAtIndex === -1) { 16272 this.closeMentionPopup(); 16273 return; 16274 } 16275 const charBefore = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : " "; 16276 if (!/\s/.test(charBefore) && lastAtIndex !== 0) { 16277 this.closeMentionPopup(); 16278 return; 16279 } 16280 if (this.isAtInsideMention(editor, lastAtIndex)) { 16281 this.closeMentionPopup(); 16282 return; 16283 } 16284 const searchText = textBeforeCursor.substring(lastAtIndex + 1); 16285 if (searchText.includes(" ") || searchText.includes("\n")) { 16286 this.closeMentionPopup(); 16287 return; 16288 } 16289 this.mentionSearchText = searchText; 16290 this.mentionStartOffset = lastAtIndex; 16291 this.currentEditor = editor; 16292 this.currentNodeEl = nodeEl; 16293 const images = this.getAvailableImages(nodeEl); 16294 const filtered = this.filterImages(images, searchText); 16295 if (filtered.length > 0) { 16296 this.showMentionPopup(filtered); 16297 } else { 16298 this.closeMentionPopup(); 16299 } 16300 }, 16301 // Get cursor position in contenteditable 16302 getCursorPosition(editor) { 16303 const selection = window.getSelection(); 16304 if (!selection.rangeCount) return 0; 16305 const range = selection.getRangeAt(0); 16306 const preCaretRange = range.cloneRange(); 16307 preCaretRange.selectNodeContents(editor); 16308 preCaretRange.setEnd(range.endContainer, range.endOffset);
16309 return preCaretRange.toString().length; 16310 }, 16311 // Check if the @ at a given text position is inside an existing mention span 16312 isAtInsideMention(editor, atTextPosition) { 16313 if (!editor) return false; 16314 let currentPos = 0; 16315 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 16316 let node; 16317 while (node = walker.nextNode()) { 16318 const nodeLength = node.textContent.length; 16319 if (currentPos + nodeLength > atTextPosition) { 16320 let parent = node.parentElement; 16321 while (parent && parent !== editor) { 16322 if (parent.classList && parent.classList.contains("mention")) { 16323 return true; 16324 } 16325 parent = parent.parentElement; 16326 } 16327 return false; 16328 } 16329 currentPos += nodeLength; 16330 } 16331 return false; 16332 }, 16333 // Get available images from reference badges 16334 getAvailableImages(currentNodeEl) { 16335 const images = []; 16336 if (!currentNodeEl) return images; 16337 const refContainers = [ 16338 currentNodeEl.querySelector("[data-flow-references-root]"), 16339 currentNodeEl.querySelector("[data-flow-references]") 16340 ].filter(Boolean); 16341 refContainers.forEach((container) => { 16342 if (container.style.display === "none") return; 16343 const badges = container.querySelectorAll("[data-ref-badge-wrapper]"); 16344 badges.forEach((badge) => { 16345 const img = badge.querySelector("[data-ref-badge-img]"); 16346 if (!img || !img.src) return; 16347 const imageNumber = images.length + 1; 16348 images.push({ 16349 id: badge.dataset.fileHash || `image-${imageNumber}`, 16350 number: imageNumber, 16351 label: `image${imageNumber}`, 16352 url: img.src, 16353 type: "reference" 16354 }); 16355 }); 16356 }); 16357 return images; 16358 }, 16359 // Filter images based on search text 16360 filterImages(images, searchText) { 16361 if (!searchText) return images; 16362 const lower = searchText.toLowerCase(); 16363 return images.filter((img) => { 16364 return img.label.toLowerCase().includes(lower) || img.prompt.toLowerCase().includes(lower); 16365 }); 16366 }, 16367 // Show mention popup 16368 showMentionPopup(images) { 16369 if (!this.mentionPopup) { 16370 this.mentionPopup = document.createElement("div"); 16371 this.mentionPopup.className = "mention-popup hidden"; 16372 this.mentionPopup.innerHTML = ` 16373 <div class="mention-popup-content"> 16374 <div class="mention-popup-header">References</div> 16375 <div class="mention-popup-list"></div> 16376 </div> 16377 `; 16378 document.body.appendChild(this.mentionPopup); 16379 } 16380 const list = this.mentionPopup.querySelector(".mention-popup-list"); 16381 list.innerHTML = ""; 16382 images.forEach((img, index) => { 16383 const item = document.createElement("div"); 16384 item.className = `mention-popup-item${index === this.mentionSelectedIndex ? " selected" : ""}`; 16385 item.dataset.index = index; 16386 item.innerHTML = ` 16387 <div class="mention-popup-thumbnail"> 16388 <img src="${img.url}" alt="${img.label}"> 16389 </div> 16390 <div class="mention-popup-info"> 16391 <span class="mention-popup-label">@${img.label}</span> 16392 </div> 16393 `; 16394 item.addEventListener("click", () => { 16395 if (this.currentEditor) { 16396 this.insertMention(this.currentEditor, img); 16397 } 16398 }); 16399 item.addEventListener("mouseenter", () => { 16400 this.mentionSelectedIndex = index; 16401 this.updateMentionSelection(); 16402 }); 16403 list.appendChild(item); 16404 }); 16405 this.positionMentionPopup(this.currentEditor); 16406 this.mentionPopup.classList.remove("hidden"); 16407 this.mentionSelectedIndex = 0; 16408 this.updateMentionSelection(); 16409 }, 16410 // Position popup near the editor 16411 positionMentionPopup(editor) { 16412 if (!this.mentionPopup || !editor) return; 16413 const editorRect = editor.getBoundingClientRect(); 16414 const selection = window.getSelection(); 16415 let top, left; 16416 let positionFound = false; 16417 if (selection.rangeCount > 0) { 16418 const range = selection.getRangeAt(0); 16419 const rect = range.getBoundingClientRect(); 16420 if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) { 16421 const atPosition = this.getAtSymbolRect(editor); 16422 if (atPosition) { 16423 top = atPosition.bottom + window.scrollY + 5; 16424 left = atPosition.left + window.scrollX; 16425 positionFound = true; 16426 } 16427 if (!positionFound) { 16428 const markerRect = this.getMarkerPosition(editor); 16429 if (markerRect) { 16430 top = markerRect.bottom + window.scrollY + 5; 16431 left = markerRect.left + window.scrollX; 16432 positionFound = true; 16433 } 16434 } 16435 if (!positionFound) { 16436 top = editorRect.bottom + window.scrollY + 5; 16437 left = editorRect.left + window.scrollX; 16438 } 16439 } else { 16440 top = rect.bottom + window.scrollY + 5; 16441 left = rect.left + window.scrollX; 16442 } 16443 } else { 16444 top = editorRect.bottom + window.scrollY + 5; 16445 left = editorRect.left + window.scrollX; 16446 } 16447 const popupWidth = 280; 16448 const popupHeight = 200; 16449 if (left + popupWidth > window.innerWidth) { 16450 left = window.innerWidth - popupWidth - 10; 16451 } 16452 if (top + popupHeight > window.innerHeight + window.scrollY) { 16453 let cursorTop = editorRect.top; 16454 if (selection.rangeCount > 0) { 16455 const rangeRect = selection.getRangeAt(0).getBoundingClientRect(); 16456 if (rangeRect.top !== 0 || rangeRect.height !== 0) { 16457 cursorTop = rangeRect.top; 16458 } 16459 } 16460 top = cursorTop + window.scrollY - popupHeight - 5; 16461 } 16462 this.mentionPopup.style.top = `${top}px`; 16463 this.mentionPopup.style.left = `${left}px`; 16464 }, 16465 // Get the bounding rect of the @ symbol at mentionStartOffset 16466 getAtSymbolRect(editor) { 16467 if (!editor || this.mentionStartOffset === void 0) return null;
16468 let currentPos = 0; 16469 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 16470 let node; 16471 while (node = walker.nextNode()) { 16472 const nodeLength = node.textContent.length; 16473 if (currentPos + nodeLength > this.mentionStartOffset) { 16474 const offsetInNode = this.mentionStartOffset - currentPos; 16475 const tempRange = document.createRange(); 16476 try { 16477 tempRange.setStart(node, offsetInNode); 16478 tempRange.setEnd(node, Math.min(offsetInNode + 1, node.textContent.length)); 16479 const rect = tempRange.getBoundingClientRect(); 16480 if (rect.top !== 0 || rect.height !== 0) { 16481 return rect; 16482 } 16483 } catch (e) { 16484 } 16485 break; 16486 } 16487 currentPos += nodeLength; 16488 } 16489 return null; 16490 }, 16491 // Insert a temporary marker span to get cursor position 16492 getMarkerPosition(editor) { 16493 const selection = window.getSelection(); 16494 if (!selection.rangeCount) return null; 16495 const range = selection.getRangeAt(0); 16496 const marker = document.createElement("span"); 16497 marker.style.cssText = "position:relative;"; 16498 marker.textContent = "\u200B"; 16499 try { 16500 range.insertNode(marker); 16501 const rect = marker.getBoundingClientRect(); 16502 marker.remove(); 16503 editor.normalize(); 16504 selection.removeAllRanges(); 16505 selection.addRange(range); 16506 if (rect.top !== 0 || rect.height !== 0) { 16507 return rect; 16508 } 16509 } catch (e) { 16510 if (marker.parentNode) { 16511 marker.remove(); 16512 editor.normalize(); 16513 } 16514 } 16515 return null; 16516 }, 16517 // Update selection highlight 16518 updateMentionSelection() { 16519 if (!this.mentionPopup) return; 16520 const items = this.mentionPopup.querySelectorAll(".mention-popup-item"); 16521 items.forEach((item, index) => { 16522 if (index === this.mentionSelectedIndex) { 16523 item.classList.add("selected"); 16524 item.scrollIntoView({ block: "nearest" }); 16525 } else { 16526 item.classList.remove("selected"); 16527 } 16528 }); 16529 }, 16530 // Handle keydown in mention popup 16531 handleMentionKeydown(e, editor) { 16532 const items = this.mentionPopup?.querySelectorAll(".mention-popup-item"); 16533 if (!items || items.length === 0) return; 16534 switch (e.key) { 16535 case "ArrowDown": 16536 e.preventDefault(); 16537 this.mentionSelectedIndex = (this.mentionSelectedIndex + 1) % items.length; 16538 this.updateMentionSelection(); 16539 break; 16540 case "ArrowUp": 16541 e.preventDefault(); 16542 this.mentionSelectedIndex = (this.mentionSelectedIndex - 1 + items.length) % items.length; 16543 this.updateMentionSelection(); 16544 break; 16545 case "Enter": 16546 case "Tab": 16547 e.preventDefault(); 16548 if (this.currentEditor && this.currentNodeEl) { 16549 const images = this.getAvailableImages(this.currentNodeEl); 16550 const filtered = this.filterImages(images, this.mentionSearchText); 16551 if (filtered[this.mentionSelectedIndex]) { 16552 this.insertMention(this.currentEditor, filtered[this.mentionSelectedIndex]); 16553 } 16554 } 16555 break; 16556 case "Escape": 16557 e.preventDefault(); 16558 this.closeMentionPopup(); 16559 break; 16560 } 16561 }, 16562 // Insert mention into editor 16563 insertMention(editor, image) { 16564 if (!editor) return; 16565 const selection = window.getSelection(); 16566 if (!selection.rangeCount) return; 16567 const range = selection.getRangeAt(0); 16568 let currentPos = 0; 16569 let targetNode = null; 16570 let atPositionInNode = 0; 16571 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 16572 let node; 16573 while (node = walker.nextNode()) { 16574 const nodeLength = node.textContent.length; 16575 if (currentPos + nodeLength > this.mentionStartOffset) { 16576 targetNode = node; 16577 atPositionInNode = this.mentionStartOffset - currentPos; 16578 break; 16579 } 16580 currentPos += nodeLength; 16581 } 16582 if (!targetNode) { 16583 this.closeMentionPopup(); 16584 return; 16585 } 16586 const deleteLength = 1 + this.mentionSearchText.length; 16587 const textBefore = targetNode.textContent.substring(0, atPositionInNode); 16588 const textAfter = targetNode.textContent.substring(atPositionInNode + deleteLength); 16589 const mentionSpan = document.createElement("span"); 16590 mentionSpan.className = "mention"; 16591 mentionSpan.contentEditable = "false"; 16592 mentionSpan.dataset.mentionId = image.id; 16593 mentionSpan.dataset.mentionLabel = image.label; 16594 mentionSpan.textContent = `@${image.label}`; 16595 const spaceNode = document.createTextNode("\xA0"); 16596 const parent = targetNode.parentNode; 16597 if (textBefore) { 16598 parent.insertBefore(document.createTextNode(textBefore), targetNode); 16599 } 16600 parent.insertBefore(mentionSpan, targetNode); 16601 parent.insertBefore(spaceNode, targetNode); 16602 if (textAfter) { 16603 parent.insertBefore(document.createTextNode(textAfter), targetNode); 16604 } 16605 parent.removeChild(targetNode); 16606 const newRange = document.createRange(); 16607 newRange.setStartAfter(spaceNode); 16608 newRange.collapse(true); 16609 selection.removeAllRanges(); 16610 selection.addRange(newRange); 16611 editor.dispatchEvent(new Event("input", { bubbles: true })); 16612 this.closeMentionPopup(); 16613 }, 16614 // Set cursor position in contenteditable 16615 setCursorPosition(editor, position) { 16616 const range = document.createRange(); 16617 const selection = window.getSelection();
16618 let currentPos = 0; 16619 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 16620 let node; 16621 while (node = walker.nextNode()) { 16622 const nodeLength = node.textContent.length; 16623 if (currentPos + nodeLength >= position) { 16624 range.setStart(node, position - currentPos); 16625 range.collapse(true); 16626 selection.removeAllRanges(); 16627 selection.addRange(range); 16628 return; 16629 } 16630 currentPos += nodeLength; 16631 } 16632 range.selectNodeContents(editor); 16633 range.collapse(false); 16634 selection.removeAllRanges(); 16635 selection.addRange(range); 16636 }, 16637 // Close mention popup 16638 closeMentionPopup() { 16639 if (this.mentionPopup) { 16640 this.mentionPopup.classList.add("hidden"); 16641 } 16642 this.mentionSearchText = ""; 16643 this.mentionStartOffset = 0; 16644 this.currentEditor = null; 16645 this.currentNodeEl = null; 16646 }, 16647 // Cleanup mentions 16648 cleanupMentions() { 16649 if (this.mentionPopup) { 16650 this.mentionPopup.remove(); 16651 this.mentionPopup = null; 16652 } 16653 }, 16654 // Extract text content from editor, converting mentions to text 16655 getEditorTextContent(editor) { 16656 if (!editor) return ""; 16657 const clone = editor.cloneNode(true); 16658 const mentions = clone.querySelectorAll(".mention"); 16659 mentions.forEach((mention) => { 16660 const text = document.createTextNode(mention.textContent); 16661 mention.parentNode.replaceChild(text, mention); 16662 }); 16663 return clone.textContent || ""; 16664 }, 16665 // Parse text content and convert @mentions and STYLE/CAMERA tags to styled spans 16666 parseAndStyleMentions(editor) { 16667 if (!editor) return; 16668 const text = editor.textContent || ""; 16669 const elements = []; 16670 const mentionPattern = /@(image\d+)/g; 16671 let match; 16672 while ((match = mentionPattern.exec(text)) !== null) { 16673 elements.push({ 16674 type: "mention", 16675 fullMatch: match[0], 16676 label: match[1], 16677 index: match.index 16678 }); 16679 } 16680 const tagPattern = /^(STYLE|CAMERA)$/gm; 16681 while ((match = tagPattern.exec(text)) !== null) { 16682 elements.push({ 16683 type: "tag", 16684 fullMatch: match[0], 16685 tagName: match[1], 16686 index: match.index 16687 }); 16688 } 16689 if (elements.length === 0) return; 16690 elements.sort((a, b) => a.index - b.index); 16691 let lastIndex = 0; 16692 const fragment = document.createDocumentFragment(); 16693 elements.forEach((elem) => { 16694 if (elem.index > lastIndex) { 16695 const beforeText = text.substring(lastIndex, elem.index); 16696 fragment.appendChild(document.createTextNode(beforeText)); 16697 } 16698 if (elem.type === "mention") { 16699 const mentionSpan = document.createElement("span"); 16700 mentionSpan.className = "mention"; 16701 mentionSpan.contentEditable = "false"; 16702 mentionSpan.dataset.mentionLabel = elem.label; 16703 mentionSpan.textContent = elem.fullMatch; 16704 fragment.appendChild(mentionSpan); 16705 } else if (elem.type === "tag") { 16706 const tagSpan = document.createElement("span"); 16707 tagSpan.className = "tag-label"; 16708 tagSpan.contentEditable = "false"; 16709 tagSpan.dataset.tagType = elem.tagName.toLowerCase(); 16710 tagSpan.textContent = elem.fullMatch; 16711 fragment.appendChild(tagSpan); 16712 } 16713 lastIndex = elem.index + elem.fullMatch.length; 16714 }); 16715 if (lastIndex < text.length) { 16716 fragment.appendChild(document.createTextNode(text.substring(lastIndex))); 16717 } 16718 editor.innerHTML = ""; 16719 editor.appendChild(fragment); 16720 } 16721}; 16722
16723// app/javascript/flow.js 16724var FlowCanvas = class { 16725 constructor() { 16726 this.container = null; 16727 this.viewport = null; 16728 this.svgLayer = null; 16729 this.nodesContainer = null; 16730 this.zoom = 1; 16731 this.panX = 0; 16732 this.panY = 0; 16733 this.isPanning = false; 16734 this.lastPosX = 0; 16735 this.lastPosY = 0; 16736 this.MIN_ZOOM = 0.2; 16737 this.MAX_ZOOM = 1; 16738 this.ZOOM_STEP = 1.1; 16739 this.ANIMATION_DURATION = 500; 16740 this.initialCenterX = null; 16741 this.initialCenterY = null; 16742 this.nodes = {}; 16743 this.gridSize = 20; 16744 this.NODE_SIZE = 400; 16745 this.horizontalSpacing = 160; 16746 this.verticalSpacing = 160; 16747 this.nodeIdToDelete = null; 16748 this.eventListeners = []; 16749 this.init(); 16750 } 16751 init() { 16752 const container = document.querySelector("[data-init-flow]"); 16753 if (!container) return; 16754 this.flowId = container.dataset.flowId || null; 16755 this.sceneId = container.dataset.sceneId || null; 16756 this.selectedModel = container.dataset.selectedModel || null; 16757 this.isOwner = container.dataset.isOwner === "true"; 16758 this.isStudioMode = container.dataset.studioMode === "true"; 16759 this.flowStatus = container.dataset.flowStatus || "personal"; 16760 const savedPanX = parseFloat(container.dataset.flowPanX); 16761 const savedPanY = parseFloat(container.dataset.flowPanY); 16762 const savedZoom = parseFloat(container.dataset.flowZoom); 16763 if (!isNaN(savedPanX)) this.panX = savedPanX; 16764 if (!isNaN(savedPanY)) this.panY = savedPanY; 16765 if (!isNaN(savedZoom)) this.zoom = savedZoom; 16766 this.setupCanvas(); 16767 this.updateTopBarRight(); 16768 this.bindZoomControls(); 16769 this.initDropdowns(); 16770 this.initScenes(); 16771 this.initGeneration(); 16772 this.initFlowInfo(); 16773 this.setupRefsListener(); 16774 this.initPins(); 16775 this.initFavs(); 16776 this.initSelection(); 16777 this.initComments(); 16778 this.initMentions(); 16779 this.initNodes(); 16780 this.repositionTree(); 16781 this.initConnections(); 16782 this.updateConnections(); 16783 this.fitToScreenOnInit(); 16784 if (this.hasProcessingNodes()) { 16785 this.startPolling(); 16786 } 16787 } 16788 // Position top bar right edge relative to user dropdown 16789 updateTopBarRight() { 16790 const topBar = document.getElementById("flow-top-bar"); 16791 const userBox = document.querySelector('[data-dropdown-trigger="user"]')?.closest(".fixed"); 16792 if (topBar && userBox) { 16793 const userRect = userBox.getBoundingClientRect(); 16794 topBar.style.right = `${window.innerWidth - userRect.left + 10}px`; 16795 } 16796 } 16797 // Check permissions and show appropriate modal if needed 16798 checkPermissions(action) { 16799 if (!window.isAuthenticated) { 16800 if (typeof loadSigninModal === "function") { 16801 loadSigninModal(); 16802 } 16803 return false; 16804 } 16805 if (!this.isOwner) { 16806 if (typeof loadCloneModal === "function") { 16807 loadCloneModal(this.flowId); 16808 } 16809 return false; 16810 } 16811 return true; 16812 } 16813 // Check if any nodes are currently processing 16814 hasProcessingNodes() { 16815 return Object.values(this.nodes).some((node) => { 16816 return node.data && node.data.nodeData && node.data.nodeData.status === "processing"; 16817 }); 16818 } 16819 // Fit to screen on initial load if we have nodes 16820 fitToScreenOnInit() { 16821 const nodeArray = Object.values(this.nodes); 16822 if (nodeArray.length === 0) return; 16823 requestAnimationFrame(() => { 16824 if (this.fitToScreenNoAnimation) { 16825 this.fitToScreenNoAnimation(); 16826 } 16827 }); 16828 } 16829 // Helper to add event listeners with cleanup 16830 addListener(target, event, handler, options) { 16831 target.addEventListener(event, handler, options); 16832 this.eventListeners.push({ target, event, handler, options }); 16833 } 16834 // Cleanup on page unload 16835 destroy() { 16836 if (this.stopPolling) { 16837 this.stopPolling(); 16838 } 16839 if (this.stopProgressUpdates) { 16840 this.stopProgressUpdates(); 16841 } 16842 if (this.clearAllNodes) { 16843 this.clearAllNodes(); 16844 } 16845 if (this.cleanupSelection) { 16846 this.cleanupSelection(); 16847 } 16848 if (this.cleanupMentions) { 16849 this.cleanupMentions(); 16850 } 16851 this.eventListeners.forEach(({ target, event, handler, options }) => { 16852 target.removeEventListener(event, handler, options); 16853 }); 16854 this.eventListeners = []; 16855 } 16856}; 16857Object.assign( 16858 FlowCanvas.prototype, 16859 CanvasMixin, 16860 NodesMixin, 16861 ConnectionsMixin, 16862 TreeMixin, 16863 PagesMixin, 16864 DropdownsMixin, 16865 UploadsMixin, 16866 GenerationMixin, 16867 FlowInfoMixin, 16868 PinsMixin, 16869 FavsMixin, 16870 SelectionMixin, 16871 ProgressMixin, 16872 CommentsMixin, 16873 MentionsMixin 16874); 16875document.addEventListener("turbo:load", () => { 16876 if (window.flowCanvas) { 16877 window.flowCanvas.destroy(); 16878 } 16879 window.flowCanvas = new FlowCanvas(); 16880}); 16881document.addEventListener("turbo:before-cache", () => { 16882 if (window.flowCanvas) { 16883 window.flowCanvas.destroy(); 16884 window.flowCanvas = null; 16885 } 16886}); 16887
16888// app/javascript/flows.js 16889var FlowsManager = class { 16890 constructor() { 16891 this.currentOpenDropdown = null; 16892 this.flowIdToDelete = null; 16893 this.flowIdToRename = null; 16894 this.flowIdToDuplicate = null; 16895 this.rowToDelete = null; 16896 this.listenersAttached = false; 16897 this.handleMenuButtonClick = this.handleMenuButtonClick.bind(this); 16898 this.handleDocumentClick = this.handleDocumentClick.bind(this); 16899 this.handleMenuOptions = this.handleMenuOptions.bind(this); 16900 this.handleRenameOption = this.handleRenameOption.bind(this); 16901 this.handleDuplicateOption = this.handleDuplicateOption.bind(this); 16902 this.handleDeleteOption = this.handleDeleteOption.bind(this); 16903 this.init(); 16904 } 16905 init() { 16906 const flowMenuBtns = document.querySelectorAll(".flow-menu-btn"); 16907 if (flowMenuBtns.length === 0) { 16908 return; 16909 } 16910 this.attachEventListeners(); 16911 } 16912 attachEventListeners() { 16913 if (this.listenersAttached) { 16914 return; 16915 } 16916 document.querySelectorAll(".flow-menu-btn").forEach((btn) => { 16917 btn.addEventListener("click", this.handleMenuButtonClick); 16918 }); 16919 document.addEventListener("click", this.handleDocumentClick); 16920 document.addEventListener("click", this.handleMenuOptions); 16921 this.listenersAttached = true; 16922 } 16923 handleMenuOptions(e) { 16924 if (e.target.classList.contains("rename-flow-option")) { 16925 this.handleRenameOption(e); 16926 } else if (e.target.classList.contains("duplicate-flow-option")) { 16927 this.handleDuplicateOption(e); 16928 } else if (e.target.classList.contains("delete-flow-option")) { 16929 this.handleDeleteOption(e); 16930 } 16931 } 16932 handleMenuButtonClick(e) { 16933 e.stopPropagation(); 16934 const btn = e.currentTarget; 16935 const dropdown = btn.closest(".relative")?.querySelector(".flow-dropdown"); 16936 if (!dropdown) return; 16937 const flowId = btn.dataset.flowId; 16938 if (this.currentOpenDropdown && this.currentOpenDropdown !== dropdown) { 16939 this.currentOpenDropdown.classList.add("hidden"); 16940 } 16941 dropdown.classList.toggle("hidden"); 16942 this.currentOpenDropdown = dropdown.classList.contains("hidden") ? null : dropdown; 16943 const renameBtn = dropdown.querySelector(".rename-flow-option"); 16944 const duplicateBtn = dropdown.querySelector(".duplicate-flow-option"); 16945 const deleteBtn = dropdown.querySelector(".delete-flow-option"); 16946 if (renameBtn) renameBtn.dataset.flowId = flowId; 16947 if (duplicateBtn) duplicateBtn.dataset.flowId = flowId; 16948 if (deleteBtn) deleteBtn.dataset.flowId = flowId; 16949 } 16950 handleDocumentClick(e) { 16951 if (this.currentOpenDropdown && !e.target.closest(".flow-menu-btn")) { 16952 this.currentOpenDropdown.classList.add("hidden"); 16953 this.currentOpenDropdown = null; 16954 } 16955 } 16956 handleRenameOption(e) { 16957 e.stopPropagation(); 16958 const flowId = e.target.dataset.flowId; 16959 this.closeCurrentDropdown(); 16960 this.flowIdToRename = flowId; 16961 this.loadModal(`/flow/${flowId}/rename`, "rename-flow-modal"); 16962 } 16963 handleDuplicateOption(e) { 16964 e.stopPropagation(); 16965 const flowId = e.target.dataset.flowId; 16966 this.closeCurrentDropdown(); 16967 this.flowIdToDuplicate = flowId; 16968 this.loadModal(`/flow/${flowId}/duplicate`, "duplicate-flow-modal"); 16969 } 16970 handleDeleteOption(e) { 16971 e.stopPropagation(); 16972 const flowId = e.target.dataset.flowId; 16973 const row = e.target.closest("tr"); 16974 this.closeCurrentDropdown(); 16975 this.flowIdToDelete = flowId; 16976 this.rowToDelete = row; 16977 this.loadModal(`/flow/${flowId}/delete`, "delete-flow-modal"); 16978 } 16979 loadModal(url, modalId) { 16980 fetch(url, { 16981 headers: { 16982 Accept: "text/html", 16983 "X-Requested-With": "XMLHttpRequest" 16984 } 16985 }).then((response) => response.text()).then((html) => { 16986 const existingModal = document.getElementById(modalId); 16987 if (existingModal) { 16988 existingModal.remove(); 16989 } 16990 document.body.insertAdjacentHTML("beforeend", html); 16991 if (modalId === "rename-flow-modal") { 16992 this.setupRenameModal(); 16993 } else if (modalId === "duplicate-flow-modal") { 16994 this.setupDuplicateModal(); 16995 } else if (modalId === "delete-flow-modal") { 16996 this.setupDeleteModal(); 16997 }
16998 if (window.Modal) { 16999 window.Modal.open(modalId); 17000 } 17001 }).catch((error) => { 17002 console.error("Error loading modal:", error); 17003 }); 17004 } 17005 setupRenameModal() { 17006 const form = document.getElementById("rename-flow-form"); 17007 const input = document.getElementById("rename-flow-input"); 17008 const flowIdInput = document.getElementById("rename-flow-id"); 17009 if (flowIdInput) { 17010 flowIdInput.value = this.flowIdToRename; 17011 } 17012 if (form) { 17013 form.addEventListener("submit", async (e) => { 17014 e.preventDefault(); 17015 const flowId = flowIdInput.value; 17016 const newName = input.value.trim(); 17017 if (!newName) { 17018 window.Toast?.show("Name cannot be empty", "alert"); 17019 return; 17020 } 17021 try { 17022 const response = await fetch(`/flow/${flowId}/rename`, { 17023 method: "PATCH", 17024 headers: { 17025 "Content-Type": "application/json", 17026 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, 17027 Accept: "application/json" 17028 }, 17029 body: JSON.stringify({ name: newName }) 17030 }); 17031 if (response.ok) { 17032 window.Modal?.close(); 17033 setTimeout(() => { 17034 window.location.reload(); 17035 }, 300); 17036 } else { 17037 const data = await response.json(); 17038 window.Toast?.show(data.error || "Failed to rename flow", "alert"); 17039 } 17040 } catch (error) { 17041 console.error("Rename error:", error); 17042 window.Toast?.show("Failed to rename flow", "alert"); 17043 } 17044 }); 17045 } 17046 } 17047 setupDuplicateModal() { 17048 const form = document.getElementById("duplicate-flow-form"); 17049 const input = document.getElementById("duplicate-flow-input"); 17050 const flowIdInput = document.getElementById("duplicate-flow-id"); 17051 if (flowIdInput) { 17052 flowIdInput.value = this.flowIdToDuplicate; 17053 } 17054 if (form) { 17055 form.addEventListener("submit", async (e) => { 17056 e.preventDefault(); 17057 const flowId = flowIdInput.value; 17058 const newName = input.value.trim(); 17059 if (!newName) { 17060 window.Toast?.show("Name cannot be empty", "alert"); 17061 return; 17062 } 17063 try { 17064 const response = await fetch(`/flow/${flowId}/duplicate`, { 17065 method: "POST", 17066 headers: { 17067 "Content-Type": "application/json", 17068 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, 17069 Accept: "application/json" 17070 }, 17071 body: JSON.stringify({ name: newName }) 17072 }); 17073 if (response.ok) { 17074 const data = await response.json(); 17075 window.Modal?.close(); 17076 window.Toast?.show("Flow duplicated successfully", "success"); 17077 setTimeout(() => { 17078 window.location.href = data.redirect_url; 17079 }, 500); 17080 } else { 17081 const data = await response.json(); 17082 window.Toast?.show( 17083 data.error || "Failed to duplicate flow", 17084 "alert" 17085 ); 17086 } 17087 } catch (error) { 17088 console.error("Duplicate error:", error); 17089 window.Toast?.show("Failed to duplicate flow", "alert"); 17090 } 17091 }); 17092 } 17093 } 17094 setupDeleteModal() { 17095 const confirmBtn = document.getElementById("delete-flow-confirm"); 17096 if (confirmBtn) { 17097 confirmBtn.addEventListener("click", async () => { 17098 if (!this.flowIdToDelete || !this.rowToDelete) return; 17099 try { 17100 const response = await fetch(`/flow/${this.flowIdToDelete}`, { 17101 method: "DELETE", 17102 headers: { 17103 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content, 17104 Accept: "application/json" 17105 } 17106 }); 17107 if (response.ok) { 17108 window.Modal?.close(); 17109 const row = this.rowToDelete; 17110 const height = row.offsetHeight; 17111 row.style.height = height + "px"; 17112 row.style.overflow = "hidden"; 17113 row.style.transition = "height 300ms ease-out, opacity 300ms ease-out"; 17114 row.offsetHeight; 17115 row.style.height = "0"; 17116 row.style.opacity = "0"; 17117 setTimeout(() => { 17118 row.remove(); 17119 }, 300); 17120 window.Toast?.show("Flow deleted successfully", "success"); 17121 this.flowIdToDelete = null; 17122 this.rowToDelete = null; 17123 } else {
17124 window.Modal?.close(); 17125 window.Toast?.show("Failed to delete flow", "alert"); 17126 } 17127 } catch (error) { 17128 console.error("Delete error:", error); 17129 window.Modal?.close(); 17130 window.Toast?.show("Failed to delete flow", "alert"); 17131 } 17132 }); 17133 } 17134 } 17135 closeCurrentDropdown() { 17136 if (this.currentOpenDropdown) { 17137 this.currentOpenDropdown.classList.add("hidden"); 17138 this.currentOpenDropdown = null; 17139 } 17140 } 17141 cleanup() { 17142 document.removeEventListener("click", this.handleDocumentClick); 17143 document.removeEventListener("click", this.handleMenuOptions); 17144 this.listenersAttached = false; 17145 } 17146}; 17147document.addEventListener("turbo:load", () => { 17148 if (!window.flowsManager) { 17149 window.flowsManager = new FlowsManager(); 17150 } else { 17151 window.flowsManager.init(); 17152 } 17153}); 17154document.addEventListener("turbo:before-cache", () => { 17155 if (window.flowsManager) { 17156 window.flowsManager.cleanup(); 17157 } 17158}); 17159
17160// app/javascript/home.js 17161var HomeVideo = class { 17162 constructor() { 17163 this.videoClickHandler = null; 17164 this.videoTimeUpdateHandler = null; 17165 this.videoFirstClick = true; 17166 this.initHomeVideo(); 17167 } 17168 destroy() { 17169 const videoElement = document.getElementById("home-video"); 17170 if (videoElement && this.videoClickHandler) { 17171 videoElement.removeEventListener("click", this.videoClickHandler); 17172 this.videoClickHandler = null; 17173 } 17174 if (videoElement && this.videoTimeUpdateHandler) { 17175 videoElement.removeEventListener( 17176 "timeupdate", 17177 this.videoTimeUpdateHandler 17178 ); 17179 this.videoTimeUpdateHandler = null; 17180 } 17181 } 17182 // Home video click to unmute functionality 17183 initHomeVideo() { 17184 const videoElement = document.getElementById("home-video"); 17185 if (!videoElement) return; 17186 videoElement.currentTime = 8; 17187 videoElement.play().catch(() => { 17188 }); 17189 this.videoTimeUpdateHandler = () => { 17190 if (videoElement.currentTime >= videoElement.duration - 0.1) { 17191 videoElement.currentTime = 8; 17192 videoElement.play(); 17193 } 17194 }; 17195 videoElement.addEventListener("timeupdate", this.videoTimeUpdateHandler); 17196 this.videoClickHandler = (e) => { 17197 if (this.videoFirstClick) { 17198 e.preventDefault(); 17199 const playIcon = document.getElementById("home-video-play-icon"); 17200 if (playIcon) { 17201 playIcon.style.opacity = "0"; 17202 setTimeout(() => { 17203 playIcon.style.display = "none"; 17204 }, 300); 17205 } 17206 videoElement.removeEventListener( 17207 "timeupdate", 17208 this.videoTimeUpdateHandler 17209 ); 17210 this.videoTimeUpdateHandler = null; 17211 videoElement.muted = false; 17212 videoElement.loop = false; 17213 videoElement.volume = 0.5; 17214 videoElement.currentTime = 0; 17215 videoElement.play(); 17216 this.videoFirstClick = false; 17217 } else { 17218 if (videoElement.paused) { 17219 videoElement.play(); 17220 } else { 17221 videoElement.pause(); 17222 } 17223 } 17224 }; 17225 videoElement.addEventListener("click", this.videoClickHandler); 17226 videoElement.style.cursor = "pointer"; 17227 } 17228}; 17229var homeVideoInstance = null; 17230document.addEventListener("turbo:load", () => { 17231 if (homeVideoInstance) { 17232 homeVideoInstance.destroy(); 17233 homeVideoInstance = null; 17234 } 17235 const videoElement = document.getElementById("home-video"); 17236 const isHomePage = window.location.pathname === "/" || window.location.pathname === "/flows"; 17237 if (videoElement && isHomePage) { 17238 homeVideoInstance = new HomeVideo(); 17239 } 17240}); 17241document.addEventListener("turbo:before-cache", () => { 17242 if (homeVideoInstance) { 17243 homeVideoInstance.destroy(); 17244 homeVideoInstance = null; 17245 } 17246}); 17247window.addEventListener("beforeunload", () => { 17248 if (homeVideoInstance) { 17249 homeVideoInstance.destroy(); 17250 homeVideoInstance = null; 17251 } 17252}); 17253window.openMediaModal = async function(mediaId) { 17254 try { 17255 const response = await fetch(`/media/${mediaId}`, { 17256 headers: { 17257 Accept: "text/html", 17258 "X-Requested-With": "XMLHttpRequest" 17259 } 17260 }); 17261 if (!response.ok) throw new Error("Failed to load media"); 17262 const html = await response.text(); 17263 const existingModal = document.getElementById("media-modal"); 17264 if (existingModal) existingModal.remove(); 17265 document.body.insertAdjacentHTML("beforeend", html); 17266 if (window.Modal) window.Modal.open("media-modal"); 17267 } catch (error) { 17268 console.error("Error opening media:", error); 17269 } 17270}; 17271
17272// app/javascript/product.js 17273var ProductFlows = class { 17274 constructor() { 17275 this.isTransitioning = false; 17276 this.autoRotateInterval = null; 17277 this.resizeHandler = this.handleResize.bind(this); 17278 this.tabClickHandlers = []; 17279 this.currentScale = 1; 17280 this.init(); 17281 this.startAutoRotation(); 17282 } 17283 init() { 17284 this.applyResponsiveScale(); 17285 setTimeout(() => { 17286 this.drawAllCurves(); 17287 }, 100); 17288 window.addEventListener("resize", this.resizeHandler); 17289 setTimeout(() => { 17290 this.initCategoryTabs(); 17291 }, 50); 17292 } 17293 handleResize() { 17294 this.applyResponsiveScale(); 17295 this.drawAllCurves(); 17296 } 17297 applyResponsiveScale() { 17298 const flowContainer = document.getElementById("flows"); 17299 if (!flowContainer) return; 17300 const windowWidth = window.innerWidth; 17301 const breakpoint = 1300; 17302 const designWidth = 1e3; 17303 let scale = 1; 17304 if (windowWidth < breakpoint) { 17305 const sidebarWidth = 84; 17306 const padding = 80; 17307 const availableWidth = windowWidth - sidebarWidth - padding; 17308 scale = availableWidth / designWidth; 17309 scale = scale * 1.3; 17310 } 17311 scale = Math.min(scale, 1); 17312 this.currentScale = scale; 17313 flowContainer.style.zoom = scale; 17314 if (!("zoom" in flowContainer.style)) { 17315 flowContainer.style.transform = `scale(${scale})`; 17316 flowContainer.style.transformOrigin = "center center"; 17317 } 17318 } 17319 drawAllCurves() { 17320 this.drawAdvertisingCurves(); 17321 this.drawFashionCurves(); 17322 this.drawPhotographyCurves(); 17323 this.drawConceptingCurves(); 17324 this.drawArchitectureCurves(); 17325 this.drawBrandingCurves(); 17326 } 17327 destroy() { 17328 this.stopAutoRotation(); 17329 window.removeEventListener("resize", this.resizeHandler); 17330 const tabs = document.querySelectorAll(".category-tab");
17331 tabs.forEach((tab, index) => { 17332 if (this.tabClickHandlers[index]) { 17333 tab.removeEventListener("click", this.tabClickHandlers[index]); 17334 } 17335 }); 17336 this.tabClickHandlers = []; 17337 } 17338 // Category tabs functionality with fade animation 17339 initCategoryTabs() { 17340 const tabs = document.querySelectorAll(".category-tab"); 17341 if (tabs.length === 0) { 17342 console.warn("No category tabs found"); 17343 return; 17344 } 17345 tabs.forEach((tab, index) => { 17346 const handler = () => { 17347 this.stopAutoRotation(); 17348 if (this.isTransitioning) return; 17349 const targetTab = tab.dataset.tab; 17350 this.switchToTab(targetTab); 17351 }; 17352 this.tabClickHandlers[index] = handler; 17353 tab.addEventListener("click", handler); 17354 }); 17355 } 17356 // Switch to a specific tab 17357 switchToTab(targetTabName) { 17358 const tabs = document.querySelectorAll(".category-tab"); 17359 const contents = document.querySelectorAll(".category-content"); 17360 const targetTab = Array.from(tabs).find((t) => t.dataset.tab === targetTabName); 17361 const targetContent = Array.from(contents).find((c) => c.dataset.content === targetTabName); 17362 const currentContent = Array.from(contents).find((c) => c.classList.contains("opacity-100")); 17363 if (currentContent === targetContent) return; 17364 this.isTransitioning = true; 17365 this.resetProgressBars();
17366 tabs.forEach((t) => { 17367 t.classList.remove("text-white"); 17368 t.classList.add("text-gray"); 17369 }); 17370 if (targetTab) { 17371 targetTab.classList.remove("text-gray"); 17372 targetTab.classList.add("text-white"); 17373 } 17374 if (currentContent) { 17375 currentContent.classList.remove("opacity-100", "pointer-events-auto"); 17376 currentContent.classList.add("opacity-0", "pointer-events-none"); 17377 } 17378 setTimeout(() => { 17379 if (targetContent) { 17380 targetContent.classList.remove("opacity-0", "pointer-events-none"); 17381 targetContent.classList.add("opacity-100", "pointer-events-auto"); 17382 } 17383 if (this.autoRotateInterval && targetTab) { 17384 this.animateProgressBar(targetTab); 17385 } 17386 setTimeout(() => { 17387 this.isTransitioning = false; 17388 }, 300); 17389 }, 400); 17390 } 17391 // Animate progress bar from 0% to 100% over 8 seconds 17392 animateProgressBar(tab) { 17393 const progressBar = tab.querySelector("[data-progress-bar]"); 17394 if (!progressBar) return; 17395 progressBar.style.transition = "none"; 17396 progressBar.style.width = "0%"; 17397 progressBar.offsetHeight; 17398 requestAnimationFrame(() => { 17399 progressBar.style.transition = "width 8000ms linear"; 17400 progressBar.style.width = "100%"; 17401 }); 17402 } 17403 // Reset all progress bars 17404 resetProgressBars() { 17405 const progressBars = document.querySelectorAll("[data-progress-bar]"); 17406 progressBars.forEach((bar) => { 17407 bar.style.transition = "none"; 17408 bar.style.width = "0%"; 17409 }); 17410 } 17411 // Auto-rotate tabs every 8 seconds 17412 startAutoRotation() { 17413 const tabs = document.querySelectorAll(".category-tab"); 17414 const tabNames = Array.from(tabs).map((t) => t.dataset.tab); 17415 if (tabs.length === 0) return; 17416 let activeTab = Array.from(tabs).find((t) => t.classList.contains("text-white")); 17417 if (!activeTab) { 17418 activeTab = tabs[0]; 17419 activeTab.classList.remove("text-gray"); 17420 activeTab.classList.add("text-white"); 17421 } 17422 let currentIndex = activeTab ? Array.from(tabs).indexOf(activeTab) : 0; 17423 setTimeout(() => { 17424 if (activeTab) { 17425 this.animateProgressBar(activeTab); 17426 } 17427 }, 100); 17428 this.autoRotateInterval = setInterval(() => { 17429 if (this.isTransitioning) return; 17430 currentIndex = (currentIndex + 1) % tabNames.length; 17431 this.switchToTab(tabNames[currentIndex]); 17432 }, 8e3); 17433 } 17434 // Stop auto-rotation 17435 stopAutoRotation() { 17436 if (this.autoRotateInterval) { 17437 clearInterval(this.autoRotateInterval); 17438 this.autoRotateInterval = null; 17439 this.resetProgressBars(); 17440 } 17441 } 17442 drawAdvertisingCurves() { 17443 const svg = document.querySelector('.category-content[data-content="advertising"] svg'); 17444 if (!svg) return; 17445 svg.innerHTML = ""; 17446 const advertisingA = document.getElementById("advertising-a"); 17447 const advertisingB = document.getElementById("advertising-b"); 17448 const advertisingC = document.getElementById("advertising-c"); 17449 const advertisingD = document.getElementById("advertising-d"); 17450 const advertisingVideo = document.getElementById("advertising-video"); 17451 if (!advertisingA || !advertisingB || !advertisingC || !advertisingD || !advertisingVideo) return; 17452 const aRect = advertisingA.getBoundingClientRect(); 17453 const bRect = advertisingB.getBoundingClientRect(); 17454 const cRect = advertisingC.getBoundingClientRect(); 17455 const dRect = advertisingD.getBoundingClientRect(); 17456 const videoRect = advertisingVideo.getBoundingClientRect(); 17457 const svgRect = svg.getBoundingClientRect(); 17458 const scale = this.currentScale || 1; 17459 const startX1 = (aRect.right - svgRect.left) / scale; 17460 const startY1 = (aRect.top + aRect.height / 2 - svgRect.top) / scale + 20; 17461 const endX1 = (cRect.left - svgRect.left) / scale; 17462 const endY1 = (cRect.top + cRect.height / 2 - svgRect.top) / scale; 17463 const distanceX1 = endX1 - startX1; 17464 const curve1 = { 17465 startX: startX1, 17466 startY: startY1, 17467 cp1X: startX1 + distanceX1 * 0.4, 17468 cp1Y: startY1, 17469 cp2X: endX1 - distanceX1 * 0.4, 17470 cp2Y: endY1, 17471 endX: endX1, 17472 endY: endY1 17473 }; 17474 const startX2 = (aRect.right - svgRect.left) / scale; 17475 const startY2 = (aRect.top + aRect.height / 2 - svgRect.top) / scale + 20; 17476 const endX2 = (dRect.left - svgRect.left) / scale; 17477 const endY2 = (dRect.top + dRect.height / 2 - svgRect.top) / scale; 17478 const distanceX2 = endX2 - startX2; 17479 const curve2 = { 17480 startX: startX2, 17481 startY: startY2, 17482 cp1X: startX2 + distanceX2 * 0.4, 17483 cp1Y: startY2, 17484 cp2X: endX2 - distanceX2 * 0.4, 17485 cp2Y: endY2, 17486 endX: endX2, 17487 endY: endY2 17488 }; 17489 const curve3 = this.calculateCurve(bRect, dRect, svgRect, "right-center", "left-center"); 17490 const startX4 = (cRect.right - svgRect.left) / scale; 17491 const startY4 = (cRect.top + cRect.height / 2 - svgRect.top) / scale; 17492 const endX4 = (videoRect.left - svgRect.left) / scale; 17493 const endY4 = (videoRect.top + videoRect.height / 2 - svgRect.top) / scale - 50; 17494 const distanceX4 = endX4 - startX4; 17495 const curve4 = { 17496 startX: startX4, 17497 startY: startY4, 17498 cp1X: startX4 + distanceX4 * 0.4, 17499 cp1Y: startY4, 17500 cp2X: endX4 - distanceX4 * 0.4, 17501 cp2Y: endY4, 17502 endX: endX4, 17503 endY: endY4 17504 }; 17505 const startX5 = (dRect.right - svgRect.left) / scale; 17506 const startY5 = (dRect.top + dRect.height / 2 - svgRect.top) / scale; 17507 const endX5 = (videoRect.left - svgRect.left) / scale; 17508 const endY5 = (videoRect.top + videoRect.height / 2 - svgRect.top) / scale - 50; 17509 const distanceX5 = endX5 - startX5; 17510 const curve5 = { 17511 startX: startX5, 17512 startY: startY5, 17513 cp1X: startX5 + distanceX5 * 0.4, 17514 cp1Y: startY5, 17515 cp2X: endX5 - distanceX5 * 0.4, 17516 cp2Y: endY5, 17517 endX: endX5, 17518 endY: endY5 17519 }; 17520 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17520s"); 17521 const gradient1 = this.createAnimatedGradient("animatedGradientAdvertising1", "0s"); 17522 const gradient2 = this.createAnimatedGradient("animatedGradientAdvertising2", "0s"); 17523 const gradient3 = this.createAnimatedGradient("animatedGradientAdvertising3", "0s"); 17524 const gradient4 = this.createAnimatedGradient("animatedGradientAdvertising4", "1s"); 17525 const gradient5 = this.createAnimatedGradient("animatedGradientAdvertising5", "1s"); 17526 defs.appendChild(gradient1); 17527 defs.appendChild(gradient2); 17528 defs.appendChild(gradient3); 17529 defs.appendChild(gradient4); 17530 defs.appendChild(gradient5); 17531 svg.appendChild(defs); 17532 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientAdvertising1"); 17533 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientAdvertising2"); 17534 const path3 = this.createPath(curve3.startX, curve3.startY, curve3.cp1X, curve3.cp1Y, curve3.cp2X, curve3.cp2Y, curve3.endX, curve3.endY, "animatedGradientAdvertising3"); 17535 const path4 = this.createPath(curve4.startX, curve4.startY, curve4.cp1X, curve4.cp1Y, curve4.cp2X, curve4.cp2Y, curve4.endX, curve4.endY, "animatedGradientAdvertising4"); 17536 const path5 = this.createPath(curve5.startX, curve5.startY, curve5.cp1X, curve5.cp1Y, curve5.cp2X, curve5.cp2Y, curve5.endX, curve5.endY, "animatedGradientAdvertising5"); 17537 svg.appendChild(path1); 17538 svg.appendChild(path2); 17539 svg.appendChild(path3); 17540 svg.appendChild(path4); 17541 svg.appendChild(path5); 17542 } 17543 drawFashionCurves() { 17544 const svg = document.querySelector('.category-content[data-content="fashion"] svg'); 17545 if (!svg) return; 17546 svg.innerHTML = ""; 17547 const fashionA = document.getElementById("fashion-a"); 17548 const fashionB = document.getElementById("fashion-b"); 17549 const fashionC = document.getElementById("fashion-c"); 17550 const fashionD = document.getElementById("fashion-d"); 17551 const fashionE = document.getElementById("fashion-e"); 17552 if (!fashionA || !fashionB || !fashionC || !fashionD || !fashionE) return; 17553 const aRect = fashionA.getBoundingClientRect(); 17554 const bRect = fashionB.getBoundingClientRect(); 17555 const cRect = fashionC.getBoundingClientRect(); 17556 const dRect = fashionD.getBoundingClientRect(); 17557 const eRect = fashionE.getBoundingClientRect(); 17558 const svgRect = svg.getBoundingClientRect(); 17559 const curve1 = this.calculateCurve(aRect, dRect, svgRect, "right-center", "left-center"); 17560 const curve2 = this.calculateCurve(bRect, dRect, svgRect, "right-center", "left-center"); 17561 const curve3 = this.calculateCurve(cRect, dRect, svgRect, "right-center", "left-center"); 17562 const curve4 = this.calculateCurve(dRect, eRect, svgRect, "right-center", "left-center"); 17563 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17563s"); 17564 const gradient1 = this.createAnimatedGradient("animatedGradientFashion1", "0s"); 17565 const gradient2 = this.createAnimatedGradient("animatedGradientFashion2", "0s"); 17566 const gradient3 = this.createAnimatedGradient("animatedGradientFashion3", "0s"); 17567 const gradient4 = this.createAnimatedGradient("animatedGradientFashion4", "2s"); 17568 defs.appendChild(gradient1); 17569 defs.appendChild(gradient2); 17570 defs.appendChild(gradient3); 17571 defs.appendChild(gradient4); 17572 svg.appendChild(defs); 17573 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientFashion1"); 17574 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientFashion2"); 17575 const path3 = this.createPath(curve3.startX, curve3.startY, curve3.cp1X, curve3.cp1Y, curve3.cp2X, curve3.cp2Y, curve3.endX, curve3.endY, "animatedGradientFashion3"); 17576 const path4 = this.createPath(curve4.startX, curve4.startY, curve4.cp1X, curve4.cp1Y, curve4.cp2X, curve4.cp2Y, curve4.endX, curve4.endY, "animatedGradientFashion4"); 17577 svg.appendChild(path1); 17578 svg.appendChild(path2); 17579 svg.appendChild(path3); 17580 svg.appendChild(path4); 17581 } 17582 drawPhotographyCurves() { 17583 const svg = document.querySelector('.category-content[data-content="photography"] svg'); 17584 if (!svg) return; 17585 svg.innerHTML = ""; 17586 const photoA = document.getElementById("photography-a"); 17587 const photoB = document.getElementById("photography-b"); 17588 const photoC = document.getElementById("photography-c"); 17589 const photoD = document.getElementById("photography-d"); 17590 const photoE = document.getElementById("photography-e"); 17591 if (!photoA || !photoB || !photoC || !photoD || !photoE) return; 17592 const aRect = photoA.getBoundingClientRect(); 17593 const bRect = photoB.getBoundingClientRect(); 17594 const cRect = photoC.getBoundingClientRect(); 17595 const dRect = photoD.getBoundingClientRect(); 17596 const eRect = photoE.getBoundingClientRect(); 17597 const svgRect = svg.getBoundingClientRect(); 17598 const scale = this.currentScale || 1; 17599 const curve1 = this.calculateCurve(aRect, bRect, svgRect, "right-center", "left-center"); 17600 const startX2 = (cRect.left + cRect.width / 2 - svgRect.left) / scale; 17601 const startY2 = (cRect.top + cRect.height * 2 / 3 - svgRect.top) / scale; 17602 const endX2 = (dRect.left - svgRect.left) / scale; 17603 const endY2 = (dRect.top + dRect.height / 2 - svgRect.top) / scale; 17604 const distanceX2 = endX2 - startX2; 17605 const curve2 = { 17606 startX: startX2, 17607 startY: startY2, 17608 cp1X: startX2 + distanceX2 * 0.4, 17609 cp1Y: startY2, 17610 cp2X: endX2 - distanceX2 * 0.4, 17611 cp2Y: endY2, 17612 endX: endX2, 17613 endY: endY2 17614 }; 17615 const curve3 = this.calculateCurve(bRect, dRect, svgRect, "right-center", "left-center"); 17616 const startX4 = (dRect.left + dRect.width / 2 - svgRect.left) / scale; 17617 const startY4 = (dRect.bottom - svgRect.top - 1) / scale; 17618 const endX4 = (eRect.left - svgRect.left) / scale; 17619 const endY4 = (eRect.top + eRect.height / 2 - svgRect.top) / scale; 17620 const distanceX4 = endX4 - startX4; 17621 const curve4 = { 17622 startX: startX4, 17623 startY: startY4, 17624 cp1X: startX4 + distanceX4 * 0.4, 17625 cp1Y: startY4, 17626 cp2X: endX4 - distanceX4 * 0.4, 17627 cp2Y: endY4, 17628 endX: endX4, 17629 endY: endY4 17630 }; 17631 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17631s"); 17632 const gradient1 = this.createAnimatedGradient("animatedGradientPhoto1", "0s"); 17633 const gradient2 = this.createAnimatedGradient("animatedGradientPhoto2", "1s"); 17634 const gradient3 = this.createAnimatedGradient("animatedGradientPhoto3", "1s"); 17635 const gradient4 = this.createAnimatedGradient("animatedGradientPhoto4", "2s"); 17636 defs.appendChild(gradient1); 17637 defs.appendChild(gradient2); 17638 defs.appendChild(gradient3); 17639 defs.appendChild(gradient4); 17640 svg.appendChild(defs); 17641 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientPhoto1"); 17642 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientPhoto2"); 17643 const path3 = this.createPath(curve3.startX, curve3.startY, curve3.cp1X, curve3.cp1Y, curve3.cp2X, curve3.cp2Y, curve3.endX, curve3.endY, "animatedGradientPhoto3"); 17644 const path4 = this.createPath(curve4.startX, curve4.startY, curve4.cp1X, curve4.cp1Y, curve4.cp2X, curve4.cp2Y, curve4.endX, curve4.endY, "animatedGradientPhoto4"); 17645 svg.appendChild(path1); 17646 svg.appendChild(path2); 17647 svg.appendChild(path3); 17648 svg.appendChild(path4); 17649 } 17650 drawConceptingCurves() { 17651 const svg = document.querySelector('.category-content[data-content="design"] svg'); 17652 if (!svg) return; 17653 svg.innerHTML = ""; 17654 const conceptA = document.getElementById("concept-a"); 17655 const conceptB = document.getElementById("concept-b"); 17656 const conceptC = document.getElementById("concept-c"); 17657 const conceptD = document.getElementById("concept-d"); 17658 if (!conceptA || !conceptB || !conceptC || !conceptD) return; 17659 const aRect = conceptA.getBoundingClientRect(); 17660 const bRect = conceptB.getBoundingClientRect(); 17661 const cRect = conceptC.getBoundingClientRect(); 17662 const dRect = conceptD.getBoundingClientRect(); 17663 const svgRect = svg.getBoundingClientRect(); 17664 const curve1 = this.calculateCurve(aRect, bRect, svgRect, "right-center", "left-center"); 17665 const curve2 = this.calculateCurve(aRect, cRect, svgRect, "right-center", "left-center"); 17666 const curve3 = this.calculateCurve(aRect, dRect, svgRect, "right-center", "left-center"); 17667 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17667s"); 17668 const gradient1 = this.createAnimatedGradient("animatedGradientConcept1", "0s"); 17669 const gradient2 = this.createAnimatedGradient("animatedGradientConcept2", "1s"); 17670 const gradient3 = this.createAnimatedGradient("animatedGradientConcept3", "2s"); 17671 defs.appendChild(gradient1); 17672 defs.appendChild(gradient2); 17673 defs.appendChild(gradient3); 17674 svg.appendChild(defs); 17675 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientConcept1"); 17676 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientConcept2"); 17677 const path3 = this.createPath(curve3.startX, curve3.startY, curve3.cp1X, curve3.cp1Y, curve3.cp2X, curve3.cp2Y, curve3.endX, curve3.endY, "animatedGradientConcept3"); 17678 svg.appendChild(path1); 17679 svg.appendChild(path2); 17680 svg.appendChild(path3); 17681 } 17682 drawArchitectureCurves() { 17683 const svg = document.querySelector('.category-content[data-content="architecture"] svg'); 17684 if (!svg) return; 17685 svg.innerHTML = ""; 17686 const archA = document.getElementById("arch-a"); 17687 const archB = document.getElementById("arch-b"); 17688 const archC = document.getElementById("arch-c"); 17689 if (!archA || !archB || !archC) return; 17690 const aRect = archA.getBoundingClientRect(); 17691 const bRect = archB.getBoundingClientRect(); 17692 const cRect = archC.getBoundingClientRect(); 17693 const svgRect = svg.getBoundingClientRect(); 17694 const scale = this.currentScale || 1; 17695 const curve1 = this.calculateCurve(aRect, bRect, svgRect, "right-center", "left-center"); 17696 const startX2 = (bRect.left + bRect.width / 2 - svgRect.left) / scale; 17697 const startY2 = (bRect.bottom - svgRect.top) / scale; 17698 const endX2 = (cRect.left - svgRect.left) / scale; 17699 const endY2 = (cRect.top + cRect.height / 2 - svgRect.top) / scale; 17700 const distanceX2 = endX2 - startX2; 17701 const curve2 = { 17702 startX: startX2, 17703 startY: startY2, 17704 cp1X: startX2 + distanceX2 * 0.4, 17705 cp1Y: startY2, 17706 cp2X: endX2 - distanceX2 * 0.4, 17707 cp2Y: endY2, 17708 endX: endX2, 17709 endY: endY2 17710 }; 17711 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17711s"); 17712 const gradient1 = this.createAnimatedGradient("animatedGradientArch1", "0s"); 17713 const gradient2 = this.createAnimatedGradient("animatedGradientArch2", "1s"); 17714 defs.appendChild(gradient1); 17715 defs.appendChild(gradient2); 17716 svg.appendChild(defs); 17717 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientArch1"); 17718 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientArch2"); 17719 svg.appendChild(path1); 17720 svg.appendChild(path2); 17721 } 17722 drawBrandingCurves() { 17723 const svg = document.querySelector('.category-content[data-content="branding"] svg'); 17724 if (!svg) return; 17725 svg.innerHTML = ""; 17726 const brandA = document.getElementById("brand-a"); 17727 const brandB = document.getElementById("brand-b"); 17728 const brandC = document.getElementById("brand-c"); 17729 const brandD = document.getElementById("brand-d"); 17730 if (!brandA || !brandB || !brandC || !brandD) return; 17731 const aRect = brandA.getBoundingClientRect(); 17732 const bRect = brandB.getBoundingClientRect(); 17733 const cRect = brandC.getBoundingClientRect(); 17734 const dRect = brandD.getBoundingClientRect(); 17735 const svgRect = svg.getBoundingClientRect(); 17736 const scale = this.currentScale || 1; 17737 const curve1 = this.calculateCurve(aRect, bRect, svgRect, "right-center", "left-center"); 17738 const startX2 = (cRect.left + cRect.width / 2 - svgRect.left) / scale; 17739 const startY2 = (cRect.top + cRect.height * 2 / 3 - svgRect.top) / scale; 17740 const endX2 = (dRect.left - svgRect.left) / scale; 17741 const endY2 = (dRect.top + dRect.height / 2 - svgRect.top) / scale; 17742 const distanceX2 = endX2 - startX2; 17743 const curve2 = { 17744 startX: startX2, 17745 startY: startY2, 17746 cp1X: startX2 + distanceX2 * 0.4, 17747 cp1Y: startY2, 17748 cp2X: endX2 - distanceX2 * 0.4, 17749 cp2Y: endY2, 17750 endX: endX2, 17751 endY: endY2 17752 }; 17753 const curve3 = this.calculateCurve(bRect, dRect, svgRect, "right-center", "left-center"); 17754 const defs = document.createElementNS("http://www.w3.org/2000/svg", "def
17754s"); 17755 const gradient1 = this.createAnimatedGradient("animatedGradientBrand1", "0s"); 17756 const gradient2 = this.createAnimatedGradient("animatedGradientBrand2", "1s"); 17757 const gradient3 = this.createAnimatedGradient("animatedGradientBrand3", "2s"); 17758 defs.appendChild(gradient1); 17759 defs.appendChild(gradient2); 17760 defs.appendChild(gradient3); 17761 svg.appendChild(defs); 17762 const path1 = this.createPath(curve1.startX, curve1.startY, curve1.cp1X, curve1.cp1Y, curve1.cp2X, curve1.cp2Y, curve1.endX, curve1.endY, "animatedGradientBrand1"); 17763 const path2 = this.createPath(curve2.startX, curve2.startY, curve2.cp1X, curve2.cp1Y, curve2.cp2X, curve2.cp2Y, curve2.endX, curve2.endY, "animatedGradientBrand2"); 17764 const path3 = this.createPath(curve3.startX, curve3.startY, curve3.cp1X, curve3.cp1Y, curve3.cp2X, curve3.cp2Y, curve3.endX, curve3.endY, "animatedGradientBrand3"); 17765 svg.appendChild(path1); 17766 svg.appendChild(path2); 17767 svg.appendChild(path3); 17768 } 17769 // Helper function to calculate curve coordinates 17770 calculateCurve(startRect, endRect, svgRect, startPosition, endPosition) { 17771 const scale = this.currentScale || 1; 17772 let startX, startY, endX, endY; 17773 if (startPosition === "right-center") { 17774 startX = (startRect.right - svgRect.left) / scale; 17775 startY = (startRect.top + startRect.height / 2 - svgRect.top) / scale; 17776 } else if (startPosition === "left-center") { 17777 startX = (startRect.left - svgRect.left) / scale; 17778 startY = (startRect.top + startRect.height / 2 - svgRect.top) / scale; 17779 } 17780 if (endPosition === "left-center") { 17781 endX = (endRect.left - svgRect.left) / scale; 17782 endY = (endRect.top + endRect.height / 2 - svgRect.top) / scale; 17783 } else if (endPosition === "right-center") { 17784 endX = (endRect.right - svgRect.left) / scale; 17785 endY = (endRect.top + endRect.height / 2 - svgRect.top) / scale; 17786 } 17787 const distanceX = endX - startX; 17788 const cp1X = startX + distanceX * 0.4; 17789 const cp1Y = startY; 17790 const cp2X = endX - distanceX * 0.4; 17791 const cp2Y = endY; 17792 return { startX, startY, cp1X, cp1Y, cp2X, cp2Y, endX, endY }; 17793 } 17794 // Helper function to create animated gradient 17795 createAnimatedGradient(id, beginTime) { 17796 const gradient = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient"); 17797 gradient.setAttribute("id", id); 17798 gradient.setAttribute("x1", "0%"); 17799 gradient.setAttribute("y1", "0%"); 17800 gradient.setAttribute("x2", "100%"); 17801 gradient.setAttribute("y2", "0%"); 17802 const stop1 = document.createElementNS("http://www.w3.org/2000/svg", "stop"); 17803 stop1.setAttribute("offset", "0%"); 17804 stop1.setAttribute("stop-color", "rgba(156, 163, 175, 0.2)"); 17805 const stop2 = document.createElementNS("http://www.w3.org/2000/svg", "stop"); 17806 stop2.setAttribute("offset", "30%"); 17807 stop2.setAttribute("stop-color", "rgba(255, 255, 255, 0.4)"); 17808 const stop3 = document.createElementNS("http://www.w3.org/2000/svg", "stop"); 17809 stop3.setAttribute("offset", "70%"); 17810 stop3.setAttribute("stop-color", "rgba(255, 255, 255, 0.4)"); 17811 const stop4 = document.createElementNS("http://www.w3.org/2000/svg", "stop"); 17812 stop4.setAttribute("offset", "100%"); 17813 stop4.setAttribute("stop-color", "rgba(156, 163, 175, 0.2)"); 17814 const animateTransform = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform"); 17815 animateTransform.setAttribute("attributeName", "gradientTransform"); 17816 animateTransform.setAttribute("type", "translate"); 17817 animateTransform.setAttribute("values", "-1 0; 1 0; 1 0"); 17818 animateTransform.setAttribute("keyTimes", "0; 0.571; 1"); 17819 animateTransform.setAttribute("dur", "3.5s"); 17820 if (beginTime !== "0s") { 17821 animateTransform.setAttribute("begin", beginTime); 17822 } 17823 animateTransform.setAttribute("repeatCount", "indefinite"); 17824 gradient.appendChild(stop1); 17825 gradient.appendChild(stop2); 17826 gradient.appendChild(stop3); 17827 gradient.appendChild(stop4); 17828 gradient.appendChild(animateTransform); 17829 return gradient; 17830 } 17831 // Helper function to create SVG path 17832 createPath(startX, startY, cp1X, cp1Y, cp2X, cp2Y, endX, endY, gradientId) { 17833 const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); 17834 path.setAttribute("d", `M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${endX} ${endY}`); 17835 path.setAttribute("stroke", `url(#${gradientId})`); 17836 path.setAttribute("stroke-width", "2"); 17837 path.setAttribute("fill", "none"); 17838 return path; 17839 } 17840}; 17841var productFlowsInstance = null; 17842document.addEventListener("turbo:load", () => { 17843 if (productFlowsInstance) { 17844 productFlowsInstance.destroy(); 17845 productFlowsInstance = null; 17846 } 17847 const flowElement = document.getElementById("flow"); 17848 const isFlowsPage = window.location.pathname === "/flows" || window.location.pathname === "/
17848product"; 17849 if (flowElement && isFlowsPage) { 17850 productFlowsInstance = new ProductFlows(); 17851 } 17852}); 17853document.addEventListener("turbo:before-cache", () => { 17854 if (productFlowsInstance) { 17855 productFlowsInstance.destroy(); 17856 productFlowsInstance = null; 17857 } 17858}); 17859window.addEventListener("beforeunload", () => { 17860 if (productFlowsInstance) { 17861 productFlowsInstance.destroy(); 17862 productFlowsInstance = null; 17863 } 17864}); 17865
17866// app/javascript/toast.js 17867var Toast = class { 17868 static activeToasts = /* @__PURE__ */ new Set(); 17869 static show(message, type = "notice", duration = null) { 17870 const container = document.getElementById("toast-container"); 17871 if (!container) return; 17872 if (duration === null) { 17873 duration = message.length > 24 ? 8e3 : 4e3; 17874 } 17875 if (this.activeToasts.has(message)) { 17876 return; 17877 } 17878 this.activeToasts.add(message); 17879 const toast = document.createElement("div"); 17880 toast.className = `toast toast-${type}`; 17881 toast.innerHTML = ` 17882 <div class="toast-content"> 17883 ${message} 17884 </div> 17885 <button class="toast-close" onclick="Toast.close(this.parentElement)"> 17886 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 17887 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 17888 </svg> 17889 </button> 17890 `; 17891 container.appendChild(toast); 17892 requestAnimationFrame(() => { 17893 toast.classList.add("toast-show"); 17894 }); 17895 setTimeout(() => { 17896 this.close(toast); 17897 this.activeToasts.delete(message); 17898 }, duration); 17899 return toast; 17900 } 17901 static close(toast) { 17902 if (!toast) return; 17903 toast.classList.remove("toast-show"); 17904 setTimeout(() => { 17905 if (toast.parentElement) { 17906 toast.parentElement.removeChild(toast); 17907 } 17908 const message = toast.querySelector(".toast-content")?.textContent?.trim(); 17909 if (message) { 17910 this.activeToasts.delete(message); 17911 } 17912 }, 300); 17913 } 17914 // Show flash messages as toasts 17915 static showFlashMessages() { 17916 const flashContainer = document.getElementById("flash-messages"); 17917 if (!flashContainer) return; 17918 const notices = flashContainer.querySelectorAll('[data-flash="notice"]'); 17919 const alerts = flashContainer.querySelectorAll('[data-flash="alert"]'); 17920 notices.forEach((notice) => { 17921 this.show(notice.textContent.trim(), "notice"); 17922 }); 17923 alerts.forEach((alert2) => { 17924 this.show(alert2.textContent.trim(), "alert"); 17925 }); 17926 flashContainer.innerHTML = ""; 17927 } 17928}; 17929document.addEventListener("DOMContentLoaded", () => { 17930 Toast.showFlashMessages(); 17931}); 17932document.addEventListener("turbo:load", () => { 17933 Toast.showFlashMessages(); 17934}); 17935var triggerObserver = null; 17936function observeTriggers() { 17937 if (triggerObserver) return; 17938 function handleTrigger(trigger) { 17939 const modalId = trigger.getAttribute("data-trigger-close-modal"); 17940 const toastMessage = trigger.getAttribute("data-show-toast"); 17941 console.log("Trigger detected:", { modalId, toastMessage }); 17942 if (modalId && window.Modal) { 17943 window.Modal.close(modalId); 17944 } 17945 if (toastMessage && window.Toast) { 17946 window.Toast.show(toastMessage, "notice"); 17947 } 17948 const flashContainer = document.getElementById("flash-messages"); 17949 if (flashContainer) { 17950 flashContainer.innerHTML = ""; 17951 } 17952 trigger.remove(); 17953 } 17954 triggerObserver = new MutationObserver((mutations) => { 17955 mutations.forEach((mutation) => { 17956 mutation.addedNodes.forEach((node) => { 17957 if (node.nodeType === 1) { 17958 if (node.hasAttribute && node.hasAttribute("data-trigger-close-modal")) { 17959 handleTrigger(node); 17960 } 17961 const triggers = node.querySelectorAll ? node.querySelectorAll("[data-trigger-close-modal]") : []; 17962 triggers.forEach(handleTrigger); 17963 } 17964 }); 17965 }); 17966 }); 17967 triggerObserver.observe(document.body, { 17968 childList: true, 17969 subtree: true 17970 }); 17971} 17972document.addEventListener("DOMContentLoaded", observeTriggers); 17973document.addEventListener("turbo:load", observeTriggers); 17974document.addEventListener("turbo:before-stream-render", (event) => { 17975 const { streamElement } = event.detail; 17976 if (streamElement && streamElement.getAttribute("action") === "append") { 17977 const template = streamElement.querySelector("template"); 17978 if (template) { 17979 const content = template.content; 17980 const trigger = content.querySelector("[data-trigger-close-modal]"); 17981 if (trigger) { 17982 console.log("Stream trigger detected"); 17983 setTimeout(() => { 17984 const modalId = trigger.getAttribute("data-trigger-close-modal"); 17985 const toastMessage = trigger.getAttribute("data-show-toast");
17986 if (modalId && window.Modal) { 17987 window.Modal.close(modalId); 17988 } 17989 if (toastMessage && window.Toast) { 17990 window.Toast.show(toastMessage, "notice"); 17991 } 17992 const flashContainer = document.getElementById("flash-messages"); 17993 if (flashContainer) { 17994 flashContainer.innerHTML = ""; 17995 } 17996 }, 100); 17997 } 17998 } 17999 } 18000}); 18001window.Toast = Toast; 18002
18003// app/javascript/favs.js 18004var FinalsManager = class { 18005 constructor() { 18006 this.currentFinalId = null; 18007 this.isConverting = false; 18008 this.handlers = { 18009 documentClick: this.handleDocumentClick.bind(this), 18010 indexClick: this.handleIndexClick.bind(this) 18011 }; 18012 this.init(); 18013 } 18014 init() { 18015 this.attachEventListeners(); 18016 this.initializeFinalData(); 18017 } 18018 // --- Index page card handlers --- 18019 handleIndexClick(e) { 18020 const grid = document.getElementById("finals-list"); 18021 if (!grid) return; 18022 const menuBtn = e.target.closest(".media-card-menu-btn"); 18023 if (menuBtn) { 18024 e.stopPropagation(); 18025 const dropdown = menuBtn.nextElementSibling; 18026 grid.querySelectorAll(".media-card-dropdown").forEach((d) => { 18027 if (d !== dropdown) d.classList.add("hidden"); 18028 }); 18029 dropdown.classList.toggle("hidden"); 18030 return; 18031 } 18032 const finalsBtn = e.target.closest(".media-card-finals"); 18033 if (finalsBtn) { 18034 e.stopPropagation(); 18035 const card2 = finalsBtn.closest(".finals-card"); 18036 const mediaId = card2?.dataset.mediaId; 18037 if (mediaId) { 18038 this.showUnsaveConfirmModal(mediaId, card2); 18039 } 18040 return; 18041 } 18042 const modifyBtn = e.target.closest(".media-card-modify"); 18043 if (modifyBtn) { 18044 e.stopPropagation(); 18045 const card2 = modifyBtn.closest(".finals-card"); 18046 const imageUrl = card2?.dataset.thumbnail || card2?.dataset.outputUrl; 18047 if (imageUrl) { 18048 this.showModifyPicker(imageUrl); 18049 } 18050 return; 18051 } 18052 const addItemBtn = e.target.closest(".media-card-add-item"); 18053 if (addItemBtn) { 18054 e.stopPropagation(); 18055 const card2 = addItemBtn.closest(".finals-card"); 18056 if (card2) { 18057 this.addToItems(card2); 18058 } 18059 return; 18060 } 18061 const card = e.target.closest(".finals-card"); 18062 if (card && !e.target.closest("button") && !e.target.closest("a")) { 18063 const mediaId = card.dataset.mediaId; 18064 const url = card.dataset.outputUrl; 18065 if (mediaId) { 18066 this.openViewer(url, mediaId); 18067 } 18068 } 18069 if (!e.target.closest(".media-card-menu-btn") && !e.target.closest(".media-card-dropdown")) { 18070 grid.querySelectorAll(".media-card-dropdown").forEach((d) => d.classList.add("hidden")); 18071 } 18072 } 18073 showUnsaveConfirmModal(mediaId, card) { 18074 const existing = document.getElementById("unsave-final-modal"); 18075 if (existing) existing.remove(); 18076 const modalHtml = ` 18077 <div id="unsave-final-modal" data-modal="unsave-final-modal" class="fixed inset-0 bg-black/90 flex justify-center hidden modal-base" style="align-items: flex-start; padding-top: 30vh; z-index: 9999;"> 18078 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 18079 <div class="flex items-start justify-between mb-3"> 18080 <h2 class="text-14 font-bold text-white">Remove from Favs</h2> 18081 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 18082 <svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> 18083 </button> 18084 </div> 18085 <div> 18086 <p class="text-neutral-300 mb-6 text-sm">Are you sure you want to remove this from your favs?</p> 18087 <div class="flex gap-3 justify-end"> 18088 <button data-modal-close class="btn-secondary">Cancel</button> 18089 <button id="confirm-unsave-final" class="btn-danger">Remove</button> 18090 </div> 18091 </div> 18092 </div> 18093 </div> 18094 `; 18095 document.body.insertAdjacentHTML("beforeend", modalHtml); 18096 const modal = document.getElementById("unsave-final-modal"); 18097 if (window.Modal) { 18098 window.Modal.open("unsave-final-modal"); 18099 } else { 18100 modal.classList.remove("hidden"); 18101 document.body.classList.add("modal-open"); 18102 requestAnimationFrame(() => modal.classList.add("modal-showing")); 18103 } 18104 const closeModal = () => { 18105 if (window.Modal) {
18106 window.Modal.close(); 18107 } else { 18108 modal.classList.remove("modal-showing"); 18109 setTimeout(() => { 18110 modal.remove(); 18111 document.body.classList.remove("modal-open"); 18112 }, 300); 18113 } 18114 }; 18115 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 18116 btn.addEventListener("click", closeModal); 18117 }); 18118 modal.addEventListener("click", (evt) => { 18119 if (evt.target === modal) closeModal(); 18120 }); 18121 document.getElementById("confirm-unsave-final").addEventListener("click", () => { 18122 closeModal(); 18123 this.unsaveFinal(mediaId, card); 18124 }); 18125 } 18126 async unsaveFinal(mediaId, card) { 18127 try { 18128 const response = await fetch("/favs/unsave", { 18129 method: "DELETE", 18130 headers: { 18131 "Content-Type": "application/json", 18132 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 18133 }, 18134 body: JSON.stringify({ media_id: mediaId }) 18135 }); 18136 if (response.ok) { 18137 card.remove(); 18138 window.Toast?.show("Removed from Favs", "success"); 18139 } 18140 } catch (err) { 18141 window.Toast?.show("Action failed", "alert"); 18142 } 18143 } 18144 async openViewer(url, mediaId) { 18145 try { 18146 const response = await fetch(`/media/${mediaId}`, { 18147 headers: { Accept: "text/html", "X-Requested-With": "XMLHttpRequest" } 18148 }); 18149 if (!response.ok) { 18150 window.open(url, "_blank"); 18151 return; 18152 } 18153 const html = await response.text(); 18154 const existingModal = document.getElementById("media-modal"); 18155 if (existingModal) existingModal.remove(); 18156 document.body.insertAdjacentHTML("beforeend", html); 18157 if (window.Modal) window.Modal.open("media-modal"); 18158 } catch (err) { 18159 window.open(url, "_blank"); 18160 } 18161 } 18162 async showModifyPicker(imageUrl) { 18163 try { 18164 const response = await fetch("/modal/apps"); 18165 const html = await response.text(); 18166 const existingModal = document.getElementById("apps-modal"); 18167 if (existingModal) existingModal.remove(); 18168 document.body.insertAdjacentHTML("beforeend", html); 18169 const modal = document.getElementById("apps-modal"); 18170 if (!modal) return; 18171 const closeModal = () => { 18172 modal.classList.remove("modal-showing"); 18173 setTimeout(() => { 18174 modal.remove(); 18175 document.body.classList.remove("modal-open"); 18176 }, 300); 18177 }; 18178 modal.querySelector("[data-modal-close]")?.addEventListener("click", closeModal); 18179 modal.addEventListener("click", (evt) => { 18180 if (evt.target === modal) closeModal(); 18181 }); 18182 modal.querySelectorAll("[data-app-slug]").forEach((btn) => { 18183 btn.addEventListener("click", () => { 18184 const slug = btn.dataset.appSlug; 18185 closeModal(); 18186 window.location.href = `/apps?app=${slug}&image=${encodeURIComponent(imageUrl)}`; 18187 }); 18188 }); 18189 modal.classList.remove("hidden"); 18190 document.body.classList.add("modal-open"); 18191 requestAnimationFrame(() => { 18192 modal.classList.add("modal-showing"); 18193 }); 18194 } catch (error) { 18195 window.Toast?.show("Failed to open apps", "alert"); 18196 } 18197 } 18198 addToItems(card) { 18199 const hash = card?.dataset.mediaHash; 18200 const thumbnail = card?.dataset.thumbnail || card?.dataset.outputUrl; 18201 if (!hash) return; 18202 const isVideo = !!card.querySelector("video"); 18203 const mediaUrl = isVideo ? card.querySelector("video")?.src || thumbnail : thumbnail; 18204 if (window.Modal) { 18205 window.Modal.showItem({ mediaHash: hash, mediaUrl, isVideo }); 18206 } 18207 } 18208 // --- Show page handlers --- 18209 handleDocumentClick(e) { 18210 const finalStatusContainer = document.querySelector( 18211 "[data-final-status-container]" 18212 ); 18213 if (!finalStatusContainer) return; 18214 const statusBtn = e.target.closest("#final-status-btn"); 18215 if (statusBtn) { 18216 const dropdown = document.getElementById("final-status-dropdown"); 18217 if (dropdown) { 18218 dropdown.classList.toggle("hidden"); 18219 } 18220 return; 18221 } 18222 const statusOption = e.target.closest(".final-status-option"); 18223 if (statusOption) { 18224 const newStatus = statusOption.dataset.status; 18225 this.updateStatus(newStatus); 18226 return; 18227 } 18228 if (!e.target.closest("#final-status-btn") && !e.target.closest("#final-status-dropdown")) { 18229 const dropdown = document.getElementById("final-status-dropdown"); 18230 if (dropdown) { 18231 dropdown.classList.add("hidden"); 18232 } 18233 } 18234 } 18235 attachEventListeners() { 18236 document.addEventListener("click", this.handlers.documentClick); 18237 document.addEventListener("click", this.handlers.indexClick); 18238 } 18239 destroy() { 18240 document.removeEventListener("click", this.handlers.documentClick); 18241 document.removeEventListener("click", this.handlers.indexClick); 18242 } 18243 initializeFinalData() { 18244 if (window.finalData) { 18245 this.currentFinalId = window.finalData.uuid; 18246 } 18247 } 18248 updateStatusDisplay(status) { 18249 const statusDisplay = document.querySelector("[data-final-status-display]"); 18250 const statusBtn = document.getElementById("final-status-btn"); 18251 if (statusBtn) { 18252 statusBtn.dataset.currentStatus = status; 18253 } 18254 let iconHtml = ""; 18255 let text = "";
18256 let colorClass = ""; 18257 switch (status) { 18258 case "personal": 18259 iconHtml = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; 18260 text = "Private"; 18261 colorClass = "text-gray-400"; 18262 break; 18263 case "team": 18264 iconHtml = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>'; 18265 text = "Team"; 18266 colorClass = "text-indigo-400"; 18267 break; 18268 case "published": 18269 iconHtml = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>'; 18270 text = "Public"; 18271 colorClass = "text-teal-400"; 18272 break; 18273 } 18274 if (statusDisplay) { 18275 statusDisplay.innerHTML = ` 18276 <span class="inline-flex items-center gap-1.5 ${colorClass}"> 18277 ${iconHtml} 18278 <span>${text}</span> 18279 </span> 18280 `; 18281 } 18282 } 18283 async updateStatus(newStatus) { 18284 if (!this.currentFinalId) return; 18285 const dropdown = document.getElementById("final-status-dropdown"); 18286 if (dropdown) { 18287 dropdown.classList.add("hidden"); 18288 } 18289 try { 18290 const response = await fetch( 18291 `/final/${this.currentFinalId}/status`, 18292 { 18293 method: "PATCH", 18294 headers: { 18295 "Content-Type": "application/json", 18296 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") 18297 }, 18298 body: JSON.stringify({ status: newStatus }) 18299 } 18300 ); 18301 if (!response.ok) { 18302 const errorData = await response.json(); 18303 throw new Error(errorData.error || "Failed to update status"); 18304 } 18305 const data = await response.json(); 18306 this.updateStatusDisplay(newStatus); 18307 if (window.Toast) { 18308 window.Toast.show("Status updated successfully", "success"); 18309 } 18310 } catch (error) { 18311 console.error("Status update error:", error); 18312 if (window.Toast) { 18313 window.Toast.show(error.message || "Failed to update status", "alert"); 18314 } 18315 } 18316 } 18317}; 18318document.addEventListener("turbo:load", () => { 18319 if (document.querySelector("[data-final-status-container]") || document.getElementById("finals-list")) { 18320 if (window.finalsManager && typeof window.finalsManager.destroy === "function") { 18321 window.finalsManager.destroy(); 18322 } 18323 window.finalsManager = new FinalsManager(); 18324 } 18325}); 18326document.addEventListener("turbo:before-cache", () => { 18327 if (window.finalsManager && typeof window.finalsManager.destroy === "function") { 18328 window.finalsManager.destroy(); 18329 } 18330 window.finalsManager = null; 18331}); 18332
18333// app/javascript/settings.js 18334var Settings = class { 18335 constructor() { 18336 this.isInitialized = false; 18337 this.init(); 18338 } 18339 init() { 18340 document.addEventListener("turbo:load", () => this.initSettings()); 18341 document.addEventListener("turbo:before-cache", () => { 18342 this.isInitialized = false; 18343 }); 18344 } 18345 initSettings() { 18346 if (!document.getElementById("rename-form")) return; 18347 if (this.isInitialized) return; 18348 this.isInitialized = true; 18349 this.setupRenameTeam(); 18350 } 18351 setupRenameTeam() { 18352 document.addEventListener("submit", async (e) => { 18353 if (e.target.id !== "rename-form") return; 18354 e.preventDefault(); 18355 const formData = new FormData(e.target); 18356 const name = formData.get("name"); 18357 try { 18358 const response = await fetch("/settings/rename", { 18359 method: "PATCH", 18360 headers: { 18361 "Content-Type": "application/json", 18362 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 18363 }, 18364 body: JSON.stringify({ name }) 18365 }); 18366 const data = await response.json(); 18367 if (response.ok) { 18368 window.Toast.show("Workspace renamed successfully", "success"); 18369 document.querySelectorAll(".teamname").forEach((el) => { 18370 el.textContent = name; 18371 }); 18372 } else { 18373 window.Toast.show( 18374 data.error || "Failed to rename workspace", 18375 "alert" 18376 ); 18377 } 18378 } catch (error) { 18379 console.error("Error renaming workspace:", error); 18380 window.Toast.show("Failed to rename workspace", "alert"); 18381 } 18382 }); 18383 } 18384}; 18385if (!window.Settings) { 18386 window.Settings = new Settings(); 18387} 18388
vendor: 33,193 bytes, lines 18389-19272
18389// node_modules/@melloware/coloris/dist/esm/coloris.js 18390var Coloris = (() => { 18391 return ((window2, document2, Math2, undefined2) => { 18392 const ctx = document2.createElement("canvas").getContext("2d"); 18393 const currentColor = { r: 0, g: 0, b: 0, h: 0, s: 0, v: 0, a: 1 }; 18394 let container, picker, colorArea, colorMarker, colorPreview, colorValue, clearButton, closeButton, hueSlider, hueMarker, alphaSlider, alphaMarker, currentEl, currentFormat, oldColor, keyboardNav, colorAreaDims = {}; 18395 const settings = { 18396 el: "[data-coloris]", 18397 parent: "body", 18398 theme: "default", 18399 themeMode: "light", 18400 rtl: false, 18401 wrap: true, 18402 margin: 2, 18403 format: "hex", 18404 formatToggle: false, 18405 swatches: [], 18406 swatchesOnly: false, 18407 alpha: true, 18408 forceAlpha: false, 18409 focusInput: true, 18410 selectInput: false, 18411 inline: false, 18412 defaultColor: "#000000", 18413 clearButton: false, 18414 clearLabel: "Clear", 18415 closeButton: false, 18416 closeLabel: "Close", 18417 onChange: () => undefined2, 18418 a11y: { 18419 open: "Open color picker", 18420 close: "Close color picker", 18421 clear: "Clear the selected color", 18422 marker: "Saturation: {s}. Brightness: {v}.", 18423 hueSlider: "Hue slider", 18424 alphaSlider: "Opacity slider", 18425 input: "Color value field", 18426 format: "Color format", 18427 swatch: "Color swatch", 18428 instruction: "Saturation and brightness selector. Use up, down, left and right arrow keys to select." 18429 } 18430 }; 18431 const instances = {}; 18432 let currentInstanceId = ""; 18433 let defaultInstance = {}; 18434 let hasInstance = false; 18435 function configure(options) { 18436 if (typeof options !== "object") { 18437 return; 18438 } 18439 for (const key in options) { 18440 switch (key) { 18441 case "el": 18442 bindFields(options.el); 18443 if (options.wrap !== false) { 18444 wrapFields(options.el); 18445 } 18446 break; 18447 case "parent": 18448 container = options.parent instanceof HTMLElement ? options.parent : document2.querySelector(options.parent); 18449 if (container) { 18450 container.appendChild(picker); 18451 settings.parent = options.parent; 18452 if (container === document2.body) { 18453 container = undefined2; 18454 } 18455 } 18456 break; 18457 case "themeMode": 18458 settings.themeMode = options.themeMode; 18459 if (options.themeMode === "auto" && window2.matchMedia && window2.matchMedia("(prefers-color-scheme: dark)").matches) { 18460 settings.themeMode = "dark"; 18461 } 18462 // The lack of a break statement is intentional 18463 case "theme": 18464 if (options.theme) { 18465 settings.theme = options.theme; 18466 } 18467 picker.className = `clr-picker clr-${settings.theme} clr-${settings.themeMode}`; 18468 if (settings.inline) { 18469 updatePickerPosition(); 18470 } 18471 break; 18472 case "rtl": 18473 settings.rtl = !!options.rtl; 18474 Array.from(document2.getElementsByClassName("clr-field")).forEach((field) => field.classList.toggle("clr-rtl", settings.rtl)); 18475 break; 18476 case "margin": 18477 options.margin *= 1; 18478 settings.margin = !isNaN(options.margin) ? options.margin : settings.margin; 18479 break; 18480 case "wrap": 18481 if (options.el && options.wrap) { 18482 wrapFields(options.el); 18483 } 18484 break; 18485 case "formatToggle": 18486 settings.formatToggle = !!options.formatToggle; 18487 getEl("clr-format").style.display = settings.formatToggle ? "block" : "none"; 18488 if (settings.formatToggle) { 18489 settings.format = "auto"; 18490 } 18491 break; 18492 case "swatches": 18493 if (Array.isArray(options.swatches)) { 18494 const swatchesContainer = getEl("clr-swatches"); 18495 const swatches = document2.createElement("div"); 18496 swatchesContainer.textContent = ""; 18497 options.swatches.forEach((swatch, i) => { 18498 const button = document2.createElement("button"); 18499 button.setAttribute("type", `button`); 18500 button.setAttribute("id", `clr-swatch-${i}`); 18501 button.setAttribute("aria-labelledby", `clr-swatch-label clr-swatch-${i}`); 18502 button.style.color = swatch; 18503 button.textContent = swatch; 18504 swatches.appendChild(button); 18505 }); 18506 if (options.swatches.length) { 18507 swatchesContainer.appendChild(swatches); 18508 } 18509 settings.swatches = options.swatches.slice(); 18510 } 18511 break; 18512 case "swatchesOnly": 18513 settings.swatchesOnly = !!options.swatchesOnly; 18514 picker.setAttribute("data-minimal", settings.swatchesOnly); 18515 break; 18516 case "alpha": 18517 settings.alpha = !!options.alpha; 18518 picker.setAttribute("data-alpha", settings.alpha); 18519 break; 18520 case "inline": 18521 settings.inline = !!options.inline; 18522 picker.setAttribute("data-inline", settings.inline); 18523 if (settings.inline) { 18524 const defaultColor = options.defaultColor || settings.defaultColor; 18525 currentFormat = getColorFormatFromStr(defaultColor); 18526 updatePickerPosition(); 18527 setColorFromStr(defaultColor); 18528 } 18529 break; 18530 case "clearButton": 18531 if (typeof options.clearButton === "object") { 18532 if (options.clearButton.label) { 18533 settings.clearLabel = options.clearButton.label; 18534 clearButton.innerHTML = settings.clearLabel; 18535 } 18536 options.clearButton = options.clearButton.show; 18537 } 18538 settings.clearButton = !!options.clearButton; 18539 clearButton.style.display = settings.clearButton ? "block" : "none"; 18540 break; 18541 case "clearLabel": 18542 settings.clearLabel = options.clearLabel; 18543 clearButton.innerHTML = settings.clearLabel; 18544 break; 18545 case "closeButton": 18546 settings.closeButton = !!options.closeButton; 18547 if (settings.closeButton) { 18548 picker.insertBefore(closeButton, colorPreview); 18549 } else { 18550 colorPreview.appendChild(closeButton); 18551 } 18552 break; 18553 case "closeLabel": 18554 settings.closeLabel = options.closeLabel; 18555 closeButton.innerHTML = settings.closeLabel; 18556 break; 18557 case "a11y": 18558 const labels = options.a11y; 18559 let update = false; 18560 if (typeof labels === "object") { 18561 for (const label in labels) { 18562 if (labels[label] && settings.a11y[label]) { 18563 settings.a11y[label] = labels[label]; 18564 update = true; 18565 } 18566 } 18567 } 18568 if (update) { 18569 const openLabel = getEl("clr-open-label"); 18570 const swatchLabel = getEl("clr-swatch-label"); 18571 openLabel.innerHTML = settings.a11y.open; 18572 swatchLabel.innerHTML = settings.a11y.swatch; 18573 closeButton.setAttribute("aria-label", settings.a11y.close); 18574 clearButton.setAttribute("aria-label", settings.a11y.clear); 18575 hueSlider.setAttribute("aria-label", settings.a11y.hueSlider); 18576 alphaSlider.setAttribute("aria-label", settings.a11y.alphaSlider); 18577 colorValue.setAttribute("aria-label", settings.a11y.input); 18578 colorArea.setAttribute("aria-label", settings.a11y.instruction); 18579 } 18580 break; 18581 default: 18582 settings[key] = options[key]; 18583 } 18584 } 18585 } 18586 function setVirtualInstance(selector, options) { 18587 if (typeof selector === "string" && typeof options === "object") { 18588 instances[selector] = options; 18589 hasInstance = true; 18590 } 18591 } 18592 function removeVirtualInstance(selector) { 18593 delete instances[selector]; 18594 if (Object.keys(instances).length === 0) { 18595 hasInstance = false; 18596 if (selector === currentInstanceId) { 18597 resetVirtualInstance(); 18598 } 18599 } 18600 } 18601 function attachVirtualInstance(element) { 18602 if (hasInstance) { 18603 const unsupportedOptions = ["el", "wrap", "rtl", "inline", "defaultColor", "a11y"]; 18604 for (let selector in instances) { 18605 const options = instances[selector]; 18606 if (element.matches(selector)) { 18607 currentInstanceId = selector; 18608 defaultInstance = {}; 18609 unsupportedOptions.forEach((option) => delete options[option]); 18610 for (let option in options) { 18611 defaultInstance[option] = Array.isArray(settings[option]) ? settings[option].slice() : settings[option]; 18612 } 18613 configure(options); 18614 break; 18615 } 18616 } 18617 } 18618 } 18619 function resetVirtualInstance() { 18620 if (Object.keys(defaultInstance).length > 0) { 18621 configure(defaultInstance); 18622 currentInstanceId = ""; 18623 defaultInstance = {}; 18624 } 18625 } 18626 function bindFields(selector) { 18627 if (selector instanceof HTMLElement) { 18628 selector = [selector]; 18629 } 18630 if (Array.isArray(selector)) { 18631 selector.forEach((field) => { 18632 addListener(field, "click", openPicker); 18633 addListener(field, "input", updateColorPreview); 18634 }); 18635 } else { 18636 addListener(document2, "click", selector, openPicker); 18637 addListener(document2, "input", selector, updateColorPreview); 18638 } 18639 } 18640 function openPicker(event) { 18641 if (settings.inline) { 18642 return; 18643 } 18644 attachVirtualInstance(event.target); 18645 currentEl = event.target; 18646 oldColor = currentEl.value; 18647 currentFormat = getColorFormatFromStr(oldColor); 18648 picker.classList.add("clr-open"); 18649 updatePickerPosition(); 18650 setColorFromStr(oldColor); 18651 if (settings.focusInput || settings.selectInput) { 18652 colorValue.focus({ preventScroll: true }); 18653 colorValue.setSelectionRange(currentEl.selectionStart, currentEl.selectionEnd); 18654 } 18655 if (settings.selectInput) { 18656 colorValue.select(); 18657 } 18658 if (keyboardNav || settings.swatchesOnly) { 18659 getFocusableElements().shift().focus(); 18660 } 18661 currentEl.dispatchEvent(new Event("open", { bubbles: false })); 18662 } 18663 function updatePickerPosition() { 18664 if (!picker || !currentEl && !settings.inline) return; 18665 const parent = container; 18666 const scrollY = window2.scrollY; 18667 const pickerWidth = picker.offsetWidth; 18668 const pickerHeight = picker.offsetHeight; 18669 const reposition = { left: false, top: false }; 18670 let parentStyle, parentMarginTop, parentBorderTop; 18671 let offset = { x: 0, y: 0 }; 18672 if (parent) { 18673 parentStyle = window2.getComputedStyle(parent); 18674 parentMarginTop = parseFloat(parentStyle.marginTop); 18675 parentBorderTop = parseFloat(parentStyle.borderTopWidth); 18676 offset = parent.getBoundingClientRect(); 18677 offset.y += parentBorderTop + scrollY; 18678 } 18679 if (!settings.inline) { 18680 const coords = currentEl.getBoundingClientRect(); 18681 let left = coords.x; 18682 let top = scrollY + coords.y + coords.height + settings.margin; 18683 if (parent) { 18684 left -= offset.x; 18685 top -= offset.y; 18686 if (left + pickerWidth > parent.clientWidth) { 18687 left += coords.width - pickerWidth; 18688 reposition.left = true; 18689 } 18690 if (top + pickerHeight > parent.clientHeight - parentMarginTop) { 18691 if (pickerHeight + settings.margin <= coords.top - (offset.y - scrollY)) { 18692 top -= coords.height + pickerHeight + settings.margin * 2; 18693 reposition.top = true; 18694 } 18695 } 18696 top += parent.scrollTop; 18697 } else { 18698 if (left + pickerWidth > document2.documentElement.clientWidth) { 18699 left += coords.width - pickerWidth; 18700 reposition.left = true; 18701 } 18702 if (top + pickerHeight - scrollY > document2.documentElement.clientHeight) { 18703 if (pickerHeight + settings.margin <= coords.top) { 18704 top = scrollY + coords.y - pickerHeight - settings.margin; 18705 reposition.top = true; 18706 } 18707 } 18708 } 18709 picker.classList.toggle("clr-left", reposition.left); 18710 picker.classList.toggle("clr-top", reposition.top); 18711 picker.style.left = `${left}px`; 18712 picker.style.top = `${top}px`; 18713 offset.x += picker.offsetLeft; 18714 offset.y += picker.offsetTop; 18715 } 18716 colorAreaDims = { 18717 width: colorArea.offsetWidth, 18718 height: colorArea.offsetHeight, 18719 x: colorArea.offsetLeft + offset.x, 18720 y: colorArea.offsetTop + offset.y 18721 }; 18722 } 18723 function wrapFields(selector) { 18724 if (selector instanceof HTMLElement) { 18725 wrapColorField(selector); 18726 } else if (Array.isArray(selector)) { 18727 selector.forEach(wrapColorField); 18728 } else { 18729 document2.querySelectorAll(selector).forEach(wrapColorField); 18730 } 18731 } 18732 function wrapColorField(field) { 18733 const parentNode = field.parentNode; 18734 if (!parentNode.classList.contains("clr-field")) { 18735 const wrapper = document2.createElement("div"); 18736 let classes = "clr-field"; 18737 if (settings.rtl || field.classList.contains("clr-rtl")) { 18738 classes += " clr-rtl"; 18739 } 18740 wrapper.innerHTML = '<button type="button" aria-labelledby="clr-open-label"></button>'; 18741 parentNode.insertBefore(wrapper, field); 18742 wrapper.className = classes; 18743 wrapper.style.color = field.value; 18744 wrapper.appendChild(field); 18745 } 18746 } 18747 function updateColorPreview(event) { 18748 const parent = event.target.parentNode; 18749 if (parent.classList.contains("clr-field")) { 18750 parent.style.color = event.target.value; 18751 } 18752 } 18753 function closePicker(revert) { 18754 if (currentEl && !settings.inline) { 18755 const prevEl = currentEl; 18756 if (revert) { 18757 currentEl = undefined2; 18758 if (oldColor !== prevEl.value) { 18759 prevEl.value = oldColor; 18760 prevEl.dispatchEvent(new Event("input", { bubbles: true })); 18761 } 18762 } 18763 setTimeout(() => { 18764 if (oldColor !== prevEl.value) { 18765 prevEl.dispatchEvent(new Event("change", { bubbles: true })); 18766 } 18767 }); 18768 picker.classList.remove("clr-open"); 18769 if (hasInstance) { 18770 resetVirtualInstance(); 18771 } 18772 prevEl.dispatchEvent(new Event("close", { bubbles: false })); 18773 if (settings.focusInput) { 18774 prevEl.focus({ preventScroll: true }); 18775 } 18776 currentEl = undefined2; 18777 } 18778 } 18779 function setColorFromStr(str) { 18780 const rgba = strToRGBA(str); 18781 const hsva = RGBAtoHSVA(rgba); 18782 updateMarkerA11yLabel(hsva.s, hsva.v); 18783 updateColor(rgba, hsva); 18784 hueSlider.value = hsva.h; 18785 picker.style.color = `hsl(${hsva.h}, 100%, 50%)`; 18786 hueMarker.style.left = `${hsva.h / 360 * 100}%`; 18787 colorMarker.style.left = `${colorAreaDims.width * hsva.s / 100}px`; 18788 colorMarker.style.top = `${colorAreaDims.height - colorAreaDims.height * hsva.v / 100}px`; 18789 alphaSlider.value = hsva.a * 100; 18790 alphaMarker.style.left = `${hsva.a * 100}%`; 18791 } 18792 function getColorFormatFromStr(str) { 18793 const format = str.substring(0, 3).toLowerCase(); 18794 if (format === "rgb" || format === "hsl") { 18795 return format; 18796 } 18797 return "hex"; 18798 } 18799 function pickColor(color) { 18800 color = color !== undefined2 ? color : colorValue.value; 18801 if (currentEl) { 18802 currentEl.value = color; 18803 currentEl.dispatchEvent(new Event("input", { bubbles: true })); 18804 } 18805 if (settings.onChange) { 18806 settings.onChange.call(window2, color, currentEl); 18807 } 18808 document2.dispatchEvent(new CustomEvent("coloris:pick", { detail: { color, currentEl } })); 18809 } 18810 function setColorAtPosition(x, y) { 18811 const hsva = { 18812 h: hueSlider.value * 1, 18813 s: x / colorAreaDims.width * 100, 18814 v: 100 - y / colorAreaDims.height * 100, 18815 a: alphaSlider.value / 100 18816 }; 18817 const rgba = HSVAtoRGBA(hsva); 18818 updateMarkerA11yLabel(hsva.s, hsva.v); 18819 updateColor(rgba, hsva); 18820 pickColor(); 18821 } 18822 function updateMarkerA11yLabel(saturation, value) { 18823 let label = settings.a11y.marker; 18824 saturation = saturation.toFixed(1) * 1; 18825 value = value.toFixed(1) * 1; 18826 label = label.replace("{s}", saturation); 18827 label = label.replace("{v}", value); 18828 colorMarker.setAttribute("aria-label", label); 18829 } 18830 function getPointerPosition(event) { 18831 return { 18832 pageX: event.changedTouches ? event.changedTouches[0].pageX : event.pageX, 18833 pageY: event.changedTouches ? event.changedTouches[0].pageY : event.pageY 18834 }; 18835 } 18836 function moveMarker(event) { 18837 const pointer = getPointerPosition(event); 18838 let x = pointer.pageX - colorAreaDims.x; 18839 let y = pointer.pageY - colorAreaDims.y; 18840 if (container) { 18841 y += container.scrollTop; 18842 } 18843 setMarkerPosition(x, y); 18844 event.preventDefault(); 18845 event.stopPropagation(); 18846 } 18847 function moveMarkerOnKeydown(offsetX, offsetY) { 18848 let x = colorMarker.style.left.replace("px", "") * 1 + offsetX; 18849 let y = colorMarker.style.top.replace("px", "") * 1 + offsetY; 18850 setMarkerPosition(x, y); 18851 } 18852 function setMarkerPosition(x, y) { 18853 x = x < 0 ? 0 : x > colorAreaDims.width ? colorAreaDims.width : x; 18854 y = y < 0 ? 0 : y > colorAreaDims.height ? colorAreaDims.height : y; 18855 colorMarker.style.left = `${x}px`; 18856 colorMarker.style.top = `${y}px`; 18857 setColorAtPosition(x, y); 18858 colorMarker.focus(); 18859 } 18860 function updateColor(rgba, hsva) { 18861 if (rgba === void 0) { 18862 rgba = {}; 18863 } 18864 if (hsva === void 0) { 18865 hsva = {}; 18866 } 18867 let format = settings.format; 18868 for (const key in rgba) { 18869 currentColor[key] = rgba[key]; 18870 } 18871 for (const key in hsva) { 18872 currentColor[key] = hsva[key]; 18873 } 18874 const hex = RGBAToHex(currentColor); 18875 const opaqueHex = hex.substring(0, 7); 18876 colorMarker.style.color = opaqueHex; 18877 alphaMarker.parentNode.style.color = opaqueHex; 18878 alphaMarker.style.color = hex; 18879 colorPreview.style.color = hex; 18880 colorArea.style.display = "none"; 18881 colorArea.offsetHeight; 18882 colorArea.style.display = ""; 18883 alphaMarker.nextElementSibling.style.display = "none"; 18884 alphaMarker.nextElementSibling.offsetHeight; 18885 alphaMarker.nextElementSibling.style.display = ""; 18886 if (format === "mixed") { 18887 format = currentColor.a === 1 ? "hex" : "rgb"; 18888 } else if (format === "auto") { 18889 format = currentFormat; 18890 } 18891 switch (format) { 18892 case "hex": 18893 colorValue.value = hex; 18894 break; 18895 case "rgb": 18896 colorValue.value = RGBAToStr(currentColor); 18897 break; 18898 case "hsl": 18899 colorValue.value = HSLAToStr(HSVAtoHSLA(currentColor)); 18900 break; 18901 } 18902 document2.querySelector(`.clr-format [value="${format}"]`).checked = true; 18903 } 18904 function setHue() { 18905 const hue = hueSlider.value * 1; 18906 const x = colorMarker.style.left.replace("px", "") * 1; 18907 const y = colorMarker.style.top.replace("px", "") * 1; 18908 picker.style.color = `hsl(${hue}, 100%, 50%)`; 18909 hueMarker.style.left = `${hue / 360 * 100}%`; 18910 setColorAtPosition(x, y); 18911 } 18912 function setAlpha() { 18913 const alpha = alphaSlider.value / 100; 18914 alphaMarker.style.left = `${alpha * 100}%`; 18915 updateColor({ a: alpha }); 18916 pickColor(); 18917 } 18918 function HSVAtoRGBA(hsva) { 18919 const saturation = hsva.s / 100; 18920 const value = hsva.v / 100; 18921 let chroma = saturation * value; 18922 let hueBy60 = hsva.h / 60; 18923 let x = chroma * (1 - Math2.abs(hueBy60 % 2 - 1)); 18924 let m = value - chroma; 18925 chroma = chroma + m; 18926 x = x + m; 18927 const index = Math2.floor(hueBy60) % 6; 18928 const red = [chroma, x, m, m, x, chroma][index]; 18929 const green = [x, chroma, chroma, x, m, m][index]; 18930 const blue = [m, m, x, chroma, chroma, x][index]; 18931 return { 18932 r: Math2.round(red * 255), 18933 g: Math2.round(green * 255), 18934 b: Math2.round(blue * 255), 18935 a: hsva.a 18936 }; 18937 } 18938 function HSVAtoHSLA(hsva) { 18939 const value = hsva.v / 100; 18940 const lightness = value * (1 - hsva.s / 100 / 2); 18941 let saturation; 18942 if (lightness > 0 && lightness < 1) { 18943 saturation = Math2.round((value - lightness) / Math2.min(lightness, 1 - lightness) * 100); 18944 } 18945 return { 18946 h: hsva.h, 18947 s: saturation || 0, 18948 l: Math2.round(lightness * 100), 18949 a: hsva.a 18950 }; 18951 } 18952 function RGBAtoHSVA(rgba) { 18953 const red = rgba.r / 255; 18954 const green = rgba.g / 255; 18955 const blue = rgba.b / 255; 18956 const xmax = Math2.max(red, green, blue); 18957 const xmin = Math2.min(red, green, blue); 18958 const chroma = xmax - xmin; 18959 const value = xmax; 18960 let hue = 0; 18961 let saturation = 0; 18962 if (chroma) { 18963 if (xmax === red) { 18964 hue = (green - blue) / chroma; 18965 } 18966 if (xmax === green) { 18967 hue = 2 + (blue - red) / chroma; 18968 } 18969 if (xmax === blue) { 18970 hue = 4 + (red - green) / chroma; 18971 } 18972 if (xmax) { 18973 saturation = chroma / xmax; 18974 } 18975 } 18976 hue = Math2.floor(hue * 60); 18977 return { 18978 h: hue < 0 ? hue + 360 : hue, 18979 s: Math2.round(saturation * 100), 18980 v: Math2.round(value * 100), 18981 a: rgba.a 18982 }; 18983 } 18984 function strToRGBA(str) { 18985 const regex = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; 18986 let match, rgba; 18987 ctx.fillStyle = "#000"; 18988 ctx.fillStyle = str; 18989 match = regex.exec(ctx.fillStyle); 18990 if (match) { 18991 rgba = { 18992 r: match[3] * 1, 18993 g: match[4] * 1, 18994 b: match[5] * 1, 18995 a: match[6] * 1 18996 }; 18997 } else { 18998 match = ctx.fillStyle.replace("#", "").match(/.{2}/g).map((h) => parseInt(h, 16)); 18999 rgba = { 19000 r: match[0], 19001 g: match[1], 19002 b: match[2], 19003 a: 1 19004 }; 19005 } 19006 return rgba; 19007 } 19008 function RGBAToHex(rgba) { 19009 let R = rgba.r.toString(16); 19010 let G = rgba.g.toString(16); 19011 let B = rgba.b.toString(16); 19012 let A = ""; 19013 if (rgba.r < 16) { 19014 R = "0" + R; 19015 } 19016 if (rgba.g < 16) { 19017 G = "0" + G; 19018 } 19019 if (rgba.b < 16) { 19020 B = "0" + B; 19021 } 19022 if (settings.alpha && (rgba.a < 1 || settings.forceAlpha)) { 19023 const alpha = rgba.a * 255 | 0; 19024 A = alpha.toString(16); 19025 if (alpha < 16) { 19026 A = "0" + A; 19027 } 19028 } 19029 return "#" + R + G + B + A; 19030 } 19031 function RGBAToStr(rgba) { 19032 if (!settings.alpha || rgba.a === 1 && !settings.forceAlpha) { 19033 return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`; 19034 } else { 19035 return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`; 19036 } 19037 } 19038 function HSLAToStr(hsla) { 19039 if (!settings.alpha || hsla.a === 1 && !settings.forceAlpha) { 19040 return `hsl(${hsla.h}, ${hsla.s}%, ${hsla.l}%)`; 19041 } else { 19042 return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})`; 19043 } 19044 } 19045 function init() { 19046 if (document2.getElementById("clr-picker")) return; 19047 container = undefined2; 19048 picker = document2.createElement("div"); 19049 picker.setAttribute("id", "clr-picker"); 19050 picker.className = "clr-picker"; 19051 picker.innerHTML = `<input id="clr-color-value" name="clr-color-value" class="clr-color" type="text" value="" spellcheck="false" aria-label="${settings.a11y.input}"><div id="clr-color-area" class="clr-gradient" role="application" aria-label="${settings.a11y.instruction}"><div id="clr-color-marker" class="clr-marker" tabindex="0"></div></div><div class="clr-hue"><input id="clr-hue-slider" name="clr-hue-slider" type="range" min="0" max="360" step="1" aria-label="${settings.a11y.hueSlider}"><div id="clr-hue-marker"></div></div><div class="clr-alpha"><input id="clr-alpha-slider" name="clr-alpha-slider" type="range" min="0" max="100" step="1" aria-label="${settings.a11y.alphaSlider}"><div id="clr-alpha-marker"></div><span></span></div><div id="clr-format" class="clr-format"><fieldset class="clr-segmented"><legend>${settings.a11y.format}</legend><input id="clr-f1" type="radio" name="clr-format" value="hex"><label for="clr-f1">Hex</label><input id="clr-f2" type="radio" name="clr-format" value="rgb"><label for="clr-f2">RGB</label><input id="clr-f3" type="radio" name="clr-format" value="hsl"><label for="clr-f3">HSL</label><span></span></fieldset></div><div id="clr-swatches" class="clr-swatches"></div><button type="button" id="clr-clear" class="clr-clear" aria-label="${settings.a11y.clear}">${settings.clearLabel}</button><div id="clr-color-preview" class="clr-preview"><button type="button" id="clr-close" class="clr-close" aria-label="${settings.a11y.close}">${settings.closeLabel}</button></div><span id="clr-open-label" hidden>${settings.a11y.open}</span><span id="clr-swatch-label" hidden>${settings.a11y.swatch}</span>`; 19052 document2.body.appendChild(picker); 19053 colorArea = getEl("clr-color-area"); 19054 colorMarker = getEl("clr-color-marker"); 19055 clearButton = getEl("clr-clear"); 19056 closeButton = getEl("clr-close"); 19057 colorPreview = getEl("clr-color-preview"); 19058 colorValue = getEl("clr-color-value"); 19059 hueSlider = getEl("clr-hue-slider"); 19060 hueMarker = getEl("clr-hue-marker"); 19061 alphaSlider = getEl("clr-alpha-slider"); 19062 alphaMarker = getEl("clr-alpha-marker"); 19063 bindFields(settings.el); 19064 wrapFields(settings.el); 19065 addListener(picker, "mousedown", (event) => { 19066 picker.classList.remove("clr-keyboard-nav"); 19067 event.stopPropagation(); 19068 }); 19069 addListener(colorArea, "mousedown", (event) => { 19070 addListener(document2, "mousemove", moveMarker); 19071 }); 19072 addListener(colorArea, "contextmenu", (event) => { 19073 event.preventDefault(); 19074 }); 19075 addListener(colorArea, "touchstart", (event) => { 19076 document2.addEventListener("touchmove", moveMarker, { passive: false }); 19077 }); 19078 addListener(colorMarker, "mousedown", (event) => { 19079 addListener(document2, "mousemove", moveMarker); 19080 }); 19081 addListener(colorMarker, "touchstart", (event) => { 19082 document2.addEventListener("touchmove", moveMarker, { passive: false }); 19083 }); 19084 addListener(colorValue, "change", (event) => { 19085 const value = colorValue.value; 19086 if (currentEl || settings.inline) { 19087 const color = value === "" ? value : setColorFromStr(value); 19088 pickColor(color); 19089 } 19090 }); 19091 addListener(clearButton, "click", (event) => { 19092 pickColor(""); 19093 closePicker(); 19094 }); 19095 addListener(closeButton, "click", (event) => { 19096 pickColor(); 19097 closePicker(); 19098 }); 19099 addListener(getEl("clr-format"), "click", ".clr-format input", (event) => { 19100 currentFormat = event.target.value; 19101 updateColor(); 19102 pickColor(); 19103 }); 19104 addListener(picker, "click", ".clr-swatches button", (event) => { 19105 setColorFromStr(event.target.textContent); 19106 pickColor(); 19107 if (settings.swatchesOnly) { 19108 closePicker(); 19109 } 19110 }); 19111 addListener(document2, "mouseup", (event) => { 19112 document2.removeEventListener("mousemove", moveMarker); 19113 }); 19114 addListener(document2, "touchend", (event) => { 19115 document2.removeEventListener("touchmove", moveMarker); 19116 }); 19117 addListener(document2, "mousedown", (event) => { 19118 keyboardNav = false; 19119 picker.classList.remove("clr-keyboard-nav"); 19120 closePicker(); 19121 }); 19122 addListener(document2, "keydown", (event) => { 19123 const key = event.key; 19124 const target = event.target; 19125 const shiftKey = event.shiftKey; 19126 const navKeys = ["Tab", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; 19127 if (key === "Escape") { 19128 closePicker(true); 19129 return; 19130 } else if (key === "Enter" && target.tagName !== "BUTTON") { 19131 closePicker(); 19132 return; 19133 } else if (navKeys.includes(key)) { 19134 keyboardNav = true; 19135 picker.classList.add("clr-keyboard-nav"); 19136 } 19137 if (key === "Tab" && target.matches(".clr-picker *")) { 19138 const focusables = getFocusableElements(); 19139 const firstFocusable = focusables.shift(); 19140 const lastFocusable = focusables.pop(); 19141 if (shiftKey && target === firstFocusable) { 19142 lastFocusable.focus(); 19143 event.preventDefault(); 19144 } else if (!shiftKey && target === lastFocusable) { 19145 firstFocusable.focus(); 19146 event.preventDefault(); 19147 } 19148 } 19149 }); 19150 addListener(document2, "click", ".clr-field button", (event) => { 19151 if (hasInstance) { 19152 resetVirtualInstance(); 19153 } 19154 event.target.nextElementSibling.dispatchEvent(new Event("click", { bubbles: true })); 19155 }); 19156 addListener(colorMarker, "keydown", (event) => { 19157 const movements = { 19158 ArrowUp: [0, -1], 19159 ArrowDown: [0, 1], 19160 ArrowLeft: [-1, 0], 19161 ArrowRight: [1, 0] 19162 }; 19163 if (Object.keys(movements).includes(event.key)) { 19164 moveMarkerOnKeydown(...movements[event.key]); 19165 event.preventDefault(); 19166 } 19167 }); 19168 addListener(colorArea, "click", moveMarker); 19169 addListener(hueSlider, "input", setHue); 19170 addListener(alphaSlider, "input", setAlpha); 19171 } 19172 function getFocusableElements() { 19173 const controls = Array.from(picker.querySelectorAll("input, button")); 19174 const focusables = controls.filter((node) => !!node.offsetWidth); 19175 return focusables; 19176 } 19177 function getEl(id) { 19178 return document2.getElementById(id); 19179 } 19180 function addListener(context, type, selector, fn) { 19181 const matches = Element.prototype.matches || Element.prototype.msMatchesSelector; 19182 if (typeof selector === "string") { 19183 context.addEventListener(type, (event) => { 19184 if (matches.call(event.target, selector)) { 19185 fn.call(event.target, event); 19186 } 19187 }); 19188 } else { 19189 fn = selector; 19190 context.addEventListener(type, fn); 19191 } 19192 } 19193 function DOMReady(fn, args) { 19194 args = args !== undefined2 ? args : []; 19195 if (document2.readyState !== "loading") { 19196 fn(...args); 19197 } else { 19198 document2.addEventListener("DOMContentLoaded", () => { 19199 fn(...args); 19200 }); 19201 } 19202 } 19203 if (NodeList !== undefined2 && NodeList.prototype && !NodeList.prototype.forEach) { 19204 NodeList.prototype.forEach = Array.prototype.forEach; 19205 } 19206 function setColor(color, target) { 19207 currentEl = target; 19208 oldColor = currentEl.value; 19209 attachVirtualInstance(target); 19210 currentFormat = getColorFormatFromStr(color); 19211 updatePickerPosition(); 19212 setColorFromStr(color); 19213 pickColor(); 19214 if (oldColor !== color) { 19215 currentEl.dispatchEvent(new Event("change", { bubbles: true })); 19216 } 19217 } 19218 const Coloris2 = (() => { 19219 const methods = { 19220 init, 19221 set: configure, 19222 wrap: wrapFields, 19223 close: closePicker, 19224 setInstance: setVirtualInstance, 19225 setColor, 19226 removeInstance: removeVirtualInstance, 19227 updatePosition: updatePickerPosition, 19228 ready: DOMReady 19229 }; 19230 function Coloris3(options) { 19231 DOMReady(() => { 19232 if (options) { 19233 if (typeof options === "string") { 19234 bindFields(options); 19235 } else { 19236 configure(options); 19237 } 19238 } 19239 }); 19240 } 19241 for (const key in methods) { 19242 Coloris3[key] = function() { 19243 for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { 19244 args[_key] = arguments[_key]; 19245 } 19246 DOMReady(methods[key], args); 19247 }; 19248 } 19249 DOMReady(() => { 19250 window2.addEventListener("resize", (event) => { 19251 Coloris3.updatePosition(); 19252 }); 19253 window2.addEventListener("scroll", (event) => { 19254 Coloris3.updatePosition(); 19255 }); 19256 }); 19257 return Coloris3; 19258 })(); 19259 Coloris2.coloris = Coloris2; 19260 return Coloris2; 19261 })(window, document, Math); 19262})(); 19263var _coloris = Coloris.coloris; 19264var _init = Coloris.init; 19265var _set = Coloris.set; 19266var _wrap = Coloris.wrap; 19267var _close = Coloris.close; 19268var _setInstance = Coloris.setInstance; 19269var _removeInstance = Coloris.removeInstance; 19270var _updatePosition = Coloris.updatePosition; 19271var coloris_default = Coloris; 19272
19273// app/javascript/sprks/pantone_picker.js 19274var SERIES_BADGE = { 19275 coated: { label: "C", cls: "bg-indigo-500/20 text-indigo-300" }, 19276 uncoated: { label: "U", cls: "bg-sky-500/20 text-sky-300" }, 19277 metallic: { label: "Metal", cls: "bg-amber-500/20 text-amber-300" }, 19278 "neon-pastel": { label: "Neon", cls: "bg-pink-500/20 text-pink-300" }, 19279 "fhi-tpg": { label: "TPG", cls: "bg-emerald-500/20 text-emerald-300" }, 19280 "fhi-tcx": { label: "TCX", cls: "bg-teal-500/20 text-teal-300" }, 19281 "fhi-tn": { label: "TN", cls: "bg-lime-500/20 text-lime-300" } 19282}; 19283var MAX_RESULTS = 50; 19284var dataCache = null; 19285var loadPromise = null; 19286async function loadPantones() { 19287 if (dataCache) return dataCache; 19288 if (loadPromise) return loadPromise; 19289 loadPromise = fetch("/pantones.json", { headers: { Accept: "application/json" } }).then((r) => { 19290 if (!r.ok) throw new Error(`HTTP ${r.status}`); 19291 return r.json(); 19292 }).then((data) => { 19293 dataCache = data; 19294 return data; 19295 }).catch((err) => { 19296 loadPromise = null; 19297 throw err; 19298 }); 19299 return loadPromise; 19300} 19301function stripSeparators(s) { 19302 return s.toLowerCase().replace(/[^a-z0-9]/g, ""); 19303} 19304function normalizeQuery(q) { 19305 return q.trim().toLowerCase().replace(/\s+/g, " "); 19306} 19307function search(query) { 19308 if (!dataCache) return []; 19309 const q = normalizeQuery(query); 19310 if (q === "") return []; 19311 const qStripped = stripSeparators(q); 19312 const results = []; 19313 for (let i = 0; i < dataCache.length && results.length < MAX_RESULTS * 3; i++) { 19314 const entry = dataCache[i]; 19315 const codeLower = entry.code.toLowerCase(); 19316 const codeStripped = stripSeparators(entry.code); 19317 const nameLower = (entry.name || "").toLowerCase(); 19318 if (codeLower.includes(q) || codeStripped.includes(qStripped) || nameLower && nameLower.includes(q)) { 19319 results.push(entry); 19320 } 19321 } 19322 results.sort((a, b) => { 19323 const ac = a.code.toLowerCase(); 19324 const bc = b.code.toLowerCase(); 19325 const acStripped = stripSeparators(a.code); 19326 const bcStripped = stripSeparators(b.code); 19327 const exactA = ac === q || acStripped === qStripped; 19328 const exactB = bc === q || bcStripped === qStripped; 19329 if (exactA !== exactB) return exactA ? -1 : 1; 19330 const startsA = ac.startsWith(q) || acStripped.startsWith(qStripped); 19331 const startsB = bc.startsWith(q) || bcStripped.startsWith(qStripped); 19332 if (startsA !== startsB) return startsA ? -1 : 1; 19333 return 0; 19334 }); 19335 return results.slice(0, MAX_RESULTS); 19336} 19337function escapeHtml(s) { 19338 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); 19339} 19340function renderResults(popup, results) { 19341 const list = popup.querySelector("#pantone-results"); 19342 const empty = popup.querySelector("#pantone-empty"); 19343 if (!list || !empty) return; 19344 if (results.length === 0) { 19345 list.innerHTML = ""; 19346 empty.textContent = "No matches"; 19347 empty.classList.remove("hidden"); 19348 return; 19349 } 19350 empty.classList.add("hidden"); 19351 list.innerHTML = results.map((r) => { 19352 const badge = SERIES_BADGE[r.series] || { label: r.series, cls: "bg-gray-700 text-gray-300" }; 19353 const nameMarkup = r.name ? `<span class="text-[10px] text-gray-400 truncate">${escapeHtml(r.name)}</span>` : ""; 19354 return ` 19355 <li role="option" data-pantone-result data-hex="${escapeHtml(r.hex)}" 19356 class="flex items-center gap-2.5 px-3 py-1.5 cursor-pointer hover:bg-white/5 transition-colors duration-150"> 19357 <span class="w-4 h-4 rounded-sm shrink-0 border border-white/10" style="background: ${escapeHtml(r.hex)}"></span> 19358 <div class="flex flex-col min-w-0 flex-1"> 19359 <span class="font-mono text-xs text-white truncate">${escapeHtml(r.code)}</span> 19360 ${nameMarkup} 19361 </div> 19362 <span class="font-mono text-[10px] text-gray-400 shrink-0">${escapeHtml(r.hex)}</span> 19363 <span class="text-[9px] uppercase tracking-wider rounded px-1.5 py-0.5 shrink-0 ${badge.cls}">${escapeHtml(badge.label)}</span> 19364 </li> 19365 `; 19366 }).join(""); 19367} 19368var PantonePicker = class { 19369 constructor() { 19370 this.popup = null; 19371 this.searchInput = null; 19372 this.isOpen = false;
19373 this.targetInput = null; 19374 this.handlers = { 19375 docClick: this.handleDocClick.bind(this), 19376 docMouseDown: this.handleDocMouseDown.bind(this), 19377 keydown: this.handleKeyDown.bind(this), 19378 search: this.handleSearchInput.bind(this), 19379 resultClick: this.handleResultClick.bind(this) 19380 }; 19381 } 19382 init() { 19383 this.popup = document.getElementById("pantone-popup"); 19384 if (!this.popup) return; 19385 this.searchInput = this.popup.querySelector("#pantone-search"); 19386 document.addEventListener("click", this.handlers.docClick); 19387 } 19388 handleDocClick(e) { 19389 const trigger = e.target.closest("[data-pantone-trigger]"); 19390 if (!trigger) return; 19391 e.preventDefault(); 19392 e.stopPropagation(); 19393 const slot = trigger.closest("[data-color-slot]"); 19394 const input = slot && slot.querySelector("[data-color-hex]"); 19395 if (!input) return; 19396 this.targetInput = input; 19397 this.open(trigger); 19398 this.ensureDataLoaded(); 19399 } 19400 async ensureDataLoaded() { 19401 try { 19402 await loadPantones(); 19403 if (this.isOpen && this.searchInput) { 19404 this.runSearch(this.searchInput.value); 19405 } 19406 } catch (err) { 19407 window.Toast?.show("Failed to load Pantone library", "alert"); 19408 this.close(); 19409 } 19410 } 19411 open(trigger) { 19412 if (!this.popup) return; 19413 if (this.searchInput) this.searchInput.value = ""; 19414 const list = this.popup.querySelector("#pantone-results"); 19415 const empty = this.popup.querySelector("#pantone-empty"); 19416 if (list) list.innerHTML = ""; 19417 if (empty) { 19418 empty.textContent = dataCache ? "Type to search Pantone colors" : "Loading\u2026"; 19419 empty.classList.remove("hidden"); 19420 } 19421 this.popup.style.left = "-9999px"; 19422 this.popup.style.top = "-9999px"; 19423 this.popup.classList.remove("hidden"); 19424 const anchor = trigger.closest("[data-color-slot]") || trigger; 19425 const anchorRect = anchor.getBoundingClientRect(); 19426 const popupRect = this.popup.getBoundingClientRect(); 19427 const margin = 8; 19428 const gap = 4; 19429 const maxX = window.innerWidth - popupRect.width - margin; 19430 const maxY = window.innerHeight - popupRect.height - margin; 19431 const finalX = Math.max(margin, Math.min(anchorRect.left, maxX)); 19432 const finalY = Math.max(margin, Math.min(anchorRect.bottom + gap, maxY)); 19433 this.popup.style.left = `${finalX}px`; 19434 this.popup.style.top = `${finalY}px`; 19435 if (!this.isOpen) { 19436 document.addEventListener("keydown", this.handlers.keydown); 19437 document.addEventListener("mousedown", this.handlers.docMouseDown); 19438 this.searchInput?.addEventListener("input", this.handlers.search); 19439 this.popup.addEventListener("click", this.handlers.resultClick); 19440 this.isOpen = true; 19441 } 19442 this.searchInput?.focus(); 19443 } 19444 close() { 19445 if (!this.popup || !this.isOpen) return; 19446 this.popup.classList.add("hidden"); 19447 document.removeEventListener("keydown", this.handlers.keydown); 19448 document.removeEventListener("mousedown", this.handlers.docMouseDown); 19449 this.searchInput?.removeEventListener("input", this.handlers.search); 19450 this.popup.removeEventListener("click", this.handlers.resultClick); 19451 this.isOpen = false; 19452 this.targetInput = null; 19453 } 19454 handleKeyDown(e) { 19455 if (e.key === "Escape") this.close(); 19456 } 19457 handleDocMouseDown(e) { 19458 if (this.popup.contains(e.target)) return; 19459 if (e.target.closest("[data-pantone-trigger]")) return; 19460 this.close(); 19461 } 19462 handleSearchInput(e) { 19463 this.runSearch(e.target.value); 19464 } 19465 runSearch(query) { 19466 if (!dataCache) return; 19467 const results = search(query); 19468 renderResults(this.popup, results); 19469 } 19470 handleResultClick(e) { 19471 const li = e.target.closest("[data-pantone-result]"); 19472 if (!li || !this.targetInput) return; 19473 const hex = li.dataset.hex; 19474 if (!hex) return; 19475 this.targetInput.value = hex; 19476 this.targetInput.dispatchEvent(new Event("input", { bubbles: true })); 19477 this.targetInput.dispatchEvent(new Event("change", { bubbles: true })); 19478 this.close(); 19479 } 19480 destroy() { 19481 document.removeEventListener("click", this.handlers.docClick); 19482 this.close(); 19483 this.popup = null; 19484 this.searchInput = null; 19485 } 19486}; 19487var instance = null; 19488document.addEventListener("turbo:load", () => { 19489 if (instance) instance.destroy(); 19490 instance = new PantonePicker(); 19491 instance.init(); 19492}); 19493document.addEventListener("turbo:before-cache", () => { 19494 if (instance) { 19495 instance.destroy(); 19496 instance = null; 19497 } 19498}); 19499
19500// app/javascript/sprks.js 19501var SprksUI = class { 19502 constructor() { 19503 this.boundHandlers = []; 19504 } 19505 unwrapColoris() { 19506 document.querySelectorAll(".clr-field").forEach((wrapper) => { 19507 const input = wrapper.querySelector("input[data-coloris]"); 19508 if (input) { 19509 wrapper.parentNode.insertBefore(input, wrapper); 19510 } 19511 wrapper.remove(); 19512 }); 19513 } 19514 init() { 19515 this.unwrapColoris(); 19516 if (document.querySelector("[data-coloris]")) { 19517 coloris_default.init(); 19518 coloris_default({ 19519 el: "[data-coloris]", 19520 format: "hex", 19521 alpha: false, 19522 themeMode: "dark", 19523 formatToggle: false, 19524 focusInput: true 19525 }); 19526 } 19527 document.querySelectorAll("[data-color-slot]").forEach((slot) => { 19528 const input = slot.querySelector("input[data-coloris]"); 19529 const clearBtn = slot.querySelector("[data-color-clear]"); 19530 const pantoneBtn = slot.querySelector("[data-pantone-trigger]"); 19531 if (!input || !clearBtn) return; 19532 const isWhiteHex = (v) => { 19533 const s = (v || "").trim().toLowerCase().replace(/^#/, ""); 19534 return s === "fff" || s === "ffffff"; 19535 }; 19536 const syncState = () => { 19537 if (input.value && input.value.trim() !== "") { 19538 clearBtn.classList.remove("hidden"); 19539 } else { 19540 clearBtn.classList.add("hidden"); 19541 } 19542 if (pantoneBtn) { 19543 const white = isWhiteHex(input.value); 19544 pantoneBtn.classList.toggle("text-gray-500", white); 19545 pantoneBtn.classList.toggle("text-white", !white); 19546 } 19547 }; 19548 const onClear = (e) => { 19549 e.preventDefault(); 19550 e.stopPropagation(); 19551 input.value = ""; 19552 input.dispatchEvent(new Event("input", { bubbles: true })); 19553 input.dispatchEvent(new Event("change", { bubbles: true })); 19554 syncState(); 19555 }; 19556 clearBtn.addEventListener("click", onClear); 19557 input.addEventListener("input", syncState); 19558 input.addEventListener("change", syncState); 19559 this.boundHandlers.push({ el: clearBtn, handler: onClear, type: "click" }); 19560 this.boundHandlers.push({ el: input, handler: syncState, type: "input" }); 19561 this.boundHandlers.push({ el: input, handler: syncState, type: "change" }); 19562 syncState(); 19563 }); 19564 const onDeleteGenClick = (e) => { 19565 const btn = e.target.closest("[data-delete-generation]"); 19566 if (!btn) return; 19567 e.preventDefault(); 19568 e.stopPropagation(); 19569 const packagingUuid = btn.dataset.packagingUuid; 19570 const generationUuid = btn.dataset.generationUuid; 19571 const form = document.getElementById("delete-generation-form"); 19572 if (form) form.action = `/sprks/${packagingUuid}/generations/${generationUuid}`; 19573 window.Modal?.open("delete-generation-modal"); 19574 }; 19575 document.addEventListener("click", onDeleteGenClick); 19576 this.boundHandlers.push({ el: document, handler: onDeleteGenClick, type: "click" }); 19577 const onDeleteBottleClick = (e) => { 19578 const btn = e.target.closest("[data-delete-bottle]"); 19579 if (!btn) return; 19580 e.preventDefault(); 19581 e.stopPropagation(); 19582 const packagingUuid = btn.dataset.packagingUuid; 19583 const bottleUuid = btn.dataset.bottleUuid; 19584 const form = document.getElementById("delete-bottle-form"); 19585 if (form) form.action = `/sprks/${packagingUuid}/bottles/${bottleUuid}`; 19586 window.Modal?.open("delete-bottle-modal"); 19587 }; 19588 document.addEventListener("click", onDeleteBottleClick); 19589 this.boundHandlers.push({ el: document, handler: onDeleteBottleClick, type: "click" }); 19590 const onEditSourceClick = (e) => { 19591 const btn = e.target.closest("[data-edit-source]"); 19592 if (!btn) return; 19593 e.preventDefault(); 19594 e.stopPropagation(); 19595 const modal = document.getElementById("sprks-edit-modal"); 19596 if (!modal) return; 19597 const img = modal.querySelector("[data-edit-source-image]"); 19598 const typeInput = modal.querySelector("[data-edit-source-type]"); 19599 const uuidInput = modal.querySelector("[data-edit-source-uuid]"); 19600 const promptEl = modal.querySelector("#sprks-edit-prompt"); 19601 if (img) img.src = btn.dataset.sourceImage || ""; 19602 if (typeInput) typeInput.value = btn.dataset.sourceType || ""; 19603 if (uuidInput) uuidInput.value = btn.dataset.sourceUuid || ""; 19604 if (promptEl) promptEl.value = "";
19605 window.Modal?.open("sprks-edit-modal"); 19606 }; 19607 document.addEventListener("click", onEditSourceClick); 19608 this.boundHandlers.push({ el: document, handler: onEditSourceClick, type: "click" }); 19609 const editForm = document.getElementById("sprks-edit-form"); 19610 if (editForm) { 19611 const onEditSubmitEnd = (e) => { 19612 if (e.detail?.success) window.Modal?.close(); 19613 }; 19614 editForm.addEventListener("turbo:submit-end", onEditSubmitEnd); 19615 this.boundHandlers.push({ el: editForm, handler: onEditSubmitEnd, type: "turbo:submit-end" }); 19616 } 19617 document.querySelectorAll("[data-status-picker]").forEach((picker) => { 19618 const trigger = picker.querySelector("[data-status-trigger]"); 19619 const menu = picker.querySelector("[data-status-menu]"); 19620 const dot = picker.querySelector("[data-status-dot]"); 19621 const label = picker.querySelector("[data-status-label]"); 19622 const uuid2 = picker.dataset.packagingUuid; 19623 if (!trigger || !menu || !uuid2) return; 19624 const closeMenu = () => menu.classList.add("hidden"); 19625 const openMenu = () => menu.classList.remove("hidden"); 19626 const onTriggerClick = (e) => { 19627 e.stopPropagation(); 19628 document.querySelectorAll("[data-sprks-menu-dropdown]").forEach((d) => d.classList.add("hidden")); 19629 menu.classList.toggle("hidden"); 19630 }; 19631 const onDocClick = (e) => { 19632 if (!picker.contains(e.target)) closeMenu(); 19633 }; 19634 trigger.addEventListener("click", onTriggerClick); 19635 document.addEventListener("click", onDocClick); 19636 this.boundHandlers.push({ el: trigger, handler: onTriggerClick, type: "click" }); 19637 this.boundHandlers.push({ el: document, handler: onDocClick, type: "click" }); 19638 picker.querySelectorAll("[data-status-option]").forEach((btn) => { 19639 if (btn.disabled) return; 19640 const handler = async (e) => { 19641 e.preventDefault(); 19642 e.stopPropagation(); 19643 const status = btn.dataset.statusOption; 19644 const csrf = document.querySelector('[name="csrf-token"]')?.content || ""; 19645 try { 19646 const resp = await fetch(`/sprks/${uuid2}`, { 19647 method: "PATCH", 19648 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrf }, 19649 body: JSON.stringify({ packaging: { status } }) 19650 }); 19651 const data = await resp.json(); 19652 if (!resp.ok) { 19653 window.Toast?.show(data.error || "Failed to update status", "alert"); 19654 return; 19655 } 19656 const cfg = data.status_config || {}; 19657 if (dot) { 19658 dot.className = "w-1.5 h-1.5 rounded-full " + (cfg.dot || "bg-gray-400"); 19659 } 19660 if (label) label.textContent = cfg.label || data.status; 19661 picker.querySelectorAll("[data-status-option]").forEach((b) => { 19662 const active = b.dataset.statusOption === data.status; 19663 b.classList.toggle("text-white", active); 19664 b.classList.toggle("text-gray", !active); 19665 const check = b.querySelector(".ml-auto"); 19666 if (active && !check) { 19667 const s = document.createElement("span"); 19668 s.className = "ml-auto text-[10px] text-gray"; 19669 s.innerHTML = "✓"; 19670 b.appendChild(s); 19671 } else if (!active && check) { 19672 check.remove(); 19673 } 19674 }); 19675 closeMenu(); 19676 } catch (err) { 19677 window.Toast?.show("Failed to update status", "alert"); 19678 } 19679 }; 19680 btn.addEventListener("click", handler); 19681 this.boundHandlers.push({ el: btn, handler, type: "click" }); 19682 }); 19683 }); 19684 document.querySelectorAll("[data-sprks-menu]").forEach((menu) => { 19685 const trigger = menu.querySelector("[data-sprks-menu-trigger]"); 19686 const dropdown = menu.querySelector("[data-sprks-menu-dropdown]"); 19687 if (!trigger || !dropdown) return; 19688 const closeMenu = () => dropdown.classList.add("hidden"); 19689 const positionDropdown = () => { 19690 const blockWidth = menu.getBoundingClientRect().width; 19691 const dropdownWidth = 128; 19692 const overflow = blockWidth > dropdownWidth; 19693 dropdown.classList.toggle("right-0", overflow); 19694 dropdown.classList.toggle("left-0", !overflow); 19695 }; 19696 const onTriggerClick = (e) => { 19697 e.stopPropagation(); 19698 document.querySelectorAll("[data-status-menu]").forEach((d) => d.classList.add("hidden")); 19699 positionDropdown(); 19700 dropdown.classList.toggle("hidden"); 19701 }; 19702 const onDocClick = (e) => { 19703 if (!menu.contains(e.target)) closeMenu(); 19704 }; 19705 const onRename = (e) => { 19706 e.preventDefault(); 19707 closeMenu();
19708 window.Modal?.open("rename-packaging-modal"); 19709 }; 19710 const onDelete = (e) => { 19711 e.preventDefault(); 19712 closeMenu(); 19713 window.Modal?.open("delete-packaging-modal"); 19714 }; 19715 trigger.addEventListener("click", onTriggerClick); 19716 document.addEventListener("click", onDocClick); 19717 const renameBtn = menu.querySelector("[data-sprks-menu-rename]"); 19718 const deleteBtn = menu.querySelector("[data-sprks-menu-delete]"); 19719 renameBtn?.addEventListener("click", onRename); 19720 deleteBtn?.addEventListener("click", onDelete); 19721 this.boundHandlers.push({ el: trigger, handler: onTriggerClick, type: "click" }); 19722 this.boundHandlers.push({ el: document, handler: onDocClick, type: "click" }); 19723 if (renameBtn) this.boundHandlers.push({ el: renameBtn, handler: onRename, type: "click" }); 19724 if (deleteBtn) this.boundHandlers.push({ el: deleteBtn, handler: onDelete, type: "click" }); 19725 }); 19726 const slotForm = document.getElementById("sprks-slot-form"); 19727 if (slotForm) { 19728 const guardSubmit = (e) => { 19729 const editableHex = Array.from(slotForm.querySelectorAll("[data-color-hex]:not([readonly])")); 19730 const first3 = editableHex.slice(0, 3).map((i) => (i.value || "").trim().toLowerCase()); 19731 const theme = (slotForm.querySelector("#theme")?.value || "").trim(); 19732 const allEmpty = first3.length === 3 && first3.every((v) => !v); 19733 if (allEmpty && !theme) { 19734 e.preventDefault(); 19735 e.stopImmediatePropagation(); 19736 window.Modal?.open("empty-input-modal"); 19737 return; 19738 } 19739 if (first3.length === 3 && first3[0] && first3.every((v) => v === first3[0])) { 19740 e.preventDefault(); 19741 e.stopImmediatePropagation(); 19742 window.Modal?.open("identical-colors-modal"); 19743 } 19744 }; 19745 slotForm.addEventListener("submit", guardSubmit, true); 19746 this.boundHandlers.push({ el: slotForm, handler: guardSubmit, type: "submit", capture: true }); 19747 } 19748 ["sprks-bottle-form", "sprks-slot-form"].forEach((formId) => { 19749 const form = document.getElementById(formId); 19750 if (!form) return; 19751 const blockEnter = (e) => { 19752 if (e.key !== "Enter") return; 19753 if (e.target.tagName === "TEXTAREA") return; 19754 e.preventDefault(); 19755 }; 19756 form.addEventListener("keydown", blockEnter); 19757 this.boundHandlers.push({ el: form, handler: blockEnter, type: "keydown" }); 19758 const modelSelect = form.querySelector("[data-sprks-model]"); 19759 const costDisplay = form.querySelector("[data-sprks-cost]"); 19760 if (modelSelect && costDisplay) { 19761 const updateCost = () => { 19762 const opt = modelSelect.options[modelSelect.selectedIndex]; 19763 const cost = opt?.dataset.cost; 19764 if (cost) costDisplay.textContent = cost; 19765 }; 19766 modelSelect.addEventListener("change", updateCost); 19767 this.boundHandlers.push({ el: modelSelect, handler: updateCost, type: "change" }); 19768 } 19769 }); 19770 document.querySelectorAll("[data-sprks-images]").forEach((container) => { 19771 const max = parseInt(container.dataset.max || "5", 10); 19772 const addBtn = container.querySelector("[data-sprks-add-image]"); 19773 const fileInput = container.querySelector("[data-sprks-image-input]"); 19774 if (!addBtn || !fileInput) return; 19775 const count = () => container.querySelectorAll("[data-sprks-image-tile]").length; 19776 const refreshAddBtn = () => { 19777 addBtn.classList.toggle("hidden", count() >= max); 19778 container.appendChild(addBtn); 19779 container.appendChild(fileInput); 19780 }; 19781 const removeTile = (tile) => { 19782 tile.remove(); 19783 refreshAddBtn(); 19784 }; 19785 const uploadFile = async (file, tile) => { 19786 const csrf = document.querySelector('[name="csrf-token"]')?.content || ""; 19787 const fd = new FormData(); 19788 fd.append("file", file); 19789 fd.append("type", "sprks-ref"); 19790 try { 19791 const resp = await fetch("/upload", { 19792 method: "POST", 19793 headers: { "X-CSRF-Token": csrf, "Accept": "application/json" }, 19794 body: fd 19795 }); 19796 const data = await resp.json(); 19797 if (!resp.ok || !data.success) throw new Error(data.error || "Upload failed"); 19798 tile.dataset.sprksImageUrl = data.url; 19799 tile.classList.add("group", "overflow-hidden"); 19800 tile.innerHTML = ` 19801 <a href="${data.url}" target="_blank" rel="noopener" class="block w-full h-full"> 19802 <img src="${data.url}" class="w-full h-full object-cover" /> 19803 </a> 19804 <button type="button" aria-label="Remove" data-sprks-remove-image 19805 class="absolute top-1 right-1 w-4 h-4 rounded-sm bg-black/60 text-white opacity-0 group-hover:opacity-100 flex items-center justify-center cursor-pointer transition-opacity duration-200"> 19806 <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewB
19806ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> 19807 </button> 19808 <input type="hidden" name="image_urls[]" value="${data.url}" /> 19809 `; 19810 } catch (err) { 19811 window.Toast?.show(err.message || "Upload failed", "alert"); 19812 tile.remove(); 19813 } 19814 refreshAddBtn(); 19815 }; 19816 const onAddClick = () => { 19817 if (count() < max) fileInput.click(); 19818 }; 19819 const onFileChange = (e) => { 19820 const files = Array.from(e.target.files || []); 19821 const remaining = max - count(); 19822 files.slice(0, remaining).forEach((file) => { 19823 const tile = document.createElement("div"); 19824 tile.className = "relative w-12 h-12 rounded-sm bg-inside flex items-center justify-center shrink-0"; 19825 tile.setAttribute("data-sprks-image-tile", ""); 19826 tile.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>'; 19827 container.insertBefore(tile, addBtn); 19828 uploadFile(file, tile); 19829 }); 19830 fileInput.value = ""; 19831 refreshAddBtn(); 19832 }; 19833 const onContainerClick = (e) => { 19834 const removeBtn = e.target.closest("[data-sprks-remove-image]"); 19835 if (!removeBtn) return; 19836 const tile = removeBtn.closest("[data-sprks-image-tile]"); 19837 if (tile) removeTile(tile); 19838 }; 19839 addBtn.addEventListener("click", onAddClick); 19840 fileInput.addEventListener("change", onFileChange); 19841 container.addEventListener("click", onContainerClick); 19842 this.boundHandlers.push({ el: addBtn, handler: onAddClick, type: "click" }); 19843 this.boundHandlers.push({ el: fileInput, handler: onFileChange, type: "change" }); 19844 this.boundHandlers.push({ el: container, handler: onContainerClick, type: "click" }); 19845 refreshAddBtn(); 19846 }); 19847 document.querySelectorAll("[data-sprks-prompt-editor]").forEach((editor) => { 19848 const form = editor.closest("form"); 19849 const hidden = form?.querySelector("[data-sprks-prompt-value]"); 19850 if (!hidden) return; 19851 const MARKER = "\uE000"; 19852 const escapeHtml2 = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); 19853 const highlight = (insertCaretMarker) => { 19854 const sel = window.getSelection(); 19855 let hasCaret = false; 19856 if (insertCaretMarker && sel.rangeCount > 0 && editor.contains(sel.anchorNode)) { 19857 const range = sel.getRangeAt(0); 19858 range.deleteContents(); 19859 range.insertNode(document.createTextNode(MARKER)); 19860 editor.normalize(); 19861 hasCaret = true; 19862 } 19863 const plain = editor.innerText; 19864 const html = escapeHtml2(plain).replace( 19865 /(?<!\w)@(box|bottle)\b/gi, 19866 (_m, w) => `<span class="text-indigo-400 mention-tag" contenteditable="false">@${w.toLowerCase()}</span>` 19867 ); 19868 editor.innerHTML = html; 19869 if (hasCaret) { 19870 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null); 19871 let node; 19872 while (node = walker.nextNode()) { 19873 const idx = node.data.indexOf(MARKER); 19874 if (idx !== -1) { 19875 node.data = node.data.slice(0, idx) + node.data.slice(idx + 1); 19876 const r = document.createRange(); 19877 r.setStart(node, idx); 19878 r.collapse(true); 19879 sel.removeAllRanges(); 19880 sel.addRange(r); 19881 break; 19882 } 19883 } 19884 } 19885 hidden.value = editor.innerText.replace(new RegExp(MARKER, "g"), ""); 19886 }; 19887 const onInput = () => highlight(true); 19888 const onPaste = (e) => { 19889 e.preventDefault(); 19890 const text = (e.clipboardData || window.clipboardData).getData("text/plain"); 19891 document.execCommand("insertText", false, text); 19892 }; 19893 const onSubmit = () => { 19894 hidden.value = editor.innerText; 19895 }; 19896 editor.addEventListener("input", onInput); 19897 editor.addEventListener("paste", onPaste); 19898 form?.addEventListener("submit", onSubmit); 19899 this.boundHandlers.push({ el: editor, handler: onInput, type: "input" }); 19900 this.boundHandlers.push({ el: editor, handler: onPaste, type: "paste" }); 19901 if (form) this.boundHandlers.push({ el: form, handler: onSubmit, type: "submit" }); 19902 }); 19903 document.querySelectorAll("[data-sprks-mentions]").forEach((container) => { 19904 const form = container.closest("form"); 19905 const editor = form?.querySelector("[data-sprks-prompt-editor]"); 19906 if (!editor) return; 19907 const handler = (e) => { 19908 const btn = e.target.closest("button[data-insert]"); 19909 if (!btn) return; 19910 e.preventDefault(); 19911 const token = btn.dataset.insert; 19912 const match = /^@(box|bottle)$/i.exec(token); 19913 if (!match) return; 19914 const slug = match[1].toLowerCase(); 19915 editor.focus(); 19916 const sel = window.getSelection(); 19917 let range; 19918 if (sel.rangeCount > 0 && editor.contains(sel.anchorNode)) { 19919 range = sel.getRangeAt(0); 19920 } else { 19921 range = document.createRange(); 19922 range.selectNodeContents(editor); 19923 range.collapse(false); 19924 } 19925 const preRange = range.cloneRange(); 19926 preRange.selectNodeContents(editor); 19927 preRange.setEnd(range.endContainer, range.endOffset); 19928 const before = preRange.toString(); 19929 const needsSpaceBefore = before.length > 0 && !/\s$/.test(before); 19930 const frag = document.createDocumentFragment(); 19931 if (needsSpaceBefore) frag.appendChild(document.createTextNode(" ")); 19932 const span = document.createElement("span"); 19933 span.className = "text-indigo-400 mention-tag"; 19934 span.setAttribute("contenteditable", "false"); 19935 span.textContent = `@${slug}`; 19936 frag.appendChild(span); 19937 frag.appendChild(document.createTextNode(" ")); 19938 range.deleteContents();
19939 range.insertNode(frag); 19940 range.collapse(false); 19941 sel.removeAllRanges(); 19942 sel.addRange(range); 19943 const form2 = editor.closest("form"); 19944 const hidden = form2?.querySelector("[data-sprks-prompt-value]"); 19945 if (hidden) hidden.value = editor.innerText; 19946 }; 19947 container.addEventListener("click", handler); 19948 this.boundHandlers.push({ el: container, handler, type: "click" }); 19949 }); 19950 document.querySelectorAll("[data-sprks-start-frame]").forEach((zone) => { 19951 const input = zone.querySelector("[data-sf-input]"); 19952 const empty = zone.querySelector("[data-sf-empty]"); 19953 const loading = zone.querySelector("[data-sf-loading]"); 19954 const filled = zone.querySelector("[data-sf-filled]"); 19955 const thumb = zone.querySelector("[data-sf-thumb]"); 19956 const removeBtn = zone.querySelector("[data-sf-remove]"); 19957 const urlField = zone.querySelector("[data-sf-url]"); 19958 const form = zone.closest("form"); 19959 const submitBtn = form?.querySelector("[data-sf-submit]"); 19960 if (!input || !empty || !loading || !filled || !urlField || !submitBtn) return; 19961 const showEmpty = () => { 19962 empty.classList.remove("hidden"); 19963 loading.classList.add("hidden"); 19964 filled.classList.add("hidden"); 19965 }; 19966 const showLoading = () => { 19967 empty.classList.add("hidden"); 19968 loading.classList.remove("hidden"); 19969 filled.classList.add("hidden"); 19970 }; 19971 const showFilled = () => { 19972 empty.classList.add("hidden"); 19973 loading.classList.add("hidden"); 19974 filled.classList.remove("hidden"); 19975 }; 19976 const setSubmitEnabled = (enabled) => { 19977 submitBtn.disabled = !enabled; 19978 }; 19979 const reset = () => { 19980 input.value = ""; 19981 urlField.value = ""; 19982 thumb.removeAttribute("src"); 19983 showEmpty(); 19984 setSubmitEnabled(false); 19985 }; 19986 const applyUrl = (url) => { 19987 urlField.value = url; 19988 thumb.src = url; 19989 showFilled(); 19990 setSubmitEnabled(true); 19991 }; 19992 let currentMenu = null; 19993 let clickOutsideHandler = null; 19994 const closeMenu = () => { 19995 if (currentMenu) { 19996 currentMenu.remove(); 19997 currentMenu = null; 19998 } 19999 if (clickOutsideHandler) { 20000 document.removeEventListener("click", clickOutsideHandler); 20001 clickOutsideHandler = null; 20002 } 20003 }; 20004 const buildOption = ({ title, subtitle, iconSvg, onClick }) => { 20005 const btn = document.createElement("button"); 20006 btn.type = "button"; 20007 btn.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 20008 btn.innerHTML = ` 20009 <div class="flex items-start gap-3"> 20010 ${iconSvg} 20011 <div> 20012 <div class="font-medium text-white">${title}</div> 20013 <div class="text-[10px] text-gray leading-tight mt-0.5">${subtitle}</div> 20014 </div> 20015 </div>`; 20016 btn.addEventListener("click", (e) => { 20017 e.stopPropagation(); 20018 closeMenu(); 20019 onClick(); 20020 }); 20021 return btn; 20022 }; 20023 const openMenu = () => { 20024 closeMenu(); 20025 const menu = document.createElement("div"); 20026 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px] top-1/2 left-1/2 -translate-x-1/2 mt-3"; 20027 const uploadSvg = `<svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 13v8"></path><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path><path d="m8 17 4-4 4 4"></path></svg>`; 20028 const favsSvg = `<svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`; 20029 menu.appendChild(buildOption({ 20030 title: "Upload", 20031 subtitle: "Image from your computer", 20032 iconSvg: uploadSvg, 20033 onClick: () => input.click() 20034 })); 20035 menu.appendChild(buildOption({ 20036 title: "Favs", 20037 subtitle: "Favorites images", 20038 iconSvg: favsSvg, 20039 onClick: () => { 20040 const onFavSelected = (evt) => { 20041 const fav = evt.detail?.favs?.[0]; 20042 if (!fav?.url) return; 20043 if (/\.(mp4|webm|mov|m4v)(\?|$)/i.test(fav.url)) { 20044 window.Toast?.show("Only images are supported", "alert"); 20045 return; 20046 } 20047 applyUrl(fav.url); 20048 }; 20049 document.addEventListener("favs:selected", onFavSelected, { once: true });
20050 window.Modal?.showFavsPicker(); 20051 } 20052 })); 20053 zone.appendChild(menu); 20054 currentMenu = menu; 20055 setTimeout(() => { 20056 clickOutsideHandler = (e) => { 20057 if (!menu.contains(e.target) && !empty.contains(e.target)) closeMenu(); 20058 }; 20059 document.addEventListener("click", clickOutsideHandler); 20060 }, 0); 20061 }; 20062 const onEmptyClick = (e) => { 20063 e.preventDefault(); 20064 e.stopPropagation(); 20065 openMenu(); 20066 }; 20067 const onChange = async () => { 20068 const file = input.files && input.files[0]; 20069 if (!file) return; 20070 if (!file.type.startsWith("image/")) { 20071 window.Toast?.show("Only image files are allowed", "alert"); 20072 input.value = ""; 20073 return; 20074 } 20075 showLoading(); 20076 setSubmitEnabled(false); 20077 try { 20078 const fd = new FormData(); 20079 fd.append("file", file); 20080 fd.append("type", "sprks_start_frame"); 20081 const csrf = document.querySelector('[name="csrf-token"]')?.content || ""; 20082 const resp = await fetch("/upload", { 20083 method: "POST", 20084 headers: { "X-CSRF-Token": csrf, "Accept": "application/json" }, 20085 body: fd 20086 }); 20087 const data = await resp.json().catch(() => ({})); 20088 if (!resp.ok || !data.url) { 20089 window.Toast?.show(data.error || "Upload failed", "alert"); 20090 reset(); 20091 return; 20092 } 20093 applyUrl(data.url); 20094 } catch (err) { 20095 window.Toast?.show("Upload failed", "alert"); 20096 reset(); 20097 } 20098 }; 20099 const onRemove = (e) => { 20100 e.preventDefault(); 20101 reset(); 20102 }; 20103 empty.addEventListener("click", onEmptyClick); 20104 input.addEventListener("change", onChange); 20105 removeBtn?.addEventListener("click", onRemove); 20106 this.boundHandlers.push({ el: empty, handler: onEmptyClick, type: "click" }); 20107 this.boundHandlers.push({ el: input, handler: onChange, type: "change" }); 20108 if (removeBtn) this.boundHandlers.push({ el: removeBtn, handler: onRemove, type: "click" }); 20109 }); 20110 document.querySelectorAll("[data-sprks-video-cost]").forEach((wrap) => { 20111 const durationInput = wrap.querySelector("[data-vc-duration]"); 20112 const audioInput = wrap.querySelector("[data-vc-audio]"); 20113 const totalEl = wrap.querySelector("[data-vc-total]"); 20114 if (!durationInput || !audioInput || !totalEl) return; 20115 const clampDuration = () => { 20116 const min = parseInt(durationInput.min, 10) || 3; 20117 const max = parseInt(durationInput.max, 10) || 15; 20118 let n = parseInt(durationInput.value, 10); 20119 if (isNaN(n) || n <= 0) n = 5; 20120 return Math.min(Math.max(n, min), max); 20121 }; 20122 const recompute = () => { 20123 const seconds = clampDuration(); 20124 const perSec = audioInput.checked ? 35 : 22; 20125 totalEl.textContent = String(seconds * perSec); 20126 }; 20127 durationInput.addEventListener("input", recompute); 20128 durationInput.addEventListener("change", recompute); 20129 audioInput.addEventListener("change", recompute); 20130 this.boundHandlers.push({ el: durationInput, handler: recompute, type: "input" }); 20131 this.boundHandlers.push({ el: durationInput, handler: recompute, type: "change" }); 20132 this.boundHandlers.push({ el: audioInput, handler: recompute, type: "change" }); 20133 recompute(); 20134 }); 20135 } 20136 cleanup() { 20137 this.boundHandlers.forEach(({ el, handler, type, capture }) => el.removeEventListener(type || "click", handler, capture || false)); 20138 this.boundHandlers = []; 20139 this.unwrapColoris(); 20140 } 20141}; 20142var instance2 = null; 20143document.addEventListener("turbo:load", () => { 20144 instance2 = new SprksUI(); 20145 instance2.init(); 20146}); 20147document.addEventListener("turbo:before-cache", () => { 20148 if (instance2) { 20149 instance2.cleanup(); 20150 instance2 = null; 20151 } 20152}); 20153
20154// app/javascript/invitations.js 20155var InvitationsManager = class { 20156 constructor() { 20157 this.inviteForm = document.getElementById("invite-form"); 20158 this.invitationLinkInput = document.getElementById("invitation-link"); 20159 this.invitationLinkContainer = document.getElementById("invitation-link-container"); 20160 this.init(); 20161 } 20162 init() { 20163 if (!this.inviteForm) return; 20164 this.inviteForm.addEventListener("submit", (e) => this.handleInviteSubmit(e)); 20165 document.querySelectorAll("[data-copy-invitation-token]").forEach((button) => { 20166 button.addEventListener("click", (e) => { 20167 const token = e.currentTarget.dataset.copyInvitationToken; 20168 this.copyInvitationLinkFromTable(token); 20169 }); 20170 }); 20171 const copyButton = document.querySelector("[data-copy-invitation-link]"); 20172 if (copyButton) { 20173 copyButton.addEventListener("click", () => this.copyInvitationLink()); 20174 } 20175 } 20176 async handleInviteSubmit(e) { 20177 e.preventDefault(); 20178 const formData = new FormData(e.target); 20179 const email = formData.get("email"); 20180 const role = formData.get("role"); 20181 try { 20182 const response = await fetch("/settings/invite", { 20183 method: "POST", 20184 headers: { 20185 "Content-Type": "application/json", 20186 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 20187 }, 20188 body: JSON.stringify({ email, role }) 20189 }); 20190 const data = await response.json(); 20191 if (response.ok) { 20192 e.target.reset(); 20193 e.target.classList.add("hidden"); 20194 const invitationUrl = `${window.location.origin}/invite/${data.invitation.token}`; 20195 this.invitationLinkInput.value = invitationUrl; 20196 this.invitationLinkContainer.classList.remove("hidden"); 20197 } else { 20198 window.Toast.show(data.error || "Failed to create invitation", "alert"); 20199 } 20200 } catch (error) { 20201 console.error("Error creating invitation:", error); 20202 window.Toast.show("Failed to create invitation", "alert"); 20203 } 20204 } 20205 copyInvitationLink() { 20206 this.invitationLinkInput.select(); 20207 this.invitationLinkInput.setSelectionRange(0, 99999); 20208 navigator.clipboard.writeText(this.invitationLinkInput.value).then(() => { 20209 window.Toast.show("Link copied to clipboard", "success"); 20210 }).catch(() => { 20211 document.execCommand("copy"); 20212 window.Toast.show("Link copied to clipboard", "success"); 20213 }); 20214 } 20215 copyInvitationLinkFromTable(token) { 20216 const invitationUrl = `${window.location.origin}/invite/${token}`; 20217 navigator.clipboard.writeText(invitationUrl).then(() => { 20218 window.Toast.show("Link copied to clipboard", "success"); 20219 }).catch(() => { 20220 const tempInput = document.createElement("input"); 20221 tempInput.value = invitationUrl; 20222 document.body.appendChild(tempInput); 20223 tempInput.select(); 20224 document.execCommand("copy"); 20225 document.body.removeChild(tempInput); 20226 window.Toast.show("Link copied to clipboard", "success"); 20227 }); 20228 } 20229 cleanup() { 20230 } 20231}; 20232document.addEventListener("turbo:load", () => { 20233 if (document.getElementById("invite-form")) { 20234 window.invitationsManager = new InvitationsManager(); 20235 } 20236}); 20237document.addEventListener("turbo:before-cache", () => { 20238 if (window.invitationsManager) { 20239 window.invitationsManager.cleanup(); 20240 window.invitationsManager = null; 20241 } 20242}); 20243
20244// app/javascript/feedback.js 20245document.addEventListener("submit", async (e) => { 20246 const form = e.target.closest("#feedback-form"); 20247 if (!form) return; 20248 e.preventDefault(); 20249 const submitBtn = document.getElementById("submit-feedback-btn"); 20250 const formData = new FormData(form); 20251 const feedbackType = formData.get("feedback_type"); 20252 const content = formData.get("content"); 20253 submitBtn.disabled = true; 20254 submitBtn.textContent = "Sending..."; 20255 try { 20256 const response = await fetch("/feedbacks", { 20257 method: "POST", 20258 headers: { 20259 "Content-Type": "application/json", 20260 "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content 20261 }, 20262 body: JSON.stringify({ 20263 feedback: { 20264 feedback_type: feedbackType, 20265 content 20266 } 20267 }) 20268 }); 20269 const data = await response.json(); 20270 if (response.ok) { 20271 if (window.Toast) { 20272 window.Toast.show(data.message, "success"); 20273 } 20274 if (window.Modal) { 20275 window.Modal.close("feedback-modal"); 20276 } 20277 form.reset(); 20278 } else { 20279 if (window.Toast) { 20280 window.Toast.show(data.error || "Failed to send feedback", "alert"); 20281 } 20282 } 20283 } catch (error) { 20284 console.error("Error submitting feedback:", error); 20285 if (window.Toast) { 20286 window.Toast.show("Failed to send feedback. Please try again.", "alert"); 20287 } 20288 } finally { 20289 submitBtn.disabled = false; 20290 submitBtn.textContent = "Send Feedback"; 20291 } 20292}); 20293
20294// app/javascript/roadmap.js 20295var RoadmapNotification = class { 20296 constructor() { 20297 this.storageKey = "roadmap-version"; 20298 this.init(); 20299 } 20300 init() { 20301 this.checkNotification(); 20302 document.addEventListener("click", (e) => { 20303 const trigger = e.target.closest('[data-modal-trigger="feedback"]'); 20304 if (trigger) { 20305 this.markAsSeen(trigger); 20306 } 20307 }); 20308 } 20309 checkNotification() { 20310 const button = document.querySelector('[data-modal-trigger="feedback"]'); 20311 if (!button) return; 20312 const currentVersion = parseInt(button.dataset.roadmapVersion); 20313 if (!currentVersion) return; 20314 const lastSeenVersion = parseInt(localStorage.getItem(this.storageKey)) || 0; 20315 const icon = button.querySelector("[data-roadmap-icon]"); 20316 if (currentVersion > lastSeenVersion) { 20317 button.classList.remove("bg-inside"); 20318 button.classList.add("bg-active", "shadow-active"); 20319 if (icon) { 20320 icon.classList.remove("text-gray"); 20321 icon.classList.add("text-white"); 20322 } 20323 } else { 20324 button.classList.remove("bg-active", "shadow-active"); 20325 button.classList.add("bg-inside"); 20326 if (icon) { 20327 icon.classList.remove("text-white"); 20328 icon.classList.add("text-gray"); 20329 } 20330 } 20331 } 20332 markAsSeen(button) { 20333 const currentVersion = button.dataset.roadmapVersion; 20334 if (!currentVersion) return; 20335 localStorage.setItem(this.storageKey, currentVersion); 20336 button.classList.remove("bg-active", "shadow-active"); 20337 button.classList.add("bg-inside"); 20338 const icon = button.querySelector("[data-roadmap-icon]"); 20339 if (icon) { 20340 icon.classList.remove("text-white"); 20341 icon.classList.add("text-gray"); 20342 } 20343 } 20344}; 20345document.addEventListener("turbo:load", () => { 20346 new RoadmapNotification(); 20347}); 20348
20349// app/javascript/mobile.js 20350var MobileMenu = class { 20351 constructor() { 20352 this.panel = document.getElementById("mobile-menu-panel"); 20353 this.content = document.getElementById("mobile-menu-content"); 20354 this.backdrop = document.getElementById("mobile-menu-backdrop"); 20355 this.toggleButton = document.getElementById("mobile-menu-toggle"); 20356 this.closeButton = document.getElementById("mobile-menu-close"); 20357 this.init(); 20358 } 20359 init() { 20360 if (!this.panel) return; 20361 this.toggleButton?.addEventListener("click", () => this.open()); 20362 this.closeButton?.addEventListener("click", () => this.close()); 20363 this.backdrop?.addEventListener("click", () => this.close()); 20364 document.addEventListener("keydown", (e) => { 20365 if (e.key === "Escape" && this.isOpen()) { 20366 this.close(); 20367 } 20368 }); 20369 const menuLinks = this.panel.querySelectorAll("nav a"); 20370 menuLinks.forEach((link) => { 20371 link.addEventListener("click", () => this.close()); 20372 }); 20373 } 20374 open() { 20375 this.panel.classList.add("pointer-events-auto"); 20376 this.backdrop.classList.add("pointer-events-auto"); 20377 this.content.classList.add("pointer-events-auto"); 20378 requestAnimationFrame(() => { 20379 this.backdrop.classList.remove("opacity-0"); 20380 this.backdrop.classList.add("opacity-100"); 20381 this.content.classList.remove("translate-x-full"); 20382 this.content.classList.add("translate-x-0"); 20383 }); 20384 document.body.style.overflow = "hidden"; 20385 } 20386 close() { 20387 this.backdrop.classList.remove("opacity-100"); 20388 this.backdrop.classList.add("opacity-0"); 20389 this.content.classList.remove("translate-x-0"); 20390 this.content.classList.add("translate-x-full"); 20391 document.body.style.overflow = ""; 20392 setTimeout(() => { 20393 this.panel.classList.remove("pointer-events-auto"); 20394 this.backdrop.classList.remove("pointer-events-auto"); 20395 this.content.classList.remove("pointer-events-auto"); 20396 }, 300); 20397 } 20398 isOpen() { 20399 return this.panel.classList.contains("pointer-events-auto"); 20400 } 20401}; 20402var mobileMenuInstance = null; 20403document.addEventListener("turbo:load", () => { 20404 mobileMenuInstance = new MobileMenu(); 20405}); 20406window.closeMobileMenu = function() { 20407 if (mobileMenuInstance) { 20408 mobileMenuInstance.close(); 20409 } 20410}; 20411document.addEventListener("turbo:before-cache", () => { 20412 const panel = document.getElementById("mobile-menu-panel"); 20413 if (panel) { 20414 panel.classList.remove("pointer-events-auto"); 20415 const backdrop = document.getElementById("mobile-menu-backdrop"); 20416 const content = document.getElementById("mobile-menu-content"); 20417 backdrop?.classList.remove("opacity-100"); 20418 backdrop?.classList.add("opacity-0"); 20419 backdrop?.classList.remove("pointer-events-auto"); 20420 content?.classList.remove("translate-x-0"); 20421 content?.classList.add("translate-x-full"); 20422 content?.classList.remove("pointer-events-auto"); 20423 document.body.style.overflow = ""; 20424 } 20425}); 20426
20427// app/javascript/dropdowns.js 20428var dropdownsInitialized = false; 20429document.addEventListener("turbo:load", () => { 20430 initDropdowns(); 20431}); 20432function initDropdowns() { 20433 if (!dropdownsInitialized) { 20434 dropdownsInitialized = true; 20435 document.addEventListener("click", (e) => { 20436 const trigger = e.target.closest("[data-dropdown-trigger]"); 20437 if (trigger) { 20438 e.stopPropagation(); 20439 const dropdownId = trigger.dataset.dropdownTrigger; 20440 const dropdown = document.querySelector(`[data-dropdown="${dropdownId}"]`); 20441 if (!dropdown) return; 20442 document.querySelectorAll("[data-dropdown]").forEach((d) => { 20443 if (d !== dropdown) { 20444 d.classList.remove("open"); 20445 } 20446 }); 20447 dropdown.classList.toggle("open"); 20448 updateSidebarVisibility(); 20449 } else { 20450 const isClickInside = e.target.closest("[data-dropdown]"); 20451 if (!isClickInside) { 20452 document.querySelectorAll("[data-dropdown]").forEach((dropdown) => { 20453 dropdown.classList.remove("open"); 20454 }); 20455 updateSidebarVisibility(); 20456 } 20457 } 20458 }); 20459 } 20460} 20461function updateSidebarVisibility() { 20462 const sidebar = document.querySelector(".sidebar-nav"); 20463 const balanceDropdown = document.querySelector('[data-dropdown="balance"]'); 20464 const projectDropdown = null; 20465 if (!sidebar) return; 20466 sidebar.style.transition = "opacity 200ms ease"; 20467 const navItems = sidebar.querySelector("[data-sidebar-nav-items]"); 20468 const navBottom = sidebar.querySelector("[data-sidebar-nav-bottom]"); 20469 if (balanceDropdown && balanceDropdown.classList.contains("open")) { 20470 sidebar.style.opacity = "0"; 20471 sidebar.style.pointerEvents = "none"; 20472 } else if (projectDropdown && projectDropdown.classList.contains("open")) { 20473 sidebar.style.opacity = "1"; 20474 sidebar.style.pointerEvents = "auto"; 20475 if (navItems) { 20476 navItems.style.transition = "opacity 200ms ease"; 20477 navItems.style.opacity = "0"; 20478 navItems.style.pointerEvents = "none"; 20479 } 20480 if (navBottom) { 20481 navBottom.style.transition = "opacity 200ms ease"; 20482 navBottom.style.opacity = "0"; 20483 navBottom.style.pointerEvents = "none"; 20484 } 20485 } else { 20486 sidebar.style.opacity = "1"; 20487 sidebar.style.pointerEvents = "auto"; 20488 if (navItems) { 20489 navItems.style.opacity = "1"; 20490 navItems.style.pointerEvents = "auto"; 20491 } 20492 if (navBottom) { 20493 navBottom.style.opacity = "1"; 20494 navBottom.style.pointerEvents = "auto"; 20495 } 20496 } 20497} 20498
20499// app/javascript/wallet.js 20500var WalletManager = class { 20501 constructor() { 20502 const packageButtons = document.querySelectorAll(".package-btn"); 20503 if (packageButtons.length === 0) { 20504 return; 20505 } 20506 this.packageButtons = packageButtons; 20507 this.init(); 20508 } 20509 init() { 20510 this.attachEventListeners(); 20511 this.handlePaymentReturn(); 20512 } 20513 attachEventListeners() { 20514 this.packageButtons.forEach((btn) => { 20515 btn.addEventListener("click", (e) => this.selectPackage(e)); 20516 }); 20517 } 20518 async selectPackage(e) { 20519 const button = e.currentTarget; 20520 const packagesContainer = document.querySelector("[data-can-purchase]"); 20521 const canPurchase = packagesContainer?.dataset.canPurchase === "true"; 20522 if (!canPurchase) { 20523 this.showErrorToast("Only team owners can add funds to the wallet"); 20524 return; 20525 } 20526 this.selectedPackageId = button.dataset.packageId; 20527 button.disabled = true; 20528 button.classList.add("opacity-50"); 20529 const originalText = button.innerHTML; 20530 button.innerHTML = '<div class="flex items-center justify-center"><svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>'; 20531 try { 20532 const response = await fetch("/settings/create-payment-intent", { 20533 method: "POST", 20534 headers: { 20535 "Content-Type": "application/json", 20536 "X-CSRF-Token": this.getCsrfToken() 20537 }, 20538 body: JSON.stringify({ 20539 package_id: this.selectedPackageId 20540 }) 20541 }); 20542 if (!response.ok) { 20543 const error = await response.json(); 20544 throw new Error(error.error || "Failed to create checkout session"); 20545 } 20546 const { checkout_url } = await response.json(); 20547 window.location.href = checkout_url; 20548 } catch (error) { 20549 console.error("Error creating checkout session:", error); 20550 this.showErrorToast(error.message); 20551 button.disabled = false; 20552 button.classList.remove("opacity-50"); 20553 button.innerHTML = originalText; 20554 } 20555 } 20556 async handlePaymentReturn() { 20557 const urlParams = new URLSearchParams(window.location.search); 20558 const sessionId = urlParams.get("session_id"); 20559 if (!sessionId) { 20560 return; 20561 } 20562 const cleanUrl = window.location.pathname; 20563 window.history.replaceState({}, "", cleanUrl); 20564 this.showSuccessToast("Payment successful! Your plan was updated."); 20565 setTimeout(() => { 20566 window.location.reload(); 20567 }, 1500); 20568 } 20569 getCsrfToken() { 20570 return document.querySelector('meta[name="csrf-token"]')?.content || ""; 20571 } 20572 // Toast notification helpers (use global Toast if available) 20573 showSuccessToast(message) { 20574 if (window.Toast) { 20575 window.Toast.show(message, "success"); 20576 } else { 20577 console.log("Success:", message); 20578 alert(message); 20579 } 20580 } 20581 showErrorToast(message) { 20582 if (window.Toast) { 20583 window.Toast.show(message, "alert"); 20584 } else { 20585 console.error("Error:", message); 20586 alert(message); 20587 } 20588 } 20589 showInfoToast(message) { 20590 if (window.Toast) { 20591 window.Toast.show(message, "notice"); 20592 } else { 20593 console.log("Info:", message); 20594 } 20595 } 20596}; 20597window.WalletManager = WalletManager; 20598var walletManagerInstance = null; 20599document.addEventListener("turbo:load", () => { 20600 walletManagerInstance = new WalletManager(); 20601 window.walletManager = walletManagerInstance; 20602}); 20603
20604// app/javascript/theme.js 20605var THEME_KEY = "theme"; 20606var FORCE_DARK = true; 20607function getSystemTheme() { 20608 return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"; 20609} 20610function getStoredTheme() { 20611 return localStorage.getItem(THEME_KEY); 20612} 20613function setTheme(theme) { 20614 if (theme === "light") { 20615 document.documentElement.classList.add("light"); 20616 } else { 20617 document.documentElement.classList.remove("light"); 20618 } 20619} 20620function getCurrentTheme() { 20621 if (FORCE_DARK) return "dark"; 20622 const stored = getStoredTheme(); 20623 if (stored === "light" || stored === "dark") { 20624 return stored; 20625 } 20626 return getSystemTheme(); 20627} 20628function toggleTheme() { 20629 if (FORCE_DARK) return; 20630 const current = getCurrentTheme(); 20631 const newTheme = current === "light" ? "dark" : "light"; 20632 localStorage.setItem(THEME_KEY, newTheme); 20633 setTheme(newTheme); 20634 updateToggleIcon(); 20635} 20636function updateToggleIcon() { 20637 const icon = document.getElementById("theme-toggle-icon"); 20638 if (!icon) return; 20639 const current = getCurrentTheme(); 20640 if (current === "light") { 20641 icon.innerHTML = `<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>`; 20642 } else { 20643 icon.innerHTML = `<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>`; 20644 } 20645} 20646(function() { 20647 setTheme(getCurrentTheme()); 20648})(); 20649window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (e) => { 20650 if (!getStoredTheme()) { 20651 setTheme(e.matches ? "light" : "dark"); 20652 updateToggleIcon(); 20653 } 20654}); 20655document.addEventListener("turbo:load", () => { 20656 updateToggleIcon(); 20657}); 20658window.toggleTheme = toggleTheme; 20659
20660// app/javascript/project/mentions.js 20661var MentionsMixin2 = { 20662 initMentions() { 20663 this._mentionPopup = null; 20664 this._mentionSearchText = ""; 20665 this._mentionStartOffset = 0; 20666 this._mentionSelectedIndex = 0; 20667 this._mentionMediaIndex = -1; 20668 this._mentionEditor = null; 20669 this._mentionElements = null; 20670 this._mentionActiveElement = null; 20671 this._mentionFocus = "elements"; 20672 }, 20673 bindMentions(editor) { 20674 if (!editor) return; 20675 editor.addEventListener("input", () => this._handleMentionInput(editor)); 20676 editor.addEventListener("keydown", (e) => { 20677 if (this._mentionPopup && this._mentionPopup.style.display !== "none") { 20678 this._handleMentionKeydown(e, editor); 20679 } 20680 }); 20681 editor.addEventListener("blur", () => { 20682 setTimeout(() => { 20683 if (this._mentionPopupHovered) return; 20684 this._closeMentionPopup(); 20685 }, 200); 20686 }); 20687 this._bindExistingMentions(editor); 20688 }, 20689 _bindExistingMentions(editor) { 20690 if (!editor) return; 20691 const elements = this._getAvailableElements(); 20692 if (!elements.length) return; 20693 editor.querySelectorAll(".mention").forEach((mention) => { 20694 if (mention.dataset.mentionType === "element") { 20695 const textNode = document.createTextNode(mention.textContent || ""); 20696 mention.parentNode.replaceChild(textNode, mention); 20697 return; 20698 } 20699 const slug = mention.dataset.mentionSlug || ""; 20700 const mediaId = mention.dataset.mediaId; 20701 const emUuid = slug.split("-")[0]; 20702 let matchedElement = null; 20703 let matchedMedia = null; 20704 for (const el of elements) { 20705 let m = (el.media || []).find((m2) => m2.em_uuid === emUuid); 20706 if (!m) m = (el.media || []).find((m2) => slug.includes(`${el.slug}-${m2.label}`)); 20707 if (m) { 20708 matchedElement = el; 20709 matchedMedia = m; 20710 break; 20711 } 20712 } 20713 const isAudio = matchedMedia && ["voice", "sound"].includes(matchedMedia.role); 20714 const isCustom = matchedMedia && matchedMedia.role === "custom"; 20715 const audioUrl = isAudio ? matchedMedia.file_url || matchedMedia.preview_url : null; 20716 const iconWrap = mention.querySelector(".mention-audio-icon"); 20717 if (isAudio && iconWrap && audioUrl) { 20718 this._bindAudioIcon(iconWrap, audioUrl); 20719 } else if (isCustom && matchedElement && matchedMedia) { 20720 mention.style.cursor = "pointer"; 20721 mention.addEventListener("click", (e) => { 20722 e.preventDefault(); 20723 e.stopPropagation(); 20724 this._openEditCustomModal(mention, matchedElement, matchedMedia); 20725 }); 20726 } else if (mediaId) { 20727 mention.style.cursor = "pointer"; 20728 mention.addEventListener("click", (e) => { 20729 e.preventDefault(); 20730 e.stopPropagation(); 20731 if (window.Modal) window.Modal.showMedia(mediaId); 20732 }); 20733 } 20734 }); 20735 }, 20736 // ââ Data ââââââââââââââââââââââââââââââââââââââââââââââââââ 20737 _getAvailableElements() { 20738 if (this._mentionElements) return this._mentionElements; 20739 const el = document.getElementById("project-elements-data"); 20740 if (!el) return []; 20741 try { 20742 this._mentionElements = JSON.parse(el.textContent) || []; 20743 } catch { 20744 this._mentionElements = []; 20745 } 20746 return this._mentionElements; 20747 }, 20748 _filterElements(elements, searchText) { 20749 if (!searchText) return elements; 20750 const lower = searchText.toLowerCase(); 20751 return elements.filter( 20752 (el) => el.slug.toLowerCase().includes(lower) || el.name.toLowerCase().includes(lower) 20753 ); 20754 }, 20755 // ââ Input Detection âââââââââââââââââââââââââââââââââââââââ 20756 _handleMentionInput(editor) { 20757 if (this._mentionInserting) return; 20758 const selection = window.getSelection(); 20759 if (!selection.rangeCount) return; 20760 const text = editor.textContent || ""; 20761 const cursorPos = this._getMentionCursorPos(editor); 20762 const textBeforeCursor = text.substring(0, cursorPos); 20763 const lastAtIndex = textBeforeCursor.lastIndexOf("@"); 20764 if (lastAtIndex === -1) { 20765 this._closeMentionPopup(); 20766 return; 20767 } 20768 const charBefore = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : " "; 20769 if (!/\s/.test(charBefore) && lastAtIndex !== 0) { 20770 this._closeMentionPopup(); 20771 return; 20772 } 20773 if (this._isAtInsideMention(editor, lastAtIndex)) { 20774 this._closeMentionPopup(); 20775 return; 20776 } 20777 const searchText = textBeforeCursor.substring(lastAtIndex + 1); 20778 if (searchText.includes(" ") || searchText.includes("\n")) { 20779 this._closeMentionPopup(); 20780 return; 20781 } 20782 this._mentionSearchText = searchText; 20783 this._mentionStartOffset = lastAtIndex; 20784 this._mentionEditor = editor; 20785 const elements = this._getAvailableElements(); 20786 const filtered = this._filterElements(elements, searchText); 20787 if (filtered.length > 0) { 20788 this._showPopup(filtered, editor); 20789 } else { 20790 this._closeMentionPopup(); 20791 } 20792 }, 20793 // ââ Cursor Helpers ââââââââââââââââââââââââââââââââââââââââ 20794 _getMentionCursorPos(editor) { 20795 const selection = window.getSelection(); 20796 if (!selection.rangeCount) return 0; 20797 const range = selection.getRangeAt(0); 20798 const pre = range.cloneRange(); 20799 pre.selectNodeContents(editor); 20800 pre.setEnd(range.endContainer, range.endOffset); 20801 return pre.toString().length; 20802 }, 20803 _isAtInsideMention(editor, atTextPosition) { 20804 let pos = 0; 20805 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 20806 let node; 20807 while (node = walker.nextNode()) { 20808 const len = node.textContent.length; 20809 if (pos + len > atTextPosition) { 20810 let p = node.parentElement; 20811 while (p && p !== editor) { 20812 if (p.classList?.contains("mention")) return true; 20813 p = p.parentElement; 20814 } 20815 return false;
20816 } 20817 pos += len; 20818 } 20819 return false; 20820 }, 20821 // ââ Popup Structure âââââââââââââââââââââââââââââââââââââââ 20822 _ensurePopup() { 20823 if (this._mentionPopup) return; 20824 this._mentionPopup = document.createElement("div"); 20825 this._mentionPopup.className = "mention-popup mention-explorer"; 20826 this._mentionPopup.style.display = "none"; 20827 this._mentionPopup.innerHTML = ` 20828 <div class="mention-col mention-col-elements" data-panel="elements"> 20829 <div class="mention-popup-header">Elements</div> 20830 <div class="mention-popup-list"></div> 20831 </div> 20832 <div class="mention-col mention-col-media" data-panel="media"> 20833 <div class="mention-popup-header" data-media-panel-title></div> 20834 <div class="mention-popup-list"></div> 20835 </div> 20836 <div class="mention-col mention-col-preview" data-panel="preview"></div> 20837 `; 20838 document.body.appendChild(this._mentionPopup); 20839 this._mentionPopupHovered = false; 20840 this._mentionPopup.addEventListener("mouseenter", () => { 20841 this._mentionPopupHovered = true; 20842 }); 20843 this._mentionPopup.addEventListener("mouseleave", () => { 20844 this._mentionPopupHovered = false; 20845 }); 20846 }, 20847 // ââ Show / Update âââââââââââââââââââââââââââââââââââââââââ 20848 _showPopup(elements, editor) { 20849 this._ensurePopup(); 20850 this._mentionFocus = "elements"; 20851 this._mentionMediaIndex = -1; 20852 this._renderElementsList(elements); 20853 this._clearMediaPanel(); 20854 this._clearPreview(); 20855 this._positionMentionPopup(editor); 20856 this._mentionPopup.style.display = "flex"; 20857 this._mentionSelectedIndex = 0; 20858 this._updateElementsSelection(elements); 20859 }, 20860 _renderElementsList(elements) { 20861 const list = this._mentionPopup.querySelector('[data-panel="elements"] .mention-popup-list'); 20862 list.innerHTML = ""; 20863 elements.forEach((el, index) => { 20864 const item = document.createElement("div"); 20865 item.className = "mention-popup-item"; 20866 item.dataset.index = index; 20867 const thumbUrl = el.thumbnail_url; 20868 const thumbHtml = thumbUrl ? `<img src="${thumbUrl}" alt="${el.name}">` : `<div class="w-full h-full flex items-center justify-center text-gray text-[10px]">${el.name.charAt(0).toUpperCase()}</div>`; 20869 const typeLabel = el.element_type || "element"; 20870 const mediaCount = el.media?.length || 0; 20871 item.innerHTML = ` 20872 <div class="mention-popup-thumbnail">${thumbHtml}</div> 20873 <div class="mention-popup-info"> 20874 <span class="mention-popup-label">@${el.slug}</span> 20875 <span class="mention-popup-prompt">${el.name} · ${typeLabel}${mediaCount > 0 ? ` · ${mediaCount}` : ""}</span> 20876 </div> 20877 `; 20878 item.addEventListener("mouseenter", () => { 20879 this._mentionSelectedIndex = index; 20880 this._updateElementsSelection(elements); 20881 this._showMediaPanel(el); 20882 }); 20883 item.addEventListener("click", () => { 20884 this._mentionFocus = "media"; 20885 this._mentionActiveElement = el; 20886 this._mentionMediaIndex = 0; 20887 this._showMediaPanel(el); 20888 this._updateMediaSelection(); 20889 }); 20890 list.appendChild(item); 20891 }); 20892 }, 20893 _showMediaPanel(element) { 20894 this._mentionActiveElement = element; 20895 const title = this._mentionPopup.querySelector("[data-media-panel-title]"); 20896 title.textContent = `@${element.slug}`; 20897 const list = this._mentionPopup.querySelector('[data-panel="media"] .mention-popup-list'); 20898 list.innerHTML = ""; 20899 const isVideo = this._editBarMode === "video"; 20900 const mediaItems = (element.media || []).filter( 20901 (m) => isVideo || !["voice", "sound", "custom"].includes(m.role) 20902 ).sort((a, b) => (b.role === "custom") - (a.role === "custom")); 20903 if (mediaItems.length === 0) { 20904 list.innerHTML = '<div class="px-3 py-4 text-[10px] text-gray text-center">No media</div>'; 20905 this._clearPreview(); 20906 return; 20907 } 20908 mediaItems.forEach((m, index) => { 20909 const item = document.createElement("div"); 20910 item.className = "mention-popup-item"; 20911 if (this._mentionFocus === "media" && index === this._mentionMediaIndex) { 20912 item.classList.add("selected"); 20913 } 20914 item.dataset.index = index; 20915 const mentionLabel = `${element.slug}-${m.label}`; 20916 const isAudio = ["voice", "sound"].includes(m.role); 20917 const isCustom = m.role === "custom"; 20918 const thumbHtml = isAudio ? `<div class="w-full h-full flex items-center justify-center bg-dark rounded"><svg class="w-3 h-3 text-gray" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></svg></div>` : m.thumbnail_url ? `<img src="${m.thumbnail_url}" alt="${m.label}">` : `<div class="w-full h-full flex items-center justify-center text-gray text-[10px]">${m.role.charAt(0).toUpperCase()}</div>`; 20919 const subtitle = isCustom ? `kling element` : `${m.role}${m.version ? ` v${m.version}` : ""}`; 20920 item.innerHTML = ` 20921 <div class="mention-popup-thumbnail mention-popup-thumbnail-sm">${thumbHtml}</div> 20922 <div class="mention-popup-info">
20923 <span class="mention-popup-label">@${mentionLabel}</span> 20924 <span class="mention-popup-prompt">${subtitle}</span> 20925 </div> 20926 ${isCustom ? '<img src="/kling.png" style="width:11px;height:11px;margin-left:auto;margin-right:4px;flex-shrink:0;">' : ""} 20927 `; 20928 item.addEventListener("mouseenter", () => { 20929 this._mentionFocus = "media"; 20930 this._mentionMediaIndex = index; 20931 this._updateMediaSelection(); 20932 this._showPreview(m.preview_url || m.thumbnail_url, mentionLabel, m.media_id, element, m); 20933 }); 20934 item.addEventListener("click", () => { 20935 this._insertMention(this._mentionEditor, element, m); 20936 }); 20937 list.appendChild(item); 20938 }); 20939 if (isVideo) { 20940 const createBtn = document.createElement("div"); 20941 createBtn.className = "px-3 py-2 text-[10px] text-active text-center cursor-pointer hover:underline"; 20942 createBtn.textContent = "+ Create Kling Element"; 20943 createBtn.addEventListener("click", (e) => { 20944 e.preventDefault(); 20945 e.stopPropagation(); 20946 this._openCreateCustomModal(element); 20947 }); 20948 list.appendChild(createBtn); 20949 } 20950 if (this._mentionFocus === "media" && mediaItems[this._mentionMediaIndex]) { 20951 const m = mediaItems[this._mentionMediaIndex]; 20952 this._showPreview(m.preview_url || m.thumbnail_url, `${element.slug}-${m.label}`, m.media_id, element, m); 20953 } else if (mediaItems[0]) { 20954 const m = mediaItems[0]; 20955 this._showPreview(m.preview_url || m.thumbnail_url, `${element.slug}-${m.label}`, m.media_id, element, m); 20956 } 20957 }, 20958 _showPreview(url, label, mediaId, element, mediaItem) { 20959 const panel = this._mentionPopup.querySelector('[data-panel="preview"]'); 20960 const isAudio = mediaItem && ["voice", "sound"].includes(mediaItem.role); 20961 const isCustom = mediaItem && mediaItem.role === "custom"; 20962 if (!isAudio && !url) { 20963 panel.innerHTML = ""; 20964 return; 20965 } 20966 if (isAudio) { 20967 const audioUrl = mediaItem.file_url || mediaItem.preview_url || url; 20968 panel.innerHTML = ` 20969 <div class="mention-preview-name">@${label}</div> 20970 <div class="mention-preview-center"> 20971 <button class="mention-audio-play" data-action="play"> 20972 <svg class="w-6 h-6 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> 20973 </button> 20974 </div> 20975 <div class="mention-preview-actions"> 20976 <button class="mention-preview-btn mention-preview-btn-select" data-action="select">Select</button> 20977 </div> 20978 `; 20979 panel.querySelector('[data-action="play"]')?.addEventListener("click", (e) => { 20980 e.preventDefault(); 20981 e.stopPropagation(); 20982 if (this._mentionAudio) { 20983 if (this._mentionAudio._iconWrap) this._mentionAudio._iconWrap.innerHTML = this._audioLinesSvg; 20984 this._mentionAudio.pause(); 20985 this._mentionAudio = null; 20986 } 20987 this._mentionAudio = new Audio(audioUrl); 20988 this._mentionAudio.play(); 20989 }); 20990 } else if (isCustom) { 20991 const refThumbs = (mediaItem.reference_thumbnails || []).slice(0, 6); 20992 const refHtml = refThumbs.map( 20993 (t) => `<img src="${t}" style="width:36px;height:36px;object-fit:cover;border-radius:3px;">` 20994 ).join(""); 20995 panel.innerHTML = ` 20996 <div class="mention-preview-name">@${label}</div> 20997 <div class="mention-preview-center" style="flex-direction:column;gap:6px;"> 20998 <img src="${url}" alt="${label}" class="mention-preview-img" style="max-height:80px;" /> 20999 ${refThumbs.length > 0 ? `<div style="display:flex;gap:3px;flex-wrap:wrap;justify-content:center;">${refHtml}</div>` : ""} 21000 </div> 21001 <div class="mention-preview-actions"> 21002 <button class="mention-preview-btn mention-preview-btn-select" data-action="select">Select</button> 21003 </div> 21004 `; 21005 } else { 21006 panel.innerHTML = ` 21007 <div class="mention-preview-name">@${label}</div> 21008 <div class="mention-preview-center"> 21009 <div class="mention-preview-loader"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="31.4 31.4" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" dur="0.8s" from="0 12 12" to="360 12 12" repeatCount="indef
21009inite"/></circle></svg></div> 21010 <img src="${url}" alt="${label}" class="mention-preview-img" /> 21011 </div> 21012 <div class="mention-preview-actions"> 21013 ${mediaId ? `<button class="mention-preview-btn" data-action="view" data-media-id="${mediaId}">View</button>` : ""} 21014 <button class="mention-preview-btn mention-preview-btn-select" data-action="select">Select</button> 21015 </div> 21016 `; 21017 const img = panel.querySelector(".mention-preview-img"); 21018 const loader = panel.querySelector(".mention-preview-loader"); 21019 img.addEventListener("load", () => { 21020 loader.style.display = "none"; 21021 }); 21022 img.addEventListener("error", () => { 21023 loader.style.display = "none"; 21024 }); 21025 panel.querySelector('[data-action="view"]')?.addEventListener("click", (e) => { 21026 e.preventDefault(); 21027 e.stopPropagation(); 21028 if (window.Modal) window.Modal.showMedia(e.target.dataset.mediaId); 21029 }); 21030 } 21031 panel.querySelector('[data-action="select"]')?.addEventListener("click", (e) => { 21032 e.preventDefault(); 21033 e.stopPropagation(); 21034 if (element && mediaItem) this._insertMention(this._mentionEditor, element, mediaItem); 21035 }); 21036 }, 21037 _clearMediaPanel() { 21038 const title = this._mentionPopup.querySelector("[data-media-panel-title]"); 21039 if (title) title.textContent = ""; 21040 const list = this._mentionPopup.querySelector('[data-panel="media"] .mention-popup-list'); 21041 if (list) list.innerHTML = '<div class="px-3 py-6 text-[10px] text-gray text-center">Hover an element</div>'; 21042 }, 21043 _clearPreview() { 21044 const panel = this._mentionPopup.querySelector('[data-panel="preview"]'); 21045 if (panel) panel.innerHTML = ""; 21046 }, 21047 // ââ Selection Highlights ââââââââââââââââââââââââââââââââââ 21048 _updateElementsSelection(elements) { 21049 const items = this._mentionPopup.querySelectorAll('[data-panel="elements"] .mention-popup-item'); 21050 items.forEach((item, i) => { 21051 item.classList.toggle("selected", i === this._mentionSelectedIndex); 21052 if (i === this._mentionSelectedIndex) item.scrollIntoView({ block: "nearest" }); 21053 }); 21054 if (elements?.[this._mentionSelectedIndex]) { 21055 this._showMediaPanel(elements[this._mentionSelectedIndex]); 21056 } 21057 }, 21058 _updateMediaSelection() { 21059 const items = this._mentionPopup.querySelectorAll('[data-panel="media"] .mention-popup-item'); 21060 items.forEach((item, i) => { 21061 item.classList.toggle("selected", this._mentionFocus === "media" && i === this._mentionMediaIndex); 21062 if (this._mentionFocus === "media" && i === this._mentionMediaIndex) item.scrollIntoView({ block: "nearest" }); 21063 }); 21064 }, 21065 // ââ Position ââââââââââââââââââââââââââââââââââââââââââââââ 21066 _positionMentionPopup(editor) { 21067 if (!this._mentionPopup || !editor) return; 21068 const bar = editor.closest("[data-project-edit-bar]")?.querySelector(".relative"); 21069 const barRect = bar ? bar.getBoundingClientRect() : editor.getBoundingClientRect(); 21070 const popupWidth = 720; 21071 let top = barRect.top - 8; 21072 let left = barRect.left + (barRect.width - popupWidth) / 2; 21073 if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10; 21074 if (left < 10) left = 10; 21075 this._mentionPopup.style.bottom = `${window.innerHeight - top}px`; 21076 this._mentionPopup.style.top = "auto"; 21077 this._mentionPopup.style.left = `${left}px`; 21078 }, 21079 // ââ Keyboard ââââââââââââââââââââââââââââââââââââââââââââââ 21080 _handleMentionKeydown(e, editor) { 21081 const elements = this._getAvailableElements(); 21082 const filtered = this._filterElements(elements, this._mentionSearchText); 21083 if (this._mentionFocus === "elements") { 21084 const count = filtered.length; 21085 if (!count) return; 21086 switch (e.key) { 21087 case "ArrowDown": 21088 e.preventDefault(); 21089 this._mentionSelectedIndex = (this._mentionSelectedIndex + 1) % count; 21090 this._updateElementsSelection(filtered); 21091 break; 21092 case "ArrowUp": 21093 e.preventDefault(); 21094 this._mentionSelectedIndex = (this._mentionSelectedIndex - 1 + count) % count; 21095 this._updateElementsSelection(filtered); 21096 break; 21097 case "ArrowRight": 21098 case "Enter": 21099 case "Tab": 21100 e.preventDefault(); 21101 if (filtered[this._mentionSelectedIndex]) { 21102 this._mentionFocus = "media"; 21103 this._mentionActiveElement = filtered[this._mentionSelectedIndex]; 21104 this._mentionMediaIndex = 0; 21105 this._showMediaPanel(this._mentionActiveElement); 21106 this._updateMediaSelection(); 21107 } 21108 break; 21109 case "Escape": 21110 e.preventDefault(); 21111 this._closeMentionPopup(); 21112 break; 21113 } 21114 } else { 21115 const el = this._mentionActiveElement; 21116 const media = el?.media || []; 21117 const isVideo = this._editBarMode === "video"; 21118 const filteredMedia = isVideo ? media : media.filter((m) => !["voice", "sound"].includes(m.role)); 21119 const totalCount = filteredMedia.length; 21120 if (!totalCount) return; 21121 switch (e.key) { 21122 case "ArrowDown": 21123 e.preventDefault(); 21124 this._mentionMediaIndex = (this._mentionMediaIndex + 1) % totalCount; 21125 this._updateMediaSelection(); 21126 this._showPreviewForIndex(el, filteredMedia); 21127 break; 21128 case "ArrowUp": 21129 e.preventDefault(); 21130 this._mentionMediaIndex = (this._mentionMediaIndex - 1 + totalCount) % totalCount; 21131 this._updateMediaSelection(); 21132 this._showPreviewForIndex(el, filteredMedia); 21133 break; 21134 case "Enter": 21135 case "Tab": 21136 e.preventDefault(); 21137 if (filteredMedia[this._mentionMediaIndex]) { 21138 this._insertMention(editor, el, filteredMedia[this._mentionMediaIndex]); 21139 } 21140 break; 21141 case "ArrowLeft": 21142 case "Escape": 21143 e.preventDefault(); 21144 this._mentionFocus = "elements"; 21145 this._mentionMediaIndex = -1; 21146 this._updateMediaSelection(); 21147 this._clearPreview(); 21148 break; 21149 } 21150 } 21151 }, 21152 // ââ Insert Mention ââââââââââââââââââââââââââââââââââââââââ 21153 _insertMention(editor, element, mediaItem) { 21154 if (!editor) return; 21155 const selection = window.getSelection(); 21156 if (!selection.rangeCount) return;
21157 let currentPos = 0; 21158 let targetNode = null; 21159 let atPositionInNode = 0; 21160 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 21161 let node; 21162 while (node = walker.nextNode()) { 21163 const len = node.textContent.length; 21164 if (currentPos + len > this._mentionStartOffset) { 21165 targetNode = node; 21166 atPositionInNode = this._mentionStartOffset - currentPos; 21167 break; 21168 } 21169 currentPos += len; 21170 } 21171 if (!targetNode) { 21172 this._closeMentionPopup(); 21173 return; 21174 } 21175 const deleteLength = 1 + this._mentionSearchText.length; 21176 const textBefore = targetNode.textContent.substring(0, atPositionInNode); 21177 const textAfter = targetNode.textContent.substring(atPositionInNode + deleteLength); 21178 const mentionDisplay = `${element.slug}-${mediaItem.label}`; 21179 const mentionSpan = document.createElement("span"); 21180 mentionSpan.className = "mention"; 21181 mentionSpan.contentEditable = "false"; 21182 mentionSpan.dataset.mentionSlug = `${mediaItem.em_uuid}-${mentionDisplay}`; 21183 if (mediaItem.media_id) mentionSpan.dataset.mediaId = mediaItem.media_id; 21184 const isAudio = ["voice", "sound"].includes(mediaItem.role); 21185 const isCustom = mediaItem.role === "custom"; 21186 if (isAudio) { 21187 const audioUrl = mediaItem.file_url || mediaItem.preview_url; 21188 const iconWrap = document.createElement("span"); 21189 iconWrap.className = "mention-audio-icon"; 21190 this._bindAudioIcon(iconWrap, audioUrl); 21191 mentionSpan.appendChild(iconWrap); 21192 } else if (mediaItem.thumbnail_url) { 21193 const img = document.createElement("img"); 21194 img.src = mediaItem.thumbnail_url; 21195 img.alt = mentionDisplay; 21196 mentionSpan.appendChild(img); 21197 } 21198 mentionSpan.appendChild(document.createTextNode(` @${mentionDisplay}`)); 21199 if (isCustom) { 21200 const cogIcon = document.createElement("span"); 21201 cogIcon.className = "mention-element-icon"; 21202 cogIcon.innerHTML = '<img src="/kling.png" style="width:12px;height:12px;">'; 21203 mentionSpan.appendChild(cogIcon); 21204 mentionSpan.style.cursor = "pointer"; 21205 mentionSpan.addEventListener("click", (e) => { 21206 e.preventDefault(); 21207 e.stopPropagation(); 21208 this._openEditCustomModal(mentionSpan, element, mediaItem); 21209 }); 21210 } else if (!isAudio && mediaItem.media_id) { 21211 mentionSpan.style.cursor = "pointer"; 21212 mentionSpan.addEventListener("click", (e) => { 21213 e.preventDefault(); 21214 e.stopPropagation(); 21215 if (window.Modal) window.Modal.showMedia(mentionSpan.dataset.mediaId); 21216 }); 21217 } 21218 const spaceNode = document.createTextNode("\xA0"); 21219 const parent = targetNode.parentNode; 21220 if (textBefore) parent.insertBefore(document.createTextNode(textBefore), targetNode); 21221 parent.insertBefore(mentionSpan, targetNode); 21222 parent.insertBefore(spaceNode, targetNode); 21223 if (textAfter) parent.insertBefore(document.createTextNode(textAfter), targetNode); 21224 parent.removeChild(targetNode); 21225 const newRange = document.createRange(); 21226 newRange.setStartAfter(spaceNode); 21227 newRange.collapse(true); 21228 selection.removeAllRanges(); 21229 selection.addRange(newRange); 21230 this._mentionInserting = true; 21231 this._closeMentionPopup(); 21232 editor.dispatchEvent(new Event("input", { bubbles: true })); 21233 setTimeout(() => { 21234 this._mentionInserting = false; 21235 }, 300); 21236 }, 21237 // ââ Audio Icon Helper ââââââââââââââââââââââââââââââââââââ 21238 _audioLinesSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></svg>`, 21239 _pauseSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>`, 21240 _elementCogSvg: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`, 21241 _bindAudioIcon(iconWrap, audioUrl) { 21242 if (!audioUrl) return; 21243 iconWrap.style.cursor = "pointer"; 21244 iconWrap.innerHTML = this._audioLinesSvg; 21245 iconWrap.addEventListener("click", (e) => { 21246 e.preventDefault(); 21247 e.stopPropagation(); 21248 if (this._mentionAudio && this._mentionAudio._iconWrap === iconWrap && !this._mentionAudio.paused) { 21249 this._mentionAudio.pause(); 21250 this._mentionAudio = null; 21251 iconWrap.innerHTML = this._audioLinesSvg; 21252 } else { 21253 if (this._mentionAudio && this._mentionAudio._iconWrap) { 21254 this._mentionAudio._iconWrap.innerHTML = this._audioLinesSvg; 21255 } 21256 if (this._mentionAudio) { 21257 this._mentionAudio.pause(); 21258 } 21259 this._mentionAudio = new Audio(audioUrl); 21260 this._mentionAudio._iconWrap = iconWrap; 21261 iconWrap.innerHTML = this._pauseSvg; 21262 this._mentionAudio.addEventListener("ended", () => { 21263 iconWrap.innerHTML = this._audioLinesSvg; 21264 this._mentionAudio = null; 21265 }); 21266 this._mentionAudio.play(); 21267 } 21268 }); 21269 }, 21270 // ââ Preview Helper ââââââââââââââââââââââââââââââââââââââââ 21271 _showPreviewForIndex(element, mediaItems) { 21272 const m = mediaItems[this._mentionMediaIndex]; 21273 if (m) this._showPreview(m.preview_url || m.thumbnail_url, `${element.slug}-${m.label}`, m.media_id, element, m); 21274 }, 21275 // ââ Extract Text ââââââââââââââââââââââââââââââââââââââââââ 21276 getEditorTextContent(editor) { 21277 if (!editor) return ""; 21278 const clone = editor.cloneNode(true); 21279 clone.querySelectorAll(".mention").forEach((mention) => { 21280 const raw = mention.dataset.mentionSlug || ""; 21281 const text = document.createTextNode(raw ? `@${raw}` : mention.textContent); 21282 mention.parentNode.replaceChild(text, mention); 21283 }); 21284 return clone.textContent?.trim() || ""; 21285 }, 21286 // ââ Create Kling Element Modal âââââââââââââââââââââââââââââ 21287 _openCreateCustomModal(element) { 21288 this._closeMentionPopup(); 21289 const visualMedia = (element.media || []).filter( 21290 (m) => !["voice", "sound", "custom"].includes(m.role) 21291 ); 21292 if (visualMedia.length === 0) return; 21293 const defaultFrontal = visualMedia[0]?.em_uuid || null; 21294 let sel = { 21295 tag: "", 21296 frontal: defaultFrontal, 21297 references: defaultFrontal ? [defaultFrontal] : [] 21298 }; 21299 const prev = document.getElementById("element-custom-modal"); 21300 if (prev) prev.remove(); 21301 const modal = document.createElement("div"); 21302 modal.id = "element-custom-modal"; 21303 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 21304 modal.style.cssText = "align-items:flex-start;padding-top:8vh;z-index:10000;"; 21305 const renderBody = () => { 21306 const itemsHtml = visualMedia.map((m) => { 21307 const isRef = sel.references.includes(m.em_uuid); 21308 const isFrontal = sel.frontal === m.em_uuid; 21309 const thumbSrc = m.thumbnail_url || m.preview_url || ""; 21310 return ` 21311 <div class="mention-popup-item" data-toggle-uuid="${m.em_uuid}" style="padding:6px 8px;cursor:pointer;">
21312 <div class="mention-custom-check ${isRef ? "mention-custom-checked" : ""}"> 21313 ${isRef ? "✓" : ""} 21314 </div> 21315 <div class="mention-popup-thumbnail mention-popup-thumbnail-sm">${thumbSrc ? `<img src="${thumbSrc}" alt="${m.label}">` : `<div class="w-full h-full flex items-center justify-center text-gray text-[10px]">${m.role.charAt(0).toUpperCase()}</div>`}</div> 21316 <div class="mention-popup-info" style="flex:1;min-width:0;"> 21317 <span class="mention-popup-label">${m.label}</span> 21318 <span class="mention-popup-prompt">${m.role}${m.version ? ` v${m.version}` : ""}</span> 21319 </div> 21320 <div class="mention-custom-star ${isFrontal ? "mention-custom-star-active" : ""}" style="margin-left:auto;" data-action="frontal" data-uuid="${m.em_uuid}" title="Set as frontal">★</div> 21321 </div> 21322 `; 21323 }).join(""); 21324 const selectedMedia = visualMedia.filter((m) => sel.references.includes(m.em_uuid)); 21325 const frontalMedia = visualMedia.find((m) => m.em_uuid === sel.frontal); 21326 const frontalHtml = frontalMedia ? `<div class="mb-4"> 21327 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">Frontal</div> 21328 <img src="${frontalMedia.preview_url || frontalMedia.thumbnail_url}" class="w-full rounded-lg object-cover" style="max-height:200px;"> 21329 <div class="text-[10px] text-gray mt-1.5 text-center">${frontalMedia.label}</div> 21330 </div>` : '<div class="text-[10px] text-gray text-center py-8">No frontal selected</div>'; 21331 const refMedia = selectedMedia.filter((m) => m.em_uuid !== sel.frontal); 21332 const refsHtml = refMedia.length > 0 ? `<div> 21333 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">References ${refMedia.length}/6</div> 21334 <div class="grid grid-cols-3 gap-2">${refMedia.map( 21335 (m) => `<div> 21336 <img src="${m.preview_url || m.thumbnail_url}" class="w-full aspect-square rounded object-cover"> 21337 <div class="text-[9px] text-gray mt-0.5 text-center truncate">${m.label}</div> 21338 </div>` 21339 ).join("")}</div> 21340 </div>` : ""; 21341 return ` 21342 <div class="modal-content bg-box rounded-lg p-5 max-w-3xl w-full mx-4 shadow-xl" style="max-height:80vh;display:flex;flex-direction:column;"> 21343 <div class="flex items-center justify-between mb-4"> 21344 <div class="text-sm font-medium text-white">Create Kling Element</div> 21345 <button type="button" data-close class="cursor-pointer text-gray hover:text-white transition-colors"> 21346 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 21347 </button> 21348 </div> 21349 <div class="mb-3"> 21350 <input type="text" data-tag-input placeholder="Preset name (e.g. angry, suit, winter)" value="${sel.tag}" class="w-full bg-dark border border-light rounded px-3 py-1.5 text-xs text-white placeholder-gray focus:outline-none focus:border-active" /> 21351 </div> 21352 <div class="flex gap-5 flex-1 min-h-0"> 21353 <div class="overflow-y-auto" style="width:33.333%;min-width:0;"> 21354 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">Select Media</div> 21355 ${itemsHtml} 21356 </div> 21357 <div class="overflow-y-auto" style="width:66.666%;"> 21358 ${frontalHtml} 21359 ${refsHtml} 21360 </div> 21361 </div> 21362 <div class="flex gap-2 mt-4 justify-end"> 21363 <button type="button" data-cancel class="px-3 py-1.5 text-xs text-gray hover:text-white bg-transparent border border-light rounded cursor-pointer transition-colors">Cancel</button> 21364 <button type="button" data-create class="px-3 py-1.5 text-xs text-white bg-active rounded cursor-pointer hover:bg-active/80 transition-colors">Create & Insert</button> 21365 </div> 21366 </div> 21367 `; 21368 }; 21369 const updateModal = () => { 21370 modal.innerHTML = renderBody(); 21371 bindModalEvents(); 21372 }; 21373 const bindModalEvents = () => { 21374 modal.querySelector("[data-close]")?.addEventListener("click", () => modal.remove()); 21375 modal.querySelector("[data-cancel]")?.addEventListener("click", () => modal.remove()); 21376 modal.addEventListener("click", (e) => { 21377 if (e.target === modal) modal.remove(); 21378 }); 21379 modal.querySelector("[data-tag-input]")?.addEventListener("input", (e) => { 21380 sel.tag = e.target.value; 21381 }); 21382 modal.querySelectorAll("[data-toggle-uuid]").forEach((row) => { 21383 row.addEventListener("click", (e) => { 21384 if (e.target.closest('[data-action="frontal"]')) return; 21385 e.stopPropagation(); 21386 const uuid2 = row.dataset.toggleUuid; 21387 const idx = sel.references.indexOf(uuid2); 21388 if (idx >= 0) { 21389 sel.references.splice(idx, 1); 21390 if (sel.frontal === uuid2) sel.frontal = sel.references[0] || null; 21391 } else { 21392 if (sel.references.length >= 6) return; 21393 sel.references.push(uuid2); 21394 } 21395 updateModal(); 21396 }); 21397 }); 21398 modal.querySelectorAll('[data-action="frontal"]').forEach((el) => { 21399 el.addEventListener("click", (e) => { 21400 e.stopPropagation(); 21401 const uuid2 = el.dataset.uuid; 21402 sel.frontal = uuid2; 21403 if (!sel.references.includes(uuid2)) { 21404 if (sel.references.length >= 6) return; 21405 sel.references.push(uuid2); 21406 } 21407 updateModal(); 21408 }); 21409 }); 21410 modal.querySelector("[data-create]")?.addEventListener("click", async () => { 21411 const tag = sel.tag.trim(); 21412 if (!tag) { 21413 window.Toast?.show("Please enter a preset name", "alert"); 21414 return; 21415 } 21416 if (!sel.frontal) { 21417 window.Toast?.show("Please select a frontal image", "alert"); 21418 return; 21419 } 21420 const frontalItem = visualMedia.find((m) => m.em_uuid === sel.frontal); 21421 if (!frontalItem) return; 21422 const refItems = sel.references.filter((r) => r !== sel.frontal).map( 21423 (uuid2) => visualMedia.find((m) => m.em_uuid === uuid2)
21424 ).filter(Boolean); 21425 try { 21426 const resp = await fetch(`/element/${element.uuid}/custom`, { 21427 method: "POST", 21428 headers: { 21429 "Content-Type": "application/json", 21430 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 21431 }, 21432 body: JSON.stringify({ 21433 tag, 21434 frontal_media_uuid: frontalItem.media_id, 21435 reference_media_uuids: refItems.map((m) => m.media_id) 21436 }) 21437 }); 21438 const data = await resp.json(); 21439 if (!resp.ok) { 21440 window.Toast?.show(data.error || "Failed to create preset", "alert"); 21441 return; 21442 } 21443 const newMediaItem = { 21444 role: "custom", 21445 version: element.version || 1, 21446 label: data.slug?.replace(`${element.slug}-v${element.version || 1}-`, "") || tag, 21447 thumbnail_url: frontalItem.thumbnail_url || frontalItem.preview_url, 21448 preview_url: frontalItem.preview_url || frontalItem.thumbnail_url, 21449 file_url: frontalItem.file_url, 21450 media_id: frontalItem.media_id, 21451 em_uuid: data.em_uuid, 21452 reference_media_ids: refItems.map((m) => m.media_id), 21453 reference_urls: refItems.map((m) => m.file_url), 21454 reference_thumbnails: refItems.map((m) => m.thumbnail_url || m.preview_url) 21455 }; 21456 if (this._mentionElements) { 21457 const cachedEl = this._mentionElements.find((e) => e.uuid === element.uuid); 21458 if (cachedEl) { 21459 cachedEl.media = cachedEl.media || []; 21460 cachedEl.media.push(newMediaItem); 21461 } 21462 } 21463 if (this._mentionEditor) { 21464 const editor = this._mentionEditor; 21465 const range = document.createRange(); 21466 range.selectNodeContents(editor); 21467 range.collapse(false); 21468 const sel2 = window.getSelection(); 21469 sel2.removeAllRanges(); 21470 sel2.addRange(range); 21471 const mentionDisplay = `${element.slug}-${newMediaItem.label}`; 21472 const mentionSpan = document.createElement("span"); 21473 mentionSpan.className = "mention"; 21474 mentionSpan.contentEditable = "false"; 21475 mentionSpan.dataset.mentionSlug = `${data.em_uuid}-${mentionDisplay}`; 21476 mentionSpan.dataset.mediaId = frontalItem.media_id; 21477 if (frontalItem.thumbnail_url) { 21478 const img = document.createElement("img"); 21479 img.src = frontalItem.thumbnail_url; 21480 img.alt = mentionDisplay; 21481 mentionSpan.appendChild(img); 21482 } 21483 mentionSpan.appendChild(document.createTextNode(` @${mentionDisplay}`)); 21484 const cogIcon = document.createElement("span"); 21485 cogIcon.className = "mention-element-icon"; 21486 cogIcon.innerHTML = '<img src="/kling.png" style="width:12px;height:12px;">'; 21487 mentionSpan.appendChild(cogIcon); 21488 mentionSpan.style.cursor = "pointer"; 21489 mentionSpan.addEventListener("click", (ev) => { 21490 ev.preventDefault(); 21491 ev.stopPropagation(); 21492 this._openEditCustomModal(mentionSpan, element, newMediaItem); 21493 }); 21494 editor.appendChild(document.createTextNode(" ")); 21495 editor.appendChild(mentionSpan); 21496 editor.appendChild(document.createTextNode("\xA0")); 21497 editor.dispatchEvent(new Event("input", { bubbles: true })); 21498 } 21499 modal.remove(); 21500 window.Toast?.show("Custom preset created", "success"); 21501 } catch (err) { 21502 window.Toast?.show("Failed to create preset", "alert"); 21503 } 21504 }); 21505 }; 21506 modal.innerHTML = renderBody(); 21507 document.body.appendChild(modal); 21508 requestAnimationFrame(() => modal.classList.add("modal-showing")); 21509 bindModalEvents(); 21510 const escHandler = (e) => { 21511 if (e.key === "Escape") { 21512 modal.remove(); 21513 document.removeEventListener("keydown", escHandler); 21514 } 21515 }; 21516 document.addEventListener("keydown", escHandler); 21517 }, 21518 // ââ Edit Kling Element Modal âââââââââââââââââââââââââââââ 21519 _openEditCustomModal(mentionSpan, elementData, mediaItem) { 21520 const elements = this._getAvailableElements(); 21521 const element = elementData || elements.find((el) => el.uuid === mentionSpan?.dataset.mentionSlug?.split("-")[0]); 21522 if (!element) return; 21523 const visualMedia = (element.media || []).filter( 21524 (m) => !["voice", "sound", "custom"].includes(m.role) 21525 ); 21526 if (visualMedia.length === 0) return; 21527 let sel = { 21528 tag: mediaItem?.label || "", 21529 frontal: null, 21530 references: [] 21531 }; 21532 if (mediaItem?.media_id) { 21533 const frontalVm = visualMedia.find((m) => m.media_id === mediaItem.media_id); 21534 if (frontalVm) sel.frontal = frontalVm.em_uuid; 21535 } 21536 if (!sel.frontal) sel.frontal = visualMedia[0]?.em_uuid; 21537 if (sel.frontal && !sel.references.includes(sel.frontal)) { 21538 sel.references.push(sel.frontal); 21539 } 21540 if (mediaItem?.reference_media_uuids) { 21541 mediaItem.reference_media_uuids.forEach((refUuid) => { 21542 const vm = visualMedia.find((m) => m.media_id === refUuid); 21543 if (vm && !sel.references.includes(vm.em_uuid)) { 21544 sel.references.push(vm.em_uuid); 21545 } 21546 }); 21547 } else if (mediaItem?.reference_urls) {
21548 visualMedia.forEach((vm) => { 21549 if (mediaItem.reference_urls?.includes(vm.file_url) && !sel.references.includes(vm.em_uuid)) { 21550 sel.references.push(vm.em_uuid); 21551 } 21552 }); 21553 } 21554 const prev = document.getElementById("element-custom-modal"); 21555 if (prev) prev.remove(); 21556 const modal = document.createElement("div"); 21557 modal.id = "element-custom-modal"; 21558 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 21559 modal.style.cssText = "align-items:flex-start;padding-top:8vh;z-index:10000;"; 21560 const renderBody = () => { 21561 const itemsHtml = visualMedia.map((m) => { 21562 const isRef = sel.references.includes(m.em_uuid); 21563 const isFrontal = sel.frontal === m.em_uuid; 21564 const thumbSrc = m.thumbnail_url || m.preview_url || ""; 21565 return ` 21566 <div class="mention-popup-item" data-toggle-uuid="${m.em_uuid}" style="padding:6px 8px;cursor:pointer;"> 21567 <div class="mention-custom-check ${isRef ? "mention-custom-checked" : ""}"> 21568 ${isRef ? "✓" : ""} 21569 </div> 21570 <div class="mention-popup-thumbnail mention-popup-thumbnail-sm">${thumbSrc ? `<img src="${thumbSrc}" alt="${m.label}">` : `<div class="w-full h-full flex items-center justify-center text-gray text-[10px]">${m.role.charAt(0).toUpperCase()}</div>`}</div> 21571 <div class="mention-popup-info" style="flex:1;min-width:0;"> 21572 <span class="mention-popup-label">${m.label}</span> 21573 <span class="mention-popup-prompt">${m.role}${m.version ? ` v${m.version}` : ""}</span> 21574 </div> 21575 <div class="mention-custom-star ${isFrontal ? "mention-custom-star-active" : ""}" style="margin-left:auto;" data-action="frontal" data-uuid="${m.em_uuid}" title="Set as frontal">★</div> 21576 </div> 21577 `; 21578 }).join(""); 21579 const frontalMedia = visualMedia.find((m) => m.em_uuid === sel.frontal); 21580 const frontalHtml = frontalMedia ? `<div class="mb-4"> 21581 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">Frontal</div> 21582 <img src="${frontalMedia.preview_url || frontalMedia.thumbnail_url}" class="w-full rounded-lg object-cover" style="max-height:200px;"> 21583 </div>` : '<div class="text-[10px] text-gray text-center py-8">No frontal selected</div>'; 21584 const refMedia = visualMedia.filter((m) => sel.references.includes(m.em_uuid) && m.em_uuid !== sel.frontal); 21585 const refsHtml = refMedia.length > 0 ? `<div> 21586 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">References ${refMedia.length}/6</div> 21587 <div class="grid grid-cols-3 gap-2">${refMedia.map( 21588 (m) => `<div> 21589 <img src="${m.preview_url || m.thumbnail_url}" class="w-full aspect-square rounded object-cover"> 21590 <div class="text-[9px] text-gray mt-0.5 text-center truncate">${m.label}</div> 21591 </div>` 21592 ).join("")}</div> 21593 </div>` : ""; 21594 return ` 21595 <div class="modal-content bg-box rounded-lg p-5 max-w-3xl w-full mx-4 shadow-xl" style="max-height:80vh;display:flex;flex-direction:column;"> 21596 <div class="flex items-center justify-between mb-4"> 21597 <div class="text-sm font-medium text-white">Edit Kling Element</div> 21598 <button type="button" data-close class="cursor-pointer text-gray hover:text-white transition-colors"> 21599 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 21600 </button> 21601 </div> 21602 <div class="flex gap-5 flex-1 min-h-0"> 21603 <div class="overflow-y-auto" style="width:33.333%;min-width:0;"> 21604 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">All Media</div> 21605 ${itemsHtml} 21606 </div> 21607 <div class="overflow-y-auto" style="width:66.666%;"> 21608 ${frontalHtml} 21609 ${refsHtml} 21610 </div> 21611 </div> 21612 <div class="flex gap-2 mt-4 justify-end"> 21613 <button type="button" data-delete class="px-3 py-1.5 text-xs text-destructive hover:text-white bg-transparent border border-destructive/30 rounded cursor-pointer transition-colors mr-auto">Delete</button> 21614 <button type="button" data-cancel class="px-3 py-1.5 text-xs text-gray hover:text-white bg-transparent border border-light rounded cursor-pointer transition-colors">Cancel</button> 21615 <button type="button" data-save class="px-3 py-1.5 text-xs text-white bg-active rounded cursor-pointer hover:bg-active/80 transition-colors">Save</button> 21616 </div> 21617 </div> 21618 `; 21619 }; 21620 const updateModal = () => { 21621 modal.innerHTML = renderBody(); 21622 bindModalEvents(); 21623 }; 21624 const bindModalEvents = () => { 21625 modal.querySelector("[data-close]")?.addEventListener("click", () => modal.remove()); 21626 modal.querySelector("[data-cancel]")?.addEventListener("click", () => modal.remove()); 21627 modal.addEventListener("click", (e) => { 21628 if (e.target === modal) modal.remove(); 21629 }); 21630 modal.querySelectorAll("[data-toggle-uuid]").forEach((row) => { 21631 row.addEventListener("click", (e) => { 21632 if (e.target.closest('[data-action="frontal"]')) return; 21633 e.stopPropagation(); 21634 const uuid2 = row.dataset.toggleUuid; 21635 const idx = sel.references.indexOf(uuid2); 21636 if (idx >= 0) { 21637 sel.references.splice(idx, 1); 21638 if (sel.frontal === uuid2) sel.frontal = sel.references[0] || null; 21639 } else { 21640 if (sel.references.length >= 6) return; 21641 sel.references.push(uuid2); 21642 } 21643 updateModal(); 21644 }); 21645 }); 21646 modal.querySelectorAll('[data-action="frontal"]').forEach((el) => { 21647 el.addEventListener("click", (e) => { 21648 e.stopPropagation(); 21649 const uuid2 = el.dataset.uuid; 21650 sel.frontal = uuid2; 21651 if (!sel.references.includes(uuid2)) { 21652 if (sel.references.length >= 6) return; 21653 sel.references.push(uuid2); 21654 } 21655 updateModal(); 21656 }); 21657 }); 21658 modal.querySelector("[data-save]")?.addEventListener("click", async () => { 21659 if (!sel.frontal) { 21660 window.Toast?.show("Please select a frontal image", "alert"); 21661 return; 21662 } 21663 const frontalItem = visualMedia.find((m) => m.em_uuid === sel.frontal); 21664 if (!frontalItem) return; 21665 const refItems = sel.references.filter((r) => r !== sel.frontal).map( 21666 (uuid2) => visualMedia.find((m) => m.em_uuid === uuid2)
21667 ).filter(Boolean); 21668 const emUuid = mentionSpan.dataset.mentionSlug?.split("-")[0]; 21669 if (!emUuid) return; 21670 try { 21671 const resp = await fetch(`/element/${element.uuid}/custom/${emUuid}`, { 21672 method: "PATCH", 21673 headers: { 21674 "Content-Type": "application/json", 21675 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 21676 }, 21677 body: JSON.stringify({ 21678 frontal_media_uuid: frontalItem.media_id, 21679 reference_media_uuids: refItems.map((m) => m.media_id) 21680 }) 21681 }); 21682 if (!resp.ok) { 21683 const data = await resp.json(); 21684 window.Toast?.show(data.error || "Failed to update preset", "alert"); 21685 return; 21686 } 21687 const badgeImg = mentionSpan.querySelector("img"); 21688 if (badgeImg && frontalItem.thumbnail_url) { 21689 badgeImg.src = frontalItem.thumbnail_url; 21690 } 21691 const editor = mentionSpan.closest("[contenteditable]"); 21692 if (editor) editor.dispatchEvent(new Event("input", { bubbles: true })); 21693 modal.remove(); 21694 window.Toast?.show("Custom preset updated", "success"); 21695 } catch (err) { 21696 window.Toast?.show("Failed to update preset", "alert"); 21697 } 21698 }); 21699 modal.querySelector("[data-delete]")?.addEventListener("click", async () => { 21700 const emUuid = mentionSpan.dataset.mentionSlug?.split("-")[0]; 21701 if (!emUuid) return; 21702 try { 21703 const resp = await fetch(`/element/${element.uuid}/custom/${emUuid}`, { 21704 method: "DELETE", 21705 headers: { 21706 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 21707 } 21708 }); 21709 if (!resp.ok) { 21710 const data = await resp.json(); 21711 window.Toast?.show(data.error || "Failed to delete preset", "alert"); 21712 return; 21713 } 21714 const editor = mentionSpan.closest("[contenteditable]"); 21715 mentionSpan.remove(); 21716 if (editor) editor.dispatchEvent(new Event("input", { bubbles: true })); 21717 if (this._mentionElements) { 21718 const cachedEl = this._mentionElements.find((e) => e.uuid === element.uuid); 21719 if (cachedEl && cachedEl.media) { 21720 cachedEl.media = cachedEl.media.filter((m) => m.em_uuid !== emUuid); 21721 } 21722 } 21723 modal.remove(); 21724 window.Toast?.show("Custom preset deleted", "success"); 21725 } catch (err) { 21726 window.Toast?.show("Failed to delete preset", "alert"); 21727 } 21728 }); 21729 }; 21730 modal.innerHTML = renderBody(); 21731 document.body.appendChild(modal); 21732 requestAnimationFrame(() => modal.classList.add("modal-showing")); 21733 bindModalEvents(); 21734 const escHandler = (e) => { 21735 if (e.key === "Escape") { 21736 modal.remove(); 21737 document.removeEventListener("keydown", escHandler); 21738 } 21739 }; 21740 document.addEventListener("keydown", escHandler); 21741 }, 21742 // ââ Close / Cleanup âââââââââââââââââââââââââââââââââââââââ 21743 _closeMentionPopup() { 21744 if (this._mentionAudio) { 21745 if (this._mentionAudio._iconWrap) this._mentionAudio._iconWrap.innerHTML = this._audioLinesSvg; 21746 this._mentionAudio.pause(); 21747 this._mentionAudio = null; 21748 } 21749 if (this._mentionPopup) this._mentionPopup.style.display = "none"; 21750 this._mentionSearchText = ""; 21751 this._mentionStartOffset = 0; 21752 this._mentionActiveElement = null; 21753 this._mentionPopupHovered = false; 21754 this._mentionFocus = "elements"; 21755 this._mentionSelectedIndex = 0; 21756 this._mentionMediaIndex = -1; 21757 }, 21758 cleanupMentions() { 21759 if (this._mentionPopup) { 21760 this._mentionPopup.remove(); 21761 this._mentionPopup = null; 21762 } 21763 this._mentionElements = null; 21764 this._mentionEditor = null; 21765 this._mentionActiveElement = null; 21766 this._mentionPopupHovered = false; 21767 } 21768}; 21769
21770// app/javascript/studio.js 21771window.cameraSelections = {}; 21772window.animationSelections = {}; 21773window.selectedRatio = "16:9"; 21774window.selectedMovement = null; 21775window.slowMotion = false; 21776window.audioEnabled = false; 21777window.selectedResolution = "2k"; 21778window.selectedCameraTab = "live"; 21779window.validElementSlugs = /* @__PURE__ */ new Set(); 21780function getVideoPromptText(el) { 21781 if (!el) return ""; 21782 return el.innerText || ""; 21783} 21784function setVideoPromptText(el, text) { 21785 if (!el) return; 21786 el.textContent = text || ""; 21787 if (window.studioManager && typeof window.studioManager._upgradePlainMentions === "function") { 21788 window.studioManager._upgradePlainMentions(el); 21789 } 21790} 21791var storageKeys = { 21792 "camera-digital": "studio-image-device", 21793 "camera-film": "studio-image-device", 21794 lens: "studio-image-lens", 21795 focal: "studio-image-focal", 21796 aperture: "studio-image-aperture", 21797 "color grade": "studio-image-grade" 21798}; 21799var ANIMATION_STORAGE_KEY = "studio-anim-style"; 21800var ratioPreviews = { 21801 "16:9": "/16-9-mini.png", 21802 "21:9": "/21-9-mini.png" 21803}; 21804function setupResolutionSelector() { 21805 const selector = document.querySelector("[data-resolution-selector]"); 21806 if (!selector) return; 21807 selector.querySelectorAll("[data-resolution]").forEach((btn) => { 21808 btn.addEventListener("click", () => { 21809 window.selectedResolution = btn.dataset.resolution; 21810 selector.querySelectorAll("[data-resolution]").forEach((b) => { 21811 b.classList.remove("text-white", "bg-active/40"); 21812 b.classList.add("text-gray"); 21813 }); 21814 btn.classList.remove("text-gray"); 21815 btn.classList.add("text-white", "bg-active/40"); 21816 localStorage.setItem("studio-resolution", window.selectedResolution); 21817 window.studioManager?.updateCost(); 21818 }); 21819 }); 21820 const saved = localStorage.getItem("studio-resolution"); 21821 if (saved && ["1k", "2k", "4k"].includes(saved)) { 21822 window.selectedResolution = saved; 21823 selector.querySelectorAll("[data-resolution]").forEach((b) => { 21824 b.classList.remove("text-white", "bg-active/40"); 21825 b.classList.add("text-gray"); 21826 if (b.dataset.resolution === saved) { 21827 b.classList.remove("text-gray"); 21828 b.classList.add("text-white", "bg-active/40"); 21829 } 21830 }); 21831 } 21832} 21833function setupImageSubmodeTabs() { 21834 const saved = localStorage.getItem("studio-image-submode"); 21835 if (saved && ["live", "animation"].includes(saved)) { 21836 window.selectedImageSubmode = saved; 21837 } else { 21838 window.selectedImageSubmode = "live"; 21839 } 21840 setImageSubmode(window.selectedImageSubmode); 21841} 21842function setImageSubmode(submode) { 21843 window.selectedImageSubmode = submode; 21844 window.selectedCameraTab = submode; 21845 localStorage.setItem("studio-image-submode", submode); 21846 const tabs = document.querySelectorAll("[data-image-submode]");
21847 tabs.forEach((btn) => { 21848 if (btn.dataset.imageSubmode === submode) { 21849 btn.classList.remove("text-gray", "hover:text-white", "bg-inside"); 21850 btn.classList.add("text-white", "bg-active/40"); 21851 } else { 21852 btn.classList.remove("text-white", "bg-active/40"); 21853 btn.classList.add("text-gray", "hover:text-white", "bg-inside"); 21854 } 21855 }); 21856 const liveGrid = document.querySelector('[data-camera-grid="live"]'); 21857 const animGrid = document.querySelector('[data-camera-grid="animation"]'); 21858 if (submode === "live") { 21859 liveGrid?.classList.remove("hidden"); 21860 animGrid?.classList.add("hidden"); 21861 } else { 21862 liveGrid?.classList.add("hidden"); 21863 animGrid?.classList.remove("hidden"); 21864 } 21865} 21866function setupVideoSubmodeTabs() { 21867 const savedMultishot = localStorage.getItem("studio-video-multishot"); 21868 window.videoMultishot = savedMultishot === "true"; 21869 const savedFrame = localStorage.getItem("studio-video-active-frame"); 21870 window.videoActiveFrame = savedFrame === "end" ? "end" : "start"; 21871 const savedShots = localStorage.getItem("studio-video-shots"); 21872 if (savedShots) { 21873 try { 21874 window.videoShots = JSON.parse(savedShots); 21875 } catch (e) { 21876 window.videoShots = [{ prompt: "", duration: 3, slow_motion: false, movement: null }]; 21877 } 21878 } else { 21879 window.videoShots = [{ prompt: "", duration: 3, slow_motion: false, movement: null }]; 21880 } 21881 window.activeShot = 0; 21882 updateMultishotUI(); 21883 updateFrameButtonsUI(); 21884 updateVideoReferenceSlots(); 21885 renderShotTabs(); 21886 setupShotTabsListeners(); 21887} 21888function toggleMultishot() { 21889 window.videoMultishot = !window.videoMultishot; 21890 localStorage.setItem("studio-video-multishot", window.videoMultishot); 21891 if (!window.videoMultishot) { 21892 const current = parseInt(window.selectedDuration); 21893 if (current < MIN_DURATION_SINGLE) { 21894 window.selectedDuration = MIN_DURATION_SINGLE + "s"; 21895 document.querySelectorAll("[data-setting-duration]").forEach((el) => { 21896 el.textContent = window.selectedDuration; 21897 }); 21898 localStorage.setItem("studio-video-duration", window.selectedDuration); 21899 } 21900 } 21901 updateMultishotUI(); 21902} 21903function updateMultishotUI() { 21904 const track = document.querySelector("[data-multishot-track]"); 21905 const thumb = document.querySelector("[data-multishot-thumb]"); 21906 const totalDurationCell = document.querySelector("[data-total-duration-cell]"); 21907 const shotTabsContainer = document.querySelector("[data-shot-tabs-container]"); 21908 const isVideoMode = window.studioManager?.mode === "video"; 21909 if (window.videoMultishot) { 21910 track?.classList.remove("bg-dark"); 21911 track?.classList.add("bg-active"); 21912 thumb?.classList.remove("left-0.5", "bg-gray"); 21913 thumb?.classList.add("left-4.5", "bg-white"); 21914 if (isVideoMode) { 21915 totalDurationCell?.classList.remove("hidden"); 21916 } else { 21917 totalDurationCell?.classList.add("hidden"); 21918 } 21919 shotTabsContainer?.classList.remove("hidden"); 21920 shotTabsContainer?.classList.add("flex"); 21921 renderShotTabs(); 21922 } else { 21923 track?.classList.remove("bg-active"); 21924 track?.classList.add("bg-dark"); 21925 thumb?.classList.remove("left-4.5", "bg-white"); 21926 thumb?.classList.add("left-0.5", "bg-gray"); 21927 totalDurationCell?.classList.add("hidden"); 21928 shotTabsContainer?.classList.add("hidden"); 21929 shotTabsContainer?.classList.remove("flex"); 21930 } 21931 updateTotalDuration(); 21932}
21933function renderShotTabs() { 21934 const tabsContainer = document.querySelector("[data-shot-tabs]"); 21935 if (!tabsContainer) return; 21936 const shots = window.videoShots || []; 21937 const activeIndex = window.activeShot || 0; 21938 let html = shots.map((shot, index) => { 21939 const isActive = index === activeIndex; 21940 const activeClasses = isActive ? "bg-active text-white" : "bg-inside text-gray hover:text-white"; 21941 const showDelete = isActive && shots.length > 1; 21942 return ` 21943 <div class="relative group" data-shot-tab="${index}"> 21944 <button type="button" class="px-4 py-1.5 rounded-md text-[9px] uppercase font-medium transition duration-300 cursor-pointer ${activeClasses}" data-shot-tab-btn="${index}"> 21945 Shot ${index + 1} 21946 </button> 21947 ${showDelete ? ` 21948 <button type="button" class="absolute -top-1 -right-1 w-3 h-3 rounded-sm bg-dark/80 hover:bg-red-500/80 flex items-center justify-center cursor-pointer" data-remove-shot-btn="${index}" title="Remove shot"> 21949 <svg class="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 21950 </button> 21951 ` : ""} 21952 </div> 21953 `; 21954 }).join(""); 21955 if (shots.length < 7) { 21956 html += ` 21957 <button type="button" class="flex-shrink-0 w-6 h-6 rounded-md bg-inside hover:bg-active/40 flex items-center justify-center text-gray hover:text-white transition duration-300 cursor-pointer" data-add-shot-btn title="Add shot"> 21958 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg> 21959 </button> 21960 `; 21961 } 21962 tabsContainer.innerHTML = html; 21963 loadShotToTextarea(activeIndex); 21964} 21965function loadShotToTextarea(index) { 21966 const textarea = document.querySelector("[data-studio-video-prompt-input]"); 21967 if (!textarea || !window.videoShots) return; 21968 const shot = window.videoShots[index]; 21969 if (shot) { 21970 if (shot._promptHtml) { 21971 textarea.innerHTML = shot._promptHtml; 21972 } else { 21973 setVideoPromptText(textarea, shot.prompt || ""); 21974 } 21975 if (window.studioManager && typeof window.studioManager._bindExistingMentions === "function") { 21976 window.studioManager._bindExistingMentions(textarea); 21977 } 21978 const duration = shot.duration || 3; 21979 window.selectedDuration = duration + "s"; 21980 document.querySelectorAll("[data-setting-duration]").forEach((el) => { 21981 el.textContent = window.selectedDuration; 21982 }); 21983 window.slowMotion = shot.slow_motion || false; 21984 document.querySelectorAll("[data-setting-slowmo]").forEach((el) => { 21985 el.textContent = window.slowMotion ? "On" : "Off"; 21986 }); 21987 if (shot.movement) { 21988 const movementDataEl = document.getElementById("studio-movement-prompts-data"); 21989 if (movementDataEl) { 21990 try { 21991 const movementData = JSON.parse(movementDataEl.textContent); 21992 const movement = movementData.find((m) => m.uuid === shot.movement); 21993 if (movement) { 21994 window.selectedMovement = { 21995 uuid: movement.uuid, 21996 name: movement.name, 21997 preview: movement.preview 21998 }; 21999 } else { 22000 window.selectedMovement = null; 22001 } 22002 } catch (e) { 22003 window.selectedMovement = null; 22004 } 22005 } 22006 } else { 22007 window.selectedMovement = null; 22008 } 22009 updateMovementName(); 22010 updateMovementPreview(); 22011 } 22012} 22013function saveTextareaToShot() { 22014 const textarea = document.querySelector("[data-studio-video-prompt-input]"); 22015 if (!textarea || !window.videoShots) return; 22016 const index = window.activeShot || 0; 22017 if (window.videoShots[index]) { 22018 if (window.studioManager && typeof window.studioManager.getEditorTextContent === "function") { 22019 window.videoShots[index].prompt = window.studioManager.getEditorTextContent(textarea); 22020 } else { 22021 window.videoShots[index].prompt = getVideoPromptText(textarea); 22022 } 22023 window.videoShots[index]._promptHtml = textarea.innerHTML || ""; 22024 saveShotsToStorage(); 22025 } 22026} 22027function saveShotsToStorage() { 22028 localStorage.setItem("studio-video-shots", JSON.stringify(window.videoShots)); 22029} 22030function switchToShot(index) { 22031 if (index < 0 || index >= window.videoShots.length) return; 22032 saveTextareaToShot(); 22033 window.activeShot = index; 22034 renderShotTabs(); 22035 updateTotalDuration(); 22036}
22037function addShot() { 22038 if (!window.videoShots) window.videoShots = []; 22039 if (window.videoShots.length >= 7) return; 22040 saveTextareaToShot(); 22041 window.videoShots.push({ prompt: "", duration: 3, slow_motion: false, movement: null }); 22042 saveShotsToStorage(); 22043 window.activeShot = window.videoShots.length - 1; 22044 renderShotTabs(); 22045 updateTotalDuration(); 22046} 22047function removeShot(index) { 22048 if (!window.videoShots || window.videoShots.length <= 1) return; 22049 if (index < 0 || index >= window.videoShots.length) return; 22050 window.videoShots.splice(index, 1); 22051 saveShotsToStorage(); 22052 if (window.activeShot >= window.videoShots.length) { 22053 window.activeShot = window.videoShots.length - 1; 22054 } else if (window.activeShot > index) { 22055 window.activeShot--; 22056 } 22057 renderShotTabs(); 22058 updateTotalDuration(); 22059} 22060function setupShotTabsListeners() { 22061 const container = document.querySelector("[data-shot-tabs-container]"); 22062 if (!container || container.dataset.listenersAttached) return; 22063 container.dataset.listenersAttached = "true"; 22064 container.addEventListener("click", (e) => { 22065 const tabBtn = e.target.closest("[data-shot-tab-btn]"); 22066 if (tabBtn) { 22067 const index = parseInt(tabBtn.dataset.shotTabBtn); 22068 switchToShot(index); 22069 return; 22070 } 22071 const removeBtn = e.target.closest("[data-remove-shot-btn]"); 22072 if (removeBtn) { 22073 e.stopPropagation(); 22074 const index = parseInt(removeBtn.dataset.removeShotBtn); 22075 removeShot(index); 22076 return; 22077 } 22078 const addBtn = e.target.closest("[data-add-shot-btn]"); 22079 if (addBtn) { 22080 addShot(); 22081 return; 22082 } 22083 }); 22084 const textarea = document.querySelector("[data-studio-video-prompt-input]"); 22085 if (textarea && !textarea.dataset.shotListenerAttached) { 22086 textarea.dataset.shotListenerAttached = "true"; 22087 textarea.addEventListener("input", () => { 22088 if (window.videoMultishot) { 22089 saveTextareaToShot(); 22090 } 22091 }); 22092 } 22093} 22094window.switchToShot = switchToShot; 22095window.addShot = addShot; 22096window.removeShot = removeShot; 22097function updateTotalDuration() { 22098 const totalDurationEl = document.querySelector("[data-total-duration]"); 22099 const totalDurationCell = document.querySelector("[data-total-duration-cell]"); 22100 const generateBtn = document.getElementById("studio-generate-btn"); 22101 if (!totalDurationEl) return; 22102 let total = 0; 22103 let isInvalid = false; 22104 if (window.videoMultishot && window.videoShots) { 22105 total = window.videoShots.reduce((sum, shot) => sum + (shot.duration || 3), 0); 22106 isInvalid = total < MIN_DURATION_SINGLE || total > MAX_DURATION; 22107 } else { 22108 total = parseInt(window.selectedDuration) || 5; 22109 } 22110 totalDurationEl.textContent = total + "s"; 22111 if (totalDurationCell) { 22112 const spans = totalDurationCell.querySelectorAll("span"); 22113 if (window.videoMultishot && isInvalid) { 22114 spans.forEach((span) => { 22115 span.classList.add("text-rose-600"); 22116 span.classList.remove("text-gray", "text-white"); 22117 }); 22118 } else { 22119 spans[0]?.classList.remove("text-rose-600"); 22120 spans[0]?.classList.add("text-gray"); 22121 spans[1]?.classList.remove("text-rose-600"); 22122 spans[1]?.classList.add("text-white"); 22123 } 22124 } 22125 if (generateBtn) { 22126 if (window.videoMultishot && isInvalid) { 22127 generateBtn.disabled = true; 22128 generateBtn.classList.add("opacity-50", "cursor-not-allowed"); 22129 generateBtn.classList.remove("hover:bg-active/90"); 22130 } else { 22131 generateBtn.disabled = false; 22132 generateBtn.classList.remove("opacity-50", "cursor-not-allowed"); 22133 generateBtn.classList.add("hover:bg-active/90"); 22134 } 22135 } 22136 window.studioManager?.updateCost(); 22137} 22138function setActiveFrame(frame) { 22139 window.videoActiveFrame = frame; 22140 localStorage.setItem("studio-video-active-frame", frame); 22141 updateFrameButtonsUI(); 22142} 22143function updateFrameButtonsUI() { 22144} 22145function updateVideoReferenceSlots() { 22146 const slot1 = document.querySelector('[data-video-ref-slot="1"]'); 22147 const slot2 = document.querySelector('[data-video-ref-slot="2"]'); 22148 slot1?.classList.remove("hidden"); 22149 slot2?.classList.remove("hidden"); 22150} 22151window.setImageSubmode = setImageSubmode; 22152window.toggleMultishot = toggleMultishot; 22153window.setActiveFrame = setActiveFrame; 22154window.setupVideoSubmodeTabs = setupVideoSubmodeTabs; 22155window.setupShotTabsListeners = setupShotTabsListeners; 22156window.setCameraTab = setImageSubmode; 22157function updateRatioButton() { 22158 const ratioButton = document.querySelector("[data-ratio-button]"); 22159 if (!ratioButton) return; 22160 const preview = ratioPreviews[window.selectedRatio]; 22161 if (preview) { 22162 ratioButton.innerHTML = ` 22163 <div class="relative w-full h-full rounded-md overflow-hidden group/img bg-inside bg-cover bg-center" style="background-image: url('${preview}')"> 22164 </div> 22165 `; 22166 } else { 22167 ratioButton.innerHTML = ` 22168 <div class="w-full h-full rounded-md bg-inside flex items-center justify-center">
22169 <span class="text-[9px] uppercase text-gray group-hover:text-white transition">${window.selectedRatio}</span> 22170 </div> 22171 `; 22172 } 22173} 22174function saveCameraSelections() { 22175 if (window.cameraSelections["device"]) { 22176 localStorage.setItem("studio-image-device", JSON.stringify(window.cameraSelections["device"])); 22177 } else { 22178 localStorage.removeItem("studio-image-device"); 22179 } 22180 ["lens", "focal", "aperture", "color grade"].forEach((cat) => { 22181 const key = storageKeys[cat]; 22182 if (window.cameraSelections[cat]) { 22183 localStorage.setItem(key, JSON.stringify(window.cameraSelections[cat])); 22184 } else { 22185 localStorage.removeItem(key); 22186 } 22187 }); 22188} 22189function loadCameraSelections() { 22190 const savedDevice = localStorage.getItem("studio-image-device"); 22191 if (savedDevice) { 22192 try { 22193 const device = JSON.parse(savedDevice); 22194 window.cameraSelections["device"] = device; 22195 } catch (e) { 22196 } 22197 } 22198 ["lens", "focal", "aperture", "color grade"].forEach((cat) => { 22199 const key = storageKeys[cat]; 22200 const saved = localStorage.getItem(key); 22201 if (saved) { 22202 try { 22203 window.cameraSelections[cat] = JSON.parse(saved); 22204 } catch (e) { 22205 } 22206 } 22207 }); 22208 const savedRatio = localStorage.getItem("studio-image-ratio"); 22209 if (savedRatio) { 22210 window.selectedRatio = savedRatio; 22211 } 22212 setDefaultCameraSelections(); 22213} 22214function setDefaultCameraSelections() { 22215 const dataEl = document.getElementById("studio-camera-prompts-data"); 22216 if (!dataEl) return; 22217 try { 22218 const cameraData = JSON.parse(dataEl.textContent); 22219 if (!window.cameraSelections["device"]) { 22220 const device = cameraData["camera"]?.find( 22221 (p) => p.name === "ARRI ALEXA 65" 22222 ); 22223 if (device) { 22224 window.cameraSelections["device"] = { 22225 uuid: device.uuid, 22226 name: device.name, 22227 preview: device.preview || "" 22228 }; 22229 } 22230 } 22231 if (!window.cameraSelections["lens"]) { 22232 const lens = cameraData["lens"]?.find( 22233 (p) => p.name === "ARRI Signature Prime" 22234 ); 22235 if (lens) { 22236 window.cameraSelections["lens"] = { 22237 uuid: lens.uuid, 22238 name: lens.name, 22239 preview: lens.preview || "" 22240 }; 22241 } 22242 } 22243 if (!window.cameraSelections["focal"]) { 22244 const focal = cameraData["focal"]?.find((p) => p.name === "50mm"); 22245 if (focal) { 22246 window.cameraSelections["focal"] = { 22247 uuid: focal.uuid, 22248 name: focal.name, 22249 preview: focal.preview || "" 22250 }; 22251 } 22252 } 22253 if (!window.cameraSelections["aperture"]) { 22254 const aperture = cameraData["aperture"]?.find((p) => p.name === "f/4"); 22255 if (aperture) { 22256 window.cameraSelections["aperture"] = { 22257 uuid: aperture.uuid, 22258 name: aperture.name, 22259 preview: aperture.preview || "" 22260 }; 22261 } 22262 } 22263 if (!window.cameraSelections["color grade"]) { 22264 const grade = cameraData["color grade"]?.find( 22265 (p) => p.name === "Natural Film" 22266 ); 22267 if (grade) { 22268 window.cameraSelections["color grade"] = { 22269 uuid: grade.uuid, 22270 name: grade.name, 22271 preview: grade.preview || "" 22272 }; 22273 } 22274 } 22275 } catch (e) { 22276 console.error("Error setting default camera selections:", e); 22277 } 22278} 22279window.clearSelection = function(category) { 22280 delete window.cameraSelections[category]; 22281 applyCameraSettings(); 22282 saveCameraSelections(); 22283}; 22284window.toggleSlowMotion = function(btn) { 22285 window.slowMotion = !window.slowMotion; 22286 document.querySelectorAll("[data-setting-slowmo]").forEach((el) => { 22287 el.textContent = window.slowMotion ? "On" : "Off"; 22288 }); 22289 localStorage.setItem("studio-video-slowmo", window.slowMotion ? "on" : "off"); 22290 if (window.videoMultishot && window.videoShots && window.activeShot !== void 0) { 22291 const index = window.activeShot; 22292 if (window.videoShots[index]) { 22293 window.videoShots[index].slow_motion = window.slowMotion; 22294 saveShotsToStorage(); 22295 } 22296 } 22297}; 22298window.toggleAudio = function(btn) { 22299 window.audioEnabled = !window.audioEnabled; 22300 document.querySelectorAll("[data-setting-audio]").forEach((el) => { 22301 el.textContent = window.audioEnabled ? "On" : "Off"; 22302 }); 22303 localStorage.setItem( 22304 "studio-video-audio", 22305 window.audioEnabled ? "on" : "off" 22306 ); 22307 window.studioManager?.updateCost(); 22308}; 22309window.selectedDuration = "3s"; 22310var MIN_DURATION_SINGLE = 3; 22311var MIN_DURATION_MULTI = 1; 22312var MAX_DURATION = 15; 22313function getMinDuration() { 22314 return window.videoMultishot ? MIN_DURATION_MULTI : MIN_DURATION_SINGLE; 22315} 22316function updateDurationDisplay() { 22317 document.querySelectorAll("[data-setting-duration]").forEach((el) => { 22318 el.textContent = window.selectedDuration; 22319 }); 22320 localStorage.setItem("studio-video-duration", window.selectedDuration); 22321 if (window.videoMultishot && window.videoShots && window.activeShot !== void 0) { 22322 const index = window.activeShot; 22323 if (window.videoShots[index]) { 22324 window.videoShots[index].duration = parseInt(window.selectedDuration); 22325 saveShotsToStorage(); 22326 } 22327 } 22328 window.studioManager?.updateCost(); 22329 updateTotalDuration(); 22330} 22331window.increaseDuration = function() { 22332 const current = parseInt(window.selectedDuration); 22333 if (current < MAX_DURATION) { 22334 window.selectedDuration = current + 1 + "s"; 22335 updateDurationDisplay(); 22336 } 22337}; 22338window.decreaseDuration = function() { 22339 const current = parseInt(window.selectedDuration); 22340 const minDuration = getMinDuration(); 22341 if (current > minDuration) { 22342 window.selectedDuration = current - 1 + "s"; 22343 updateDurationDisplay(); 22344 } 22345}; 22346function loadDurationSelection() { 22347 const saved = localStorage.getItem("studio-video-duration"); 22348 if (saved) { 22349 const value = parseInt(saved); 22350 const minDuration = getMinDuration(); 22351 if (value >= minDuration && value <= MAX_DURATION) { 22352 window.selectedDuration = saved; 22353 document.querySelectorAll("[data-setting-duration]").forEach((el) => { 22354 el.textContent = window.selectedDuration; 22355 }); 22356 } 22357 } 22358} 22359function loadSlowMotionSelection() { 22360 const saved = localStorage.getItem("studio-video-slowmo"); 22361 if (saved === "on") { 22362 window.slowMotion = true; 22363 document.querySelectorAll("[data-setting-slowmo]").forEach((el) => { 22364 el.textContent = "On"; 22365 }); 22366 } 22367} 22368function loadAudioSelection() { 22369 const saved = localStorage.getItem("studio-video-audio"); 22370 if (saved === "on") { 22371 window.audioEnabled = true; 22372 document.querySelectorAll("[data-setting-audio]").forEach((el) => { 22373 el.textContent = "On"; 22374 }); 22375 } 22376} 22377window.openRatioModal = function() { 22378 const existingModal = document.getElementById("ratio-modal"); 22379 if (existingModal) existingModal.remove(); 22380 const savedRatio = localStorage.getItem("studio-image-ratio"); 22381 if (savedRatio) { 22382 window.selectedRatio = savedRatio; 22383 } 22384 const ratios = [ 22385 { 22386 value: "16:9", 22387 name: "16:9", 22388 subtitle: "Widescreen, standard cinema, YouTube, TV", 22389 preview: "/16-9.png" 22390 }, 22391 { 22392 value: "21:9", 22393 name: "21:9", 22394 subtitle: "Ultra-wide, cinematic anamorphic, epic scope", 22395 preview: "/21-9.png" 22396 } 22397 ]; 22398 const modal = document.createElement("div"); 22399 modal.id = "ratio-modal"; 22400 modal.dataset.modal = "ratio-modal"; 22401 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 22402 modal.style.cssText = "align-items: flex-start; padding-top: 25vh; z-index: 9999;"; 22403 const ratiosHtml = ratios.map((r) => { 22404 const isSelected = r.value === window.selectedRatio; 22405 const selectedClass = isSelected ? "border-active bg-active/10" : "border-light/10"; 22406 const width = r.value === "16:9" ? "224px" : "294px"; 22407 const imageHtml = r.preview ? `<div class="bg-inside rounded-t-md overflow-hidden" style="width: ${width}; height: 126px;"><img src="${r.preview}" class="w-full h-full object-cover" alt="${r.name}" /></div>` : `<div class="bg-inside rounded-t-md flex items-center justify-center" style="width: ${width}; height: 126px;"><svg class="w-8 h-8 text-gray/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" stroke-width="2"/></svg></div>`; 22408 return ` 22409 <div class="ratio-option cursor-pointer rounded-md border ${selectedClass} hover:border-active/50 transition duration-300 overflow-hidden float-left mr-3 mb-3" 22410 data-ratio="${r.value}"> 22411 ${imageHtml} 22412 <div class="p-2"> 22413 <div class="text-[10px] text-gray leading-tight">
22413${r.subtitle}</div> 22414 </div> 22415 </div> 22416 `; 22417 }).join(""); 22418 modal.innerHTML = ` 22419 <div class="bg-box rounded-md mx-4 overflow-hidden flex flex-col modal-content shadow-xl" style="max-width: 800px;"> 22420 <div class="flex items-center justify-between px-4 py-3 border-b border-light/10"> 22421 <h2 class="text-sm font-medium text-white">Aspect Ratio</h2> 22422 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 22423 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 22424 </button> 22425 </div> 22426 <div class="p-4 clearfix"> 22427 ${ratiosHtml} 22428 </div> 22429 </div> 22430 `; 22431 document.body.appendChild(modal); 22432 requestAnimationFrame(() => { 22433 window.Modal.activeModal = "ratio-modal"; 22434 modal.classList.remove("hidden"); 22435 document.body.classList.add("modal-open"); 22436 requestAnimationFrame(() => { 22437 modal.classList.add("modal-showing"); 22438 }); 22439 }); 22440 modal.querySelectorAll(".ratio-option").forEach((option) => { 22441 option.addEventListener("click", () => { 22442 window.selectedRatio = option.dataset.ratio; 22443 updateRatioButton(); 22444 localStorage.setItem("studio-image-ratio", window.selectedRatio); 22445 window.Modal?.close(); 22446 }); 22447 }); 22448 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 22449 btn.addEventListener("click", () => window.Modal?.close()); 22450 }); 22451 modal.addEventListener("click", (e) => { 22452 if (e.target === modal) window.Modal?.close(); 22453 }); 22454}; 22455window.openMovementModal = function() { 22456 const existingModal = document.getElementById("movement-modal"); 22457 if (existingModal) existingModal.remove(); 22458 const movementDataSource = document.getElementById("studio-movement-prompts-data") || document.getElementById("project-movement-prompts-data"); 22459 if (!movementDataSource) return; 22460 const movementData = JSON.parse(movementDataSource.textContent); 22461 const modal = document.createElement("div"); 22462 modal.id = "movement-modal"; 22463 modal.dataset.modal = "movement-modal"; 22464 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 22465 modal.style.cssText = "align-items: flex-start; padding-top: 10vh; z-index: 9999;"; 22466 const movementsHtml = movementData.map((m) => { 22467 const isSelected = window.selectedMovement?.uuid == m.uuid; 22468 const selectedClass = isSelected ? "border-active bg-active/10" : "border-light/10"; 22469 const isVideo = m.preview && /\.(mp4|webm|mov)(\?|$)/i.test(m.preview); 22470 let mediaHtml; 22471 if (m.preview) { 22472 if (isVideo) { 22473 mediaHtml = `<div class="w-full aspect-square bg-inside rounded-t-md overflow-hidden"><video src="${m.preview}" class="w-full h-full object-cover" muted loop autoplay playsinline></video></div>`; 22474 } else { 22475 mediaHtml = `<div class="w-full aspect-square bg-inside rounded-t-md overflow-hidden"><img src="${m.preview}" class="w-full h-full object-cover" alt="${m.name}" /></div>`; 22476 } 22477 } else { 22478 mediaHtml = `<div class="w-full aspect-square bg-inside rounded-t-md flex items-center justify-center"><svg class="w-8 h-8 text-gray/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg></div>`; 22479 } 22480 return ` 22481 <div class="movement-option cursor-pointer rounded-md border ${selectedClass} hover:border-active/50 transition duration-300 overflow-hidden" 22482 data-uuid="${m.uuid}" 22483 data-movement-name="${m.name}" 22484 data-movement-preview="${m.preview || ""}"> 22485 ${mediaHtml} 22486 <div class="p-2"> 22487 <div class="text-xs font-medium text-white line-clamp-1 text-center">${m.name}</div> 22488 </div> 22489 </div> 22490 `; 22491 }).join(""); 22492 modal.innerHTML = ` 22493 <div class="bg-box rounded-md w-full max-w-4xl mx-4 max-h-[80vh] overflow-hidden flex flex-col modal-content shadow-xl"> 22494 <div class="flex items-center justify-between p-4 border-b border-light/10"> 22495 <h2 class="text-sm font-medium text-white">Camera Movement</h2> 22496 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 22497 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 22498 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 22499 </svg> 22500 </button> 22501 </div> 22502 <div class="flex-1 overflow-y-auto p-4"> 22503 <div class="grid grid-cols-6 gap-3"> 22504 ${movementsHtml} 22505 </div> 22506 </div> 22507 </div> 22508 `; 22509 document.body.appendChild(modal); 22510 requestAnimationFrame(() => {
22511 window.Modal.activeModal = "movement-modal"; 22512 modal.classList.remove("hidden"); 22513 document.body.classList.add("modal-open"); 22514 requestAnimationFrame(() => { 22515 modal.classList.add("modal-showing"); 22516 }); 22517 }); 22518 modal.querySelectorAll(".movement-option").forEach((option) => { 22519 option.addEventListener("click", () => { 22520 window.selectedMovement = { 22521 uuid: option.dataset.uuid, 22522 name: option.dataset.movementName, 22523 preview: option.dataset.movementPreview 22524 }; 22525 updateMovementName(); 22526 updateMovementPreview(); 22527 localStorage.setItem( 22528 "studio-video-movement", 22529 JSON.stringify(window.selectedMovement) 22530 ); 22531 if (window.videoMultishot && window.videoShots && window.activeShot !== void 0) { 22532 const index = window.activeShot; 22533 if (window.videoShots[index]) { 22534 window.videoShots[index].movement = window.selectedMovement.uuid; 22535 saveShotsToStorage(); 22536 } 22537 } 22538 window.Modal?.close(); 22539 }); 22540 }); 22541 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 22542 btn.addEventListener("click", () => window.Modal?.close()); 22543 }); 22544 modal.addEventListener("click", (e) => { 22545 if (e.target === modal) window.Modal?.close(); 22546 }); 22547}; 22548window.openCameraModal = function(category) { 22549 const existingModal = document.getElementById("camera-modal"); 22550 if (existingModal) existingModal.remove(); 22551 const cameraDataEl = document.getElementById("studio-camera-prompts-data") || document.getElementById("project-camera-prompts-data"); 22552 if (!cameraDataEl) return; 22553 const cameraData = JSON.parse(cameraDataEl.textContent); 22554 const modal = document.createElement("div"); 22555 modal.id = "camera-modal"; 22556 modal.dataset.modal = "camera-modal"; 22557 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 22558 const paddingTop = { focal: "12vh", "color grade": "17vh", aperture: "25vh" }[category] || "10vh"; 22559 modal.style.cssText = `align-items: flex-start; padding-top: ${paddingTop}; z-index: 9999;`; 22560 const titles = { 22561 camera: "Camera", 22562 lens: "Lens", 22563 focal: "Focal Length", 22564 aperture: "Aperture", 22565 "color grade": "Color Grade" 22566 }; 22567 const modalTitle = titles[category] || "Settings"; 22568 let contentHtml = ""; 22569 const prompts = cameraData[category] || []; 22570 if (category === "camera") { 22571 const promptsHtml = prompts.map((p) => { 22572 const labelCategory = p.label === "digital" ? "camera-digital" : "camera-film"; 22573 const isSelected = window.cameraSelections["device"]?.uuid == p.uuid; 22574 const selectedClass = isSelected ? "border-active bg-active/10" : "border-light/10"; 22575 const badgeHtml = p.label ? `<span class="absolute top-1.5 right-1.5 px-1.5 py-0.5 text-[6px] uppercase font-medium bg-black/50 backdrop-blur-sm rounded text-white/80">${p.label}</span>` : ""; 22576 const imageHtml = p.preview ? `<div class="relative w-full aspect-5/3 bg-inside rounded-t-md overflow-hidden"><img src="${p.preview}" class="w-full h-full object-cover" alt="${p.name}" />${badgeHtml}</div>` : `<div class="relative w-full aspect-5/3 bg-inside rounded-t-md flex items-center justify-center"><svg class="w-8 h-8 text-gray/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><circle cx="12" cy="13" r="3"/></svg>${badgeHtml}</div>`; 22577 return ` 22578 <div class="camera-option cursor-pointer rounded-md border ${selectedClass} hover:border-active/50 transition duration-300 overflow-hidden" 22579 data-prompt-uuid="${p.uuid}" 22580 data-category="${labelCategory}" 22581 data-name="${p.name}" 22582 data-preview="${p.preview || ""}"> 22583 ${imageHtml} 22584 <div class="p-2"> 22585 <div class="text-xs font-medium text-white line-clamp-1">${p.name}</div> 22586 ${p.subtitle ? `<div class="text-[10px] text-gray mt-0.5 line-clamp-3 leading-tight">${p.subtitle}</div>` : ""} 22587 </div> 22588 </div> 22589 `; 22590 }).join(""); 22591 contentHtml = ` 22592 <div class="grid grid-cols-4 gap-3"> 22593 ${promptsHtml} 22594 </div> 22595 `; 22596 } else { 22597 const lineClamp = category === "color grade" ? "line-clamp-4" : "line-clamp-3"; 22598 const aspectClass = category === "focal" || category === "aperture" ? "aspect-square" : "aspect-5/3"; 22599 const promptsHtml = prompts.map((p) => { 22600 const isSelected = window.cameraSelections[category]?.uuid == p.uuid; 22601 const selectedClass = isSelected ? "border-active bg-active/10" : "border-light/10"; 22602 const imageHtml = p.preview ? `<div class="w-full ${aspectClass} bg-inside rounded-t-md overflow-hidden"><img src="${p.preview}" class="w-full h-full object-cover" alt="${p.name}" /></div>` : `<div class="w-full ${aspectClass} bg-inside rounded-t-md flex items-center justify-center"><svg class="w-8 h-8 text-gray/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
22602<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/><circle cx="12" cy="13" r="3"/></svg></div>`; 22603 const textHtml = category === "focal" || category === "aperture" ? p.subtitle ? `<div class="p-2"><div class="text-[10px] text-gray ${lineClamp} leading-tight">${p.subtitle}</div></div>` : "" : `<div class="p-2"> 22604 <div class="text-xs font-medium text-white line-clamp-1">${p.name}</div> 22605 ${p.subtitle ? `<div class="text-[10px] text-gray mt-0.5 ${lineClamp} leading-tight">${p.subtitle}</div>` : ""} 22606 </div>`; 22607 return ` 22608 <div class="camera-option cursor-pointer rounded-md border ${selectedClass} hover:border-active/50 transition duration-300 overflow-hidden" 22609 data-prompt-uuid="${p.uuid}" 22610 data-category="${category}" 22611 data-name="${p.name}" 22612 data-preview="${p.preview || ""}"> 22613 ${imageHtml}${textHtml} 22614 </div> 22615 `; 22616 }).join(""); 22617 const gridCols = category === "color grade" ? "grid-cols-3" : "grid-cols-4"; 22618 contentHtml = ` 22619 <div class="grid ${gridCols} gap-3"> 22620 ${promptsHtml} 22621 </div> 22622 `; 22623 } 22624 modal.innerHTML = ` 22625 <div class="bg-box rounded-md w-full max-w-3xl mx-4 max-h-[80vh] overflow-hidden flex flex-col modal-content shadow-xl"> 22626 <div class="flex items-center justify-between p-4 border-b border-light/10"> 22627 <h2 class="text-sm font-medium text-white">${modalTitle}</h2> 22628 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 22629 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 22630 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 22631 </svg> 22632 </button> 22633 </div> 22634 22635 <div class="flex-1 overflow-y-auto p-4 space-y-4"> 22636 ${contentHtml} 22637 </div> 22638 </div> 22639 `; 22640 document.body.appendChild(modal); 22641 setupCameraModal(modal, category); 22642}; 22643function setupCameraModal(modal, category) { 22644 if (window.Modal) { 22645 window.Modal.activeModal = "camera-modal"; 22646 modal.classList.remove("hidden"); 22647 document.body.classList.add("modal-open"); 22648 requestAnimationFrame(() => { 22649 modal.classList.add("modal-showing"); 22650 }); 22651 } 22652 const selectionKey = category === "camera" ? "device" : category; 22653 modal.querySelectorAll(".camera-option").forEach((option) => { 22654 option.addEventListener("click", () => { 22655 const promptUuid = option.dataset.promptUuid; 22656 if (window.cameraSelections[selectionKey]?.uuid == promptUuid) { 22657 delete window.cameraSelections[selectionKey]; 22658 } else { 22659 window.cameraSelections[selectionKey] = { 22660 uuid: promptUuid, 22661 name: option.dataset.name, 22662 preview: option.dataset.preview 22663 }; 22664 } 22665 applyCameraSettings(); 22666 saveCameraSelections(); 22667 window.Modal?.close(); 22668 }); 22669 }); 22670 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 22671 btn.addEventListener("click", () => { 22672 window.Modal?.close(); 22673 }); 22674 }); 22675 modal.addEventListener("click", (e) => { 22676 if (e.target === modal) { 22677 window.Modal?.close(); 22678 } 22679 }); 22680} 22681function applyCameraSettings() { 22682 const device = window.cameraSelections["device"]; 22683 const deviceButton = document.querySelector("[data-device-button]"); 22684 if (device?.preview && deviceButton) { 22685 deviceButton.innerHTML = ` 22686 <div class="relative w-full aspect-square rounded-md overflow-hidden group/img bg-white bg-cover bg-center" style="background-image: url('${device.preview}')"> 22687 <span class="absolute bottom-0 left-0 right-0 text-[6px] font-medium text-white bg-black/60 px-0.5 py-0.5 truncate text-center">${device.name}</span> 22688 <button onclick="event.stopPropagation(); clearSelection('device')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22689 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
22689<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22690 </button> 22691 </div> 22692 `; 22693 } else if (device && deviceButton) { 22694 deviceButton.innerHTML = ` 22695 <div class="relative w-full aspect-square rounded-md bg-inside flex items-center justify-center group/img"> 22696 <span class="text-[7px] uppercase text-white text-center leading-tight px-0.5 line-clamp-2">${device.name}</span> 22697 <button onclick="event.stopPropagation(); clearSelection('device')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22698 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22699 </button> 22700 </div> 22701 `; 22702 } else if (deviceButton) { 22703 deviceButton.innerHTML = ` 22704 <div class="w-full aspect-square rounded-md bg-inside flex items-center justify-center"> 22705 <span class="text-[9px] uppercase text-gray group-hover:text-white transition">Camera</span> 22706 </div> 22707 `; 22708 } 22709 const lens = window.cameraSelections["lens"]; 22710 const lensButton = document.querySelector("[data-lens-button]"); 22711 if (lens?.preview && lensButton) { 22712 lensButton.innerHTML = ` 22713 <div class="relative w-full aspect-square rounded-md overflow-hidden group/img bg-white bg-cover bg-center" style="background-image: url('${lens.preview}')"> 22714 <span class="absolute bottom-0 left-0 right-0 text-[6px] font-medium text-white bg-black/60 px-0.5 py-0.5 truncate text-center">${lens.name}</span> 22715 <button onclick="event.stopPropagation(); clearSelection('lens')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22716 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22717 </button> 22718 </div> 22719 `; 22720 } else if (lens && lensButton) { 22721 lensButton.innerHTML = ` 22722 <div class="relative w-full aspect-square rounded-md bg-inside flex items-center justify-center group/img"> 22723 <span class="text-[7px] uppercase text-white text-center leading-tight px-0.5 line-clamp-2">${lens.name}</span> 22724 <button onclick="event.stopPropagation(); clearSelection('lens')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22725 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22726 </button> 22727 </div> 22728 `; 22729 } else if (lensButton) { 22730 lensButton.innerHTML = ` 22731 <div class="w-full aspect-square rounded-md bg-inside flex items-center justify-center"> 22732 <span class="text-[9px] uppercase text-gray group-hover:text-white transition">Lens</span> 22733 </div> 22734 `; 22735 } 22736 const focal = window.cameraSelections["focal"]; 22737 const focalButton = document.querySelector("[data-focal-button]"); 22738 if (focal?.preview && focalButton) { 22739 focalButton.innerHTML = ` 22740 <div class="relative w-full aspect-square rounded-md overflow-hidden group/img bg-inside bg-cover bg-center" style="background-image: url('${focal.preview}')"> 22741 <button onclick="event.stopPropagation(); clearSelection('focal')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22742 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22743 </button> 22744 </div> 22745 `; 22746 } else if (focalButton) { 22747 focalButton.innerHTML = ` 22748 <div class="w-full aspect-square rounded-md bg-inside flex items-center justify-center">
22749 <span class="text-[9px] uppercase text-gray group-hover:text-white transition">Focal</span> 22750 </div> 22751 `; 22752 } 22753 const aperture = window.cameraSelections["aperture"]; 22754 const apertureButton = document.querySelector("[data-aperture-button]"); 22755 if (aperture?.preview && apertureButton) { 22756 apertureButton.innerHTML = ` 22757 <div class="relative w-full aspect-square rounded-md overflow-hidden group/img bg-inside bg-cover bg-center" style="background-image: url('${aperture.preview}')"> 22758 <button onclick="event.stopPropagation(); clearSelection('aperture')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22759 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22760 </button> 22761 </div> 22762 `; 22763 } else if (apertureButton) { 22764 apertureButton.innerHTML = ` 22765 <div class="w-full aspect-square rounded-md bg-inside flex items-center justify-center"> 22766 <span class="text-[8px] uppercase text-gray group-hover:text-white transition">Aperture</span> 22767 </div> 22768 `; 22769 } 22770 const grade = window.cameraSelections["color grade"]; 22771 const gradeButton = document.querySelector("[data-grade-button]"); 22772 if (grade?.preview && gradeButton) { 22773 gradeButton.innerHTML = ` 22774 <div class="relative w-full aspect-square rounded-md overflow-hidden group/img bg-inside bg-cover bg-center" style="background-image: url('${grade.preview}')"> 22775 <span class="absolute bottom-0 left-0 right-0 text-[6px] font-medium text-white bg-black/60 px-0.5 py-0.5 truncate text-center">${grade.name}</span> 22776 <button onclick="event.stopPropagation(); clearSelection('color grade')" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22777 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22778 </button> 22779 </div> 22780 `; 22781 } else if (gradeButton) { 22782 gradeButton.innerHTML = ` 22783 <div class="w-full aspect-square rounded-md bg-inside flex items-center justify-center"> 22784 <span class="text-[9px] uppercase text-gray group-hover:text-white transition">Grade</span> 22785 </div> 22786 `; 22787 } 22788} 22789window.openAnimationModal = function() { 22790 const existingModal = document.getElementById("animation-modal"); 22791 if (existingModal) existingModal.remove(); 22792 const animationDataSource = document.getElementById("studio-animation-prompts-data") || document.getElementById("project-animation-prompts-data"); 22793 if (!animationDataSource) return; 22794 const animationData = JSON.parse(animationDataSource.textContent); 22795 const modal = document.createElement("div"); 22796 modal.id = "animation-modal"; 22797 modal.dataset.modal = "animation-modal"; 22798 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 22799 modal.style.cssText = "align-items: flex-start; padding-top: 12vh; z-index: 9999;"; 22800 const prompts = Array.isArray(animationData) ? animationData : animationData.all || []; 22801 const promptsHtml = prompts.map((p) => { 22802 const isSelected = window.animationSelections.style?.uuid == p.uuid; 22803 const selectedClass = isSelected ? "border-active bg-active/10" : "border-light/10"; 22804 const imageHtml = p.preview ? `<div class="w-full aspect-5/3 bg-inside rounded-t-md overflow-hidden"><img src="${p.preview}" class="w-full h-full object-cover" alt="${p.name}" /></div>` : `<div class="w-full aspect-5/3 bg-inside rounded-t-md flex items-center justify-center"><svg class="w-8 h-8 text-gray/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg></div>`; 22805 return ` 22806 <div class="animation-option cursor-pointer rounded-md border ${selectedClass} hover:border-active/50 transition duration-300 overflow-hidden" 22807 data-prompt-uuid="${p.uuid}" 22808 data-name="${p.name}" 22809 data-preview="${p.preview || ""}"> 22810 ${imageHtml} 22811 <div class="p-2"> 22812 <div class="text-xs font-medium text-white line-clamp-1">${p.name}</div> 22813 ${p.subtitle ? `<div class="text-[10px] text-gray mt-0.5 line-clamp-3 leading-tight">${p.subtitle}</div>` : ""} 22814 </div> 22815 </div> 22816 `; 22817 }).join(""); 22818 modal.innerHTML = ` 22819 <div class="bg-box rounded-md w-full max-w-3xl mx-4 max-h-[80vh] overflow-hidden flex flex-col modal-content shadow-xl"> 22820 <div class="flex items-center justify-between p-4 border-b border-light/10"> 22821 <h2 class="text-sm font-medium text-white">Animation Style</h2> 22822 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 22823 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 22824 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 22825 </svg> 22826 </button> 22827 </div> 22828 22829 <div class="flex-1 overflow-y-auto p-4 space-y-4"> 22830 <div class="grid grid-cols-4 gap-3"> 22831 ${promptsHtml} 22832 </div> 22833 </div> 22834 </div> 22835 `; 22836 document.body.appendChild(modal); 22837 setupAnimationModal(modal); 22838}; 22839function setupAnimationModal(modal) {
22840 if (window.Modal) { 22841 window.Modal.activeModal = "animation-modal"; 22842 modal.classList.remove("hidden"); 22843 document.body.classList.add("modal-open"); 22844 requestAnimationFrame(() => { 22845 modal.classList.add("modal-showing"); 22846 }); 22847 } 22848 modal.querySelectorAll(".animation-option").forEach((option) => { 22849 option.addEventListener("click", () => { 22850 const promptUuid = option.dataset.promptUuid; 22851 if (window.animationSelections.style?.uuid == promptUuid) { 22852 delete window.animationSelections.style; 22853 } else { 22854 window.animationSelections.style = { 22855 uuid: promptUuid, 22856 name: option.dataset.name, 22857 preview: option.dataset.preview 22858 }; 22859 } 22860 applyAnimationSettings(); 22861 saveAnimationSelections(); 22862 window.Modal?.close(); 22863 }); 22864 }); 22865 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 22866 btn.addEventListener("click", () => { 22867 window.Modal?.close(); 22868 }); 22869 }); 22870 modal.addEventListener("click", (e) => { 22871 if (e.target === modal) { 22872 window.Modal?.close(); 22873 } 22874 }); 22875} 22876function saveAnimationSelections() { 22877 if (window.animationSelections.style) { 22878 localStorage.setItem( 22879 ANIMATION_STORAGE_KEY, 22880 JSON.stringify(window.animationSelections.style) 22881 ); 22882 } else {
22883 localStorage.removeItem(ANIMATION_STORAGE_KEY); 22884 } 22885} 22886function loadAnimationSelections() { 22887 const saved = localStorage.getItem(ANIMATION_STORAGE_KEY); 22888 if (saved) { 22889 try { 22890 window.animationSelections.style = JSON.parse(saved); 22891 } catch (e) { 22892 } 22893 } 22894 setDefaultAnimationSelection(); 22895} 22896function setDefaultAnimationSelection() { 22897 if (window.animationSelections.style) return; 22898 const dataEl = document.getElementById("studio-animation-prompts-data"); 22899 if (!dataEl) return; 22900 try { 22901 const animData = JSON.parse(dataEl.textContent); 22902 const arcane = animData?.find((p) => p.name === "Arcane"); 22903 if (arcane) { 22904 window.animationSelections.style = { 22905 uuid: arcane.uuid, 22906 name: arcane.name, 22907 preview: arcane.preview || "" 22908 }; 22909 } 22910 } catch (e) { 22911 console.error("Error setting default animation selection:", e); 22912 } 22913} 22914window.clearAnimationSelection = function() { 22915 delete window.animationSelections.style; 22916 applyAnimationSettings(); 22917 saveAnimationSelections(); 22918}; 22919function applyAnimationSettings() { 22920 const button = document.querySelector("[data-anim-style-button]"); 22921 if (!button) return; 22922 const selection = window.animationSelections?.style; 22923 if (selection?.preview) { 22924 button.innerHTML = ` 22925 <div class="relative w-full h-full rounded-md overflow-hidden group/img bg-inside bg-cover bg-center" style="background-image: url('${selection.preview}')"> 22926 <span class="absolute bottom-0 left-0 right-0 text-[6px] font-medium text-white bg-black/60 px-0.5 py-0.5 truncate text-center">${selection.name}</span> 22927 <button onclick="event.stopPropagation(); clearAnimationSelection()" class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-dark rounded-md flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition-opacity cursor-pointer"> 22928 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 22929 </button> 22930 </div> 22931 `; 22932 } else if (selection?.name) { 22933 button.innerHTML = ` 22934 <div class="w-full h-full rounded-md bg-inside flex items-center justify-center"> 22935 <span class="text-[9px] uppercase text-white transition duration-300 ease">${selection.name}</span> 22936 </div> 22937 `; 22938 } else { 22939 button.innerHTML = ` 22940 <div class="w-full h-full rounded-md bg-inside flex items-center justify-center"> 22941 <span class="text-[9px] uppercase text-gray group-hover:text-white transition duration-300 ease">Style</span> 22942 </div> 22943 `; 22944 } 22945} 22946function loadMovementSelection() { 22947 const saved = localStorage.getItem("studio-video-movement"); 22948 if (saved) { 22949 try { 22950 window.selectedMovement = JSON.parse(saved); 22951 updateMovementName(); 22952 } catch (e) { 22953 } 22954 } 22955 updateMovementPreview(); 22956} 22957function updateMovementName() { 22958 const nameEl = document.querySelector("[data-setting-movement]"); 22959 if (!nameEl) return; 22960 if (window.selectedMovement?.name) { 22961 nameEl.textContent = window.selectedMovement.name; 22962 nameEl.classList.remove("hidden"); 22963 } else { 22964 nameEl.textContent = ""; 22965 nameEl.classList.add("hidden"); 22966 } 22967} 22968function updateMovementPreview() { 22969 const preview = document.querySelector("[data-movement-preview]"); 22970 const clearBtn = document.querySelector("[data-movement-clear]"); 22971 if (preview) { 22972 let previewUrl = window.selectedMovement?.preview; 22973 if (!previewUrl) { 22974 const movementDataEl = document.getElementById( 22975 "studio-movement-prompts-data" 22976 ); 22977 if (movementDataEl) { 22978 try { 22979 const movementData = JSON.parse(movementDataEl.textContent); 22980 const staticMovement = movementData.find( 22981 (m) => m.name.toLowerCase() === "static" 22982 ); 22983 if (staticMovement?.preview) { 22984 previewUrl = staticMovement.preview; 22985 } 22986 } catch (e) { 22987 } 22988 } 22989 } 22990 if (previewUrl) { 22991 const isVideo = /\.(mp4|webm|mov)(\?|$)/i.test(previewUrl); 22992 if (isVideo) { 22993 preview.innerHTML = `<video src="${previewUrl}" class="w-full h-full object-cover" muted loop autoplay playsinline></video>`; 22994 } else { 22995 preview.innerHTML = `<img src="${previewUrl}" class="w-full h-full object-cover" alt="Movement preview" />`; 22996 } 22997 } else { 22998 preview.innerHTML = ""; 22999 } 23000 } 23001 if (clearBtn) { 23002 if (window.selectedMovement) { 23003 clearBtn.classList.remove("hidden"); 23004 clearBtn.classList.add("flex"); 23005 } else { 23006 clearBtn.classList.add("hidden"); 23007 clearBtn.classList.remove("flex"); 23008 } 23009 } 23010} 23011window.clearMovement = function() { 23012 window.selectedMovement = null; 23013 localStorage.removeItem("studio-video-movement"); 23014 updateMovementName(); 23015 updateMovementPreview(); 23016}; 23017function initializeStudioSettings() { 23018 loadCameraSelections(); 23019 applyCameraSettings(); 23020 loadAnimationSelections(); 23021 applyAnimationSettings(); 23022 updateRatioButton(); 23023 loadMovementSelection(); 23024 loadDurationSelection(); 23025 loadSlowMotionSelection(); 23026 loadAudioSelection(); 23027 setupResolutionSelector(); 23028 setupImageSubmodeTabs(); 23029 setupVideoSubmodeTabs(); 23030 loadStudioMode(); 23031} 23032function loadStudioMode() { 23033 const savedMode = localStorage.getItem("studio-mode"); 23034 if (savedMode && ["image", "video"].includes(savedMode)) { 23035 if (window.studioManager) { 23036 window.studioManager.setMode(savedMode); 23037 } else { 23038 applyModeToUI(savedMode); 23039 } 23040 } 23041} 23042function applyModeToUI(mode) { 23043 const toggle = document.querySelector("[data-studio-mode-toggle]"); 23044 if (toggle) { 23045 toggle.querySelectorAll("button").forEach((btn) => { 23046 if (btn.dataset.mode === mode) { 23047 btn.classList.add("text-white", "bg-active/60"); 23048 btn.classList.remove("text-gray", "hover:text-white"); 23049 } else { 23050 btn.classList.remove("text-white", "bg-active/60"); 23051 btn.classList.add("text-gray", "hover:text-white"); 23052 } 23053 }); 23054 } 23055 const imageSubmodeTabs = document.querySelector('[data-submode-tabs="image"]'); 23056 const videoSubmodeTabs = document.querySelector('[data-submode-tabs="video"]'); 23057 if (imageSubmodeTabs && videoSubmodeTabs) { 23058 if (mode === "video") { 23059 imageSubmodeTabs.classList.add("hidden"); 23060 videoSubmodeTabs.classList.remove("hidden"); 23061 } else { 23062 imageSubmodeTabs.classList.remove("hidden"); 23063 videoSubmodeTabs.classList.add("hidden"); 23064 } 23065 } 23066 const imagePromptArea = document.querySelector("[data-studio-prompt-area]"); 23067 const videoPromptWrapper = document.querySelector("[data-studio-video-wrapper]"); 23068 const resolutionSelector = document.querySelector("[data-resolution-selector]"); 23069 const audioCell = document.querySelector("[data-audio-cell]"); 23070 const totalDurationCell = document.querySelector("[data-total-duration-cell]"); 23071 if (mode === "video") { 23072 imagePromptArea?.classList.add("hidden"); 23073 videoPromptWrapper?.classList.remove("hidden"); 23074 resolutionSelector?.classList.add("hidden"); 23075 audioCell?.classList.remove("hidden"); 23076 if (window.videoMultishot) { 23077 totalDurationCell?.classList.remove("hidden"); 23078 } else { 23079 totalDurationCell?.classList.add("hidden"); 23080 } 23081 updateTotalDuration(); 23082 updateVideoReferenceSlots(); 23083 } else { 23084 imagePromptArea?.classList.remove("hidden"); 23085 videoPromptWrapper?.classList.add("hidden"); 23086 resolutionSelector?.classList.remove("hidden"); 23087 audioCell?.classList.add("hidden"); 23088 totalDurationCell?.classList.add("hidden"); 23089 } 23090} 23091window.StudioManager = class StudioManager2 {
23092 constructor() { 23093 const savedMode = localStorage.getItem("studio-mode"); 23094 this.mode = savedMode && ["image", "video"].includes(savedMode) ? savedMode : "image"; 23095 this.count = 1; 23096 this.ratio = "16:9"; 23097 this.camera = null; 23098 this.references = { 1: null, 2: null }; 23099 this.isGenerating = false; 23100 this.pollTimeout = null; 23101 this.pollActive = false; 23102 this.pendingRequests = []; 23103 this.imageModels = []; 23104 this.videoModels = []; 23105 this.loadModels(); 23106 this.handlers = { 23107 documentClick: this.handleDocumentClick.bind(this), 23108 keydown: this.handleKeydown.bind(this) 23109 }; 23110 this.init(); 23111 } 23112 init() { 23113 this.attachEventListeners(); 23114 this.setupTextarea(); 23115 this.setRatio(this.ratio); 23116 this.updateCost(); 23117 this.startProgressUpdates(); 23118 this.setupInfiniteScroll(); 23119 this._editBarMode = this.mode; 23120 this.initMentions(); 23121 const imagePromptEl = document.querySelector("[data-studio-prompt-input]"); 23122 if (imagePromptEl) this.bindMentions(imagePromptEl); 23123 const videoPromptEl = document.querySelector("[data-studio-video-prompt-input]"); 23124 if (videoPromptEl) this.bindMentions(videoPromptEl); 23125 if (this.mode !== "image") { 23126 this.applyModeUI(this.mode); 23127 } 23128 } 23129 setupInfiniteScroll() { 23130 this.isLoadingMore = false; 23131 this.scrollHandler = this.handleScroll.bind(this); 23132 window.addEventListener("scroll", this.scrollHandler); 23133 } 23134 handleScroll() { 23135 if (this.isLoadingMore) return; 23136 const grid = document.querySelector("[data-studio-grid]"); 23137 if (!grid) return; 23138 const hasMore = grid.dataset.hasMore === "true"; 23139 if (!hasMore) return; 23140 const scrollBottom = window.innerHeight + window.scrollY; 23141 const docHeight = document.documentElement.scrollHeight; 23142 if (docHeight - scrollBottom < 300) { 23143 this.loadMore(); 23144 } 23145 } 23146 async loadMore() { 23147 const grid = document.querySelector("[data-studio-grid]"); 23148 if (!grid) return; 23149 const currentPage = parseInt(grid.dataset.page) || 1; 23150 const sequence = grid.dataset.sequence || ""; 23151 const nextPage = currentPage + 1; 23152 this.isLoadingMore = true; 23153 const loadingEl = document.querySelector("[data-loading-more]"); 23154 if (loadingEl) loadingEl.classList.remove("hidden"); 23155 try { 23156 const url = `/studio/more?page=${nextPage}${sequence ? `&sequence_id=${sequence}` : ""}`; 23157 const response = await fetch(url, { 23158 headers: { "Accept": "application/json" } 23159 }); 23160 if (!response.ok) throw new Error("Failed to load more"); 23161 const data = await response.json(); 23162 const container = document.querySelector("[data-cards-container]"); 23163 if (container && data.html) { 23164 container.insertAdjacentHTML("beforeend", data.html); 23165 } 23166 grid.dataset.page = nextPage; 23167 grid.dataset.hasMore = data.has_more; 23168 } catch (error) { 23169 console.error("Error loading more:", error); 23170 } finally { 23171 this.isLoadingMore = false; 23172 if (loadingEl) loadingEl.classList.add("hidden"); 23173 } 23174 } 23175 // Progress bar methods 23176 calculateProgress(createdAt, estimatedDuration) { 23177 if (!createdAt || !estimatedDuration) return 0; 23178 const createdAtDate = new Date(createdAt); 23179 const now2 = /* @__PURE__ */ new Date(); 23180 const elapsedSeconds = (now2 - createdAtDate) / 1e3; 23181 const estimatedSeconds = parseFloat(estimatedDuration); 23182 if (isNaN(elapsedSeconds) || isNaN(estimatedSeconds) || estimatedSeconds === 0) { 23183 return 0; 23184 } 23185 const percentage = Math.floor(elapsedSeconds / estimatedSeconds * 100); 23186 return Math.min(percentage, 98); 23187 } 23188 updateAllProgressBars() { 23189 const progressBars = document.querySelectorAll("[data-studio-progress-bar]"); 23190 progressBars.forEach((bar) => { 23191 const createdAt = bar.dataset.createdAt; 23192 const estimatedDuration = parseFloat(bar.dataset.estimatedDuration); 23193 if (!createdAt || !estimatedDuration || estimatedDuration === 0) return; 23194 const percentage = this.calculateProgress(createdAt, estimatedDuration); 23195 const fill = bar.querySelector("[data-studio-progress-fill]"); 23196 if (fill) { 23197 fill.style.width = `${percentage}%`; 23198 } 23199 }); 23200 } 23201 startProgressUpdates() { 23202 if (this.progressUpdateInterval) return; 23203 this.progressUpdateInterval = setInterval(() => { 23204 this.updateAllProgressBars(); 23205 }, 100); 23206 } 23207 stopProgressUpdates() { 23208 if (this.progressUpdateInterval) { 23209 clearInterval(this.progressUpdateInterval); 23210 this.progressUpdateInterval = null; 23211 } 23212 } 23213 applyModeUI(mode) { 23214 const toggle = document.querySelector("[data-studio-mode-toggle]"); 23215 if (toggle) { 23216 toggle.querySelectorAll("button").forEach((btn) => { 23217 if (btn.dataset.mode === mode) { 23218 btn.classList.add("text-white", "bg-active/60"); 23219 btn.classList.remove("text-gray", "hover:text-white"); 23220 } else { 23221 btn.classList.remove("text-white", "bg-active/60"); 23222 btn.classList.add("text-gray", "hover:text-white"); 23223 } 23224 }); 23225 } 23226 const cameraCard = document.querySelector("[data-studio-camera]"); 23227 const movementCard = document.querySelector("[data-studio-movement]"); 23228 const resolutionSelector = document.querySelector( 23229 "[data-resolution-selector]" 23230 ); 23231 if (cameraCard && movementCard) { 23232 if (mode === "video") { 23233 cameraCard.classList.add("hidden"); 23234 movementCard.classList.remove("hidden"); 23235 resolutionSelector?.classList.add("hidden"); 23236 } else { 23237 cameraCard.classList.remove("hidden"); 23238 movementCard.classList.add("hidden"); 23239 resolutionSelector?.classList.remove("hidden"); 23240 } 23241 } 23242 const refPlusIcons = document.querySelectorAll("[data-ref-plus]"); 23243 const refLabels = document.querySelectorAll("[data-ref-label]"); 23244 const refLabelOverlays = document.querySelectorAll( 23245 "[data-ref-label-overlay]" 23246 ); 23247 refPlusIcons.forEach((plus) => { 23248 if (mode === "video") { 23249 plus.classList.add("hidden"); 23250 } else { 23251 plus.classList.remove("hidden"); 23252 } 23253 });
23254 refLabels.forEach((label) => { 23255 if (mode === "video") { 23256 label.classList.remove("hidden"); 23257 } else { 23258 label.classList.add("hidden"); 23259 } 23260 }); 23261 refLabelOverlays.forEach((label) => { 23262 if (mode === "video") { 23263 label.classList.remove("hidden"); 23264 } else { 23265 label.classList.add("hidden"); 23266 } 23267 }); 23268 this.updateSlot2Visibility(); 23269 } 23270 loadModels() { 23271 const imageModelsEl = document.getElementById("studio-models-data"); 23272 const videoModelsEl = document.getElementById("studio-video-models-data"); 23273 if (imageModelsEl) { 23274 try { 23275 this.imageModels = JSON.parse(imageModelsEl.textContent); 23276 } catch (e) { 23277 console.error("Failed to parse image models:", e); 23278 } 23279 } 23280 if (videoModelsEl) { 23281 try { 23282 this.videoModels = JSON.parse(videoModelsEl.textContent); 23283 } catch (e) { 23284 console.error("Failed to parse video models:", e); 23285 } 23286 } 23287 } 23288 attachEventListeners() { 23289 document.addEventListener("click", this.handlers.documentClick); 23290 document.addEventListener("keydown", this.handlers.keydown); 23291 this.handlers.closeDropdownsOnHover = (e) => { 23292 if (!e.target || typeof e.target.closest !== "function") return; 23293 const btn = e.target.closest("button, a"); 23294 if (!btn) return; 23295 if (!btn.classList.contains("studio-reuse-btn") && !btn.closest(".studio-reuse-dropdown")) { 23296 document.querySelectorAll(".studio-reuse-dropdown").forEach((d) => { 23297 if (!d.classList.contains("hidden")) { 23298 d.classList.add("hidden"); 23299 d.parentElement.querySelector(".studio-reuse-btn")?.classList.add("tooltip"); 23300 } 23301 }); 23302 } 23303 if (!btn.classList.contains("studio-menu-btn") && !btn.closest(".studio-dropdown")) { 23304 document.querySelectorAll(".studio-dropdown").forEach((d) => { 23305 if (!d.classList.contains("hidden")) { 23306 d.classList.add("hidden"); 23307 } 23308 }); 23309 } 23310 }; 23311 document.addEventListener( 23312 "mouseenter", 23313 this.handlers.closeDropdownsOnHover, 23314 true 23315 ); 23316 this.handlers.itemsSelected = (e) => { 23317 if (this.pendingRefSlot && e.detail?.hash && e.detail?.url) { 23318 this.showSlotLoading(this.pendingRefSlot); 23319 this.setReferenceToSlot( 23320 this.pendingRefSlot, 23321 e.detail.hash, 23322 e.detail.url 23323 ); 23324 this.pendingRefSlot = null; 23325 } 23326 }; 23327 document.addEventListener( 23328 "items-selected", 23329 this.handlers.itemsSelected 23330 ); 23331 this.handlers.generatedSelected = (e) => { 23332 if (this.pendingRefSlot && e.detail?.hash && e.detail?.url) { 23333 this.showSlotLoading(this.pendingRefSlot); 23334 const displayUrl = e.detail.thumbnail || e.detail.url; 23335 this.setReferenceToSlot(this.pendingRefSlot, e.detail.hash, displayUrl); 23336 this.pendingRefSlot = null; 23337 } 23338 }; 23339 document.addEventListener( 23340 "generated-selected", 23341 this.handlers.generatedSelected 23342 ); 23343 this.handlers.favsSelected = (e) => { 23344 if (this.pendingRefSlot && e.detail?.favs?.length) { 23345 const fav = e.detail.favs[0]; 23346 if (fav.file_hash && fav.url) { 23347 this.showSlotLoading(this.pendingRefSlot); 23348 this.setReferenceToSlot(this.pendingRefSlot, fav.file_hash, fav.url); 23349 this.pendingRefSlot = null; 23350 } 23351 } 23352 }; 23353 document.addEventListener( 23354 "favs:selected", 23355 this.handlers.favsSelected 23356 ); 23357 this.setupDragAndDrop(); 23358 } 23359 setupDragAndDrop() { 23360 this.handlers.dragStart = (e) => { 23361 const card = e.target.closest(".studio-card"); 23362 if (!card) return; 23363 if (card.dataset.status !== "completed") return; 23364 const isVideo = card.querySelector("video") !== null; 23365 if (isVideo) return; 23366 e.dataTransfer.setData("text/plain", card.dataset.requestId); 23367 e.dataTransfer.effectAllowed = "copy"; 23368 const img = card.querySelector("img"); 23369 if (img) { 23370 const dragImg = document.createElement("div"); 23371 dragImg.style.cssText = "width:52px;height:52px;border-radius:8px;overflow:hidden;position:fixed;left:-9999px;top:0;z-index:9999;"; 23372 dragImg.innerHTML = `<img src="${img.src}" style="width:100%;height:100%;object-fit:cover;">`; 23373 document.body.appendChild(dragImg); 23374 e.dataTransfer.setDragImage(dragImg, 26, 26); 23375 this.dragImageElement = dragImg; 23376 } 23377 card.classList.add("opacity-50"); 23378 document.body.style.cursor = "grabbing"; 23379 document.body.style.setProperty("cursor", "grabbing", "important"); 23380 document.querySelectorAll("[data-add-ref-btn]").forEach((btn) => { 23381 btn.classList.remove("border-light/10"); 23382 btn.classList.add("border-dashed", "border-indigo-400"); 23383 }); 23384 }; 23385 this.handlers.dragEnd = (e) => { 23386 const card = e.target.closest(".studio-card"); 23387 if (card) card.classList.remove("opacity-50"); 23388 document.body.style.cursor = ""; 23389 document.body.style.removeProperty("cursor"); 23390 if (this.dragImageElement) { 23391 this.dragImageElement.remove(); 23392 this.dragImageElement = null; 23393 }
23394 document.querySelectorAll("[data-add-ref-btn], [data-video-add-ref-btn]").forEach((btn) => { 23395 btn.classList.remove("border-dashed", "border-indigo-400", "border-indigo-300", "bg-indigo-400/10"); 23396 btn.classList.add("border-light/10"); 23397 }); 23398 }; 23399 this.handlers.dragOver = (e) => { 23400 const refBtn = e.target.closest("[data-add-ref-btn], [data-video-add-ref-btn]"); 23401 if (!refBtn) return; 23402 e.preventDefault(); 23403 e.dataTransfer.dropEffect = "copy"; 23404 refBtn.classList.remove("border-dashed", "border-indigo-400"); 23405 refBtn.classList.add("border-indigo-300", "bg-indigo-400/10"); 23406 }; 23407 this.handlers.dragLeave = (e) => { 23408 const refBtn = e.target.closest("[data-add-ref-btn], [data-video-add-ref-btn]"); 23409 if (refBtn) { 23410 refBtn.classList.remove("border-indigo-300", "bg-indigo-400/10"); 23411 refBtn.classList.add("border-dashed", "border-indigo-400"); 23412 } 23413 }; 23414 this.handlers.drop = (e) => { 23415 e.preventDefault(); 23416 const refBtn = e.target.closest("[data-add-ref-btn], [data-video-add-ref-btn]"); 23417 if (!refBtn) return; 23418 refBtn.classList.remove("border-indigo-300", "bg-indigo-400/10", "border-dashed", "border-indigo-400"); 23419 refBtn.classList.add("border-light/10"); 23420 document.body.style.cursor = ""; 23421 document.body.style.removeProperty("cursor"); 23422 const requestId = e.dataTransfer.getData("text/plain"); 23423 if (requestId) { 23424 this.addReferenceFromCard(requestId); 23425 } 23426 }; 23427 document.addEventListener("dragstart", this.handlers.dragStart); 23428 document.addEventListener("dragend", this.handlers.dragEnd); 23429 document.addEventListener("dragover", this.handlers.dragOver); 23430 document.addEventListener("dragleave", this.handlers.dragLeave); 23431 document.addEventListener("drop", this.handlers.drop); 23432 this.makeCardsDraggable(); 23433 } 23434 makeCardsDraggable() { 23435 document.querySelectorAll(".studio-card").forEach((card) => { 23436 if (card.dataset.status !== "completed") return; 23437 const isVideo = card.querySelector("video") !== null; 23438 if (isVideo) return; 23439 if (card.getAttribute("draggable") === "true") return; 23440 card.setAttribute("draggable", "true"); 23441 }); 23442 } 23443 cleanup() { 23444 if (typeof this.cleanupMentions === "function") this.cleanupMentions(); 23445 document.removeEventListener("click", this.handlers.documentClick); 23446 document.removeEventListener("keydown", this.handlers.keydown); 23447 if (this.handlers.closeDropdownsOnHover) { 23448 document.removeEventListener( 23449 "mouseenter", 23450 this.handlers.closeDropdownsOnHover, 23451 true 23452 ); 23453 } 23454 if (this.handlers.itemsSelected) { 23455 document.removeEventListener( 23456 "items-selected", 23457 this.handlers.itemsSelected 23458 ); 23459 } 23460 if (this.handlers.generatedSelected) { 23461 document.removeEventListener( 23462 "generated-selected", 23463 this.handlers.generatedSelected 23464 ); 23465 } 23466 if (this.handlers.favsSelected) { 23467 document.removeEventListener( 23468 "favs:selected", 23469 this.handlers.favsSelected 23470 ); 23471 } 23472 if (this.handlers.dragStart) { 23473 document.removeEventListener("dragstart", this.handlers.dragStart); 23474 document.removeEventListener("dragend", this.handlers.dragEnd); 23475 document.removeEventListener("dragover", this.handlers.dragOver); 23476 document.removeEventListener("dragleave", this.handlers.dragLeave); 23477 document.removeEventListener("drop", this.handlers.drop); 23478 } 23479 this.stopPolling(); 23480 if (this.scrollHandler) { 23481 window.removeEventListener("scroll", this.scrollHandler); 23482 } 23483 } 23484 handleDocumentClick(e) { 23485 const modeBtn = e.target.closest("[data-studio-mode-toggle] button"); 23486 if (modeBtn) { 23487 this.setMode(modeBtn.dataset.mode); 23488 return; 23489 } 23490 const imageSubmodeBtn = e.target.closest("[data-image-submode]"); 23491 if (imageSubmodeBtn) { 23492 setImageSubmode(imageSubmodeBtn.dataset.imageSubmode); 23493 return; 23494 } 23495 const multishotToggle = e.target.closest("[data-multishot-toggle]"); 23496 if (multishotToggle) { 23497 toggleMultishot(); 23498 return; 23499 } 23500 const removeVideoFrameBtn = e.target.closest("[data-remove-video-frame]"); 23501 if (removeVideoFrameBtn) { 23502 e.stopPropagation(); 23503 const slot = parseInt(removeVideoFrameBtn.dataset.removeVideoFrame); 23504 this.removeReferenceFromSlot(slot); 23505 return; 23506 }
23507 const videoFrameBtn = e.target.closest("[data-video-frame]"); 23508 if (videoFrameBtn) { 23509 const isAuthenticated = document.querySelector("[data-studio]")?.dataset.authenticated === "true"; 23510 if (!isAuthenticated) { 23511 window.loadSigninModal?.(); 23512 return; 23513 } 23514 const frame = videoFrameBtn.dataset.videoFrame; 23515 const slot = frame === "start" ? 1 : 2; 23516 this.showRefContextMenu(videoFrameBtn, slot); 23517 return; 23518 } 23519 const countBtn = e.target.closest("[data-studio-count] button"); 23520 if (countBtn) { 23521 if (countBtn.dataset.action === "increase" && this.count < 4) { 23522 this.count++; 23523 } else if (countBtn.dataset.action === "decrease" && this.count > 1) { 23524 this.count--; 23525 } 23526 this.updateCountDisplay(); 23527 this.updateCost(); 23528 return; 23529 } 23530 const ratioTrigger = e.target.closest('[data-dropdown-trigger="ratio"]'); 23531 if (ratioTrigger) { 23532 const dropdown = document.querySelector('[data-dropdown="ratio"]'); 23533 dropdown?.classList.toggle("hidden"); 23534 return; 23535 } 23536 const ratioBtn = e.target.closest("[data-ratio]"); 23537 if (ratioBtn) { 23538 this.setRatio(ratioBtn.dataset.ratio); 23539 document.querySelector('[data-dropdown="ratio"]')?.classList.add("hidden"); 23540 return; 23541 } 23542 const generateBtn = e.target.closest("[data-studio-generate]"); 23543 if (generateBtn) { 23544 this.generate(); 23545 return; 23546 } 23547 const addRefBtn = e.target.closest("[data-add-ref-btn], [data-video-add-ref-btn]"); 23548 if (addRefBtn) { 23549 const isAuthenticated = document.querySelector("[data-studio]")?.dataset.authenticated === "true"; 23550 if (!isAuthenticated) { 23551 window.loadSigninModal?.(); 23552 return; 23553 } 23554 const slot = parseInt(addRefBtn.dataset.addRefBtn || addRefBtn.dataset.videoAddRefBtn); 23555 this.showRefContextMenu(addRefBtn, slot); 23556 return; 23557 } 23558 const removeRefBtn = e.target.closest("[data-remove-ref-btn], [data-video-remove-ref-btn]"); 23559 if (removeRefBtn) { 23560 e.stopPropagation(); 23561 const slot = parseInt(removeRefBtn.dataset.removeRefBtn || removeRefBtn.dataset.videoRemoveRefBtn); 23562 this.removeReferenceFromSlot(slot); 23563 return; 23564 } 23565 const refPreview = e.target.closest("[data-ref-preview], [data-video-ref-preview]"); 23566 if (refPreview && !e.target.closest("[data-remove-ref-btn], [data-video-remove-ref-btn]")) { 23567 const slot = parseInt(refPreview.dataset.refPreview || refPreview.dataset.videoRefPreview); 23568 const ref = this.references[slot]; 23569 if (ref?.hash) { 23570 this.openMediaByHash(ref.hash); 23571 } 23572 return; 23573 } 23574 const menuBtn = e.target.closest(".studio-menu-btn"); 23575 if (menuBtn) { 23576 e.stopPropagation(); 23577 const dropdown = menuBtn.parentElement.querySelector(".studio-dropdown"); 23578 document.querySelectorAll(".studio-dropdown").forEach((d) => { 23579 if (d !== dropdown) d.classList.add("hidden"); 23580 }); 23581 document.querySelectorAll(".studio-reuse-dropdown").forEach((d) => d.classList.add("hidden")); 23582 dropdown?.classList.toggle("hidden"); 23583 return; 23584 } 23585 const reuseBtn = e.target.closest(".studio-reuse-btn"); 23586 if (reuseBtn) { 23587 e.stopPropagation(); 23588 const dropdown = reuseBtn.parentElement.querySelector( 23589 ".studio-reuse-dropdown" 23590 ); 23591 document.querySelectorAll(".studio-dropdown").forEach((d) => d.classList.add("hidden")); 23592 document.querySelectorAll(".studio-reuse-dropdown").forEach((d) => { 23593 if (d !== dropdown) { 23594 d.classList.add("hidden"); 23595 d.parentElement.querySelector(".studio-reuse-btn")?.classList.add("tooltip"); 23596 } 23597 }); 23598 const isOpening = dropdown?.classList.toggle("hidden") === false; 23599 if (isOpening) { 23600 reuseBtn.classList.remove("tooltip"); 23601 } else { 23602 reuseBtn.classList.add("tooltip"); 23603 } 23604 return; 23605 } 23606 const menuDelete = e.target.closest(".studio-menu-delete"); 23607 if (menuDelete) { 23608 e.stopPropagation(); 23609 const card2 = menuDelete.closest(".studio-card"); 23610 if (card2) { 23611 this.deleteRequest(card2.dataset.requestId); 23612 } 23613 document.querySelectorAll(".studio-dropdown").forEach((d) => d.classList.add("hidden")); 23614 return; 23615 } 23616 const menuMove = e.target.closest(".studio-menu-move"); 23617 if (menuMove) { 23618 e.stopPropagation(); 23619 const card2 = menuMove.closest(".studio-card"); 23620 if (card2) { 23621 const requestId = card2.dataset.requestId; 23622 const currentSequence = menuMove.dataset.currentSequence || ""; 23623 this.openMoveModal(requestId, currentSequence); 23624 } 23625 document.querySelectorAll(".studio-dropdown").forEach((d) => d.classList.add("hidden")); 23626 return; 23627 } 23628 const actionBtn = e.target.closest("[data-action]"); 23629 if (actionBtn) { 23630 const card2 = actionBtn.closest(".studio-card"); 23631 if (card2) { 23632 const requestId = card2.dataset.requestId; 23633 const action = actionBtn.dataset.action; 23634 this.handleCardAction(requestId, action); 23635 } 23636 return; 23637 } 23638 const card = e.target.closest(".studio-card"); 23639 if (card && !e.target.closest("a") && !e.target.closest(".studio-menu-btn") && !e.target.closest(".studio-dropdown") && card.dataset.mediaId) { 23640 this.openMediaViewer(card.dataset.mediaId); 23641 return; 23642 } 23643 if (!e.target.closest(".studio-menu-btn") && !e.target.closest(".studio-dropdown")) { 23644 document.querySelectorAll(".studio-dropdown").forEach((d) => d.classList.add("hidden")); 23645 } 23646 if (!e.target.closest(".studio-reuse-btn") && !e.target.closest(".studio-reuse-dropdown")) { 23647 document.querySelectorAll(".studio-reuse-dropdown").forEach((d) => d.classList.add("hidden")); 23648 } 23649 if (!e.target.closest("[data-dropdown]") && !e.target.closest("[data-dropdown-trigger]")) { 23650 document.querySelectorAll('[data-dropdown="ratio"]').forEach((d) => d.classList.add("hidden")); 23651 } 23652 } 23653 handleKeydown(e) { 23654 if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { 23655 const prompt2 = document.querySelector("[data-studio-prompt]"); 23656 if (prompt2 && document.activeElement === prompt2) { 23657 e.preventDefault(); 23658 this.generate(); 23659 } 23660 } 23661 } 23662 setupTextarea() { 23663 const textarea = document.querySelector("[data-studio-prompt]"); 23664 if (!textarea) return; 23665 const savedHtml = localStorage.getItem("studio-prompt-html"); 23666 const savedPlain = !savedHtml ? localStorage.getItem("studio-prompt") : null; 23667 if (savedHtml || savedPlain) { 23668 if (savedHtml) { 23669 textarea.innerHTML = savedHtml; 23670 } else { 23671 textarea.textContent = savedPlain; 23672 localStorage.removeItem("studio-prompt"); 23673 } 23674 this._upgradePlainMentions(textarea); 23675 if (textarea.textContent.trim()) { 23676 localStorage.setItem("studio-prompt-html", textarea.innerHTML); 23677 } 23678 } 23679 textarea.addEventListener("input", () => { 23680 const html = textarea.innerHTML; 23681 if (textarea.textContent.trim()) { 23682 localStorage.setItem("studio-prompt-html", html); 23683 } else { 23684 localStorage.removeItem("studio-prompt-html"); 23685 } 23686 }); 23687 } 23688 // Scan editor for plain-text @slug patterns and .mention-tag spans, convert to rich .mention badges 23689 _upgradePlainMentions(editor) { 23690 if (!editor) return; 23691 const elements = typeof this._getAvailableElements === "function" ? this._getAvailableElements() : []; 23692 if (!elements.length) return; 23693 const lookup = /* @__PURE__ */ new Map(); 23694 for (const el of elements) { 23695 for (const m of el.media || []) {
23696 const key = `${el.slug}-${m.label}`; 23697 lookup.set(key.toLowerCase(), { element: el, media: m }); 23698 } 23699 } 23700 if (!lookup.size) return; 23701 editor.querySelectorAll(".mention-tag").forEach((tag) => { 23702 const slug = tag.textContent.replace(/^@/, "").trim(); 23703 const entry = lookup.get(slug.toLowerCase()); 23704 if (entry) { 23705 const badge = this._createMentionBadge(entry); 23706 tag.parentNode.replaceChild(badge, tag); 23707 } 23708 }); 23709 const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null, false); 23710 const nodesToProcess = []; 23711 let node; 23712 while (node = walker.nextNode()) { 23713 if (node.parentElement?.closest(".mention")) continue; 23714 if (/@[\w-]+/.test(node.textContent)) { 23715 nodesToProcess.push(node); 23716 } 23717 } 23718 for (const textNode of nodesToProcess) { 23719 const text = textNode.textContent; 23720 const regex = /@([\w][\w-]*)/g; 23721 let match; 23722 const fragments = []; 23723 let lastIndex = 0; 23724 while ((match = regex.exec(text)) !== null) { 23725 const fullMatch = match[0]; 23726 const slug = match[1]; 23727 const entry = lookup.get(slug.toLowerCase()); 23728 if (entry) { 23729 if (match.index > lastIndex) { 23730 fragments.push(document.createTextNode(text.substring(lastIndex, match.index))); 23731 } 23732 fragments.push(this._createMentionBadge(entry)); 23733 lastIndex = match.index + fullMatch.length; 23734 } 23735 } 23736 if (fragments.length > 0) { 23737 if (lastIndex < text.length) { 23738 fragments.push(document.createTextNode(text.substring(lastIndex))); 23739 } 23740 const parent = textNode.parentNode; 23741 for (const frag of fragments) { 23742 parent.insertBefore(frag, textNode); 23743 } 23744 parent.removeChild(textNode); 23745 } 23746 } 23747 } 23748 // Create a rich .mention badge span from an element/media entry 23749 _createMentionBadge(entry) { 23750 const mentionSpan = document.createElement("span"); 23751 mentionSpan.className = "mention"; 23752 mentionSpan.contentEditable = "false"; 23753 mentionSpan.dataset.mentionSlug = `${entry.media.em_uuid}-${entry.element.slug}-${entry.media.label}`; 23754 if (entry.media.media_id) mentionSpan.dataset.mediaId = entry.media.media_id; 23755 const isAudio = ["voice", "sound"].includes(entry.media.role); 23756 if (isAudio) { 23757 const iconWrap = document.createElement("span"); 23758 iconWrap.className = "mention-audio-icon"; 23759 if (typeof this._bindAudioIcon === "function") { 23760 this._bindAudioIcon(iconWrap, entry.media.file_url || entry.media.preview_url); 23761 } 23762 mentionSpan.appendChild(iconWrap); 23763 } else if (entry.media.thumbnail_url) { 23764 const img = document.createElement("img"); 23765 img.src = entry.media.thumbnail_url; 23766 img.alt = `${entry.element.slug}-${entry.media.label}`; 23767 mentionSpan.appendChild(img); 23768 } 23769 mentionSpan.appendChild(document.createTextNode(` @${entry.element.slug}-${entry.media.label}`)); 23770 return mentionSpan; 23771 } 23772 setMode(mode) { 23773 if (mode === this.mode) return; 23774 this.mode = mode; 23775 this._editBarMode = mode; 23776 localStorage.setItem("studio-mode", mode); 23777 const toggle = document.querySelector("[data-studio-mode-toggle]"); 23778 if (toggle) { 23779 toggle.querySelectorAll("button").forEach((btn) => { 23780 if (btn.dataset.mode === mode) { 23781 btn.classList.add("text-white", "bg-active/60"); 23782 btn.classList.remove("text-gray", "hover:text-white"); 23783 } else { 23784 btn.classList.remove("text-white", "bg-active/60"); 23785 btn.classList.add("text-gray", "hover:text-white"); 23786 } 23787 }); 23788 } 23789 const imageSubmodeTabs = document.querySelector('[data-submode-tabs="image"]'); 23790 const videoSubmodeTabs = document.querySelector('[data-submode-tabs="video"]'); 23791 if (imageSubmodeTabs && videoSubmodeTabs) { 23792 if (mode === "video") { 23793 imageSubmodeTabs.classList.add("hidden"); 23794 videoSubmodeTabs.classList.remove("hidden"); 23795 } else { 23796 imageSubmodeTabs.classList.remove("hidden"); 23797 videoSubmodeTabs.classList.add("hidden"); 23798 } 23799 } 23800 const imagePromptArea = document.querySelector("[data-studio-prompt-area]"); 23801 const videoPromptWrapper = document.querySelector("[data-studio-video-wrapper]"); 23802 const resolutionSelector = document.querySelector("[data-resolution-selector]"); 23803 const audioCell = document.querySelector("[data-audio-cell]"); 23804 const totalDurationCell = document.querySelector("[data-total-duration-cell]"); 23805 if (mode === "video") { 23806 imagePromptArea?.classList.add("hidden"); 23807 videoPromptWrapper?.classList.remove("hidden"); 23808 resolutionSelector?.classList.add("hidden"); 23809 audioCell?.classList.remove("hidden"); 23810 if (window.videoMultishot) { 23811 totalDurationCell?.classList.remove("hidden"); 23812 } else { 23813 totalDurationCell?.classList.add("hidden"); 23814 } 23815 const imagePrompt = document.querySelector("[data-studio-prompt]"); 23816 const videoPrompt = document.querySelector("[data-studio-video-prompt-input]"); 23817 if (imagePrompt && videoPrompt && imagePrompt.textContent.trim() && !getVideoPromptText(videoPrompt)) { 23818 setVideoPromptText(videoPrompt, imagePrompt.textContent.trim()); 23819 } 23820 updateVideoReferenceSlots(); 23821 } else { 23822 imagePromptArea?.classList.remove("hidden"); 23823 videoPromptWrapper?.classList.add("hidden"); 23824 resolutionSelector?.classList.remove("hidden"); 23825 audioCell?.classList.add("hidden"); 23826 totalDurationCell?.classList.add("hidden"); 23827 const imagePrompt = document.querySelector("[data-studio-prompt]"); 23828 const videoPrompt = document.querySelector("[data-studio-video-prompt-input]"); 23829 const videoText = getVideoPromptText(videoPrompt); 23830 if (imagePrompt && videoPrompt && videoText && !imagePrompt.textContent.trim()) { 23831 imagePrompt.textContent = videoText; 23832 } 23833 const generateBtn = document.getElementById("studio-generate-btn"); 23834 if (generateBtn) { 23835 generateBtn.disabled = false;
23836 generateBtn.classList.remove("opacity-50", "cursor-not-allowed"); 23837 generateBtn.classList.add("hover:bg-active/90"); 23838 } 23839 } 23840 if (mode === "video" && this.ratio === "1:1") { 23841 this.setRatio("16:9"); 23842 } 23843 this.updateCost(); 23844 } 23845 setRatio(ratio) { 23846 this.ratio = ratio; 23847 window.selectedRatio = ratio; 23848 const display = document.querySelector("[data-ratio-value]"); 23849 if (display) { 23850 display.textContent = ratio; 23851 } 23852 document.querySelectorAll("[data-setting-ratio]").forEach((el) => { 23853 el.textContent = ratio; 23854 }); 23855 document.querySelectorAll("[data-ratio]").forEach((btn) => { 23856 const check = btn.querySelector(".ratio-check"); 23857 if (btn.dataset.ratio === ratio) { 23858 btn.classList.add("text-white"); 23859 btn.classList.remove("text-gray"); 23860 check?.classList.remove("hidden"); 23861 } else { 23862 btn.classList.remove("text-white"); 23863 btn.classList.add("text-gray"); 23864 check?.classList.add("hidden"); 23865 } 23866 }); 23867 } 23868 updateCountDisplay() { 23869 const display = document.querySelector("[data-count-value]"); 23870 if (display) { 23871 display.textContent = this.count; 23872 } 23873 } 23874 updateCost() {
23875 let cost = 0; 23876 if (this.mode === "video") { 23877 let totalDuration = 0; 23878 if (window.videoMultishot && window.videoShots) { 23879 totalDuration = window.videoShots.reduce((sum, shot) => sum + (shot.duration || 3), 0); 23880 } else { 23881 const durationStr = window.selectedDuration || "5s"; 23882 totalDuration = parseInt(durationStr) || 5; 23883 } 23884 const hasAudio = window.audioEnabled === true; 23885 const ratePerSecond = hasAudio ? 35 : 22; 23886 cost = totalDuration * ratePerSecond * this.count; 23887 } else { 23888 const resolution = (window.selectedResolution || "2k").toLowerCase(); 23889 const baseCost = resolution === "4k" ? 16 : 12; 23890 cost = baseCost * this.count; 23891 } 23892 const display = document.querySelector("[data-cost-value]"); 23893 if (display) { 23894 display.textContent = cost; 23895 } 23896 } 23897 async generate() { 23898 if (this.isGenerating) return; 23899 const isAuthenticated = document.querySelector("[data-studio]")?.dataset.authenticated === "true"; 23900 if (!isAuthenticated) { 23901 window.loadSigninModal?.(); 23902 return; 23903 } 23904 const promptSelector = this.mode === "video" ? "[data-studio-video-prompt-input]" : "[data-studio-prompt]"; 23905 const promptEl = document.querySelector(promptSelector); 23906 const prompt2 = typeof this.getEditorTextContent === "function" ? this.getEditorTextContent(promptEl) : this.mode === "video" ? getVideoPromptText(promptEl)?.trim() : promptEl?.textContent?.trim(); 23907 if (this.mode === "video" && window.videoMultishot && window.videoShots) { 23908 if (typeof saveTextareaToShot === "function") { 23909 saveTextareaToShot(); 23910 } 23911 for (let i = 0; i < window.videoShots.length; i++) { 23912 const shotPrompt = window.videoShots[i]?.prompt?.trim(); 23913 if (!shotPrompt) { 23914 window.Toast?.show(`Please enter prompt on Shot ${i + 1}`, "alert"); 23915 if (typeof switchToShot === "function") { 23916 switchToShot(i); 23917 } 23918 return; 23919 } 23920 } 23921 } else if (!prompt2) { 23922 window.Toast?.show("Please enter a prompt", "alert"); 23923 return; 23924 } 23925 if (this.mode === "video" && !this.references[1]?.hash) { 23926 window.Toast?.show("Please add a start image", "alert"); 23927 return; 23928 } 23929 this.isGenerating = true; 23930 const btn = document.querySelector("[data-studio-generate]"); 23931 const originalBtnHTML = btn?.innerHTML; 23932 if (btn) { 23933 btn.disabled = true; 23934 btn.innerHTML = '<span class="animate-pulse">Generating...</span>'; 23935 } 23936 try { 23937 const ratio = window.selectedRatio || this.ratio; 23938 const body = { 23939 mode: this.mode, 23940 ratio, 23941 dry_run: false 23942 }; 23943 if (this.mode === "image") { 23944 const sequenceId = document.querySelector("[data-studio]")?.dataset.currentSequence; 23945 body.prompt = prompt2; 23946 body.count = this.count; 23947 body.reference_hashes = this.getReferenceHashes(); 23948 body.sequence_id = sequenceId; 23949 body.resolution = window.selectedResolution || "2k"; 23950 if (window.selectedCameraTab === "animation") { 23951 if (window.animationSelections?.style?.uuid) { 23952 body.animation_prompts = { 23953 style: window.animationSelections.style.uuid 23954 }; 23955 } 23956 } else { 23957 const cameraPrompts = {}; 23958 if (window.cameraSelections) { 23959 const selections = window.cameraSelections; 23960 if (selections["color grade"]?.uuid) 23961 cameraPrompts.grade = selections["color grade"].uuid; 23962 if (selections["device"]?.uuid) 23963 cameraPrompts.device = selections["device"].uuid; 23964 if (selections["lens"]?.uuid) 23965 cameraPrompts.lens = selections["lens"].uuid; 23966 if (selections["focal"]?.uuid) 23967 cameraPrompts.focal = selections["focal"].uuid; 23968 if (selections["aperture"]?.uuid) 23969 cameraPrompts.aperture = selections["aperture"].uuid; 23970 } 23971 if (Object.keys(cameraPrompts).length > 0) { 23972 body.camera_prompts = cameraPrompts; 23973 } 23974 } 23975 } else { 23976 body.audio = window.audioEnabled === true; 23977 body.multishot = window.videoMultishot === true; 23978 if (this.references[1]?.hash) { 23979 body.start_frame = this.references[1].hash; 23980 } 23981 if (this.references[2]?.hash) { 23982 body.end_frame = this.references[2].hash; 23983 } 23984 if (body.multishot) { 23985 body.shots = this.getVideoShots(); 23986 } else { 23987 const duration = parseInt(window.selectedDuration) || 5; 23988 body.prompt = prompt2; 23989 body.duration = duration.toString(); 23990 body.slow_motion = window.slowMotion === true; 23991 if (window.selectedMovement?.uuid) { 23992 body.movement = window.selectedMovement.uuid; 23993 } 23994 } 23995 } 23996 const response = await fetch("/studio/generate", { 23997 method: "POST", 23998 headers: { 23999 "Content-Type": "application/json", 24000 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 24001 }, 24002 body: JSON.stringify(body) 24003 }); 24004 const data = await response.json(); 24005 if (!response.ok) { 24006 throw new Error(data.error || "Generation failed"); 24007 } 24008 if (data.dry_run) { 24009 console.log("%c[DRY-RUN] Fal.ai payload:", "color: #00ff88; font-weight: bold; font-size: 14px;", data); 24010 window.Toast?.show(`DRY-RUN: payload logged to console (${data.mode} / ${data.model})`, "success"); 24011 return; 24012 } 24013 if (this.mode === "image") { 24014 localStorage.removeItem("studio-prompt-html"); 24015 const imgPrompt = document.querySelector("[data-studio-prompt]");
24016 if (imgPrompt) imgPrompt.innerHTML = ""; 24017 } 24018 if (data.requests) { 24019 this.addCardsToGrid(data.requests); 24020 this.pendingRequests.push( 24021 ...data.requests.filter((r) => r.status !== "completed").map((r) => r.id) 24022 ); 24023 this.startPolling(); 24024 window.scrollTo({ top: 0, behavior: "smooth" }); 24025 } 24026 if (data.balance !== void 0) { 24027 this.updateBalanceDisplay(data.balance); 24028 } 24029 } catch (error) { 24030 console.error("Generation error:", error); 24031 window.Toast?.show(error.message || "Generation failed", "alert"); 24032 } finally { 24033 this.isGenerating = false; 24034 if (btn) { 24035 btn.disabled = false; 24036 btn.innerHTML = originalBtnHTML; 24037 this.updateCost(); 24038 } 24039 } 24040 } 24041 calculateCost() { 24042 const models = this.mode === "video" ? this.videoModels : this.imageModels; 24043 const defaultModel = models[0]; 24044 if (!defaultModel) return 0; 24045 if (this.mode === "video") { 24046 return 8 * (defaultModel.cost || 0) * this.count; 24047 } 24048 return (defaultModel.cost || 0) * this.count; 24049 } 24050 addCardsToGrid(requests) { 24051 const studioGrid = document.querySelector("[data-studio-grid]"); 24052 if (!studioGrid) return; 24053 const emptyState = studioGrid.querySelector(".space-y-8"); 24054 let grid; 24055 if (emptyState) { 24056 emptyState.remove(); 24057 grid = document.createElement("div"); 24058 grid.className = "grid grid-cols-2 md:grid-cols-3 gap-1"; 24059 grid.dataset.resultsGrid = "true"; 24060 studioGrid.appendChild(grid); 24061 } else { 24062 grid = studioGrid.querySelector(":scope > .grid"); 24063 if (!grid) { 24064 grid = document.createElement("div"); 24065 grid.className = "grid grid-cols-2 md:grid-cols-3 gap-1"; 24066 grid.dataset.resultsGrid = "true"; 24067 studioGrid.appendChild(grid); 24068 } 24069 } 24070 requests.forEach((request) => { 24071 const card = this.createCardElement(request); 24072 grid.insertBefore(card, grid.firstChild); 24073 }); 24074 } 24075 createCardElement(request) { 24076 const isProcessing = request.status === "processing" || request.status === "pending"; 24077 const isVideo = request.request_type === "video"; 24078 const isGrid = request.is_grid === true; 24079 const div = document.createElement("div"); 24080 div.className = "studio-card group relative rounded-xs overflow-hidden bg-inside aspect-video"; 24081 div.dataset.requestId = request.id; 24082 div.dataset.status = request.status; 24083 if (isGrid) div.dataset.grid = "true"; 24084 if (isProcessing) { 24085 const createdAt = request.created_at || (/* @__PURE__ */ new Date()).toISOString(); 24086 const estimatedDuration = request.estimated_duration || 10; 24087 div.innerHTML = ` 24088 <div class="absolute inset-0 h-full magic-card !rounded-xs !p-[1px]"> 24089 <div class="magic-card-inner !rounded-xs" data-studio-progress-bar data-created-at="${createdAt}" data-estimated-duration="${estimatedDuration}"> 24090 <div class="absolute inset-0 h-full bg-gradient-to-r from-active/10 to-indigo-400/10 transition-all duration-100 ease-linear" style="width: 0%;" data-studio-progress-fill></div> 24091 <div class="firefly firefly-1" style="opacity: 1;"></div> 24092 <div class="firefly firefly-2" style="opacity: 1;"></div> 24093 <div class="firefly firefly-3" style="opacity: 1;"></div> 24094 <div class="firefly firefly-4" style="opacity: 1;"></div> 24095 <div class="firefly firefly-5" style="opacity: 1;"></div> 24096 <div class="firefly firefly-6" style="opacity: 1;"></div> 24097 <div class="firefly firefly-7" style="opacity: 1;"></div> 24098 </div> 24099 </div> 24100 `; 24101 } 24102 return div; 24103 } 24104 startPolling() { 24105 if (this.pollActive) return; 24106 console.log("[Poll] Starting polling with requests:", this.pendingRequests); 24107 this.pollActive = true; 24108 this.schedulePoll(0); 24109 } 24110 schedulePoll(delay) { 24111 if (!this.pollActive) return; 24112 this.pollTimeout = setTimeout(async () => { 24113 await this.pollStatus(); 24114 if (this.pollActive) { 24115 this.schedulePoll(3e3); 24116 } 24117 }, delay); 24118 } 24119 stopPolling() { 24120 this.pollActive = false;
24121 if (this.pollTimeout) { 24122 clearTimeout(this.pollTimeout); 24123 this.pollTimeout = null; 24124 } 24125 } 24126 async pollStatus() { 24127 if (this.pendingRequests.length === 0) { 24128 this.stopPolling(); 24129 return; 24130 } 24131 console.log("[Poll] Polling for:", this.pendingRequests); 24132 try { 24133 const response = await fetch( 24134 `/studio/status?ids=${this.pendingRequests.join(",")}` 24135 ); 24136 const data = await response.json(); 24137 console.log("[Poll] Response:", data); 24138 if (data.requests) { 24139 data.requests.forEach((request) => { 24140 console.log("[Poll] Updating card:", request.id, request.status); 24141 this.updateCard(request); 24142 if (request.status === "completed" || request.status === "failed") { 24143 this.pendingRequests = this.pendingRequests.filter( 24144 (id) => id !== request.id 24145 ); 24146 if (request.status === "failed") { 24147 window.Toast?.show(request.error_message || "Generation failed", "alert"); 24148 } 24149 } 24150 }); 24151 } 24152 if (data.balance !== void 0) { 24153 this.updateBalanceDisplay(data.balance); 24154 } 24155 } catch (error) { 24156 console.error("Polling error:", error); 24157 } 24158 } 24159 updateCard(request) { 24160 const card = document.querySelector(`[data-request-id="${request.id}"]`); 24161 console.log("[Poll] Card found:", !!card, "for request:", request.id); 24162 if (!card) return; 24163 card.dataset.status = request.status; 24164 if (request.media_id) { 24165 card.dataset.mediaId = request.media_id; 24166 } 24167 if (request.media_hash) { 24168 card.dataset.mediaHash = request.media_hash; 24169 } 24170 if (request.thumbnail_url) { 24171 card.dataset.thumbnail = request.thumbnail_url; 24172 } 24173 if (request.prompt) { 24174 card.dataset.prompt = request.prompt; 24175 } 24176 const isVideo = request.request_type === "video"; 24177 const isGrid = request.is_grid === true || card.dataset.grid === "true"; 24178 if (request.is_grid) card.dataset.grid = "true"; 24179 if (request.status === "completed" && request.output_url) { 24180 card.innerHTML = ` 24181 ${isVideo ? ` 24182 <video 24183 src="${request.output_url}" 24184 poster="${request.thumbnail_url || ""}" 24185 class="w-full h-full object-cover" 24186 muted loop playsinline 24187 ></video> 24188 ` : ` 24189 <img src="${request.thumbnail_url || request.output_url}" alt="" class="w-full h-full object-cover" loading="lazy" /> 24190 `} 24191 ${isGrid ? ` 24192 <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 24193 <div class="absolute bottom-0 left-0 p-3"> 24194 <button type="button" class="flex items-center gap-2 px-3 py-2 bg-white/10 rounded-lg hover:bg-white/20 transition duration-300 cursor-pointer" data-action="upscale-multishot"> 24195 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg> 24196 <span class="text-xs text-white">Select shots to upscale</span> 24197 </button> 24198 </div> 24199 </div> 24200 ` : ` 24201 <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 24202 <div class="absolute bottom-0 left-0 right-0 p-2"> 24203 <div class="flex items-center justify-between"> 24204 <div class="flex gap-2"> 24205 ${isVideo ? ` 24206 <button type="button" class="tooltip tooltip-left p-2 bg-white/10 rounded-lg hover:bg-white/20 transition duration-300 cursor-pointer" data-tooltip="Extract last frame" data-action="extract-last-frame"> 24207 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 19H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg> 24208 </button> 24209 ` : ` 24210 <div class="relative"> 24211 <button type="button" class="studio-reuse-btn tooltip tooltip-left p-2 bg-white/10 rounded-lg hover:bg-white/20 transition duration-300 cursor-pointer" data-tooltip="Reuse"> 24212 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 15.003a1 1 0 0 1 1.517-.859l4.997 2.997a1 1 0 0 1 0 1.718l-4.997 2.997a1 1 0 0 1-1.517-.86z"/><path d="M21 12.17V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h6"/><path d="m6 21 5-5"/><circle cx="9" cy="9" r="2"/></svg> 24213 </button> 24214 <div class="studio-reuse-dropdown hidden absolute bottom-full left-0 mb-1 w-44 bg-box rounded-lg shadow-lg z-50 overflow-hidden"> 24215 <div class="py-1"> 24216 <button type="button" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer" data-action="use-for-image"> 24217 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg> 24218 Use for Image 24219 </button> 24220 <button type="button" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer" data-action="use-start-frame"> 24221 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="19 20 9 12 19 4 19 20"/><line x1="5" x2="5" y1="19" y2="5"/></svg> 24222 Use as Start Frame 24223 </button> 24224 <button type="button" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer" data-action="use-end-frame"> 24225 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/>
24225<line x1="19" x2="19" y1="5" y2="19"/></svg> 24226 Use as End Frame 24227 </button> 24228 </div> 24229 </div> 24230 </div> 24231 <button type="button" class="tooltip tooltip-left p-2 bg-white/10 rounded-lg hover:bg-white/20 transition duration-300 cursor-pointer" data-tooltip="Modify" data-action="modify"> 24232 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg> 24233 </button> 24234 `} 24235 </div> 24236 <div class="flex gap-2"> 24237 <button type="button" class="studio-finals-btn tooltip tooltip-right p-2 bg-white/10 rounded-lg hover:bg-white/20 transition duration-300 cursor-pointer" data-tooltip="Add to Favs" data-action="save" data-saved="false"> 24238 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> 24239 </button> 24240 </div> 24241 </div> 24242 </div> 24243 </div> 24244 `} 24245 ${isVideo ? ` 24246 <div class="absolute top-2 left-2"> 24247 <div class="w-6 h-6 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center"> 24248 <svg class="w-3 h-3 text-white fill-white" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> 24249 </div> 24250 </div> 24251 ` : ""} 24252 <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 24253 <div class="relative"> 24254 <button type="button" class="studio-menu-btn p-1.5 rounded-lg bg-black/50 backdrop-blur-sm hover:bg-black/70 transition duration-300 cursor-pointer" data-request-id="${request.id}"> 24255 <svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> 24256 </button> 24257 <div class="studio-dropdown hidden absolute top-full right-0 mt-1 w-36 bg-box rounded-lg shadow-lg z-50 overflow-hidden"> 24258 <div class="py-1"> 24259 <a href="/media/download?url=${encodeURIComponent(request.output_url)}&is_video=${isVideo}" download class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer"> 24260 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg> 24261 Download 24262 </a> 24263 <button type="button" class="studio-menu-move flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer" data-current-sequence="${request.sequence_uuid || ""}"> 24264 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M9 14v-1a3 3 0 1 1 6 0v1"/><path d="M9 14h6"/><path d="M9 14v3"/><path d="M15 14v3"/></svg> 24265 Move to... 24266 </button> 24267 <button type="button" class="studio-menu-delete flex items-center w-full px-3 py-2 text-xs text-red-400 hover:text-red-300 hover:bg-inside transition duration-300 cursor-pointer"> 24268 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
24268<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> 24269 Delete 24270 </button> 24271 </div> 24272 </div> 24273 </div> 24274 </div> 24275 `; 24276 if (isVideo) { 24277 card.addEventListener("mouseenter", () => { 24278 const video = card.querySelector("video"); 24279 if (video) video.play(); 24280 }); 24281 card.addEventListener("mouseleave", () => { 24282 const video = card.querySelector("video"); 24283 if (video) { 24284 video.pause(); 24285 video.currentTime = 0; 24286 } 24287 }); 24288 } else { 24289 card.setAttribute("draggable", "true"); 24290 } 24291 } else if (request.status === "failed") { 24292 card.innerHTML = ` 24293 <div class="absolute inset-0 flex items-center justify-center bg-red-900/20"> 24294 <div class="text-center"> 24295 <svg class="w-8 h-8 text-red-400 mx-auto mb-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 24296 <circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/> 24297 </svg> 24298 <span class="text-xs text-red-400">${request.error_message || "Failed"}</span> 24299 </div> 24300 </div> 24301 <div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300"> 24302 <div class="relative"> 24303 <button type="button" class="studio-menu-btn p-1.5 rounded-lg bg-black/50 backdrop-blur-sm hover:bg-black/70 transition duration-300 cursor-pointer" data-request-id="${request.id}"> 24304 <svg class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> 24305 </button> 24306 <div class="studio-dropdown hidden absolute top-full right-0 mt-1 w-32 bg-box rounded-lg shadow-lg z-50 overflow-hidden"> 24307 <div class="py-1"> 24308 <button type="button" class="studio-menu-delete flex items-center w-full px-3 py-2 text-xs text-red-400 hover:text-red-300 hover:bg-inside transition duration-300 cursor-pointer"> 24309 <svg class="w-3.5 h-3.5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> 24310 Delete 24311 </button> 24312 </div> 24313 </div> 24314 </div> 24315 </div> 24316 `; 24317 } 24318 } 24319 updateBalanceDisplay(balance) { 24320 const balanceEl = document.querySelector( 24321 ".text-indigo-400.text-xs.font-medium.ml-3" 24322 ); 24323 if (balanceEl) { 24324 const formatted = balance.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 24325 balanceEl.innerHTML = `<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 1L14.5 8.5L22 11L14.5 13.5L12 21L9.5 13.5L2 11L9.5 8.5L12 1Z"/></svg>${formatted}`; 24326 } 24327 } 24328 async handleCardAction(requestId, action) { 24329 document.querySelectorAll(".studio-reuse-dropdown").forEach((d) => d.classList.add("hidden")); 24330 switch (action) { 24331 case "animate": 24332 this.animateImage(requestId); 24333 break; 24334 case "regenerate": 24335 window.Toast?.show("Regenerate coming soon", "notice"); 24336 break; 24337 case "save": 24338 await this.saveToFinals(requestId); 24339 break; 24340 case "add-reference": 24341 this.addReferenceFromCard(requestId); 24342 break; 24343 case "use-for-image": 24344 this.useForImage(requestId); 24345 break; 24346 case "use-start-frame": 24347 this.useAsStartFrame(requestId); 24348 break; 24349 case "use-end-frame": 24350 this.useAsEndFrame(requestId); 24351 break; 24352 case "extract-last-frame": 24353 this.extractLastFrame(requestId); 24354 break; 24355 case "regenerate-video": 24356 this.regenerateVideo(requestId); 24357 break; 24358 case "modify": 24359 this.showModifyModal(requestId); 24360 break; 24361 case "upscale-multishot": 24362 const upscaleCard = document.querySelector( 24363 `[data-request-id="${requestId}"]` 24364 ); 24365 if (upscaleCard?.dataset.mediaId) { 24366 this.openMediaViewer(upscaleCard.dataset.mediaId); 24367 } 24368 break; 24369 } 24370 } 24371 animateImage(requestId) { 24372 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24373 if (!card) return; 24374 const hash = card.dataset.mediaHash; 24375 const thumbnail = card.dataset.thumbnail; 24376 if (!hash) { 24377 window.Toast?.show("Image not ready yet", "notice"); 24378 return; 24379 } 24380 this.clearAllReferences(); 24381 this.setReferenceToSlot(1, hash, thumbnail); 24382 this.setMode("video"); 24383 } 24384 addReferenceFromCard(requestId) { 24385 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24386 if (!card) return; 24387 const hash = card.dataset.mediaHash; 24388 const thumbnail = card.dataset.thumbnail; 24389 if (!hash) { 24390 window.Toast?.show("Image not ready yet", "notice"); 24391 return; 24392 } 24393 if (this.references[1]?.hash === hash || this.references[2]?.hash === hash) { 24394 return; 24395 } 24396 if (this.references[1] && this.references[2]) { 24397 window.Toast?.show("Remove a reference to add another", "notice"); 24398 return; 24399 } 24400 const slot = this.references[1] ? 2 : 1; 24401 this.setReferenceToSlot(slot, hash, thumbnail); 24402 } 24403 useForImage(requestId) { 24404 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24405 if (!card) return; 24406 const hash = card.dataset.mediaHash; 24407 const thumbnail = card.dataset.thumbnail; 24408 if (!hash) { 24409 window.Toast?.show("Image not ready yet", "notice"); 24410 return; 24411 } 24412 this.setMode("image"); 24413 this.clearAllReferences(); 24414 this.setReferenceToSlot(1, hash, thumbnail); 24415 } 24416 useAsStartFrame(requestId) { 24417 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24418 if (!card) return; 24419 const hash = card.dataset.mediaHash; 24420 const thumbnail = card.dataset.thumbnail; 24421 if (!hash) { 24422 window.Toast?.show("Image not ready yet", "notice"); 24423 return; 24424 } 24425 this.setMode("video"); 24426 this.setReferenceToSlot(1, hash, thumbnail); 24427 } 24428 useAsEndFrame(requestId) { 24429 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24430 if (!card) return; 24431 const hash = card.dataset.mediaHash; 24432 const thumbnail = card.dataset.thumbnail; 24433 if (!hash) { 24434 window.Toast?.show("Image not ready yet", "notice"); 24435 return; 24436 } 24437 this.setMode("video"); 24438 this.setReferenceToSlot(2, hash, thumbnail); 24439 } 24440 async extractLastFrame(requestId) { 24441 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24442 if (!card) return; 24443 window.Toast?.show("Extracting last frame...", "notice"); 24444 try { 24445 const response = await fetch("/studio/extract-frame", { 24446 method: "POST", 24447 headers: { 24448 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, 24449 "Content-Type": "application/json" 24450 }, 24451 body: JSON.stringify({ request_id: requestId }) 24452 }); 24453 if (response.ok) { 24454 const data = await response.json(); 24455 if (data.request) { 24456 this.addCardsToGrid([data.request]); 24457 this.updateCard(data.request); 24458 } 24459 window.Toast?.show("Frame extracted", "success"); 24460 } else { 24461 const errorData = await response.json().catch(() => ({})); 24462 window.Toast?.show(errorData.error || "Failed to extract frame", "alert"); 24463 } 24464 } catch (error) { 24465 console.error("Extract frame error:", error); 24466 window.Toast?.show("Failed to extract frame", "alert"); 24467 } 24468 } 24469 async regenerateVideo(requestId) { 24470 try { 24471 const response = await fetch("/studio/regenerate", { 24472 method: "POST", 24473 headers: { 24474 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, 24475 "Content-Type": "application/json" 24476 }, 24477 body: JSON.stringify({ request_id: requestId }) 24478 }); 24479 const data = await response.json(); 24480 if (response.ok && data.request) { 24481 window.Toast?.show("Video regeneration started", "success");
24482 if (data.balance !== void 0) { 24483 this.updateBalanceDisplay(data.balance); 24484 } 24485 this.addCardsToGrid([data.request]); 24486 this.startPolling(); 24487 } else { 24488 window.Toast?.show(data.error || "Failed to regenerate", "alert"); 24489 } 24490 } catch (error) { 24491 console.error("Regenerate error:", error); 24492 window.Toast?.show("Failed to regenerate", "alert"); 24493 } 24494 } 24495 async showModifyModal(requestId) { 24496 const card = document.querySelector(`[data-request-id="${requestId}"]`); 24497 if (!card) return; 24498 const hash = card.dataset.mediaHash; 24499 const thumbnail = card.dataset.thumbnail; 24500 if (!hash) { 24501 window.Toast?.show("Image not ready yet", "notice"); 24502 return; 24503 } 24504 try { 24505 const response = await fetch( 24506 "/modal/apps?exclude=upscale,remove-background,reframe" 24507 ); 24508 const html = await response.text(); 24509 const existingModal = document.getElementById("apps-modal"); 24510 if (existingModal) existingModal.remove(); 24511 document.body.insertAdjacentHTML("beforeend", html); 24512 const modal = document.getElementById("apps-modal"); 24513 if (!modal) return; 24514 const closeModal = () => { 24515 modal.classList.remove("modal-showing"); 24516 setTimeout(() => { 24517 modal.remove(); 24518 document.body.classList.remove("modal-open"); 24519 }, 200); 24520 }; 24521 modal.querySelector("[data-modal-close]")?.addEventListener("click", closeModal); 24522 modal.addEventListener("click", (e) => { 24523 if (e.target === modal) closeModal(); 24524 }); 24525 modal.querySelectorAll("[data-app-slug]").forEach((appCard) => { 24526 appCard.addEventListener("click", () => { 24527 const slug = appCard.dataset.appSlug; 24528 closeModal(); 24529 setTimeout( 24530 () => this.openStudioAppModal(slug, hash, thumbnail, requestId), 24531 250 24532 ); 24533 }); 24534 }); 24535 modal.classList.remove("hidden"); 24536 document.body.classList.add("modal-open"); 24537 requestAnimationFrame(() => { 24538 modal.classList.add("modal-showing"); 24539 }); 24540 } catch (error) { 24541 console.error("[Studio Apps Modal] Error:", error); 24542 window.Toast?.show("Failed to open apps", "alert"); 24543 } 24544 } 24545 async openStudioAppModal(slug, imageHash, imageUrl, requestId) { 24546 try { 24547 const response = await fetch(`/modal/app?slug=${slug}`); 24548 if (!response.ok) { 24549 window.Toast?.show("Failed to load app", "alert"); 24550 return; 24551 } 24552 const html = await response.text(); 24553 const existingModal = document.getElementById("flow-app-modal"); 24554 if (existingModal) existingModal.remove(); 24555 document.body.insertAdjacentHTML("beforeend", html); 24556 const modal = document.getElementById("flow-app-modal"); 24557 if (!modal) return; 24558 const imgEl = modal.querySelector("#flow-app-image"); 24559 if (imgEl) imgEl.src = imageUrl; 24560 const closeModal = () => { 24561 modal.classList.remove("modal-showing"); 24562 setTimeout(() => { 24563 modal.remove(); 24564 document.body.classList.remove("modal-open"); 24565 }, 200); 24566 }; 24567 modal.querySelector("[data-modal-close]")?.addEventListener("click", closeModal); 24568 modal.addEventListener("click", (e) => { 24569 if (e.target === modal) closeModal(); 24570 }); 24571 const generateBtn = modal.querySelector("#flow-app-generate-btn"); 24572 modal.querySelectorAll(".flow-app-model-btn").forEach((btn) => { 24573 btn.addEventListener("click", () => { 24574 modal.querySelectorAll(".flow-app-model-btn").forEach((b) => { 24575 b.classList.remove("border-active", "bg-active/10"); 24576 b.classList.add("border-light/40"); 24577 }); 24578 btn.classList.add("border-active", "bg-active/10"); 24579 btn.classList.remove("border-light/40"); 24580 if (generateBtn) { 24581 generateBtn.dataset.model = btn.dataset.model; 24582 const costEl = modal.querySelector("#flow-app-cost"); 24583 if (costEl) costEl.textContent = btn.dataset.cost; 24584 } 24585 }); 24586 }); 24587 modal.querySelectorAll(".flow-app-option-btn").forEach((btn) => { 24588 btn.addEventListener("click", () => { 24589 const container = btn.closest("[data-param]"); 24590 if (!container) return; 24591 const input = modal.querySelector( 24592 `input[name="${container.dataset.param}"]` 24593 ); 24594 if (input) input.value = btn.dataset.value; 24595 container.querySelectorAll("button").forEach((b) => { 24596 b.classList.remove("border-active", "bg-active/10", "text-white"); 24597 b.classList.add("border-light/40", "text-gray"); 24598 }); 24599 btn.classList.add("border-active", "bg-active/10", "text-white"); 24600 btn.classList.remove("border-light/40", "text-gray"); 24601 }); 24602 }); 24603 const randomGridContainer = modal.querySelector( 24604 ".locations-grid-container" 24605 ); 24606 if (randomGridContainer) { 24607 const randomUrl = randomGridContainer.dataset.randomUrl || "/locations/random"; 24608 const hidden = randomGridContainer.previousElementSibling; 24609 const updateHidden = () => { 24610 const values = [ 24611 ...randomGridContainer.querySelectorAll(".locations-input") 24612 ].map((i) => i.value); 24613 if (hidden) hidden.value = JSON.stringify(values); 24614 }; 24615 randomGridContainer.querySelectorAll(".locations-input").forEach((input) => { 24616 input.addEventListener("input", updateHidden); 24617 }); 24618 randomGridContainer.querySelectorAll(".locations-dice").forEach((btn) => { 24619 btn.addEventListener("click", async () => { 24620 const idx = parseInt(btn.dataset.index); 24621 const input = randomGridContainer.querySelector( 24622 `.locations-input[data-index="${idx}"]` 24623 ); 24624 const current = [ 24625 ...randomGridContainer.querySelectorAll(".locations-input") 24626 ].map((i) => i.value); 24627 btn.classList.add("animate-spin"); 24628 try { 24629 const res = await fetch( 24630 `${randomUrl}?exclude=${encodeURIComponent(JSON.stringify(current))}` 24631 ); 24632 const data = await res.json(); 24633 if (data.location) { 24634 input.value = data.location; 24635 updateHidden(); 24636 } 24637 } catch (e) { 24638 console.error("Failed to fetch random item:", e); 24639 } 24640 btn.classList.remove("animate-spin"); 24641 }); 24642 }); 24643 } 24644 const perspGrid = modal.querySelector(".perspectives-grid"); 24645 if (perspGrid) { 24646 const perspHidden = perspGrid.previousElementSibling; 24647 const perspCount = modal.querySelector(".perspectives-count"); 24648 const maxPersp = parseInt(perspGrid.dataset.max || "9"); 24649 const checkSvg = '<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>'; 24650 const getSelected = () => [...perspGrid.querySelectorAll(".perspective-checkbox.checked")].map( 24651 (l) => l.dataset.perspective 24652 ); 24653 perspGrid.querySelectorAll(".perspective-checkbox").forEach((label) => { 24654 label.addEventListener("click", (e) => { 24655 e.preventDefault(); 24656 const box = label.querySelector(".perspective-box"); 24657 const txt = label.querySelector(".perspective-label"); 24658 const isChecked = label.classList.contains("checked"); 24659 if (isChecked) { 24660 label.classList.remove("checked"); 24661 box.classList.remove("bg-active", "border-active"); 24662 box.classList.add("border-light/40"); 24663 box.innerHTML = ""; 24664 txt.classList.remove("text-white"); 24665 txt.classList.add("text-gray"); 24666 } else { 24667 if (getSelected().length >= maxPersp) { 24668 window.Toast?.show( 24669 `Maximum ${maxPersp} perspectives`, 24670 "alert" 24671 ); 24672 return; 24673 } 24674 label.classList.add("checked"); 24675 box.classList.add("bg-active", "border-active"); 24676 box.classList.remove("border-light/40"); 24677 box.innerHTML = checkSvg; 24678 txt.classList.add("text-white"); 24679 txt.classList.remove("text-gray"); 24680 } 24681 const selected = getSelected(); 24682 if (perspHidden) 24683 perspHidden.value = JSON.stringify(selected); 24684 if (perspCount) 24685 perspCount.textContent = `${selected.length}
24685/${maxPersp} selected`; 24686 }); 24687 }); 24688 } 24689 if (generateBtn) { 24690 generateBtn.addEventListener("click", () => { 24691 this.generateFromStudioApp( 24692 modal, 24693 slug, 24694 generateBtn.dataset.model, 24695 imageHash 24696 ); 24697 }); 24698 } 24699 modal.classList.remove("hidden"); 24700 document.body.classList.add("modal-open"); 24701 requestAnimationFrame(() => { 24702 modal.classList.add("modal-showing"); 24703 }); 24704 } catch (error) { 24705 console.error("[Studio App Modal] Error:", error); 24706 window.Toast?.show("Failed to open app", "alert"); 24707 } 24708 } 24709 async generateFromStudioApp(modal, slug, model, referenceHash) { 24710 const params = {}; 24711 modal.querySelectorAll(".flow-app-param").forEach((input) => { 24712 if (input.name) params[input.name] = input.value; 24713 }); 24714 modal.classList.remove("modal-showing"); 24715 setTimeout(() => { 24716 modal.remove(); 24717 document.body.classList.remove("modal-open"); 24718 }, 200); 24719 const studioEl = document.querySelector("[data-studio]"); 24720 const sequenceId = studioEl?.dataset.currentSequence || null; 24721 const csrfToken = document.querySelector( 24722 'meta[name="csrf-token"]' 24723 )?.content; 24724 try { 24725 const response = await fetch("/studio/generate-app", { 24726 method: "POST", 24727 headers: { 24728 "Content-Type": "application/json", 24729 "X-CSRF-Token": csrfToken || "" 24730 }, 24731 body: JSON.stringify({ 24732 app_slug: slug, 24733 model, 24734 reference_hash: referenceHash, 24735 sequence_id: sequenceId, 24736 ...params 24737 }) 24738 }); 24739 const data = await response.json(); 24740 if (response.ok && data.requests) { 24741 window.Toast?.show("Generating...", "success"); 24742 if (data.balance !== void 0) { 24743 this.updateBalanceDisplay(data.balance); 24744 } 24745 const grid = document.querySelector("[data-studio-grid] .grid"); 24746 if (grid) { 24747 data.requests.forEach((request) => { 24748 const card = this.createCardElement(request); 24749 grid.insertBefore(card, grid.firstChild); 24750 }); 24751 } 24752 this.startPolling(); 24753 } else { 24754 window.Toast?.show(data.error || "Failed to generate", "alert"); 24755 } 24756 } catch (error) { 24757 console.error("[Studio App] Generate error:", error); 24758 window.Toast?.show("Failed to generate", "alert"); 24759 } 24760 } 24761 async generateMultishot(requestId, hash) { 24762 try { 24763 const studioEl = document.querySelector("[data-studio]"); 24764 const sequenceId = studioEl?.dataset.currentSequence || null; 24765 const response = await fetch("/studio/multishot", { 24766 method: "POST", 24767 headers: { 24768 "Content-Type": "application/json", 24769 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 24770 }, 24771 body: JSON.stringify({ 24772 reference_hash: hash, 24773 sequence_id: sequenceId 24774 }) 24775 }); 24776 const data = await response.json(); 24777 if (response.ok && data.requests) { 24778 window.Toast?.show("Generating multishot grid...", "success"); 24779 if (data.balance !== void 0) { 24780 this.updateBalanceDisplay(data.balance); 24781 } 24782 const grid = document.querySelector("[data-studio-grid] .grid"); 24783 if (grid) { 24784 data.requests.forEach((request) => { 24785 const card = this.createCardElement(request); 24786 grid.insertBefore(card, grid.firstChild); 24787 }); 24788 } 24789 this.startPolling(); 24790 } else { 24791 window.Toast?.show(data.error || "Failed to generate", "alert"); 24792 } 24793 } catch (error) { 24794 console.error("Multishot error:", error); 24795 window.Toast?.show("Failed to generate", "alert"); 24796 } 24797 } 24798 setReferenceToSlot(slot, hash, thumbnail) { 24799 this.references[slot] = { hash, thumbnail }; 24800 if (this.mode === "video") { 24801 const frame = slot === 1 ? "start" : "end"; 24802 const frameBtn = document.querySelector(`[data-video-frame="${frame}"]`); 24803 if (frameBtn) { 24804 delete frameBtn.dataset.originalHtml; 24805 frameBtn.disabled = false;
24806 frameBtn.style.backgroundImage = `url('${thumbnail}')`; 24807 frameBtn.style.backgroundSize = "cover"; 24808 frameBtn.style.backgroundPosition = "center"; 24809 frameBtn.classList.add("text-white", "group/frame"); 24810 frameBtn.classList.remove("text-gray"); 24811 frameBtn.innerHTML = ` 24812 <span class="bg-black/60 px-1 py-0.5 rounded text-[7px] uppercase">${frame}</span> 24813 <div class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-red-500/80 rounded-md flex items-center justify-center opacity-0 group-hover/frame:opacity-100 transition-opacity cursor-pointer z-10" data-remove-video-frame="${slot}"> 24814 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 24815 </div> 24816 `; 24817 } 24818 } else { 24819 this.hideSlotLoading(slot); 24820 const addBtn = document.querySelector(`[data-add-ref-btn="${slot}"]`); 24821 const preview = document.querySelector(`[data-ref-preview="${slot}"]`); 24822 const img = document.querySelector(`[data-ref-img="${slot}"]`); 24823 if (addBtn && preview && img) { 24824 addBtn.classList.add("hidden"); 24825 preview.classList.remove("hidden"); 24826 img.src = thumbnail; 24827 } 24828 } 24829 this.updateSlot2Visibility(); 24830 } 24831 removeReferenceFromSlot(slot) { 24832 this.references[slot] = null; 24833 this.hideSlotLoading(slot); 24834 if (this.mode === "video") { 24835 const frame = slot === 1 ? "start" : "end"; 24836 const frameBtn = document.querySelector(`[data-video-frame="${frame}"]`); 24837 if (frameBtn) { 24838 frameBtn.style.backgroundImage = ""; 24839 frameBtn.style.backgroundSize = ""; 24840 frameBtn.style.backgroundPosition = ""; 24841 frameBtn.classList.remove("text-white"); 24842 frameBtn.classList.add("text-gray"); 24843 frameBtn.innerHTML = frame === "start" ? "Start<br>Frame" : "End<br>Frame"; 24844 } 24845 } else { 24846 const addBtn = document.querySelector(`[data-add-ref-btn="${slot}"]`); 24847 const preview = document.querySelector(`[data-ref-preview="${slot}"]`); 24848 const img = document.querySelector(`[data-ref-img="${slot}"]`); 24849 if (addBtn && preview && img) { 24850 addBtn.classList.remove("hidden"); 24851 preview.classList.add("hidden"); 24852 img.src = ""; 24853 } 24854 } 24855 if (slot === 1 && this.references[2]) { 24856 const ref2 = this.references[2]; 24857 this.references[2] = null; 24858 this.setReferenceToSlot(1, ref2.hash, ref2.thumbnail); 24859 const prefix = this.mode === "video" ? "video-" : ""; 24860 const addBtn2 = document.querySelector(`[data-${prefix}add-ref-btn="2"]`); 24861 const preview2 = document.querySelector(`[data-${prefix}ref-preview="2"]`); 24862 const img2 = document.querySelector(`[data-${prefix}ref-img="2"]`); 24863 if (addBtn2 && preview2 && img2) { 24864 addBtn2.classList.remove("hidden"); 24865 preview2.classList.add("hidden"); 24866 img2.src = ""; 24867 } 24868 } 24869 this.updateSlot2Visibility(); 24870 } 24871 clearAllReferences() { 24872 this.removeReferenceFromSlot(1); 24873 this.removeReferenceFromSlot(2); 24874 } 24875 getReferenceHashes() { 24876 const hashes = []; 24877 if (this.references[1]?.hash) hashes.push(this.references[1].hash); 24878 if (this.references[2]?.hash) hashes.push(this.references[2].hash); 24879 return hashes; 24880 } 24881 // Get video shots array for multishot mode 24882 getVideoShots() { 24883 if (typeof saveTextareaToShot === "function") { 24884 saveTextareaToShot(); 24885 } 24886 const shots = []; 24887 const shotCount = window.videoShots?.length || 1; 24888 for (let i = 0; i < shotCount; i++) { 24889 const shotData = window.videoShots?.[i] || {}; 24890 const shot = { 24891 prompt: shotData.prompt || "", 24892 duration: (shotData.duration || 3).toString(), 24893 slow_motion: shotData.slow_motion || false, 24894 movement: shotData.movement || null 24895 }; 24896 if (!shot.movement) { 24897 delete shot.movement; 24898 } 24899 shots.push(shot); 24900 } 24901 return shots; 24902 } 24903 updateSlot2Visibility() { 24904 const imageSlot2 = document.querySelector('[data-ref-slot="2"]');
24905 const videoSlot2 = document.querySelector('[data-video-ref-slot="2"]'); 24906 if (imageSlot2) { 24907 const slot1Preview = document.querySelector('[data-ref-preview="1"]'); 24908 const slot1HasRef = slot1Preview && !slot1Preview.classList.contains("hidden"); 24909 if (this.mode === "image" && slot1HasRef) { 24910 imageSlot2.classList.remove("hidden"); 24911 } else if (this.mode === "image") { 24912 imageSlot2.classList.add("hidden"); 24913 } 24914 } 24915 if (videoSlot2 && this.mode === "video") { 24916 const submode = window.selectedVideoSubmode || "images"; 24917 if (submode === "images") { 24918 videoSlot2.classList.remove("hidden"); 24919 } else { 24920 videoSlot2.classList.add("hidden"); 24921 } 24922 } 24923 } 24924 async deleteRequest(requestId) { 24925 const confirmed = await this.showDeleteConfirmModal(); 24926 if (!confirmed) return; 24927 try { 24928 const response = await fetch(`/studio/${requestId}`, { 24929 method: "DELETE", 24930 headers: { 24931 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 24932 } 24933 }); 24934 if (!response.ok) { 24935 const data = await response.json(); 24936 throw new Error(data.error || "Failed to delete"); 24937 } 24938 window.location.reload(); 24939 } catch (error) { 24940 console.error("Delete error:", error); 24941 window.Toast?.show(error.message || "Failed to delete", "alert"); 24942 } 24943 } 24944 async openMoveModal(requestId, currentSequenceUuid) { 24945 const existingModal = document.getElementById("move-request-modal"); 24946 if (existingModal) existingModal.remove(); 24947 let sequences = []; 24948 try { 24949 const response = await fetch("/sequences", { 24950 headers: { Accept: "application/json" } 24951 }); 24952 if (response.ok) { 24953 sequences = await response.json(); 24954 } 24955 } catch (err) { 24956 console.error("Failed to load sequences:", err); 24957 } 24958 const modal = document.createElement("div"); 24959 modal.id = "move-request-modal"; 24960 modal.dataset.modal = "move-request-modal"; 24961 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 24962 modal.style.cssText = "align-items: flex-start; padding-top: 20vh; z-index: 9999;"; 24963 const isAllSelected = !currentSequenceUuid; 24964 const sequenceItems = [ 24965 `<button type="button" class="move-sequence-option flex items-center justify-between w-full px-3 py-2 rounded hover:bg-inside/50 transition duration-300 ease cursor-pointer" data-sequence-uuid=""> 24966 <span class="text-sm ${isAllSelected ? "text-white" : "text-gray"}">All</span> 24967 ${isAllSelected ? '<svg class="w-4 h-4 text-active" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""} 24968 </button>`, 24969 ...sequences.map((s) => { 24970 const isSelected = s.uuid === currentSequenceUuid; 24971 return `<button type="button" class="move-sequence-option flex items-center justify-between w-full px-3 py-2 rounded hover:bg-inside/50 transition duration-300 ease cursor-pointer" data-sequence-uuid="${s.uuid}"> 24972 <span class="text-sm ${isSelected ? "text-white" : "text-gray"}">${s.name}</span> 24973 ${isSelected ? '<svg class="w-4 h-4 text-active" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""} 24974 </button>`; 24975 }) 24976 ].join(""); 24977 modal.innerHTML = ` 24978 <div class="bg-box rounded-lg p-6 max-w-sm w-full mx-4 modal-content shadow-xl"> 24979 <div class="flex items-start justify-between mb-4"> 24980 <h2 class="text-14 font-bold text-white">Move to</h2> 24981 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 24982 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 24983 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 24984 </svg> 24985 </button> 24986 </div> 24987 <div class="space-y-1 max-h-60 overflow-y-auto"> 24988 ${sequenceItems} 24989 </div> 24990 </div> 24991 `; 24992 document.body.appendChild(modal);
24993 if (window.Modal) { 24994 window.Modal.activeModal = "move-request-modal"; 24995 modal.classList.remove("hidden"); 24996 document.body.classList.add("modal-open"); 24997 requestAnimationFrame(() => { 24998 modal.classList.add("modal-showing"); 24999 }); 25000 } 25001 const csrfToken = document.querySelector('[name="csrf-token"]')?.content; 25002 modal.querySelectorAll(".move-sequence-option").forEach((btn) => { 25003 btn.addEventListener("click", async () => { 25004 const sequenceUuid = btn.dataset.sequenceUuid; 25005 if (sequenceUuid === (currentSequenceUuid || "")) return; 25006 try { 25007 const response = await fetch(`/studio/${requestId}/move`, { 25008 method: "PATCH", 25009 headers: { 25010 "Content-Type": "application/json", 25011 "X-CSRF-Token": csrfToken 25012 }, 25013 body: JSON.stringify({ sequence_id: sequenceUuid }) 25014 }); 25015 if (response.ok) { 25016 const data = await response.json(); 25017 window.Toast?.show(`Moved to ${data.sequence_name}`, "success"); 25018 window.Modal?.close(); 25019 const card = document.querySelector( 25020 `.studio-card[data-request-id="${requestId}"]` 25021 ); 25022 if (card) { 25023 card.remove(); 25024 } 25025 } else { 25026 window.Toast?.show("Failed to move", "alert"); 25027 } 25028 } catch (err) { 25029 console.error("Failed to move:", err); 25030 window.Toast?.show("Failed to move", "alert"); 25031 } 25032 }); 25033 }); 25034 modal.addEventListener("click", (e) => { 25035 if (e.target === modal) { 25036 window.Modal?.close(); 25037 } 25038 }); 25039 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 25040 btn.addEventListener("click", () => { 25041 window.Modal?.close(); 25042 }); 25043 }); 25044 } 25045 showDeleteConfirmModal() { 25046 return new Promise((resolve) => { 25047 const existing = document.getElementById("delete-generation-modal"); 25048 if (existing) existing.remove(); 25049 const modal = document.createElement("div"); 25050 modal.id = "delete-generation-modal"; 25051 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 25052 modal.style.cssText = "align-items: flex-start; padding-top: 30vh; z-index: 9999;"; 25053 modal.innerHTML = ` 25054 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 25055 <div class="flex items-start justify-between mb-3"> 25056 <h2 class="text-14 font-bold text-white">Delete Generation</h2> 25057 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 25058 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 25059 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 25060 </svg> 25061 </button> 25062 </div> 25063 <div> 25064 <p class="text-neutral-300 mb-6 text-sm"> 25065 Are you sure you want to delete this generation? 25066 <br> 25067 This action cannot be undone. 25068 </p> 25069 <div class="flex gap-3 justify-end"> 25070 <button data-modal-close class="btn-secondary"> 25071 Cancel 25072 </button> 25073 <button data-confirm-delete class="btn-danger"> 25074 Delete 25075 </button> 25076 </div> 25077 </div> 25078 </div> 25079 `; 25080 document.body.appendChild(modal); 25081 document.body.classList.add("modal-open"); 25082 requestAnimationFrame(() => { 25083 modal.classList.add("modal-showing"); 25084 }); 25085 const closeModal = (result) => { 25086 modal.classList.remove("modal-showing"); 25087 setTimeout(() => { 25088 modal.remove(); 25089 document.body.classList.remove("modal-open"); 25090 resolve(result); 25091 }, 200); 25092 }; 25093 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 25094 btn.addEventListener("click", () => closeModal(false)); 25095 }); 25096 modal.addEventListener("click", (e) => { 25097 if (e.target === modal) closeModal(false); 25098 }); 25099 modal.querySelector("[data-confirm-delete]").addEventListener("click", () => { 25100 closeModal(true); 25101 }); 25102 const handleEscape = (e) => { 25103 if (e.key === "Escape") { 25104 document.removeEventListener("keydown", handleEscape); 25105 closeModal(false); 25106 } 25107 }; 25108 document.addEventListener("keydown", handleEscape); 25109 }); 25110 } 25111 async saveToFinals(requestId) { 25112 const card = document.querySelector(`[data-request-id="${requestId}"]`); 25113 if (!card) return; 25114 const mediaId = card.dataset.mediaId; 25115 if (!mediaId) { 25116 window.Toast?.show("Media not ready yet", "notice"); 25117 return; 25118 } 25119 const btn = card.querySelector(".studio-finals-btn"); 25120 const isSaved = btn?.dataset.saved === "true"; 25121 try { 25122 let response; 25123 if (isSaved) { 25124 response = await fetch(`/favs/unsave?media_id=${mediaId}`, { 25125 method: "DELETE", 25126 headers: { 25127 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 25128 } 25129 }); 25130 } else { 25131 response = await fetch("/favs", { 25132 method: "POST", 25133 headers: { 25134 "Content-Type": "application/json", 25135 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 25136 }, 25137 body: JSON.stringify({ media_id: mediaId }) 25138 }); 25139 } 25140 const data = await response.json(); 25141 if (data.success) { 25142 const newSaved = !isSaved; 25143 btn.dataset.saved = newSaved; 25144 btn.dataset.tooltip = newSaved ? "Remove from Favs" : "Add to Favs"; 25145 const icon = btn.querySelector("svg"); 25146 if (icon) { 25147 if (newSaved) { 25148 icon.classList.add("fill-white"); 25149 } else { 25150 icon.classList.remove("fill-white"); 25151 } 25152 } 25153 window.Toast?.show( 25154 newSaved ? "Added to Favs" : "Removed from Favs", 25155 "success" 25156 ); 25157 } else { 25158 window.Toast?.show(data.error || "Failed", "alert"); 25159 } 25160 } catch (error) { 25161 console.error("Save error:", error); 25162 window.Toast?.show("Failed", "alert"); 25163 } 25164 } 25165 showRefContextMenu(buttonEl, slot) { 25166 this.closeRefContextMenu(); 25167 this.pendingRefSlot = slot; 25168 this.activeRefButton = buttonEl; 25169 const label = buttonEl.querySelector("[data-ref-label]"); 25170 if (label) { 25171 label.classList.remove("text-gray"); 25172 label.classList.add("text-white"); 25173 } 25174 const menu = document.createElement("div"); 25175 menu.id = "studio-ref-context-menu"; 25176 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px]"; 25177 const isVideoFrame = buttonEl.hasAttribute("data-video-frame"); 25178 if (isVideoFrame) { 25179 menu.style.bottom = "100%"; 25180 menu.style.left = "0"; 25181 menu.style.marginBottom = "8px"; 25182 } else { 25183 menu.style.bottom = "0"; 25184 menu.style.left = "100%"; 25185 menu.style.marginLeft = "8px"; 25186 } 25187 const uploadOption = document.createElement("button"); 25188 uploadOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 25189 uploadOption.innerHTML = ` 25190 <div class="flex items-start gap-3"> 25191 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 25192 <path d="M12 13v8"></path> 25193 <path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path> 25194 <path d="m8 17 4-4 4 4"></path> 25195 </svg> 25196 <div> 25197 <div class="font-medium text-white">Upload</div> 25198 <div class="text-[10px] text-gray leading-tight mt-0.5">Image from your computer</div> 25199 </div> 25200 </div> 25201 `; 25202 uploadOption.onclick = (e) => { 25203 e.stopPropagation(); 25204 this.closeRefContextMenu(); 25205 this.triggerUpload(slot); 25206 }; 25207 menu.appendChild(uploadOption); 25208 const generatedOption = document.createElement("button"); 25209 generatedOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 25210 generatedOption.innerHTML = ` 25211 <div class="flex items-start gap-3"> 25212 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 25213 <path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 25214 <path d="M14 3v4a2 2 0 0 0 2 2h4"></path> 25215 <path d="M14 3l7 7"></path> 25216 </svg> 25217 <div> 25218 <div class="font-medium text-white">Generated</div> 25219 <div class="text-[10px] text-gray leading-tight mt-0.5">Images from this folder</div> 25220 </div> 25221 </div> 25222 `; 25223 generatedOption.onclick = (e) => { 25224 e.stopPropagation(); 25225 this.closeRefContextMenu(); 25226 this.openGeneratedModal(slot); 25227 }; 25228 menu.appendChild(generatedOption); 25229 const favsOption = document.createElement("button"); 25230 favsOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 25231 favsOption.innerHTML = ` 25232 <div class="flex items-start gap-3"> 25233 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 25234 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 25235 </svg> 25236 <div> 25237 <div class="font-medium text-white">Favs</div> 25238 <div class="text-[10px] text-gray leading-tight mt-0.5">Favorites images</div> 25239 </div> 25240 </div> 25241 `; 25242 favsOption.onclick = (e) => { 25243 e.stopPropagation(); 25244 this.closeRefContextMenu(); 25245 this.openFavsModal(slot); 25246 }; 25247 menu.appendChild(favsOption); 25248 if (!isVideoFrame) { 25249 const elementsOption = document.createElement("button"); 25250 elementsOption.className = "w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 25251 elementsOption.innerHTML = ` 25252 <div class="flex items-start gap-3"> 25253 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 25254 <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path> 25255 <path d="m3.3 7 8.7 5 8.7-5"></path> 25256 <path d="M12 22V12"></path> 25257 </svg> 25258 <div> 25259 <div class="font-medium text-white">Elements</div> 25260 <div class="text-[10px] text-gray leading-tight mt-0.5">Characters, objects & sets</div> 25261 </div> 25262 </div> 25263 `; 25264 elementsOption.onclick = (e) => { 25265 e.stopPropagation(); 25266 this.closeRefContextMenu(); 25267 this.openElementsModal(slot); 25268 }; 25269 menu.appendChild(elementsOption); 25270 } 25271 const slotContainer = buttonEl.closest("[data-ref-slot]") || buttonEl.closest("[data-vi
25271deo-frame-slot]"); 25272 if (slotContainer) { 25273 slotContainer.appendChild(menu); 25274 } 25275 setTimeout(() => { 25276 document.addEventListener("click", this.handleRefMenuClickOutside); 25277 }, 0); 25278 } 25279 closeRefContextMenu() { 25280 const menu = document.getElementById("studio-ref-context-menu"); 25281 if (menu) { 25282 menu.remove(); 25283 } 25284 if (this.activeRefButton) { 25285 const label = this.activeRefButton.querySelector("[data-ref-label]"); 25286 if (label) { 25287 label.classList.remove("text-white"); 25288 label.classList.add("text-gray"); 25289 } 25290 this.activeRefButton = null; 25291 } 25292 document.removeEventListener("click", this.handleRefMenuClickOutside); 25293 } 25294 handleRefMenuClickOutside = (e) => { 25295 const menu = document.getElementById("studio-ref-context-menu"); 25296 if (menu && !menu.contains(e.target)) { 25297 this.closeRefContextMenu(); 25298 } 25299 }; 25300 _getSlotButton(slot) { 25301 if (this.mode === "video") { 25302 const frame = slot === 1 ? "start" : "end"; 25303 return document.querySelector(`[data-video-frame="${frame}"]`); 25304 } 25305 return document.querySelector(`[data-add-ref-btn="${slot}"]`); 25306 } 25307 showSlotLoading(slot) { 25308 const btn = this._getSlotButton(slot); 25309 if (!btn) return; 25310 btn.dataset.originalHtml = btn.innerHTML; 25311 btn.innerHTML = `<svg class="w-5 h-5 animate-spin text-gray" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 25312 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 25313 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 25314 </svg>`; 25315 btn.disabled = true; 25316 } 25317 hideSlotLoading(slot) { 25318 const btn = this._getSlotButton(slot); 25319 if (!btn) return; 25320 if (btn.dataset.originalHtml) { 25321 btn.innerHTML = btn.dataset.originalHtml; 25322 delete btn.dataset.originalHtml; 25323 } 25324 btn.disabled = false; 25325 } 25326 triggerUpload(slot) { 25327 const input = document.createElement("input"); 25328 input.type = "file"; 25329 input.accept = "image/*"; 25330 input.onchange = async (e) => { 25331 const file = e.target.files[0]; 25332 if (!file) return; 25333 this.showSlotLoading(slot);
25334 try { 25335 const formData = new FormData(); 25336 formData.append("file", file); 25337 const response = await fetch("/studio/upload", { 25338 method: "POST", 25339 headers: { 25340 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 25341 }, 25342 body: formData 25343 }); 25344 if (response.ok) { 25345 const data = await response.json(); 25346 this.setReferenceToSlot(slot, data.hash, data.thumbnail); 25347 } else { 25348 this.hideSlotLoading(slot); 25349 window.Toast?.show("Upload failed", "alert"); 25350 } 25351 } catch (error) { 25352 console.error("Upload error:", error); 25353 this.hideSlotLoading(slot); 25354 window.Toast?.show("Upload failed", "alert"); 25355 } 25356 }; 25357 input.click(); 25358 } 25359 openGeneratedModal(slot) { 25360 this.pendingRefSlot = slot; 25361 const sequenceId = document.querySelector("[data-studio]")?.dataset.currentSequence; 25362 if (window.Modal && window.Modal.showGenerated) { 25363 window.Modal.showGenerated(sequenceId); 25364 } 25365 } 25366 async openFavsModal(slot) { 25367 this.pendingRefSlot = slot; 25368 if (window.Modal) window.Modal.showFavsPicker(); 25369 } 25370 async openElementsModal(slot) { 25371 this.pendingRefSlot = slot; 25372 if (window.Modal) window.Modal.showElementsPicker(); 25373 } 25374 async openMediaByHash(hash) { 25375 try { 25376 const response = await fetch(`/media/by_hash/${hash}`, { 25377 headers: { Accept: "application/json" } 25378 }); 25379 if (response.ok) { 25380 const data = await response.json(); 25381 if (data.uuid) { 25382 this.openMediaViewer(data.uuid); 25383 } 25384 } 25385 } catch (error) { 25386 console.error("Error opening media:", error); 25387 } 25388 } 25389 async openMediaViewer(mediaId) { 25390 try { 25391 const response = await fetch(`/media/${mediaId}`, { 25392 headers: { 25393 Accept: "text/html", 25394 "X-Requested-With": "XMLHttpRequest" 25395 } 25396 }); 25397 if (!response.ok) { 25398 throw new Error("Failed to load media viewer"); 25399 } 25400 const html = await response.text(); 25401 const existingModal = document.getElementById("media-modal"); 25402 if (existingModal) { 25403 existingModal.remove(); 25404 } 25405 document.body.insertAdjacentHTML("beforeend", html); 25406 const saveBtn = document.querySelector("[data-media-save]"); 25407 if (saveBtn) { 25408 saveBtn.addEventListener("click", async () => { 25409 const btnMediaId = saveBtn.dataset.mediaId; 25410 try { 25411 const csrfToken = document.querySelector('[name="csrf-token"]'); 25412 const resp = await fetch("/favs", { 25413 method: "POST", 25414 headers: { 25415 "Content-Type": "application/json", 25416 "X-CSRF-Token": csrfToken ? csrfToken.content : "" 25417 }, 25418 body: JSON.stringify({ media_id: btnMediaId }) 25419 }); 25420 const data = await resp.json(); 25421 if (!resp.ok) { 25422 window.Toast?.show(data.error || "Failed to save", "alert"); 25423 return; 25424 } 25425 window.Toast?.show("Added to Favs!", "success"); 25426 saveBtn.innerHTML = ` 25427 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" class="w-4 h-4"> 25428 <path d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"/> 25429 </svg> 25430 <span>SAVED</span> 25431 `; 25432 saveBtn.disabled = true; 25433 saveBtn.classList.remove("btn-primary"); 25434 saveBtn.classList.add("btn-secondary"); 25435 } catch (error) { 25436 console.error("Save to finals error:", error); 25437 window.Toast?.show("Failed to save", "alert"); 25438 } 25439 }); 25440 } 25441 if (window.Modal) { 25442 window.Modal.open("media-modal"); 25443 } 25444 this.setupMultishotSelection(); 25445 } catch (error) { 25446 console.error("Error loading media viewer:", error); 25447 window.Toast?.show("Failed to load media viewer", "alert"); 25448 } 25449 } 25450 setupMultishotSelection() { 25451 const cells = document.querySelectorAll(".multishot-cell"); 25452 if (cells.length === 0) return; 25453 const upscaleBar = document.getElementById("multishot-upscale-bar"); 25454 const res2k = document.getElementById("multishot-res-2k"); 25455 const res4k = document.getElementById("multishot-res-4k"); 25456 const upscaleBtn = document.getElementById("multishot-upscale-btn"); 25457 const costSpan = document.getElementById("multishot-upscale-cost"); 25458 const selectedCells = /* @__PURE__ */ new Set(); 25459 const COST_2K = 12; 25460 const COST_4K = 16; 25461 let selectedResolution = "2K";
25462 const starSvg = '<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L14.5 8.5L22 11L14.5 13.5L12 21L9.5 13.5L2 11L9.5 8.5L12 1Z"/></svg>'; 25463 const updateCost = () => { 25464 if (selectedCells.size > 0) { 25465 upscaleBar?.classList.remove("hidden"); 25466 const cost = selectedResolution === "4K" ? COST_4K : COST_2K; 25467 costSpan.innerHTML = `${starSvg} ${selectedCells.size * cost}`; 25468 } else { 25469 upscaleBar?.classList.add("hidden"); 25470 costSpan.innerHTML = ""; 25471 } 25472 }; 25473 const setActiveRes = (btn) => { 25474 [res2k, res4k].forEach((b) => { 25475 b.classList.remove("text-white", "bg-active/40"); 25476 b.classList.add("text-gray", "hover:text-white"); 25477 }); 25478 btn.classList.add("text-white", "bg-active/40"); 25479 btn.classList.remove("text-gray", "hover:text-white"); 25480 }; 25481 res2k?.addEventListener("click", () => { 25482 selectedResolution = "2K"; 25483 setActiveRes(res2k); 25484 updateCost(); 25485 }); 25486 res4k?.addEventListener("click", () => { 25487 selectedResolution = "4K"; 25488 setActiveRes(res4k); 25489 updateCost(); 25490 }); 25491 cells.forEach((cell) => { 25492 cell.addEventListener("click", () => { 25493 const cellIndex = cell.dataset.cell; 25494 const checkIcon = cell.querySelector(".multishot-check"); 25495 if (selectedCells.has(cellIndex)) { 25496 selectedCells.delete(cellIndex); 25497 cell.classList.remove("ring-2", "ring-active"); 25498 if (checkIcon) checkIcon.classList.add("opacity-0"); 25499 } else { 25500 selectedCells.add(cellIndex); 25501 cell.classList.add("ring-2", "ring-active"); 25502 if (checkIcon) checkIcon.classList.remove("opacity-0"); 25503 } 25504 updateCost(); 25505 }); 25506 }); 25507 upscaleBtn?.addEventListener("click", async () => { 25508 if (selectedCells.size === 0) return; 25509 const indices = Array.from(selectedCells).map(Number).sort((a, b) => a - b); 25510 const modal = document.getElementById("media-modal"); 25511 const sourceHash = modal?.dataset.mediaHash; 25512 if (!sourceHash) { 25513 window.Toast?.show("Could not find source image", "alert"); 25514 return; 25515 } 25516 const urlParams = new URLSearchParams(window.location.search); 25517 const sequenceId = urlParams.get("sequence_id") || document.querySelector("[data-sequence-id]")?.dataset.sequenceId; 25518 upscaleBtn.disabled = true; 25519 upscaleBtn.innerHTML = `<span class="animate-pulse">Upscaling...</span>`; 25520 try { 25521 const response = await fetch("/studio/upscale-multishot", { 25522 method: "POST", 25523 headers: { 25524 "Content-Type": "application/json", 25525 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 25526 }, 25527 body: JSON.stringify({ 25528 source_hash: sourceHash, 25529 cell_indices: indices, 25530 sequence_id: sequenceId, 25531 resolution: selectedResolution 25532 }) 25533 }); 25534 const data = await response.json(); 25535 if (!response.ok) { 25536 window.Toast?.show(data.error || "Upscale failed", "alert"); 25537 return; 25538 } 25539 window.Toast?.show( 25540 `Upscaling ${indices.length} ${indices.length === 1 ? "shot" : "shots"} to ${selectedResolution}...`, 25541 "success" 25542 ); 25543 window.Modal?.close("media-modal"); 25544 if (data.requests && window.studioManager) { 25545 const grid = document.querySelector("[data-studio-grid] .grid"); 25546 if (grid) { 25547 data.requests.forEach((req) => { 25548 const card = window.studioManager.createCardElement(req); 25549 const firstCard = grid.querySelector(".studio-card"); 25550 if (firstCard) { 25551 grid.insertBefore(card, firstCard); 25552 } else { 25553 grid.appendChild(card); 25554 } 25555 }); 25556 } 25557 window.studioManager.pendingRequests.push( 25558 ...data.requests.filter( 25559 (r) => r.status === "pending" || r.status === "processing" 25560 ).map((r) => r.id) 25561 ); 25562 window.studioManager.startPolling(); 25563 window.scrollTo({ top: 0, behavior: "smooth" }); 25564 }
25565 if (data.balance !== void 0) { 25566 const balanceEl = document.querySelector("[data-balance]"); 25567 if (balanceEl) 25568 balanceEl.textContent = data.balance.toLocaleString(); 25569 } 25570 } catch (error) { 25571 console.error("Upscale error:", error); 25572 window.Toast?.show("Upscale failed", "alert"); 25573 } finally { 25574 upscaleBtn.disabled = false; 25575 upscaleBtn.innerHTML = `<span>Upscale</span><span class="flex items-center gap-1 text-white" id="multishot-upscale-cost"></span>`; 25576 updateCost(); 25577 } 25578 }); 25579 } 25580}; 25581Object.assign(StudioManager.prototype, MentionsMixin2); 25582StudioManager.prototype._getAvailableElements = function() { 25583 if (this._mentionElements) return this._mentionElements; 25584 const el = document.getElementById("studio-elements-data"); 25585 if (!el) return []; 25586 try { 25587 this._mentionElements = JSON.parse(el.textContent) || []; 25588 } catch { 25589 this._mentionElements = []; 25590 } 25591 return this._mentionElements; 25592}; 25593document.addEventListener("turbo:load", () => { 25594 if (document.querySelector("[data-studio]")) { 25595 if (window.studioManager) { 25596 window.studioManager.cleanup(); 25597 window.studioManager = null; 25598 } 25599 initializeStudioSettings(); 25600 window.studioManager = new StudioManager(); 25601 const hash = window.location.hash; 25602 if (hash === "#cameras") { 25603 if (window.studioManager) { 25604 window.studioManager.setMode("image"); 25605 } 25606 if (window.setCameraTab) { 25607 window.setCameraTab("live"); 25608 } 25609 loadCameraSelections(); 25610 applyCameraSettings(); 25611 setTimeout(() => { 25612 if (window.openCameraModal) { 25613 window.openCameraModal("camera"); 25614 } 25615 }, 1e3); 25616 history.replaceState(null, "", window.location.pathname); 25617 } else if (hash === "#animation") { 25618 if (window.studioManager) { 25619 window.studioManager.setMode("image"); 25620 } 25621 if (window.setCameraTab) { 25622 window.setCameraTab("animation"); 25623 } 25624 loadAnimationSelections(); 25625 applyAnimationSettings(); 25626 setTimeout(() => { 25627 if (window.openAnimationModal) { 25628 window.openAnimationModal(); 25629 } 25630 }, 1e3); 25631 history.replaceState(null, "", window.location.pathname); 25632 } else if (hash === "#movements") { 25633 if (window.studioManager) { 25634 window.studioManager.setMode("video"); 25635 } 25636 loadMovementSelection(); 25637 updateMovementName(); 25638 updateMovementPreview(); 25639 setTimeout(() => { 25640 if (window.openMovementModal) { 25641 window.openMovementModal(); 25642 } 25643 }, 1e3); 25644 history.replaceState(null, "", window.location.pathname); 25645 } 25646 } 25647}); 25648document.addEventListener("DOMContentLoaded", () => { 25649 if (document.querySelector("[data-studio]")) { 25650 initializeStudioSettings(); 25651 const hash = window.location.hash; 25652 if (hash === "#cameras") { 25653 if (window.studioManager) { 25654 window.studioManager.setMode("image"); 25655 } 25656 if (window.setCameraTab) { 25657 window.setCameraTab("live"); 25658 } 25659 loadCameraSelections(); 25660 applyCameraSettings(); 25661 setTimeout(() => { 25662 if (window.openCameraModal) { 25663 window.openCameraModal("camera"); 25664 } 25665 }, 1e3); 25666 history.replaceState(null, "", window.location.pathname); 25667 } else if (hash === "#animation") { 25668 if (window.studioManager) { 25669 window.studioManager.setMode("image"); 25670 } 25671 if (window.setCameraTab) { 25672 window.setCameraTab("animation"); 25673 } 25674 loadAnimationSelections(); 25675 applyAnimationSettings(); 25676 setTimeout(() => { 25677 if (window.openAnimationModal) { 25678 window.openAnimationModal(); 25679 } 25680 }, 1e3); 25681 history.replaceState(null, "", window.location.pathname); 25682 } else if (hash === "#movements") { 25683 if (window.studioManager) { 25684 window.studioManager.setMode("video"); 25685 } 25686 loadMovementSelection(); 25687 updateMovementName(); 25688 updateMovementPreview(); 25689 setTimeout(() => { 25690 if (window.openMovementModal) { 25691 window.openMovementModal(); 25692 } 25693 }, 1e3); 25694 history.replaceState(null, "", window.location.pathname); 25695 } 25696 } 25697}); 25698document.addEventListener("turbo:before-cache", () => { 25699 if (window.studioManager) { 25700 window.studioManager.cleanup(); 25701 window.studioManager = null; 25702 } 25703}); 25704document.addEventListener("turbo:load", () => { 25705 const createModal = document.getElementById("create-studio-modal"); 25706 if (!createModal) return; 25707 const csrfToken = document.querySelector('[name="csrf-token"]')?.content; 25708 document.querySelectorAll("[data-create-studio]").forEach((btn) => { 25709 btn.addEventListener("click", () => { 25710 createModal.classList.remove("hidden"); 25711 document.body.classList.add("modal-open"); 25712 requestAnimationFrame(() => { 25713 createModal.classList.add("modal-showing"); 25714 const nameInput = createModal.querySelector('input[name="name"]'); 25715 if (nameInput) nameInput.focus(); 25716 }); 25717 }); 25718 }); 25719 document.querySelectorAll("[data-create-studio-close]").forEach((btn) => { 25720 btn.addEventListener("click", () => { 25721 createModal.classList.remove("modal-showing"); 25722 createModal.classList.add("hidden"); 25723 document.body.classList.remove("modal-open"); 25724 }); 25725 }); 25726 createModal.addEventListener("click", (e) => { 25727 if (e.target === createModal) { 25728 createModal.classList.remove("modal-showing"); 25729 createModal.classList.add("hidden"); 25730 document.body.classList.remove("modal-open"); 25731 } 25732 }); 25733 document.querySelectorAll(".create-studio-ratio").forEach((btn) => { 25734 btn.addEventListener("click", () => { 25735 const ratio = btn.dataset.ratio; 25736 const hiddenInput = createModal.querySelector('input[name="ratio"]'); 25737 if (hiddenInput) hiddenInput.value = ratio; 25738 document.querySelectorAll(".create-studio-ratio").forEach((b) => { 25739 b.classList.remove("text-white", "bg-active/10", "border-active"); 25740 b.classList.add("border-light/40", "text-gray"); 25741 }); 25742 btn.classList.remove("border-light/40", "text-gray"); 25743 btn.classList.add("text-white", "bg-active/10", "border-active"); 25744 }); 25745 }); 25746 const createForm = document.getElementById("create-studio-form"); 25747 if (createForm) { 25748 createForm.addEventListener("submit", async (e) => { 25749 e.preventDefault(); 25750 const formData = new FormData(createForm); 25751 const name = formData.get("name") || ""; 25752 const ratio = formData.get("ratio") || "9:16";
25753 try { 25754 const response = await fetch("/studio", { 25755 method: "POST", 25756 headers: { 25757 "Content-Type": "application/json", 25758 "X-CSRF-Token": csrfToken, 25759 "Accept": "application/json" 25760 }, 25761 body: JSON.stringify({ name, ratio }) 25762 }); 25763 if (response.ok) { 25764 const data = await response.json(); 25765 if (data.redirect_to) { 25766 window.location.href = data.redirect_to; 25767 } else { 25768 window.location.reload(); 25769 } 25770 } else { 25771 window.Toast?.show("Failed to create short", "alert"); 25772 } 25773 } catch (err) { 25774 console.error("Failed to create studio:", err); 25775 window.Toast?.show("Failed to create short", "alert"); 25776 } 25777 }); 25778 } 25779 const deleteModal = document.getElementById("delete-studio-modal"); 25780 let studioToDelete = null; 25781 document.querySelectorAll("[data-delete-studio-btn]").forEach((btn) => { 25782 btn.addEventListener("click", (e) => { 25783 e.preventDefault(); 25784 e.stopPropagation(); 25785 studioToDelete = btn.dataset.deleteStudioBtn; 25786 if (deleteModal) { 25787 deleteModal.classList.remove("hidden"); 25788 document.body.classList.add("modal-open"); 25789 requestAnimationFrame(() => deleteModal.classList.add("modal-showing")); 25790 } 25791 }); 25792 }); 25793 const closeDeleteModal = () => { 25794 if (deleteModal) { 25795 deleteModal.classList.remove("modal-showing"); 25796 deleteModal.classList.add("hidden"); 25797 document.body.classList.remove("modal-open"); 25798 } 25799 studioToDelete = null; 25800 }; 25801 document.querySelectorAll("[data-delete-studio-modal-close]").forEach((btn) => { 25802 btn.addEventListener("click", closeDeleteModal); 25803 }); 25804 if (deleteModal) { 25805 deleteModal.addEventListener("click", (e) => { 25806 if (e.target === deleteModal) closeDeleteModal(); 25807 }); 25808 } 25809 const confirmDeleteBtn = document.querySelector("[data-delete-studio-confirm]"); 25810 if (confirmDeleteBtn) { 25811 confirmDeleteBtn.addEventListener("click", async () => { 25812 if (!studioToDelete) return; 25813 try { 25814 const response = await fetch(`/studio/${studioToDelete}`, { 25815 method: "DELETE", 25816 headers: { 25817 "X-CSRF-Token": csrfToken, 25818 "Accept": "application/json" 25819 } 25820 }); 25821 if (response.ok) { 25822 const card = document.querySelector(`[data-studio-id="${studioToDelete}"]`); 25823 if (card) card.remove(); 25824 closeDeleteModal(); 25825 window.Toast?.show("Short deleted", "success"); 25826 if (!document.querySelector("[data-studio-id]")) { 25827 window.location.reload(); 25828 } 25829 } else { 25830 window.Toast?.show("Failed to delete short", "alert"); 25831 } 25832 } catch (err) { 25833 console.error("Failed to delete studio:", err); 25834 window.Toast?.show("Failed to delete short", "alert"); 25835 } 25836 }); 25837 } 25838 const settingsModal = document.getElementById("studio-settings-modal"); 25839 const settingsForm = document.getElementById("studio-settings-form"); 25840 let studioToEdit = null; 25841 document.querySelectorAll("[data-studio-settings-btn]").forEach((btn) => { 25842 btn.addEventListener("click", (e) => { 25843 e.preventDefault(); 25844 e.stopPropagation(); 25845 studioToEdit = btn.dataset.studioSettingsBtn; 25846 const card = document.querySelector(`[data-studio-id="${studioToEdit}"]`); 25847 const nameEl = card?.querySelector("[data-studio-name]"); 25848 if (settingsModal && settingsForm) { 25849 settingsForm.querySelector('input[name="name"]').value = nameEl?.textContent || ""; 25850 settingsModal.classList.remove("hidden"); 25851 document.body.classList.add("modal-open"); 25852 requestAnimationFrame(() => settingsModal.classList.add("modal-showing")); 25853 } 25854 }); 25855 }); 25856 document.querySelectorAll("[data-rename-studio-btn]").forEach((btn) => { 25857 btn.addEventListener("click", (e) => { 25858 e.preventDefault(); 25859 e.stopPropagation(); 25860 studioToEdit = btn.dataset.renameStudioBtn; 25861 if (settingsModal && settingsForm) { 25862 settingsForm.querySelector('input[name="name"]').value = btn.dataset.renameStudioName || ""; 25863 settingsModal.classList.remove("hidden"); 25864 document.body.classList.add("modal-open"); 25865 requestAnimationFrame(() => settingsModal.classList.add("modal-showing")); 25866 } 25867 }); 25868 }); 25869 const closeSettingsModal = () => { 25870 if (settingsModal) { 25871 settingsModal.classList.remove("modal-showing"); 25872 settingsModal.classList.add("hidden"); 25873 document.body.classList.remove("modal-open"); 25874 } 25875 studioToEdit = null; 25876 }; 25877 document.querySelectorAll("[data-studio-settings-close]").forEach((btn) => { 25878 btn.addEventListener("click", closeSettingsModal); 25879 }); 25880 if (settingsModal) { 25881 settingsModal.addEventListener("click", (e) => { 25882 if (e.target === settingsModal) closeSettingsModal(); 25883 }); 25884 } 25885 if (settingsForm) { 25886 settingsForm.addEventListener("submit", async (e) => { 25887 e.preventDefault(); 25888 if (!studioToEdit) return; 25889 const name = settingsForm.querySelector('input[name="name"]').value.trim();
25890 try { 25891 const response = await fetch(`/studio/${studioToEdit}/rename`, { 25892 method: "PATCH", 25893 headers: { 25894 "Content-Type": "application/json", 25895 "X-CSRF-Token": csrfToken, 25896 "Accept": "application/json" 25897 }, 25898 body: JSON.stringify({ name }) 25899 }); 25900 if (response.ok) { 25901 const card = document.querySelector(`[data-studio-id="${studioToEdit}"]`); 25902 const nameEl = card?.querySelector("[data-studio-name]"); 25903 if (nameEl) nameEl.textContent = name; 25904 const renameBtn = card?.querySelector("[data-rename-studio-btn]"); 25905 if (renameBtn) renameBtn.dataset.renameStudioName = name; 25906 closeSettingsModal(); 25907 window.Toast?.show("Short renamed", "success"); 25908 } else { 25909 window.Toast?.show("Failed to rename short", "alert"); 25910 } 25911 } catch (err) { 25912 console.error("Failed to rename studio:", err); 25913 window.Toast?.show("Failed to rename short", "alert"); 25914 } 25915 }); 25916 } 25917}); 25918window.createNewSequence = async function() { 25919 window.openSequenceModal(); 25920}; 25921window.openSequenceModal = async function() { 25922 const existingModal = document.getElementById("sequences-modal"); 25923 if (existingModal) existingModal.remove(); 25924 let sequences = []; 25925 try { 25926 const response = await fetch("/sequences", { 25927 headers: { Accept: "application/json" } 25928 }); 25929 if (response.ok) { 25930 sequences = await response.json(); 25931 } 25932 } catch (err) { 25933 console.error("Failed to load sequences:", err); 25934 } 25935 const modal = document.createElement("div"); 25936 modal.id = "sequences-modal"; 25937 modal.dataset.modal = "sequences-modal"; 25938 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 25939 modal.style.cssText = "align-items: flex-start; padding-top: 20vh; z-index: 9999;"; 25940 const sequenceItems = sequences.map( 25941 (s, index) => ` 25942 <div class="sequence-item flex items-center gap-2 group" data-sequence-uuid="${s.uuid}" data-order="${index}"> 25943 <div class="sequence-drag-handle cursor-grab active:cursor-grabbing text-gray hover:text-white p-1"> 25944 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 25945 <circle cx="9" cy="12" r="1"></circle><circle cx="9" cy="5" r="1"></circle><circle cx="9" cy="19" r="1"></circle> 25946 <circle cx="15" cy="12" r="1"></circle><circle cx="15" cy="5" r="1"></circle><circle cx="15" cy="19" r="1"></circle> 25947 </svg> 25948 </div> 25949 <input type="text" value="${s.name}" 25950 class="flex-1 px-3 py-2 bg-inside rounded text-white focus:outline-none sequence-name-input" 25951 data-original="${s.name}" /> 25952 <button type="button" class="sequence-save-btn hidden text-xs text-indigo-400 hover:text-indigo-500 cursor-pointer px-2">save</button> 25953 <button type="button" class="sequence-delete-btn text-gray hover:text-red-400 cursor-pointer p-1 transition duration-300 ease"> 25954 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 25955 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> 25956 </svg> 25957 </button> 25958 </div> 25959 ` 25960 ).join(""); 25961 const hasSequences = sequences.length > 0; 25962 modal.innerHTML = ` 25963 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 25964 <div class="flex items-start justify-between mb-4"> 25965 <h2 class="text-14 font-bold text-white">${hasSequences ? "Edit Folders" : "New Folder"}</h2> 25966 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 25967 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 25968 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 25969 </svg> 25970 </button> 25971 </div> 25972
25973 ${!hasSequences ? `<p class="text-sm text-gray mb-4">Organize your generations into folders.</p>` : ""} 25974 25975 <div class="flex gap-2 items-stretch ${hasSequences ? "pb-4 mb-4 border-b border-gray/10" : ""}"> 25976 <input type="text" id="new-sequence-input" placeholder="New folder name" 25977 class="flex-1 px-3 py-2 bg-inside rounded text-white focus:outline-none" /> 25978 <button type="button" id="add-sequence-btn" class="btn-primary px-4">Create</button> 25979 </div> 25980 25981 ${hasSequences ? `<div id="sequences-list" class="space-y-2 max-h-60 overflow-y-auto">${sequenceItems}</div>` : ""} 25982 </div> 25983 `; 25984 document.body.appendChild(modal); 25985 setupSequencesModal(modal); 25986}; 25987function setupSequencesModal(modal) { 25988 if (window.Modal) { 25989 window.Modal.activeModal = "sequences-modal"; 25990 modal.classList.remove("hidden"); 25991 document.body.classList.add("modal-open"); 25992 requestAnimationFrame(() => { 25993 modal.classList.add("modal-showing"); 25994 }); 25995 } 25996 const csrfToken = document.querySelector('[name="csrf-token"]')?.content; 25997 modal.querySelectorAll(".sequence-name-input").forEach((input) => { 25998 input.addEventListener("input", () => { 25999 const saveBtn = input.parentElement.querySelector(".sequence-save-btn"); 26000 if (input.value !== input.dataset.original && input.value.trim()) { 26001 saveBtn.classList.remove("hidden"); 26002 } else { 26003 saveBtn.classList.add("hidden"); 26004 } 26005 }); 26006 input.addEventListener("keydown", (e) => { 26007 if (e.key === "Enter") { 26008 e.preventDefault(); 26009 const saveBtn = input.parentElement.querySelector(".sequence-save-btn"); 26010 if (!saveBtn.classList.contains("hidden")) { 26011 saveBtn.click(); 26012 } 26013 } 26014 }); 26015 }); 26016 modal.querySelectorAll(".sequence-save-btn").forEach((btn) => { 26017 btn.addEventListener("click", async () => { 26018 const container = btn.closest("[data-sequence-uuid]"); 26019 const uuid2 = container.dataset.sequenceUuid; 26020 const input = container.querySelector(".sequence-name-input"); 26021 const name = input.value.trim(); 26022 if (!name) return; 26023 try { 26024 const response = await fetch(`/sequence/${uuid2}`, { 26025 method: "PATCH", 26026 headers: { 26027 "Content-Type": "application/json", 26028 "X-CSRF-Token": csrfToken 26029 }, 26030 body: JSON.stringify({ sequence: { name } }) 26031 }); 26032 if (response.ok) { 26033 input.dataset.original = name; 26034 btn.classList.add("hidden"); 26035 window.Toast?.show("Folder renamed", "success"); 26036 } else { 26037 const data = await response.json(); 26038 window.Toast?.show( 26039 data.error || "Failed to rename folder", 26040 "alert" 26041 ); 26042 } 26043 } catch (err) { 26044 console.error("Failed to rename sequence:", err); 26045 window.Toast?.show("Failed to rename folder", "alert"); 26046 } 26047 }); 26048 }); 26049 modal.querySelectorAll(".sequence-delete-btn").forEach((btn) => { 26050 btn.addEventListener("click", async () => { 26051 if (!confirm("Delete this folder? Generations will be moved to All.")) 26052 return; 26053 const container = btn.closest("[data-sequence-uuid]"); 26054 const uuid2 = container.dataset.sequenceUuid; 26055 try { 26056 const response = await fetch(`/sequence/${uuid2}`, { 26057 method: "DELETE", 26058 headers: { "X-CSRF-Token": csrfToken } 26059 }); 26060 if (response.ok) { 26061 container.remove(); 26062 window.Toast?.show("Folder deleted", "success"); 26063 const list = document.getElementById("sequences-list"); 26064 if (list && !list.querySelector("[data-sequence-uuid]")) { 26065 list.innerHTML = '<p class="text-gray text-sm">No folders yet</p>'; 26066 } 26067 } else { 26068 window.Toast?.show("Failed to delete folder", "alert"); 26069 } 26070 } catch (err) { 26071 console.error("Failed to delete sequence:", err); 26072 window.Toast?.show("Failed to delete folder", "alert"); 26073 } 26074 }); 26075 }); 26076 setupSequencesDragDrop(modal, csrfToken); 26077 const addBtn = document.getElementById("add-sequence-btn"); 26078 const newInput = document.getElementById("new-sequence-input"); 26079 const addSequence = async () => { 26080 const name = newInput.value.trim(); 26081 if (!name) return; 26082 try { 26083 const response = await fetch("/sequences", { 26084 method: "POST", 26085 headers: { 26086 "Content-Type": "application/json", 26087 "X-CSRF-Token": csrfToken 26088 }, 26089 body: JSON.stringify({ sequence: { name } }) 26090 }); 26091 if (response.ok) { 26092 const data = await response.json(); 26093 let list = document.getElementById("sequences-list"); 26094 if (!list) { 26095 const container = modal.querySelector(".modal-content"); 26096 const listDiv = document.createElement("div"); 26097 listDiv.id = "sequences-list"; 26098 listDiv.className = "space-y-2 max-h-60 overflow-y-auto"; 26099 container.appendChild(listDiv); 26100 list = listDiv; 26101 const createSection = modal.querySelector( 26102 ".flex.gap-2.items-stretch" 26103 ); 26104 if (createSection) { 26105 createSection.classList.add( 26106 "pb-4", 26107 "mb-4", 26108 "border-b", 26109 "border-gray/10" 26110 ); 26111 } 26112 } 26113 const emptyMsg = list.querySelector("p"); 26114 if (emptyMsg) emptyMsg.remove(); 26115 const newItem = document.createElement("div"); 26116 newItem.className = "flex items-center gap-2 group"; 26117 newItem.dataset.sequenceUuid = data.uuid; 26118 newItem.innerHTML = ` 26119 <input type="text" value="${data.name}" 26120 class="flex-1 px-3 py-2 bg-inside rounded text-white focus:outline-none sequence-name-input" 26121 data-original="${data.name}" /> 26122 <button type="button" class="sequence-save-btn hidden text-xs text-indigo-400 hover:text-indigo-500 cursor-pointer px-2">save</button> 26123 <button type="button" class="sequence-delete-btn text-gray hover:text-red-400 cursor-pointer p-1 transition duration-300 ease"> 26124 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 26125 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> 26126 </svg> 26127 </button> 26128 `; 26129 list.appendChild(newItem); 26130 const input = newItem.querySelector(".sequence-name-input"); 26131 const saveBtn = newItem.querySelector(".sequence-save-btn"); 26132 const deleteBtn = newItem.querySelector(".sequence-delete-btn"); 26133 input.addEventListener("input", () => { 26134 if (input.value !== input.dataset.original && input.value.trim()) { 26135 saveBtn.classList.remove("hidden"); 26136 } else { 26137 saveBtn.classList.add("hidden"); 26138 } 26139 }); 26140 input.addEventListener("keydown", (e) => { 26141 if (e.key === "Enter") { 26142 e.preventDefault(); 26143 if (!saveBtn.classList.contains("hidden")) saveBtn.click(); 26144 } 26145 }); 26146 saveBtn.addEventListener("click", async () => { 26147 const uuid2 = newItem.dataset.sequenceUuid; 26148 const newName = input.value.trim(); 26149 if (!newName) return; 26150 try { 26151 const resp = await fetch(`/sequence/${uuid2}`, { 26152 method: "PATCH", 26153 headers: { 26154 "Content-Type": "application/json", 26155 "X-CSRF-Token": csrfToken 26156 }, 26157 body: JSON.stringify({ sequence: { name: newName } }) 26158 }); 26159 if (resp.ok) { 26160 input.dataset.original = newName; 26161 saveBtn.classList.add("hidden"); 26162 window.Toast?.show("Folder renamed", "success"); 26163 } else { 26164 const d = await resp.json(); 26165 window.Toast?.show( 26166 d.error || "Failed to rename folder", 26167 "alert" 26168 ); 26169 } 26170 } catch (err) { 26171 console.error("Failed to rename sequence:", err); 26172 window.Toast?.show("Failed to rename folder", "alert"); 26173 } 26174 }); 26175 deleteBtn.addEventListener("click", async () => { 26176 if (!confirm("Delete this folder? Generations will be moved to All.")) 26177 return; 26178 try { 26179 const resp = await fetch( 26180 `/sequence/${newItem.dataset.sequenceUuid}`, 26181 { 26182 method: "DELETE", 26183 headers: { "X-CSRF-Token": csrfToken } 26184 } 26185 ); 26186 if (resp.ok) { 26187 newItem.remove(); 26188 window.Toast?.show("Folder deleted", "success"); 26189 const list2 = document.getElementById("sequences-list"); 26190 if (list2 && !list2.querySelector("[data-sequence-uuid]")) { 26191 list2.innerHTML = '<p class="text-gray text-sm">No folders yet</p>'; 26192 } 26193 } else { 26194 window.Toast?.show("Failed to delete folder", "alert"); 26195 } 26196 } catch (err) { 26197 console.error("Failed to delete sequence:", err); 26198 window.Toast?.show("Failed to delete folder", "alert"); 26199 } 26200 }); 26201 newInput.value = ""; 26202 window.Toast?.show("Folder created", "success"); 26203 } else { 26204 const data = await response.json(); 26205 window.Toast?.show(data.error || "Failed to create folder", "alert"); 26206 } 26207 } catch (err) { 26208 console.error("Failed to create sequence:", err); 26209 window.Toast?.show("Failed to create folder", "alert"); 26210 } 26211 }; 26212 addBtn.addEventListener("click", addSequence); 26213 newInput.addEventListener("keydown", (e) => { 26214 if (e.key === "Enter") { 26215 e.preventDefault(); 26216 addSequence(); 26217 } 26218 }); 26219 modal.addEventListener("click", (e) => { 26220 if (e.target === modal) {
26221 window.Modal?.close(); 26222 window.location.reload(); 26223 } 26224 }); 26225 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 26226 btn.addEventListener("click", () => { 26227 window.Modal?.close(); 26228 window.location.reload(); 26229 }); 26230 }); 26231} 26232function setupSequencesDragDrop(modal, csrfToken) { 26233 const list = modal.querySelector("#sequences-list"); 26234 if (!list) return; 26235 let draggedItem = null; 26236 let placeholder = null; 26237 let isDragging = false; 26238 let startX = 0; 26239 let startY = 0; 26240 let offsetX = 0; 26241 let offsetY = 0; 26242 const onMouseDown = (e, item) => { 26243 if (e.button !== 0) return; 26244 e.preventDefault(); 26245 const rect = item.getBoundingClientRect(); 26246 draggedItem = item; 26247 startX = e.clientX; 26248 startY = e.clientY; 26249 offsetX = e.clientX - rect.left; 26250 offsetY = e.clientY - rect.top; 26251 isDragging = false; 26252 }; 26253 const onMouseMove = (e) => { 26254 if (!draggedItem) return; 26255 const deltaX = Math.abs(e.clientX - startX); 26256 const deltaY = Math.abs(e.clientY - startY); 26257 if (!isDragging && (deltaX > 5 || deltaY > 5)) { 26258 isDragging = true; 26259 const rect = draggedItem.getBoundingClientRect(); 26260 placeholder = document.createElement("div"); 26261 placeholder.className = "sequence-placeholder rounded border border-dashed border-active/50 bg-active/10"; 26262 placeholder.style.height = rect.height + "px"; 26263 draggedItem.parentNode.insertBefore(placeholder, draggedItem); 26264 document.body.appendChild(draggedItem); 26265 draggedItem.style.position = "fixed"; 26266 draggedItem.style.width = rect.width + "px"; 26267 draggedItem.style.height = rect.height + "px"; 26268 draggedItem.style.left = e.clientX - offsetX + "px"; 26269 draggedItem.style.top = e.clientY - offsetY + "px"; 26270 draggedItem.style.zIndex = "9999"; 26271 draggedItem.style.pointerEvents = "none"; 26272 draggedItem.style.transition = "none"; 26273 draggedItem.classList.add("bg-box", "rounded", "shadow-xl"); 26274 draggedItem.style.outline = "1px dashed var(--color-active)"; 26275 draggedItem.style.outlineOffset = "2px"; 26276 } 26277 if (isDragging) { 26278 draggedItem.style.left = e.clientX - offsetX + "px"; 26279 draggedItem.style.top = e.clientY - offsetY + "px"; 26280 const items = Array.from( 26281 list.querySelectorAll('.sequence-item:not([style*="position: fixed"])') 26282 ); 26283 let closestItem = null; 26284 let closestDistance = Infinity; 26285 let insertBefore = true; 26286 for (const item of items) { 26287 const rect = item.getBoundingClientRect(); 26288 const centerY = rect.top + rect.height / 2; 26289 const distance = Math.abs(e.clientY - centerY); 26290 if (distance < closestDistance) { 26291 closestDistance = distance; 26292 closestItem = item; 26293 insertBefore = e.clientY < centerY; 26294 } 26295 } 26296 if (closestItem && placeholder) { 26297 if (insertBefore) { 26298 closestItem.parentNode.insertBefore(placeholder, closestItem); 26299 } else { 26300 closestItem.parentNode.insertBefore( 26301 placeholder, 26302 closestItem.nextSibling 26303 ); 26304 } 26305 } 26306 } 26307 }; 26308 const onMouseUp = async () => { 26309 if (!draggedItem) return; 26310 if (isDragging) { 26311 draggedItem.style.position = ""; 26312 draggedItem.style.width = ""; 26313 draggedItem.style.height = ""; 26314 draggedItem.style.top = ""; 26315 draggedItem.style.left = ""; 26316 draggedItem.style.zIndex = ""; 26317 draggedItem.style.pointerEvents = ""; 26318 draggedItem.style.transition = ""; 26319 draggedItem.style.outline = ""; 26320 draggedItem.style.outlineOffset = ""; 26321 draggedItem.classList.remove("bg-box", "rounded", "shadow-xl"); 26322 if (placeholder && placeholder.parentNode) { 26323 placeholder.parentNode.insertBefore(draggedItem, placeholder); 26324 placeholder.remove(); 26325 } 26326 const items = list.querySelectorAll(".sequence-item"); 26327 const uuids = Array.from(items).map((item) => item.dataset.sequenceUuid); 26328 try { 26329 await fetch("/sequences/reorder", { 26330 method: "POST", 26331 headers: { 26332 "Content-Type": "application/json", 26333 "X-CSRF-Token": csrfToken 26334 }, 26335 body: JSON.stringify({ uuids }) 26336 }); 26337 } catch (err) { 26338 console.error("Failed to save order:", err); 26339 } 26340 placeholder = null; 26341 } 26342 draggedItem = null; 26343 isDragging = false;
26344 }; 26345 list.querySelectorAll(".sequence-drag-handle").forEach((handle) => { 26346 handle.addEventListener("mousedown", (e) => { 26347 const item = handle.closest(".sequence-item"); 26348 onMouseDown(e, item); 26349 }); 26350 }); 26351 document.addEventListener("mousemove", onMouseMove); 26352 document.addEventListener("mouseup", onMouseUp); 26353} 26354
26355// app/javascript/elements/bar.js 26356var BarMixin = { 26357 // ââ Public API ââââââââââââââââââââââââââââââââââââââââââââââ 26358 async openEditBar(mode = "image") { 26359 this.closeEditBar(true); 26360 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 26361 if (!uuid2) return; 26362 try { 26363 const response = await fetch(`/element/${uuid2}/edit-bar`); 26364 if (!response.ok) throw new Error("Failed to load edit bar"); 26365 const html = await response.text(); 26366 document.body.insertAdjacentHTML("beforeend", html); 26367 const bar = document.querySelector("[data-element-edit-bar]"); 26368 if (!bar) return; 26369 this._editBar = bar; 26370 this._editBarMode = mode; 26371 this._syncElementGlobalsFromLS(); 26372 this.initEditBar(bar, mode); 26373 } catch (error) { 26374 console.error("[Elements] Error loading edit bar:", error); 26375 if (window.Toast) window.Toast.show("Failed to load edit bar", "alert"); 26376 } 26377 }, 26378 initEditBar(bar, mode) { 26379 const initEl = document.querySelector("[data-element-init]"); 26380 const elementType2 = initEl?.dataset?.elementType; 26381 this._editBarImageSubmode = localStorage.getItem("element-image-submode") || "live"; 26382 this.setupEditBarEvents(bar); 26383 this._initEditBarCameraDefaults(bar); 26384 if (this.modifyMode && this.modifyImage) { 26385 this._prefillReference(bar); 26386 } 26387 const promptEl = bar.querySelector("[data-element-edit-prompt]"); 26388 if (promptEl && this.currentPrompt) { 26389 promptEl.textContent = this.currentPrompt; 26390 } 26391 this._librarySelectHandler = (e) => { 26392 if (!e.detail?.url) return; 26393 const refSlot = this._pendingRefSlot; 26394 if (refSlot) { 26395 const addBtn = bar.querySelector(`[data-element-add-ref-btn="${refSlot}"]`); 26396 const preview = bar.querySelector(`[data-element-ref-preview="${refSlot}"]`); 26397 const img = bar.querySelector(`[data-element-ref-img="${refSlot}"]`); 26398 if (addBtn && preview && img) { 26399 img.src = e.detail.url; 26400 addBtn.classList.add("hidden"); 26401 preview.classList.remove("hidden"); 26402 } 26403 if (refSlot === 1 || refSlot === "1") { 26404 this._editBarReferenceHash = e.detail.hash || null; 26405 this._editBarReferenceUrl = e.detail.url || null; 26406 } else { 26407 this._editBarReference2Hash = e.detail.hash || null; 26408 this._editBarReference2Url = e.detail.url || null; 26409 } 26410 this._pendingRefSlot = null; 26411 this._updateRefSlot2Visibility(bar); 26412 } 26413 }; 26414 document.addEventListener("items-selected", this._librarySelectHandler); 26415 requestAnimationFrame(() => { 26416 bar.style.opacity = "1"; 26417 const prompt2 = bar.querySelector("[data-element-edit-prompt]"); 26418 if (prompt2) prompt2.focus(); 26419 }); 26420 }, 26421 closeEditBar(force = false) { 26422 if (this._editBar) { 26423 this._editBar.remove(); 26424 this._editBar = null; 26425 this._editBarMode = null; 26426 } 26427 if (this._editBarEscHandler) { 26428 document.removeEventListener("keydown", this._editBarEscHandler); 26429 this._editBarEscHandler = null; 26430 } 26431 if (this._librarySelectHandler) { 26432 document.removeEventListener("items-selected", this._librarySelectHandler); 26433 this._librarySelectHandler = null; 26434 } 26435 }, 26436 // ââ Events ââââââââââââââââââââââââââââââââââââââââââââââââââ 26437 setupEditBarEvents(bar) { 26438 const closeBtn = bar.querySelector("[data-element-edit-bar-close]"); 26439 if (closeBtn) { 26440 closeBtn.addEventListener("click", (e) => { 26441 e.stopPropagation(); 26442 this.closeEditBar(true); 26443 }); 26444 } 26445 const backdrop = bar.querySelector("[data-element-edit-bar-backdrop]"); 26446 if (backdrop) { 26447 backdrop.addEventListener("click", (e) => { 26448 e.stopPropagation(); 26449 this.closeEditBar(true); 26450 }); 26451 } 26452 this._editBarEscHandler = (e) => { 26453 if (e.key === "Escape") this.closeEditBar(true); 26454 }; 26455 document.addEventListener("keydown", this._editBarEscHandler); 26456 bar.querySelectorAll("[data-element-camera-btn]").forEach((btn) => { 26457 btn.addEventListener("click", () => { 26458 const category = btn.dataset.elementCameraBtn; 26459 if (typeof window.openCameraModal === "function") { 26460 window.openCameraModal(category); 26461 } 26462 }); 26463 }); 26464 const animStyleBtn = bar.querySelector("[data-element-animation-btn]"); 26465 if (animStyleBtn) { 26466 animStyleBtn.addEventListener("click", () => { 26467 if (typeof window.openAnimationModal === "function") { 26468 window.openAnimationModal(); 26469 } 26470 }); 26471 } 26472 const resSelector = bar.querySelector("[data-resolution-selector]"); 26473 if (resSelector) { 26474 resSelector.querySelectorAll("[data-resolution]").forEach((btn) => { 26475 btn.addEventListener("click", () => { 26476 this._editBarResolution = btn.dataset.resolution; 26477 localStorage.setItem("element-resolution", btn.dataset.resolution); 26478 resSelector.querySelectorAll("[data-resolution]").forEach((b) => { 26479 b.classList.remove("text-white", "bg-active/40"); 26480 b.classList.add("text-gray"); 26481 }); 26482 btn.classList.remove("text-gray"); 26483 btn.classList.add("text-white", "bg-active/40"); 26484 this._updateEditBarCost(); 26485 }); 26486 }); 26487 } 26488 ; 26489 [1, 2].forEach((slot) => { 26490 const addBtn = bar.querySelector(`[data-element-add-ref-btn="${slot}"]`); 26491 if (addBtn) { 26492 addBtn.addEventListener("click", (e) => { 26493 e.stopPropagation(); 26494 this._showRefContextMenu(bar, addBtn, slot); 26495 }); 26496 } 26497 const removeBtn = bar.querySelector(`[data-element-remove-ref-btn="${slot}"]`); 26498 if (removeBtn) { 26499 removeBtn.addEventListener("click", (e) => { 26500 e.stopPropagation(); 26501 this._editBarClearReference(bar, slot); 26502 }); 26503 } 26504 }); 26505 const generateBtn = bar.querySelector("[data-element-edit-generate]"); 26506 if (generateBtn) { 26507 this._generateBtnOriginalHTML = generateBtn.innerHTML; 26508 generateBtn.addEventListener("click", () => { 26509 this.generateFromEditBar(); 26510 }); 26511 } 26512 }, 26513 // ââ Prefill âââââââââââââââââââââââââââââââââââââââââââââââââ 26514 _prefillReference(bar) { 26515 if (!this.modifyImage) return; 26516 const addBtn = bar.querySelector('[data-element-add-ref-btn="1"]'); 26517 const preview = bar.querySelector('[data-element-ref-preview="1"]'); 26518 const img = bar.querySelector('[data-element-ref-img="1"]'); 26519 if (addBtn && preview && img && this.modifyImage.url) { 26520 img.src = this.modifyImage.url; 26521 addBtn.classList.add("hidden"); 26522 preview.classList.remove("hidden"); 26523 this._editBarReferenceHash = this.modifyImage.uuid || null; 26524 this._editBarReferenceUrl = this.modifyImage.url || null; 26525 this._updateRefSlot2Visibility(bar); 26526 } 26527 }, 26528 // ââ Generate ââââââââââââââââââââââââââââââââââââââââââââââââ 26529 async generateFromEditBar() { 26530 if (this.generating) return; 26531 const bar = this._editBar; 26532 if (!bar) return; 26533 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 26534 if (!uuid2) return; 26535 const promptEl = bar.querySelector("[data-element-edit-prompt]"); 26536 const prompt2 = promptEl?.textContent?.trim() || ""; 26537 if (!prompt2) { 26538 window.Toast?.show("Please enter a prompt", "alert"); 26539 return; 26540 } 26541 this.generating = true; 26542 const generateBtn = bar.querySelector("[data-element-edit-generate]"); 26543 if (generateBtn) { 26544 generateBtn.disabled = true; 26545 generateBtn.classList.add("opacity-50"); 26546 generateBtn.textContent = "GENERATING..."; 26547 } 26548 const bgImg = document.querySelector("[data-element-bg-img]"); 26549 if (bgImg) { 26550 bgImg.style.transition = "opacity 0.3s ease"; 26551 bgImg.style.opacity = "0"; 26552 } 26553 document.querySelector("[data-element-left-panel]")?.style.setProperty("opacity", "0"); 26554 this.addVersionLoadingBlock(); 26555 this.closeEditBar(true);
26556 this.showFirstGenerationLoading(); 26557 const resolution = this._editBarResolution || "2k"; 26558 const submode = this._editBarImageSubmode || "live"; 26559 const params = { prompt: prompt2, resolution }; 26560 if (submode === "live") { 26561 const cameraPrompts = {}; 26562 if (window.cameraSelections) { 26563 if (window.cameraSelections["color grade"]) cameraPrompts.grade = window.cameraSelections["color grade"].uuid; 26564 if (window.cameraSelections["device"]) cameraPrompts.device = window.cameraSelections["device"].uuid; 26565 if (window.cameraSelections["lens"]) cameraPrompts.lens = window.cameraSelections["lens"].uuid; 26566 if (window.cameraSelections["focal"]) cameraPrompts.focal = window.cameraSelections["focal"].uuid; 26567 if (window.cameraSelections["aperture"]) cameraPrompts.aperture = window.cameraSelections["aperture"].uuid; 26568 } 26569 if (Object.keys(cameraPrompts).length > 0) params.camera_prompts = cameraPrompts; 26570 } else if (submode === "animation") { 26571 const animStyle = window.animationSelections?.style; 26572 if (animStyle) { 26573 params.animation_prompts = { style: { uuid: animStyle.uuid || animStyle } }; 26574 } 26575 } 26576 if (this.modifyMode) { 26577 params.modify_version = this.modifyVersion; 26578 } else { 26579 const refHashes = [this._editBarReferenceHash, this._editBarReference2Hash].filter(Boolean); 26580 if (refHashes.length > 0) params.reference_hashes = refHashes; 26581 } 26582 try { 26583 const response = await fetch(`/element/${uuid2}/generate`, { 26584 method: "POST", 26585 headers: { 26586 "Content-Type": "application/json", 26587 "X-CSRF-Token": this.csrfToken 26588 }, 26589 body: JSON.stringify(params) 26590 }); 26591 if (response.ok) { 26592 this.pollGenerationStatus(uuid2); 26593 } else if (response.status === 402) { 26594 const data = await response.json(); 26595 window.Toast?.show(data.error || "Insufficient credits", "alert"); 26596 this.resetGenerateState(); 26597 } else { 26598 const data = await response.json().catch(() => ({})); 26599 window.Toast?.show(data.error || "Failed to start generation", "alert"); 26600 this.resetGenerateState(); 26601 } 26602 } catch (err) { 26603 console.error("Failed to generate:", err); 26604 window.Toast?.show("Failed to start generation", "alert"); 26605 this.resetGenerateState(); 26606 } 26607 }, 26608 // ââ Private helpers âââââââââââââââââââââââââââââââââââââââââ 26609 _initEditBarCameraDefaults(bar) { 26610 this._editBarResolution = localStorage.getItem("element-resolution") || "2k"; 26611 this._editBarImageSubmode = localStorage.getItem("element-image-submode") || "live"; 26612 const resSelector = bar.querySelector("[data-resolution-selector]"); 26613 if (resSelector) { 26614 resSelector.querySelectorAll("[data-resolution]").forEach((btn) => { 26615 if (btn.dataset.resolution === this._editBarResolution) { 26616 btn.classList.remove("text-gray"); 26617 btn.classList.add("text-white", "bg-active/40"); 26618 } else { 26619 btn.classList.remove("text-white", "bg-active/40"); 26620 btn.classList.add("text-gray"); 26621 } 26622 }); 26623 } 26624 const liveGrid = bar.querySelector('[data-camera-grid="live"]'); 26625 const animGrid = bar.querySelector('[data-camera-grid="animation"]'); 26626 if (this._editBarImageSubmode === "live") { 26627 liveGrid?.classList.remove("hidden"); 26628 animGrid?.classList.add("hidden"); 26629 } else { 26630 liveGrid?.classList.add("hidden"); 26631 animGrid?.classList.remove("hidden"); 26632 } 26633 this._updateEditBarCameraLabels(bar); 26634 this._updateEditBarCost(); 26635 }, 26636 _updateEditBarCameraLabels(bar) { 26637 if (!window.cameraSelections) return; 26638 const mapping = { 26639 "color grade": "[data-grade-button]", 26640 "camera": "[data-device-button]", 26641 "lens": "[data-lens-button]", 26642 "focal": "[data-focal-button]", 26643 "aperture": "[data-aperture-button]" 26644 }; 26645 for (const [category, selector] of Object.entries(mapping)) { 26646 const btn = bar.querySelector(selector); 26647 if (!btn) continue; 26648 const selection = window.cameraSelections[category === "camera" ? "device" : category]; 26649 if (selection?.preview) { 26650 btn.innerHTML = `<div class="w-full h-full rounded-md overflow-hidden bg-cover bg-center" style="background-image: url('${selection.preview}')"></div>`; 26651 } 26652 } 26653 }, 26654 _updateEditBarCost() { 26655 const bar = this._editBar; 26656 if (!bar) return; 26657 const costEl = bar.querySelector("[data-element-cost-value]"); 26658 if (!costEl) return; 26659 const resolution = (this._editBarResolution || "2k").toLowerCase(); 26660 const cost = resolution === "4k" ? 16 : 12; 26661 costEl.textContent = Math.round(cost); 26662 }, 26663 // ââ Reference context menu ââââââââââââââââââââââââââââââââââ 26664 _showRefContextMenu(bar, btnEl, slot) { 26665 const existing = bar.querySelector("[data-ref-context-menu]"); 26666 if (existing) { 26667 existing.remove(); 26668 return; 26669 } 26670 const menu = document.createElement("div"); 26671 menu.setAttribute("data-ref-context-menu", ""); 26672 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px]"; 26673 menu.style.cssText = "bottom: 100%; left: 0; margin-bottom: 8px;"; 26674 const uploadBtn = document.createElement("button"); 26675 uploadBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 26676 uploadBtn.innerHTML = ` 26677 <div class="flex items-start gap-3"> 26678 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 26679 <path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/> 26680 </svg> 26681 <div> 26682 <div class="font-medium text-white">Upload</div> 26683 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Image from your computer</div> 26684 </div> 26685 </div> 26686 `; 26687 uploadBtn.addEventListener("click", (e) => { 26688 e.stopPropagation(); 26689 menu.remove(); 26690 this._editBarPickReference(slot); 26691 }); 26692 menu.appendChild(uploadBtn); 26693 const favsBtn = document.createElement("button"); 26694 favsBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 26695 favsBtn.innerHTML = ` 26696 <div class="flex items-start gap-3"> 26697 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 26698 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 26699 </svg> 26700 <div> 26701 <div class="font-medium text-white">Favs</div> 26702 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Favorites images</div> 26703 </div> 26704 </div> 26705 `; 26706 favsBtn.addEventListener("click", (e) => { 26707 e.stopPropagation(); 26708 menu.remove(); 26709 this._pendingRefSlot = slot; 26710 this._openFavsModal(); 26711 }); 26712 menu.appendChild(favsBtn); 26713 const slotContainer = btnEl.closest("[data-element-ref-slot]"); 26714 if (slotContainer) slotContainer.appendChild(menu); 26715 const dismiss = (e) => { 26716 if (!menu.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) { 26717 menu.remove(); 26718 document.removeEventListener("mousedown", dismiss); 26719 } 26720 }; 26721 setTimeout(() => document.addEventListener("mousedown", dismiss), 0); 26722 }, 26723 _editBarPickReference(slot = 1) { 26724 const fileInput = document.createElement("input"); 26725 fileInput.type = "file"; 26726 fileInput.accept = "image/jpeg,image/jpg,image/png,image/webp"; 26727 fileInput.multiple = false;
26728 fileInput.onchange = async (e) => { 26729 const file = e.target.files[0]; 26730 if (!file) return; 26731 const bar = this._editBar; 26732 if (!bar) return; 26733 const url = URL.createObjectURL(file); 26734 const addBtn = bar.querySelector(`[data-element-add-ref-btn="${slot}"]`); 26735 const preview = bar.querySelector(`[data-element-ref-preview="${slot}"]`); 26736 const img = bar.querySelector(`[data-element-ref-img="${slot}"]`); 26737 if (addBtn && preview && img) { 26738 img.src = url; 26739 addBtn.classList.add("hidden"); 26740 preview.classList.remove("hidden"); 26741 } 26742 try { 26743 const formData = new FormData(); 26744 formData.append("file", file); 26745 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 26746 const response = await fetch("/studio/upload", { 26747 method: "POST", 26748 headers: { "X-CSRF-Token": csrfToken }, 26749 body: formData 26750 }); 26751 if (response.ok) { 26752 const data = await response.json(); 26753 if (slot === 1 || slot === "1") { 26754 this._editBarReferenceHash = data.hash || null; 26755 this._editBarReferenceUrl = data.url || null; 26756 this._updateRefSlot2Visibility(bar); 26757 } else { 26758 this._editBarReference2Hash = data.hash || null; 26759 this._editBarReference2Url = data.url || null; 26760 } 26761 } 26762 } catch (err) { 26763 console.error("[Elements] Reference upload error:", err); 26764 } 26765 URL.revokeObjectURL(url); 26766 }; 26767 fileInput.click(); 26768 }, 26769 _editBarClearReference(bar, slot = 1) { 26770 const addBtn = bar.querySelector(`[data-element-add-ref-btn="${slot}"]`); 26771 const preview = bar.querySelector(`[data-element-ref-preview="${slot}"]`); 26772 const img = bar.querySelector(`[data-element-ref-img="${slot}"]`); 26773 if (addBtn) addBtn.classList.remove("hidden"); 26774 if (preview) preview.classList.add("hidden"); 26775 if (img) img.src = ""; 26776 if (slot === 1 || slot === "1") { 26777 this._editBarReferenceHash = null; 26778 this._editBarReferenceUrl = null; 26779 this._editBarClearReference(bar, 2); 26780 } else { 26781 this._editBarReference2Hash = null; 26782 this._editBarReference2Url = null; 26783 } 26784 this._updateRefSlot2Visibility(bar); 26785 }, 26786 async _openFavsModal() { 26787 try { 26788 const resp = await fetch("/favs.json"); 26789 if (!resp.ok) throw new Error("Failed to load favs"); 26790 const data = await resp.json(); 26791 this._showFavsPickerModal(data.finals); 26792 } catch (err) { 26793 console.error("[Elements] Favs picker error:", err); 26794 if (window.Toast) window.Toast.show("Failed to load favs", "alert"); 26795 } 26796 }, 26797 _showFavsPickerModal(finals) { 26798 const prev = document.getElementById("favs-picker-modal"); 26799 if (prev) prev.remove(); 26800 const modal = document.createElement("div"); 26801 modal.id = "favs-picker-modal"; 26802 modal.className = "fixed inset-0 bg-black/90 flex justify-center"; 26803 modal.style.cssText = "align-items: flex-start; padding-top: 10vh; z-index: 9999;"; 26804 const isEmpty = !finals || finals.length === 0; 26805 let bodyHTML = ""; 26806 if (isEmpty) { 26807 bodyHTML = ` 26808 <div class="flex flex-col items-center justify-center py-12 text-center"> 26809 <svg class="w-12 h-12 text-gray mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 26810 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 26811 </svg> 26812 <h3 class="text-white font-medium mb-2">No Favs Yet</h3> 26813 <p class="text-gray text-sm max-w-md">Save images to your favs to use them here.</p> 26814 </div>`; 26815 } else { 26816 const grid = finals.map((f) => { 26817 const inner = f.is_video ? `<video src="${f.file_url}" muted playsinline class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-110"></video>` : `<img src="${f.thumbnail_url}" alt="" class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-110" loading="lazy" />`; 26818 return `<button type="button" class="favs-media-node group relative rounded-md overflow-hidden bg-inside cursor-pointer aspect-square" data-media-url="${f.file_url}" data-media-hash="${f.file_hash}" data-media-is-video="${f.is_video}">${inner}</button>`; 26819 }).join(""); 26820 bodyHTML = ` 26821 <div class="overflow-y-auto" style="max-height: 60vh;"> 26822 <div class="grid grid-cols-4 gap-2">${grid}</div> 26823 </div>`; 26824 } 26825 modal.innerHTML = ` 26826 <div class="bg-box rounded-lg p-6 max-w-3xl w-full mx-4 shadow-xl max-h-[80vh] overflow-hidden flex flex-col"> 26827 <div class="flex items-start justify-between mb-4"> 26828 <div> 26829 <h2 class="text-14 font-bold text-white">Favs</h2> 26830 <p class="text-xs text-gray mt-1">Select from your favorites</p> 26831 </div> 26832 <button type="button" data-favs-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 26833 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
26833<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 26834 </button> 26835 </div> 26836 <div class="flex-1 overflow-hidden">${bodyHTML}</div> 26837 </div>`; 26838 document.body.appendChild(modal); 26839 const closeModal = () => modal.remove(); 26840 modal.querySelector("[data-favs-close]").addEventListener("click", closeModal); 26841 modal.addEventListener("click", (e) => { 26842 if (e.target === modal) closeModal(); 26843 }); 26844 modal.querySelectorAll(".favs-media-node").forEach((node) => { 26845 node.addEventListener("click", () => { 26846 const url = node.dataset.mediaUrl; 26847 const hash = node.dataset.mediaHash; 26848 const isVideo = node.dataset.mediaIsVideo === "true"; 26849 closeModal(); 26850 document.dispatchEvent(new CustomEvent("items-selected", { 26851 detail: { hash, url, isVideo } 26852 })); 26853 }); 26854 }); 26855 }, 26856 _updateRefSlot2Visibility(bar) { 26857 const slot2 = bar?.querySelector('[data-element-ref-slot="2"]'); 26858 if (!slot2) return; 26859 const hasRef1 = this._editBarReferenceHash || this._editBarReferenceUrl; 26860 if (hasRef1) { 26861 slot2.classList.remove("hidden"); 26862 } else { 26863 slot2.classList.add("hidden"); 26864 } 26865 }, 26866 // ââ localStorage sync ââââââââââââââââââââââââââââââââââââââ 26867 _syncElementGlobalsFromLS() { 26868 const get = (k) => localStorage.getItem(k); 26869 const json = (k) => { 26870 try { 26871 return JSON.parse(get(k)); 26872 } catch { 26873 return null; 26874 } 26875 }; 26876 const device = json("element-image-device"); 26877 const lens = json("element-image-lens"); 26878 const focal = json("element-image-focal"); 26879 const aperture = json("element-image-aperture"); 26880 const grade = json("element-image-grade"); 26881 if (device || lens || focal || aperture || grade) { 26882 window.cameraSelections = window.cameraSelections || {}; 26883 if (device) window.cameraSelections["device"] = device; 26884 if (lens) window.cameraSelections["lens"] = lens; 26885 if (focal) window.cameraSelections["focal"] = focal; 26886 if (aperture) window.cameraSelections["aperture"] = aperture; 26887 if (grade) window.cameraSelections["color grade"] = grade; 26888 } 26889 const resolution = get("element-resolution"); 26890 if (resolution) window.selectedResolution = resolution; 26891 const submode = get("element-image-submode"); 26892 if (submode) window.selectedImageSubmode = submode; 26893 } 26894}; 26895
26896// app/javascript/elements.js 26897var ElementsIndex = class { 26898 constructor() { 26899 this.csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 26900 this.pollInterval = null; 26901 this.generating = false; 26902 this.bind(); 26903 } 26904 bind() { 26905 document.querySelectorAll("[data-create-element]").forEach((btn) => { 26906 btn.addEventListener("click", () => { 26907 const catStep = document.querySelector('[data-create-step="category"]'); 26908 const formStep = document.querySelector('[data-create-step="form"]'); 26909 if (catStep) { 26910 catStep.classList.remove("hidden"); 26911 if (formStep) formStep.classList.add("hidden"); 26912 const title = document.querySelector("[data-create-modal-title]"); 26913 if (title) title.textContent = "New"; 26914 } 26915 this.openModal("create-element-modal"); 26916 }); 26917 }); 26918 document.querySelectorAll("[data-create-element-close]").forEach((btn) => { 26919 btn.addEventListener("click", () => this.closeModal("create-element-modal")); 26920 }); 26921 const createModal = document.getElementById("create-element-modal"); 26922 if (createModal) createModal.addEventListener("click", (e) => { 26923 if (e.target === createModal) this.closeModal("create-element-modal"); 26924 }); 26925 const realisticCategories = ["actor", "product", "place"]; 26926 document.querySelectorAll("[data-pick-category]").forEach((btn) => { 26927 btn.addEventListener("click", () => { 26928 const category = btn.dataset.pickCategory; 26929 const label = btn.dataset.pickLabel || ""; 26930 const catInput = document.querySelector('#create-element-form input[name="category"]'); 26931 if (catInput) catInput.value = category; 26932 const styleSection = document.querySelector("[data-create-style-section]"); 26933 if (realisticCategories.includes(category)) { 26934 const styleInput = document.querySelector('#create-element-form input[name="style"]'); 26935 if (styleInput) styleInput.value = "realistic"; 26936 if (styleSection) styleSection.classList.add("hidden"); 26937 } else { 26938 if (styleSection) styleSection.classList.remove("hidden"); 26939 } 26940 const title = document.querySelector("[data-create-modal-title]"); 26941 if (title) title.textContent = `New ${label}`; 26942 const catStep = document.querySelector('[data-create-step="category"]'); 26943 const formStep = document.querySelector('[data-create-step="form"]'); 26944 if (catStep) catStep.classList.add("hidden"); 26945 if (formStep) formStep.classList.remove("hidden"); 26946 const nameInput2 = formStep?.querySelector('input[name="name"]'); 26947 if (nameInput2) requestAnimationFrame(() => nameInput2.focus()); 26948 }); 26949 }); 26950 document.querySelectorAll("[data-create-element-style]").forEach((btn) => { 26951 btn.addEventListener("click", () => { 26952 btn.blur(); 26953 const value = btn.dataset.createElementStyle; 26954 const hiddenInput = document.querySelector('#create-element-form input[name="style"]'); 26955 if (hiddenInput) hiddenInput.value = value; 26956 document.querySelectorAll("[data-create-element-style]").forEach((b) => { 26957 b.style.outline = "none"; 26958 }); 26959 btn.style.outline = "2px solid var(--color-active)"; 26960 btn.style.outlineOffset = "2px"; 26961 }); 26962 }); 26963 const nameInput = document.querySelector('#create-element-form input[name="name"]'); 26964 const mentionEl = document.querySelector("[data-create-element-mention]"); 26965 if (nameInput && mentionEl) { 26966 nameInput.addEventListener("keyup", () => { 26967 const val = nameInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, ""); 26968 mentionEl.textContent = val ? `@${val}` : ""; 26969 }); 26970 } 26971 const createForm = document.getElementById("create-element-form"); 26972 if (createForm) { 26973 createForm.addEventListener("submit", (e) => { 26974 e.preventDefault(); 26975 this.createElement(createForm); 26976 }); 26977 } 26978 window.addEventListener("hashchange", () => { 26979 const hash = location.hash.replace("#", ""); 26980 if (hash) this.selectMediaBySlug(hash); 26981 }); 26982 const initEl = document.querySelector("[data-element-init]"); 26983 this.uuid = initEl?.dataset?.elementUuid; 26984 this.elementType = initEl?.dataset?.elementType; 26985 if (this.uuid) this.fetchData(); 26986 const emptyUploadBtn = document.querySelector("[data-element-empty-upload]"); 26987 if (emptyUploadBtn) { 26988 emptyUploadBtn.addEventListener("click", () => this.handleEmptyUpload()); 26989 } 26990 const emptyCreateBtn = document.querySelector("[data-element-empty-create]"); 26991 if (emptyCreateBtn) { 26992 emptyCreateBtn.addEventListener("click", () => { 26993 this.modifyMode = false;
26994 this.openEditBar(); 26995 }); 26996 } 26997 const openCreatorBtn = document.querySelector("[data-element-open-creator]"); 26998 if (openCreatorBtn) { 26999 openCreatorBtn.addEventListener("click", () => this.openCharacterCreator()); 27000 } 27001 document.querySelectorAll("[data-element-open-bar]").forEach((btn) => { 27002 btn.addEventListener("click", () => { 27003 this.modifyMode = false; 27004 this.openEditBar(); 27005 }); 27006 }); 27007 this.modifyMode = false; 27008 this.modifyVersion = null; 27009 const modifyBtn = document.querySelector("[data-element-modify-btn]"); 27010 if (modifyBtn) { 27011 modifyBtn.addEventListener("click", () => { 27012 this.modifyMode = true; 27013 this.modifyVersion = document.querySelector("[data-element-init]")?.dataset?.displayVersion; 27014 this.modifyImage = this.currentBg ? { url: this.currentBg.url, uuid: this.currentBg.uuid } : null; 27015 this.openEditBar(); 27016 }); 27017 } 27018 const deleteShowBtn = document.querySelector("[data-delete-element-show]"); 27019 if (deleteShowBtn) { 27020 deleteShowBtn.addEventListener("click", () => { 27021 this.deleteElementUuid = deleteShowBtn.dataset.deleteElementShow; 27022 this.openModal("delete-element-show-modal"); 27023 }); 27024 } 27025 const deleteShowConfirm = document.querySelector("[data-delete-element-show-confirm]"); 27026 if (deleteShowConfirm) { 27027 deleteShowConfirm.addEventListener("click", () => this.deleteElementAndRedirect()); 27028 } 27029 document.querySelectorAll("[data-delete-element-show-close]").forEach((btn) => { 27030 btn.addEventListener("click", () => this.closeModal("delete-element-show-modal")); 27031 }); 27032 const deleteShowModal = document.getElementById("delete-element-show-modal"); 27033 if (deleteShowModal) deleteShowModal.addEventListener("click", (e) => { 27034 if (e.target === deleteShowModal) this.closeModal("delete-element-show-modal"); 27035 }); 27036 this._initVoiceGeneratorModal(); 27037 this._initSoundGeneratorModal(); 27038 const deleteAudioModal = document.getElementById("delete-audio-modal"); 27039 if (deleteAudioModal) { 27040 deleteAudioModal.addEventListener("click", (e) => { 27041 if (e.target === deleteAudioModal) this.closeModal("delete-audio-modal"); 27042 }); 27043 deleteAudioModal.querySelectorAll("[data-delete-audio-close]").forEach((btn) => { 27044 btn.addEventListener("click", () => this.closeModal("delete-audio-modal")); 27045 }); 27046 deleteAudioModal.querySelector("[data-delete-audio-confirm]")?.addEventListener("click", () => { 27047 this.closeModal("delete-audio-modal"); 27048 if (this._pendingDeleteVoice) { 27049 this._doDeleteVoice(this._pendingDeleteVoice.mediaUuid, this._pendingDeleteVoice.cardEl); 27050 this._pendingDeleteVoice = null; 27051 } 27052 }); 27053 } 27054 document.querySelectorAll("[data-rename-element-show-close]").forEach((btn) => { 27055 btn.addEventListener("click", () => this.closeModal("rename-element-show-modal")); 27056 }); 27057 const renameShowModal = document.getElementById("rename-element-show-modal"); 27058 if (renameShowModal) renameShowModal.addEventListener("click", (e) => { 27059 if (e.target === renameShowModal) this.closeModal("rename-element-show-modal"); 27060 }); 27061 const renameForm = document.getElementById("rename-element-show-form"); 27062 if (renameForm) { 27063 renameForm.addEventListener("submit", async (e) => { 27064 e.preventDefault(); 27065 const uuid2 = renameForm.dataset.elementUuid; 27066 const name = renameForm.querySelector('input[name="name"]').value.trim(); 27067 if (!name) return; 27068 try { 27069 const response = await fetch(`/element/${uuid2}/rename`, { 27070 method: "PATCH", 27071 headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken }, 27072 body: JSON.stringify({ name }) 27073 }); 27074 if (response.ok) { 27075 this.closeModal("rename-element-show-modal"); 27076 const nameEl = document.querySelector("[data-element-show-name]"); 27077 if (nameEl) nameEl.textContent = name; 27078 const h1 = document.querySelector("[data-element-init] h1"); 27079 if (h1) h1.textContent = name; 27080 const resData = await response.json(); 27081 const initEl2 = document.querySelector("[data-element-init]"); 27082 if (initEl2 && resData.slug) initEl2.dataset.elementSlug = resData.slug; 27083 if (this.uuid) this.fetchData(); 27084 } else { 27085 window.Toast?.show("Failed to rename element", "alert"); 27086 } 27087 } catch (err) { 27088 window.Toast?.show("Failed to rename element", "alert"); 27089 } 27090 }); 27091 } 27092 const menuBtn = document.querySelector(".element-show-menu-btn"); 27093 const menuDropdown = document.querySelector(".element-show-dropdown"); 27094 if (menuBtn && menuDropdown) { 27095 menuBtn.addEventListener("click", (e) => { 27096 e.stopPropagation(); 27097 menuDropdown.classList.toggle("hidden"); 27098 }); 27099 document.addEventListener("click", (e) => { 27100 if (!e.target.closest(".element-show-menu-btn")) { 27101 menuDropdown.classList.add("hidden"); 27102 } 27103 }); 27104 const renameOption = menuDropdown.querySelector(".rename-element-show-option"); 27105 if (renameOption) { 27106 renameOption.addEventListener("click", (e) => { 27107 e.stopPropagation(); 27108 menuDropdown.classList.add("hidden"); 27109 this.openModal("rename-element-show-modal"); 27110 const input = document.querySelector('#rename-element-show-form input[name="name"]'); 27111 if (input) requestAnimationFrame(() => input.focus()); 27112 }); 27113 } 27114 const duplicateOption = menuDropdown.querySelector(".duplicate-element-show-option"); 27115 if (duplicateOption) { 27116 duplicateOption.addEventListener("click", (e) => { 27117 e.stopPropagation(); 27118 menuDropdown.classList.add("hidden"); 27119 this.duplicateElementShow(duplicateOption.dataset.elementUuid); 27120 }); 27121 } 27122 const deleteOption = menuDropdown.querySelector(".delete-element-show-option"); 27123 if (deleteOption) { 27124 deleteOption.addEventListener("click", (e) => { 27125 e.stopPropagation(); 27126 menuDropdown.classList.add("hidden"); 27127 this.deleteElementUuid = deleteOption.dataset.elementUuid; 27128 this.openModal("delete-element-show-modal"); 27129 }); 27130 } 27131 } 27132 } 27133 setupVersionDropdown() { 27134 const container = document.querySelector("[data-element-versions]"); 27135 if (!container || container._dropdownReady) return; 27136 const btn = container.querySelector("[data-element-version-btn]"); 27137 const menu = container.querySelector("[data-element-version-menu]"); 27138 if (!btn || !menu) return; 27139 btn.addEventListener("click", (e) => { 27140 e.stopPropagation(); 27141 menu.classList.toggle("hidden"); 27142 }); 27143 document.addEventListener("click", () => menu.classList.add("hidden")); 27144 container._dropdownReady = true; 27145 } 27146 async fetchData() { 27147 const v = document.querySelector("[data-element-init]")?.dataset?.displayVersion; 27148 const url = v ? `/element/${this.uuid}/data/${v}` : `/element/${this.uuid}/data`; 27149 try { 27150 const response = await fetch(url); 27151 if (!response.ok) return; 27152 const data = await response.json(); 27153 this.populatePage(data); 27154 const hash = location.hash.replace("#", ""); 27155 if (hash) this.selectMediaBySlug(hash); 27156 } catch (err) { 27157 console.error("Failed to fetch element data:", err); 27158 } 27159 } 27160 populatePage(data) { 27161 const initEl = document.querySelector("[data-element-init]"); 27162 if (!initEl) return; 27163 initEl.dataset.displayVersion = data.display_version; 27164 this.elementType = data.element_type; 27165 this.currentPrompt = data.prompt || ""; 27166 this.currentBg = data.bg || null; 27167 this.currentMetadata = data.metadata || {}; 27168 const isCharacter = data.element_type !== "set"; 27169 const isProp = ["object"].includes(data.element_type); 27170 const bgContainer = document.querySelector("[data-element-bg]"); 27171 if (bgContainer) { 27172 if (data.bg && data.has_media && !data.generating) { 27173 let bgImg = bgContainer.querySelector("[data-element-bg-img]"); 27174 if (!bgImg) { 27175 bgImg = document.createElement("img"); 27176 bgImg.setAttribute("data-element-bg-img", ""); 27177 bgImg.alt = ""; 27178 bgContainer.appendChild(bgImg); 27179 } 27180 bgImg.src = data.bg.url; 27181 bgImg.dataset.mediaUuid = data.bg.uuid; 27182 bgImg.className = "w-full h-full object-cover object-top"; 27183 bgContainer.classList.remove("hidden"); 27184 } else { 27185 bgContainer.classList.add("hidden"); 27186 } 27187 } 27188 this.renderVersionBar(data.versions, data.display_version); 27189 this.renderScenesStrip(data); 27190 const recreateBtn = document.querySelector("[data-element-recreate-btn]"); 27191 const emptyNode = document.querySelector("[data-element-empty-node]"); 27192 if (data.failed_error && !data.has_media) { 27193 if (recreateBtn) recreateBtn.classList.add("hidden"); 27194 if (emptyNode) emptyNode.classList.add("hidden"); 27195 this.showCanvasError(data.failed_error || "Generation failed. Try again."); 27196 } else if (data.generating && !data.has_media) { 27197 if (recreateBtn) recreateBtn.classList.add("hidden"); 27198 if (emptyNode) emptyNode.classList.add("hidden"); 27199 const canvas = document.querySelector("[data-element-canvas]"); 27200 if (canvas) { 27201 canvas.classList.add("hidden"); 27202 canvas.innerHTML = ""; 27203 } 27204 } else if (data.has_media) { 27205 if (recreateBtn) recreateBtn.classList.remove("hidden"); 27206 if (emptyNode) emptyNode.classList.add("hidden"); 27207 const canvas = document.querySelector("[data-element-canvas]"); 27208 if (canvas) { 27209 canvas.classList.add("hidden"); 27210 canvas.innerHTML = ""; 27211 } 27212 } else { 27213 if (recreateBtn) recreateBtn.classList.add("hidden"); 27214 if (emptyNode) emptyNode.classList.remove("hidden"); 27215 const canvas = document.querySelector("[data-element-canvas]"); 27216 if (canvas) { 27217 canvas.classList.add("hidden"); 27218 canvas.innerHTML = ""; 27219 } 27220 } 27221 this.currentData = data; 27222 if (data.has_media && !data.generating) { 27223 const hash = location.hash.replace("#", ""); 27224 const defaultSlug = "cover"; 27225 this.updateLeftPanelForSlug(hash || defaultSlug, data, isProp, isCharacter); 27226 } 27227 if (data.has_media && !data.generating) { 27228 this.renderRightPanel(data); 27229 } 27230 if (data.expressions_generating && !this.expressionsPollInterval) { 27231 this.expressionsGenerating = true; 27232 this.pollExpressionsStatus(this.uuid); 27233 } 27234 if (data.aging_generating && !this.agingPollInterval) { 27235 this.agingGenerating = true; 27236 this.pollAgingStatus(this.uuid); 27237 } 27238 if (data.weather_generating && !this.weatherPollInterval) { 27239 this.weatherGenerating = true; 27240 this.pollWeatherStatus(this.uuid); 27241 } 27242 } 27243 updateLeftPanelForSlug(slug, data, isProp, isCharacter) { 27244 if (!data) data = this.currentData; 27245 if (!data) return; 27246 if (isProp === void 0) isProp = ["object"].includes(data.element_type); 27247 if (isCharacter === void 0) isCharacter = data.element_type !== "set"; 27248 const shotEl = document.querySelector(`[data-shot-slug="${slug}"]`); 27249 const scene = shotEl?.getAttribute("data-shot-scene") || slug;
27250 const isCover = scene === "cover"; 27251 const isFaceScene = scene === "face" || scene === "view" || /-(face|side)\d*$/.test(slug); 27252 let displaySlug; 27253 if (isCover) { 27254 displaySlug = null; 27255 } else { 27256 displaySlug = slug; 27257 } 27258 const panel = document.querySelector("[data-element-left-panel]"); 27259 if (!panel) return; 27260 let nameEl = document.querySelector("[data-element-left-name]"); 27261 if (!nameEl) { 27262 nameEl = document.createElement("h1"); 27263 nameEl.className = "absolute z-10 text-5xl font-bold text-white select-none"; 27264 nameEl.style.top = "140px"; 27265 nameEl.style.left = "60px"; 27266 nameEl.setAttribute("data-element-left-name", ""); 27267 panel.parentElement.appendChild(nameEl); 27268 } 27269 nameEl.textContent = data.name; 27270 nameEl.classList.remove("hidden"); 27271 panel.style.top = "200px"; 27272 panel.style.left = "0"; 27273 panel.style.bottom = "0"; 27274 panel.style.paddingLeft = "60px"; 27275 panel.style.paddingBottom = "100px"; 27276 panel.style.overflowY = "auto"; 27277 panel.style.scrollbarWidth = "none"; 27278 panel.innerHTML = ""; 27279 const rightPanel = document.querySelector("[data-element-right-panel]"); 27280 if (rightPanel) { 27281 rightPanel.classList.toggle("hidden", !isCover); 27282 rightPanel.style.opacity = isCover ? "1" : "0"; 27283 } 27284 if (displaySlug) { 27285 const labelEl = document.createElement("div"); 27286 labelEl.className = "text-xs font-medium text-white/40"; 27287 labelEl.textContent = `@${displaySlug}`; 27288 panel.appendChild(labelEl); 27289 } 27290 let itemPrompt; 27291 if (isFaceScene) { 27292 if (scene === "view") { 27293 itemPrompt = slug.includes("side") ? "Detail" : "Wide"; 27294 } else { 27295 itemPrompt = slug.includes("side") ? "Side" : "Face"; 27296 } 27297 } else { 27298 itemPrompt = shotEl?.getAttribute("data-media-prompt") || data.prompt; 27299 } 27300 if (itemPrompt) { 27301 if (isCover && data.ref_url) { 27302 const promptRow = document.createElement("div"); 27303 promptRow.className = "mt-1 flex items-start gap-3"; 27304 const refImg = document.createElement("img"); 27305 refImg.src = data.ref_url; 27306 refImg.alt = "Reference"; 27307 refImg.className = "w-16 h-16 object-cover rounded-md bg-inside cursor-pointer hover:opacity-80 transition duration-300 shrink-0"; 27308 refImg.addEventListener("click", () => { 27309 window.open(data.ref_url, "_blank"); 27310 }); 27311 promptRow.appendChild(refImg); 27312 const promptEl = document.createElement("p"); 27313 promptEl.className = "text-sm text-white/60 max-w-[200px] leading-[21px] select-text cursor-text"; 27314 promptEl.textContent = itemPrompt; 27315 promptRow.appendChild(promptEl); 27316 panel.appendChild(promptRow); 27317 } else { 27318 const promptEl = document.createElement("p"); 27319 promptEl.className = "mt-3 text-sm text-white/60 max-w-[240px] leading-[21px] select-text cursor-text"; 27320 promptEl.textContent = itemPrompt; 27321 panel.appendChild(promptEl); 27322 } 27323 } 27324 const btnsRow = document.createElement("div"); 27325 btnsRow.className = "flex items-center gap-1 mt-3"; 27326 if (isCover) { 27327 const faces = data.faces || []; 27328 const bodies = data.bodies || []; 27329 const faceImg = faces[0]; 27330 const sideImg = faces[1]; 27331 const bodyImg = bodies[0]; 27332 if (faceImg || sideImg || bodyImg) { 27333 const thumbsWrap = document.createElement("div"); 27334 thumbsWrap.className = "mt-6 flex flex-col gap-2"; 27335 if (faceImg) { 27336 const faceThumb = document.createElement("div"); 27337 faceThumb.className = "w-full rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition duration-300 border border-white/50"; 27338 faceThumb.style.maxWidth = "200px"; 27339 const faceImgEl = document.createElement("img"); 27340 faceImgEl.src = faceImg.thumbnail_url || faceImg.url; 27341 faceImgEl.alt = "Face"; 27342 faceImgEl.className = "w-full object-cover object-top"; 27343 faceImgEl.style.aspectRatio = "8 / 5"; 27344 faceThumb.appendChild(faceImgEl); 27345 faceThumb.addEventListener("click", () => this.setBgImage(faceImg.url, faceImg.uuid)); 27346 thumbsWrap.appendChild(faceThumb); 27347 } 27348 if (sideImg || bodyImg) { 27349 const grid = document.createElement("div"); 27350 grid.className = "grid grid-cols-2 gap-2"; 27351 grid.style.maxWidth = "200px"; 27352 if (sideImg) { 27353 const sideThumb = document.createElement("div"); 27354 sideThumb.className = "rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition duration-300 border border-white/50"; 27355 const sideImgEl = document.createElement("img"); 27356 sideImgEl.src = sideImg.thumb_url || sideImg.url; 27357 sideImgEl.alt = "3/4"; 27358 sideImgEl.className = "w-full aspect-[4/3] object-cover object-top"; 27359 sideThumb.appendChild(sideImgEl); 27360 sideThumb.addEventListener("click", () => this.setBgImage(sideImg.url, sideImg.uuid)); 27361 grid.appendChild(sideThumb); 27362 } 27363 if (bodyImg) { 27364 const bodyThumb = document.createElement("div"); 27365 bodyThumb.className = "rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition duration-300 border border-white/50"; 27366 const bodyImgEl = document.createElement("img"); 27367 bodyImgEl.src = bodyImg.thumb_url || bodyImg.url; 27368 bodyImgEl.alt = "Body"; 27369 bodyImgEl.className = "w-full aspect-[4/3] object-cover object-top"; 27370 bodyThumb.appendChild(bodyImgEl); 27371 bodyThumb.addEventListener("click", () => this.setBgImage(bodyImg.url, bodyImg.uuid)); 27372 grid.appendChild(bodyThumb); 27373 } 27374 thumbsWrap.appendChild(grid); 27375 } 27376 const otherItems = [
27377 ...(data.customs || []).map((m) => ({ ...m, _scene: "preset" })) 27378 ].filter((m) => m.url && m.status !== "generating" && m.status !== "upscaling" && m.status !== "processing").slice(0, 3); 27379 if (true) { 27380 const othersGrid = document.createElement("div"); 27381 othersGrid.className = "flex flex-col gap-1.5"; 27382 othersGrid.style.maxWidth = "96px"; 27383 const label = document.createElement("div"); 27384 label.className = "text-[8px] text-white/40 uppercase tracking-wider font-medium text-center flex items-center justify-center gap-1 mt-2"; 27385 label.innerHTML = '<img src="/kling.png" style="width:9px;height:9px;"> Kling Elements'; 27386 othersGrid.appendChild(label); 27387 otherItems.forEach((item) => { 27388 const thumb = document.createElement("div"); 27389 thumb.className = "rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition duration-300 border border-white/50"; 27390 const img = document.createElement("img"); 27391 img.src = item.thumb_url || item.url; 27392 img.alt = item._scene; 27393 img.className = "w-full object-cover object-top"; 27394 thumb.appendChild(img); 27395 thumb.addEventListener("click", () => item._scene === "preset" ? this.openCustomPresetModal(item) : this.setBgImage(item.url, item.uuid)); 27396 othersGrid.appendChild(thumb); 27397 }); 27398 const addBtn = document.createElement("button"); 27399 addBtn.type = "button"; 27400 addBtn.className = "group/add w-full rounded-lg px-3 py-2.5 flex items-center justify-center gap-2 cursor-pointer transition duration-300 hover:bg-white/10"; 27401 addBtn.style.cssText = "background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15);"; 27402 addBtn.addEventListener("mouseenter", () => { 27403 addBtn.style.borderColor = "rgba(255,255,255,0.5)"; 27404 }); 27405 addBtn.addEventListener("mouseleave", () => { 27406 addBtn.style.borderColor = "rgba(255,255,255,0.15)"; 27407 }); 27408 addBtn.innerHTML = '<svg class="w-3.5 h-3.5 text-white/40 group-hover/add:text-white transition duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="text-[11px] text-white/40 group-hover/add:text-white font-medium transition duration-300">Kling</span>'; 27409 addBtn.addEventListener("click", () => this.openCustomPresetModal()); 27410 othersGrid.appendChild(addBtn); 27411 thumbsWrap.appendChild(othersGrid); 27412 } 27413 panel.appendChild(thumbsWrap); 27414 } 27415 } else { 27416 const sceneActions = { 27417 "body": () => this.openBodyPicker(), 27418 "wardrobe": () => this.openWardrobeModal(), 27419 "expression": () => this.openExpressionsPicker(), 27420 "aging": () => this.openAgingPicker(), 27421 "weather": () => this.openWeatherPicker(), 27422 "variation": () => this.openVariationModal(), 27423 "preset": () => this.openCustomPresetModal() 27424 }; 27425 const sceneAction = sceneActions[scene]; 27426 if (sceneAction && scene !== "weather" && scene !== "variation" && scene !== "preset") { 27427 const newBtn = document.createElement("button"); 27428 newBtn.type = "button"; 27429 newBtn.className = "h-8 px-4 text-[10px] font-medium text-white/60 hover:text-white bg-white/10 hover:bg-white/15 rounded-md transition duration-300 cursor-pointer flex items-center"; 27430 newBtn.textContent = "New"; 27431 newBtn.addEventListener("click", sceneAction); 27432 btnsRow.appendChild(newBtn); 27433 } 27434 } 27435 const mediaUuid = shotEl?.getAttribute("data-media-uuid") || data.bg?.uuid; 27436 if (mediaUuid && !isCover) { 27437 const viewBtn = document.createElement("button"); 27438 viewBtn.type = "button"; 27439 viewBtn.className = "w-7 h-8 flex items-center justify-center text-white/60 hover:text-white bg-white/10 hover:bg-white/15 rounded-md transition duration-300 cursor-pointer"; 27440 viewBtn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>'; 27441 viewBtn.addEventListener("click", () => { 27442 if (window.openMediaModal) window.openMediaModal(mediaUuid); 27443 }); 27444 btnsRow.appendChild(viewBtn); 27445 } 27446 if (btnsRow.children.length > 0) panel.appendChild(btnsRow); 27447 panel.classList.remove("hidden"); 27448 document.querySelector("[data-element-left-gradient]")?.classList.remove("hidden"); 27449 } 27450 renderRightPanel(data) { 27451 const panel = document.querySelector("[data-element-right-panel]"); 27452 if (!panel) return; 27453 panel.style.top = "200px"; 27454 panel.style.right = "0"; 27455 panel.style.paddingRight = "60px"; 27456 panel.style.width = "310px"; 27457 panel.innerHTML = ""; 27458 const wrap = document.createElement("div"); 27459 wrap.className = "flex flex-col gap-2.5"; 27460 wrap.setAttribute("data-audio-list", ""); 27461 const voices = data.audio || [];
27462 voices.forEach((voice) => { 27463 wrap.appendChild(this._createVoiceCard(voice)); 27464 }); 27465 const addBtn = document.createElement("button"); 27466 addBtn.setAttribute("data-audio-add-btn", ""); 27467 addBtn.type = "button"; 27468 addBtn.className = "group/add w-full rounded-lg px-3 py-2.5 flex items-center justify-center gap-2 cursor-pointer transition duration-300 hover:bg-white/10"; 27469 addBtn.style.cssText = "background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15);"; 27470 addBtn.addEventListener("mouseenter", () => { 27471 addBtn.style.borderColor = "rgba(255,255,255,0.5)"; 27472 }); 27473 addBtn.addEventListener("mouseleave", () => { 27474 addBtn.style.borderColor = "rgba(255,255,255,0.15)"; 27475 }); 27476 addBtn.innerHTML = '<svg class="w-3.5 h-3.5 text-white/40 group-hover/add:text-white transition duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="text-[11px] text-white/40 group-hover/add:text-white font-medium transition duration-300">New Audio</span>'; 27477 addBtn.addEventListener("click", (e) => { 27478 if (this.elementType !== "character") { 27479 this.openSoundGenerator(); 27480 return; 27481 } 27482 const existing = document.querySelector("[data-audio-type-menu]"); 27483 if (existing) existing.remove(); 27484 const menu = document.createElement("div"); 27485 menu.setAttribute("data-audio-type-menu", ""); 27486 menu.className = "absolute top-full left-0 right-0 mt-1 bg-dark border border-white/10 rounded-lg shadow-xl py-1 z-20"; 27487 const options = [ 27488 { label: "Standard Voice", sub: "ElevenLabs TTS", action: () => this.openVoiceGenerator(false) }, 27489 { label: "Kling Voice", sub: "5-30s for video", action: () => this.openVoiceGenerator(true) } 27490 ]; 27491 options.forEach((opt) => { 27492 const item = document.createElement("button"); 27493 item.type = "button"; 27494 item.className = "w-full flex flex-col px-3 py-2 hover:bg-white/10 transition cursor-pointer"; 27495 item.innerHTML = `<span class="text-[11px] text-white font-medium">${opt.label}</span><span class="text-[9px] text-white/30">${opt.sub}</span>`; 27496 item.addEventListener("click", (ev) => { 27497 ev.stopPropagation(); 27498 menu.remove(); 27499 opt.action(); 27500 }); 27501 menu.appendChild(item); 27502 }); 27503 addBtn.style.position = "relative"; 27504 addBtn.appendChild(menu); 27505 const dismiss = (ev) => { 27506 if (!menu.contains(ev.target)) { 27507 menu.remove(); 27508 document.removeEventListener("click", dismiss); 27509 } 27510 }; 27511 setTimeout(() => document.addEventListener("click", dismiss), 0); 27512 }); 27513 wrap.appendChild(addBtn); 27514 panel.appendChild(wrap); 27515 panel.classList.remove("hidden"); 27516 } 27517 _createVoiceCard(voice) { 27518 const wrapper = document.createElement("div"); 27519 wrapper.className = "relative group"; 27520 const card = document.createElement("div"); 27521 card.className = "rounded-lg px-3 py-2.5 flex items-center gap-2.5 transition duration-300"; 27522 card.style.cssText = "background: rgba(255,255,255,0.06); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08);"; 27523 const playBtn = document.createElement("div"); 27524 playBtn.className = "w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center shrink-0 transition duration-300 cursor-pointer"; 27525 const playIcon = '<svg class="w-3 h-3 text-white/80 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>'; 27526 const pauseIcon = '<svg class="w-3 h-3 text-white/80" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'; 27527 playBtn.innerHTML = playIcon; 27528 const audio = new Audio(voice.url); 27529 let playing = false; 27530 playBtn.addEventListener("click", () => { 27531 document.querySelectorAll("[data-voice-audio]").forEach((a) => { 27532 if (a !== audio && !a.paused) { 27533 a.pause(); 27534 a.currentTime = 0; 27535 } 27536 }); 27537 document.querySelectorAll("[data-voice-play-btn]").forEach((b) => { 27538 b.innerHTML = playIcon; 27539 }); 27540 if (playing) { 27541 audio.pause(); 27542 audio.currentTime = 0; 27543 playBtn.innerHTML = playIcon; 27544 playing = false;
27545 } else { 27546 audio.play(); 27547 playBtn.innerHTML = pauseIcon; 27548 playing = true; 27549 } 27550 }); 27551 audio.setAttribute("data-voice-audio", ""); 27552 playBtn.setAttribute("data-voice-play-btn", ""); 27553 audio.addEventListener("ended", () => { 27554 playBtn.innerHTML = playIcon; 27555 playing = false; 27556 }); 27557 const center = document.createElement("div"); 27558 center.className = "flex-1 min-w-0"; 27559 const textEl = document.createElement("div"); 27560 textEl.className = "text-[11px] text-white/60 truncate leading-tight"; 27561 textEl.textContent = voice.text || ""; 27562 center.appendChild(textEl); 27563 const nameEl = document.createElement("div"); 27564 nameEl.className = "text-[10px] text-white/30 truncate mt-0.5"; 27565 nameEl.textContent = voice.name || "Voice"; 27566 center.appendChild(nameEl); 27567 const totalBars = 24; 27568 const barHeights = Array.from({ length: totalBars }, () => Math.max(3, Math.round(Math.random() * 14 + 2))); 27569 const waveform = document.createElement("div"); 27570 waveform.className = "relative shrink-0"; 27571 waveform.style.height = "18px"; 27572 waveform.style.width = totalBars * 2 + (totalBars - 1) * 2 + "px"; 27573 const bgLayer = document.createElement("div"); 27574 bgLayer.className = "flex items-center gap-[2px] absolute inset-0"; 27575 barHeights.forEach((h) => { 27576 const bar = document.createElement("div"); 27577 bar.className = "rounded-full"; 27578 bar.style.cssText = `width:2px;height:${h}px;background:rgba(255,255,255,0.15);`; 27579 bgLayer.appendChild(bar); 27580 }); 27581 const fgLayer = document.createElement("div"); 27582 fgLayer.className = "flex items-center gap-[2px] absolute inset-0"; 27583 fgLayer.style.clipPath = "inset(0 100% 0 0)"; 27584 barHeights.forEach((h) => { 27585 const bar = document.createElement("div"); 27586 bar.className = "rounded-full"; 27587 bar.style.cssText = `width:2px;height:${h}px;background:rgba(255,255,255,0.85);`; 27588 fgLayer.appendChild(bar); 27589 }); 27590 waveform.appendChild(bgLayer); 27591 waveform.appendChild(fgLayer); 27592 let rafId = null; 27593 const updateProgress = () => { 27594 if (!audio.duration) return; 27595 const pct = audio.currentTime / audio.duration * 100; 27596 fgLayer.style.clipPath = `inset(0 ${100 - pct}% 0 0)`; 27597 if (!audio.paused) rafId = requestAnimationFrame(updateProgress); 27598 }; 27599 audio.addEventListener("play", () => { 27600 rafId = requestAnimationFrame(updateProgress); 27601 }); 27602 audio.addEventListener("pause", () => { 27603 if (rafId) { 27604 cancelAnimationFrame(rafId); 27605 rafId = null; 27606 } 27607 if (audio.currentTime === 0) fgLayer.style.clipPath = "inset(0 100% 0 0)"; 27608 }); 27609 audio.addEventListener("ended", () => { 27610 if (rafId) { 27611 cancelAnimationFrame(rafId); 27612 rafId = null; 27613 } 27614 fgLayer.style.clipPath = "inset(0 100% 0 0)"; 27615 }); 27616 card.appendChild(playBtn); 27617 card.appendChild(center); 27618 card.appendChild(waveform); 27619 wrapper.appendChild(card); 27620 const menuBtn = document.createElement("div"); 27621 menuBtn.className = "absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-black/80 border border-white/10 flex items-center justify-center opacity-0 group-hover:opacity-100 transition duration-200 cursor-pointer text-white/40 hover:text-white/70 z-10"; 27622 menuBtn.innerHTML = '<svg class="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>'; 27623 const menu = document.createElement("div"); 27624 menu.className = "absolute top-4 right-0 bg-dark border border-white/10 rounded-lg shadow-xl py-1 z-20 hidden min-w-[100px]"; 27625 const editItem = document.createElement("button"); 27626 editItem.type = "button"; 27627 editItem.className = "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition cursor-pointer"; 27628 editItem.innerHTML = '<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg><span>Edit</span>'; 27629 editItem.addEventListener("click", (e) => { 27630 e.stopPropagation(); 27631 menu.classList.add("hidden"); 27632 voice.type === "sound" ? this.openSoundGeneratorForEdit(voice) : this.openVoiceGeneratorForEdit(voice); 27633 }); 27634 const deleteItem = document.createElement("button"); 27635 deleteItem.type = "button"; 27636 deleteItem.className = "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400/70 hover:text-red-400 hover:bg-white/10 transition cursor-pointer"; 27637 deleteItem.innerHTML = '<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg><span>
27637Delete</span>'; 27638 deleteItem.addEventListener("click", (e) => { 27639 e.stopPropagation(); 27640 menu.classList.add("hidden"); 27641 this.deleteVoice(voice.media_uuid, wrapper); 27642 }); 27643 menu.appendChild(editItem); 27644 menu.appendChild(deleteItem); 27645 menuBtn.addEventListener("click", (e) => { 27646 e.stopPropagation(); 27647 document.querySelectorAll("[data-voice-ctx-menu]").forEach((m) => m.classList.add("hidden")); 27648 menu.classList.toggle("hidden"); 27649 }); 27650 document.addEventListener("click", () => menu.classList.add("hidden")); 27651 menu.setAttribute("data-voice-ctx-menu", ""); 27652 wrapper.appendChild(menuBtn); 27653 wrapper.appendChild(menu); 27654 if (voice.kling_voice_id) { 27655 const badge = document.createElement("div"); 27656 badge.className = "absolute bottom-1 right-1 px-1.5 py-0.5 rounded text-[8px] font-medium uppercase tracking-wide"; 27657 badge.style.cssText = "background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.5); line-height: 1;"; 27658 badge.textContent = "Kling Voice"; 27659 wrapper.appendChild(badge); 27660 } 27661 return wrapper; 27662 } 27663 _createVoiceLoaderCard(text, voiceName) { 27664 const wrapper = document.createElement("div"); 27665 wrapper.className = "relative"; 27666 const card = document.createElement("div"); 27667 card.className = "rounded-lg px-3 py-2.5 flex items-center gap-2.5 transition duration-300 animate-pulse"; 27668 card.style.cssText = "background: rgba(255,255,255,0.06); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.08);"; 27669 const spinner = document.createElement("div"); 27670 spinner.className = "w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"; 27671 spinner.innerHTML = '<div class="w-4 h-4 border-2 border-white/10 border-t-white/60 rounded-full animate-spin"></div>'; 27672 const center = document.createElement("div"); 27673 center.className = "flex-1 min-w-0"; 27674 const textEl = document.createElement("div"); 27675 textEl.className = "text-[11px] text-white/40 truncate leading-tight"; 27676 textEl.textContent = text; 27677 center.appendChild(textEl); 27678 const nameEl = document.createElement("div"); 27679 nameEl.className = "text-[10px] text-white/20 truncate mt-0.5"; 27680 nameEl.textContent = voiceName; 27681 center.appendChild(nameEl); 27682 const label = document.createElement("div"); 27683 label.className = "text-[10px] text-white/30 shrink-0"; 27684 label.textContent = "Generating..."; 27685 card.appendChild(spinner); 27686 card.appendChild(center); 27687 card.appendChild(label); 27688 wrapper.appendChild(card); 27689 return wrapper; 27690 } 27691 openVoiceGeneratorForEdit(voice) { 27692 const modal = document.getElementById("voice-generator-modal"); 27693 if (!modal) return; 27694 const textInput = modal.querySelector("[data-voice-text]"); 27695 if (textInput) textInput.textContent = voice.text || ""; 27696 this._selectedVoiceId = voice.voice_id || null; 27697 this._selectedVoiceName = voice.name || ""; 27698 this._selectedVoiceDescription = voice.voice_description || [voice.voice_gender, voice.voice_age, voice.voice_accent].filter(Boolean).join(" \xB7 "); 27699 this._selectedVoiceImage = voice.voice_image || ""; 27700 this._selectedVoicePreview = voice.voice_preview || ""; 27701 this._updateSelectedVoiceCard({ 27702 voice_id: this._selectedVoiceId, 27703 name: this._selectedVoiceName, 27704 description: this._selectedVoiceDescription, 27705 image_url: this._selectedVoiceImage, 27706 preview_url: this._selectedVoicePreview 27707 }); 27708 this.openModal("voice-generator-modal", { autoFocus: false }); 27709 this._loadLanguagesOnce(modal); 27710 const voiceList = modal.querySelector("[data-voice-list]"); 27711 this._populateVoiceList(voiceList); 27712 if (textInput) textInput.dispatchEvent(new Event("input")); 27713 } 27714 openVoiceGenerator(klingMode = false) { 27715 this._klingVoiceMode = klingMode; 27716 const modal = document.getElementById("voice-generator-modal"); 27717 if (!modal) return; 27718 const textInput = modal.querySelector("[data-voice-text]"); 27719 if (textInput) textInput.textContent = ""; 27720 const title = modal.querySelector("h2"); 27721 if (title) title.textContent = klingMode ? "Generate Kling Voice" : "Generate Audio"; 27722 let durationIndicator = modal.querySelector("[data-voice-duration]"); 27723 if (klingMode) { 27724 if (!durationIndicator) { 27725 durationIndicator = document.createElement("div"); 27726 durationIndicator.setAttribute("data-voice-duration", ""); 27727 durationIndicator.className = "text-[10px] text-white/30 mt-1.5 text-right"; 27728 textInput?.parentNode?.insertBefore(durationIndicator, textInput.nextSibling); 27729 } 27730 durationIndicator.classList.remove("hidden"); 27731 durationIndicator.textContent = "~0s (min 5s, max 30s)"; 27732 this._voiceDurationHandler = () => { 27733 const words = (textInput?.innerText || "").trim().split(/\s+/).filter(
27733Boolean).length; 27734 const estSeconds = Math.round(words / 2.5); 27735 const ok = estSeconds >= 5 && estSeconds <= 30; 27736 durationIndicator.textContent = `~${estSeconds}s ${ok ? "" : estSeconds < 5 ? "(too short, min 5s)" : "(too long, max 30s)"}`; 27737 durationIndicator.style.color = ok ? "rgba(255,255,255,0.3)" : "rgba(248,113,113,0.8)"; 27738 }; 27739 textInput?.addEventListener("input", this._voiceDurationHandler); 27740 } else { 27741 if (durationIndicator) durationIndicator.classList.add("hidden"); 27742 if (this._voiceDurationHandler && textInput) { 27743 textInput.removeEventListener("input", this._voiceDurationHandler); 27744 this._voiceDurationHandler = null; 27745 } 27746 } 27747 const voices = this.currentData?.audio || []; 27748 const lastVoice = voices[voices.length - 1]; 27749 if (lastVoice?.voice_id) { 27750 this._selectedVoiceId = lastVoice.voice_id; 27751 this._selectedVoiceName = lastVoice.name; 27752 this._selectedVoiceDescription = lastVoice.voice_description || [lastVoice.voice_gender, lastVoice.voice_age, lastVoice.voice_accent].filter(Boolean).join(" \xB7 "); 27753 this._selectedVoiceImage = lastVoice.voice_image || ""; 27754 this._selectedVoicePreview = lastVoice.voice_preview || ""; 27755 this._updateSelectedVoiceCard({ 27756 voice_id: this._selectedVoiceId, 27757 name: this._selectedVoiceName, 27758 description: this._selectedVoiceDescription, 27759 image_url: this._selectedVoiceImage, 27760 preview_url: this._selectedVoicePreview 27761 }); 27762 } else { 27763 this._selectedVoiceId = null; 27764 this._selectedVoiceName = null; 27765 this._updateSelectedVoiceCard(null); 27766 } 27767 this.openModal("voice-generator-modal", { autoFocus: false }); 27768 this._loadLanguagesOnce(modal); 27769 const voiceList = modal.querySelector("[data-voice-list]"); 27770 this._populateVoiceList(voiceList); 27771 } 27772 _updateSelectedVoiceCard(voice) { 27773 const modal = document.getElementById("voice-generator-modal"); 27774 if (!modal) return; 27775 const empty = modal.querySelector("[data-voice-selected-empty]"); 27776 const img = modal.querySelector("[data-voice-selected-img]"); 27777 const info = modal.querySelector("[data-voice-selected-info]"); 27778 const name = modal.querySelector("[data-voice-selected-name]"); 27779 const desc = modal.querySelector("[data-voice-selected-desc]"); 27780 const previewBtn = modal.querySelector("[data-voice-preview-btn]"); 27781 const previewAudio = modal.querySelector("[data-voice-preview]"); 27782 if (!voice) { 27783 empty?.classList.remove("hidden"); 27784 img?.classList.add("hidden"); 27785 info?.classList.add("hidden"); 27786 previewBtn?.classList.add("hidden"); 27787 return; 27788 } 27789 empty?.classList.add("hidden"); 27790 if (img) { 27791 img.src = voice.image_url || ""; 27792 voice.image_url ? img.classList.remove("hidden") : img.classList.add("hidden"); 27793 } 27794 if (info) info.classList.remove("hidden"); 27795 if (name) name.textContent = voice.name; 27796 if (desc) desc.textContent = voice.description || [voice.gender, voice.age, voice.accent].filter(Boolean).join(" \xB7 "); 27797 if (previewBtn && voice.preview_url) { 27798 previewBtn.classList.remove("hidden"); 27799 } else { 27800 previewBtn?.classList.add("hidden"); 27801 } 27802 if (previewAudio) previewAudio.src = voice.preview_url || ""; 27803 } 27804 async loadVoicesForPicker(search2 = "", gender = "", age = "", language = "", page = 1) { 27805 let url = `/api/voices?per_page=50&page=${page}`; 27806 if (search2) url += `&search=${encodeURIComponent(search2)}`; 27807 if (gender) url += `&gender=${encodeURIComponent(gender)}`; 27808 if (age) url += `&age=${encodeURIComponent(age)}`; 27809 if (language) url += `&language=${encodeURIComponent(language)}`; 27810 try { 27811 const resp = await fetch(url); 27812 const data = await resp.json(); 27813 return { voices: data.voices || [], total_pages: data.total_pages || 1, current_page: data.current_page || 1 }; 27814 } catch { 27815 return { voices: [], total_pages: 1, current_page: 1 }; 27816 } 27817 } 27818 async submitVoiceGeneration() { 27819 if (!this._selectedVoiceId || !this.uuid) return; 27820 const modal = document.getElementById("voice-generator-modal"); 27821 const textInput = modal?.querySelector("[data-voice-text]"); 27822 const text = (textInput?.innerText || textInput?.textContent || "").trim(); 27823 if (!text) { 27824 window.Toast?.show("Enter some text for the voice", "alert"); 27825 return; 27826 } 27827 if (this._klingVoiceMode) {
27828 const words = text.split(/\s+/).filter(Boolean).length; 27829 const estSeconds = Math.round(words / 2.5); 27830 if (estSeconds < 5) { 27831 window.Toast?.show("Text too short for Kling Voice (min ~5 seconds)", "alert"); 27832 return; 27833 } 27834 if (estSeconds > 30) { 27835 window.Toast?.show("Text too long for Kling Voice (max ~30 seconds)", "alert"); 27836 return; 27837 } 27838 } 27839 const voiceName = this._selectedVoiceName || "Voice"; 27840 this.closeModal("voice-generator-modal"); 27841 const wrap = document.querySelector("[data-audio-list]"); 27842 const loaderCard = this._createVoiceLoaderCard(text, voiceName); 27843 if (wrap) { 27844 const addBtn = wrap.querySelector("[data-audio-add-btn]"); 27845 if (addBtn) wrap.insertBefore(loaderCard, addBtn); 27846 else wrap.appendChild(loaderCard); 27847 } 27848 try { 27849 const resp = await fetch(`/element/${this.uuid}/voice`, { 27850 method: "POST", 27851 headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken }, 27852 body: JSON.str
27852ingify({ 27853 voice_id: this._selectedVoiceId, 27854 text, 27855 voice_name: voiceName, 27856 voice_description: this._selectedVoiceDescription || "", 27857 voice_image: this._selectedVoiceImage || "", 27858 voice_preview: this._selectedVoicePreview || "", 27859 kling_voice: this._klingVoiceMode || false 27860 }) 27861 }); 27862 const data = await resp.json(); 27863 if (data.success && data.voice) { 27864 const card = this._createVoiceCard(data.voice); 27865 loaderCard.replaceWith(card); 27866 if (this.currentData) { 27867 if (!this.currentData.audio) this.currentData.audio = []; 27868 this.currentData.audio.push(data.voice); 27869 } 27870 } else { 27871 loaderCard.remove(); 27872 window.Toast?.show(data.error || "Voice generation failed", "alert"); 27873 } 27874 } catch { 27875 loaderCard.remove(); 27876 window.Toast?.show("Voice generation failed", "alert"); 27877 } 27878 } 27879 deleteVoice(mediaUuid, cardEl) { 27880 this._pendingDeleteVoice = { mediaUuid, cardEl }; 27881 this.openModal("delete-audio-modal"); 27882 } 27883 async _doDeleteVoice(mediaUuid, cardEl) { 27884 if (!this.uuid || !mediaUuid) return; 27885 try { 27886 const resp = await fetch(`/element/${this.uuid}/media/${mediaUuid}`, { 27887 method: "DELETE", 27888 headers: { "X-CSRF-Token": this.csrfToken } 27889 }); 27890 const data = await resp.json(); 27891 if (data.success) { 27892 cardEl?.remove(); 27893 } 27894 } catch { 27895 } 27896 } 27897 _initVoiceGeneratorModal() { 27898 const modal = document.getElementById("voice-generator-modal"); 27899 if (!modal) return; 27900 modal.querySelector("[data-voice-modal-close]")?.addEventListener("click", () => this.closeModal("voice-generator-modal")); 27901 modal.addEventListener("click", (e) => { 27902 if (e.target === modal) this.closeModal("voice-generator-modal"); 27903 }); 27904 const searchInput = modal.querySelector("[data-voice-search-input]"); 27905 const languageFilter = modal.querySelector("[data-voice-filter-language]"); 27906 const genderFilter = modal.querySelector("[data-voice-filter-gender]"); 27907 const ageFilter = modal.querySelector("[data-voice-filter-age]"); 27908 const voiceList = modal.querySelector("[data-voice-list]"); 27909 const refreshList = () => this._populateVoiceList(voiceList, searchInput?.value, genderFilter?.value, ageFilter?.value, languageFilter?.value); 27910 this._voiceModalFirstOpen = true; 27911 this._languagesLoaded = false; 27912 let searchTimer; 27913 searchInput?.addEventListener("input", () => { 27914 clearTimeout(searchTimer); 27915 searchTimer = setTimeout(refreshList, 300); 27916 }); 27917 languageFilter?.addEventListener("change", refreshList); 27918 genderFilter?.addEventListener("change", refreshList); 27919 ageFilter?.addEventListener("change", refreshList); 27920 const previewBtn = modal.querySelector("[data-voice-preview-btn]"); 27921 const previewAudio = modal.querySelector("[data-voice-preview]"); 27922 if (previewBtn && previewAudio) { 27923 previewBtn.addEventListener("click", () => { 27924 if (previewAudio.src) { 27925 if (previewAudio.paused) previewAudio.play(); 27926 else { 27927 previewAudio.pause(); 27928 previewAudio.currentTime = 0; 27929 } 27930 } 27931 }); 27932 } 27933 const editor = modal.querySelector("[data-voice-text]"); 27934 if (editor) { 27935 editor.addEventListener("paste", (e) => { 27936 e.preventDefault(); 27937 const text = e.clipboardData.getData("text/plain"); 27938 document.execCommand("insertText", false, text); 27939 }); 27940 } 27941 modal.querySelectorAll("[data-voice-tag]").forEach((btn) => { 27942 btn.addEventListener("click", () => { 27943 const editorEl = modal.querySelector("[data-voice-text]"); 27944 if (!editorEl) return; 27945 editorEl.focus(); 27946 const span = document.createElement("span"); 27947 span.className = "text-active bg-active/20 rounded px-0.5"; 27948 span.textContent = btn.dataset.voiceTag; 27949 span.contentEditable = "false"; 27950 editorEl.appendChild(span); 27951 const space = document.createTextNode("\xA0"); 27952 editorEl.appendChild(space); 27953 const sel = window.getSelection(); 27954 const range = document.createRange(); 27955 range.setStartAfter(space); 27956 range.collapse(true); 27957 sel.removeAllRanges(); 27958 sel.addRange(range); 27959 }); 27960 }); 27961 modal.querySelector("[data-voice-submit]")?.addEventListener("click", () => this.submitVoiceGeneration()); 27962 } 27963 _initSoundGeneratorModal() { 27964 const modal = document.getElementById("sound-generator-modal"); 27965 if (!modal) return; 27966 modal.querySelector("[data-sound-modal-close]")?.addEventListener("click", () => this.closeModal("sound-generator-modal")); 27967 modal.addEventListener("click", (e) => { 27968 if (e.target === modal) this.closeModal("sound-generator-modal"); 27969 }); 27970 modal.querySelector("[data-sound-submit]")?.addEventListener("click", () => this.submitSoundGeneration()); 27971 } 27972 openSoundGenerator() { 27973 const modal = document.getElementById("sound-generator-modal"); 27974 if (!modal) return; 27975 const textInput = modal.querySelector("[data-sound-text]"); 27976 if (textInput) textInput.value = ""; 27977 this.openModal("sound-generator-modal", { autoFocus: false }); 27978 } 27979 openSoundGeneratorForEdit(sound) { 27980 const modal = document.getElementById("sound-generator-modal"); 27981 if (!modal) return; 27982 const textInput = modal.querySelector("[data-sound-text]"); 27983 if (textInput) textInput.value = sound.text || ""; 27984 this.openModal("sound-generator-modal", { autoFocus: false }); 27985 } 27986 async submitSoundGeneration() { 27987 if (!this.uuid) return; 27988 const modal = document.getElementById("sound-generator-modal"); 27989 const textInput = modal?.querySelector("[data-sound-text]"); 27990 const text = (textInput?.value || "").trim(); 27991 if (!text) { 27992 window.Toast?.show("Describe the sound effect", "alert"); 27993 return; 27994 } 27995 this.closeModal("sound-generator-modal"); 27996 const wrap = document.querySelector("[data-audio-list]");
27997 const loaderCard = this._createVoiceLoaderCard(text, "Sound"); 27998 if (wrap) { 27999 const addBtn = wrap.querySelector("[data-audio-add-btn]"); 28000 if (addBtn) wrap.insertBefore(loaderCard, addBtn); 28001 else wrap.appendChild(loaderCard); 28002 } 28003 try { 28004 const resp = await fetch(`/element/${this.uuid}/sound`, { 28005 method: "POST", 28006 headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken }, 28007 body: JSON.stringify({ text }) 28008 }); 28009 const data = await resp.json(); 28010 if (data.success && data.sound) { 28011 const card = this._createVoiceCard(data.sound); 28012 loaderCard.replaceWith(card); 28013 if (this.currentData) { 28014 if (!this.currentData.audio) this.currentData.audio = []; 28015 this.currentData.audio.push(data.sound); 28016 } 28017 } else { 28018 loaderCard.remove(); 28019 window.Toast?.show(data.error || "Sound generation failed", "alert"); 28020 } 28021 } catch { 28022 loaderCard.remove(); 28023 window.Toast?.show("Sound generation failed", "alert"); 28024 } 28025 } 28026 _loadLanguagesOnce(modal) { 28027 if (this._languagesLoaded) return; 28028 this._languagesLoaded = true; 28029 const languageFilter = modal.querySelector("[data-voice-filter-language]"); 28030 if (!languageFilter) return; 28031 fetch("/api/languages").then((r) => r.json()).then((data) => { 28032 (data.languages || []).forEach((lang) => { 28033 const opt = document.createElement("option"); 28034 opt.value = lang.identifier; 28035 opt.textContent = lang.name; 28036 languageFilter.appendChild(opt); 28037 }); 28038 }).catch(() => { 28039 }); 28040 } 28041 async _populateVoiceList(listEl, search2 = "", gender = "", age = "", language = "") { 28042 if (!listEl) return; 28043 listEl.innerHTML = '<div class="text-center py-6 text-xs text-gray">Loading...</div>'; 28044 const result = await this.loadVoicesForPicker(search2, gender, age, language, 1); 28045 listEl.innerHTML = ""; 28046 if (result.voices.length === 0) { 28047 listEl.innerHTML = '<div class="text-center py-6 text-xs text-gray">No voices found</div>'; 28048 return; 28049 } 28050 if (!this._voiceListAudio) this._voiceListAudio = new Audio(); 28051 this._voiceListPage = result.current_page; 28052 this._voiceListTotalPages = result.total_pages; 28053 this._voiceListLoading = false; 28054 this._voiceListSearch = search2; 28055 this._voiceListGender = gender; 28056 this._voiceListAge = age; 28057 this._voiceListLanguage = language; 28058 this._appendVoiceItems(listEl, result.voices); 28059 listEl.removeEventListener("scroll", this._voiceScrollHandler); 28060 this._voiceScrollHandler = () => { 28061 if (this._voiceListLoading || this._voiceListPage >= this._voiceListTotalPages) return; 28062 if (listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 40) { 28063 this._loadMoreVoices(listEl); 28064 } 28065 }; 28066 listEl.addEventListener("scroll", this._voiceScrollHandler); 28067 } 28068 async _loadMoreVoices(listEl) { 28069 this._voiceListLoading = true; 28070 const nextPage = this._voiceListPage + 1; 28071 const result = await this.loadVoicesForPicker(this._voiceListSearch, this._voiceListGender, this._voiceListAge, this._voiceListLanguage, nextPage); 28072 this._voiceListPage = result.current_page; 28073 this._voiceListTotalPages = result.total_pages; 28074 this._appendVoiceItems(listEl, result.voices); 28075 this._voiceListLoading = false; 28076 } 28077 _appendVoiceItems(listEl, voices) { 28078 const listAudio = this._voiceListAudio; 28079 voices.forEach((v) => { 28080 const item = document.createElement("div"); 28081 const isSelected = this._selectedVoiceId === v.voice_id; 28082 item.className = `group/voice flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer transition ${isSelected ? "bg-active/20" : "hover:bg-white/8"}`; 28083 const avatar = document.createElement("div"); 28084 avatar.className = "relative w-7 h-7 shrink-0"; 28085 if (v.image_url) { 28086 avatar.innerHTML = `<img src="${v.image_url}" class="w-7 h-7 rounded-full object-cover" />`; 28087 } else { 28088 avatar.innerHTML = '<div class="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center"><svg class="w-3 h-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/></svg></div>'; 28089 } 28090 if (v.preview_url) { 28091 const playOverlay = document.createElement("div"); 28092 playOverlay.className = "absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover/voice:opacity-100 transition duration-200"; 28093 playOverlay.innerHTML = '<svg class="w-3 h-3 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>'; 28094 playOverlay.setAttribute("data-voice-play-overlay", ""); 28095 playOverlay.addEventListener("click", (e) => { 28096 e.stopPropagation(); 28097 listEl.querySelectorAll("[data-voice-play-overlay]").forEach((o) => { 28098 o.innerHTML = '<svg class="w-3 h-3 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>'; 28099 }); 28100 if (!listAudio.paused && listAudio.src.includes(v.preview_url)) { 28101 listAudio.pause(); 28102 listAudio.currentTime = 0; 28103 } else { 28104 listAudio.src = v.preview_url; 28105 listAudio.play(); 28106 playOverlay.innerHTML = '<svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>'; 28107 } 28108 }); 28109 listAudio.addEventListener("ended", () => { 28110 listEl.querySelectorAll("[data-voice-play-overlay]").forEach((o) => { 28111 o.innerHTML = '<svg class="w-3 h-3 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg>'; 28112 }); 28113 }); 28114 avatar.appendChild(playOverlay); 28115 } 28116 const info = document.createElement("div"); 28117 info.className = "flex-1 min-w-0"; 28118 info.innerHTML = ` 28119 <div class="text-[11px] text-white font-medium truncate">${v.name}</div> 28120 <div class="text-[10px] text-white/30 truncate">
28120${[v.gender, v.age, v.accent].filter(Boolean).join(" \xB7 ")}</div> 28121 `; 28122 item.appendChild(avatar); 28123 item.appendChild(info); 28124 item.addEventListener("click", () => { 28125 this._selectedVoiceId = v.voice_id; 28126 this._selectedVoiceName = v.name; 28127 this._selectedVoiceDescription = v.description || [v.gender, v.age, v.accent].filter(Boolean).join(" \xB7 "); 28128 this._selectedVoiceImage = v.image_url || ""; 28129 this._selectedVoicePreview = v.preview_url || ""; 28130 this._updateSelectedVoiceCard(v); 28131 listEl.querySelectorAll("[data-voice-item]").forEach((el) => { 28132 el.className = el.className.replace("bg-active/20", "").replace("hover:bg-white/8", "") + " hover:bg-white/8"; 28133 }); 28134 item.className = item.className.replace("hover:bg-white/8", "") + " bg-active/20"; 28135 }); 28136 item.setAttribute("data-voice-item", ""); 28137 listEl.appendChild(item); 28138 }); 28139 } 28140 createMediaRow(media, aspectClass, version, menuOpts = {}) { 28141 const row = document.createElement("div"); 28142 row.className = "relative group/row"; 28143 const imgWrap = document.createElement("div"); 28144 imgWrap.className = `relative w-full rounded-md overflow-visible cursor-pointer group/img`; 28145 imgWrap.setAttribute("data-element-thumb", ""); 28146 imgWrap.setAttribute("data-full", media.url); 28147 imgWrap.setAttribute("data-media-uuid", media.uuid); 28148 const aspectMap = { "aspect-4/3": "4/3", "aspect-5/3": "5/3", "aspect-2/1": "2/1", "aspect-9/16": "9/16", "aspect-video": "16/9" }; 28149 const imgInner = document.createElement("div"); 28150 imgInner.className = "w-full rounded-md overflow-hidden border border-white/50"; 28151 imgInner.style.aspectRatio = aspectMap[aspectClass] || "4/3"; 28152 imgInner.innerHTML = `<img src="${media.thumbnail_url || media.thumb_url || media.url}" alt="" class="w-full h-full object-cover group-hover/img:scale-105 transition duration-300" />`; 28153 imgInner.addEventListener("click", () => this.setBgImage(media.url, media.uuid)); 28154 imgWrap.appendChild(imgInner); 28155 const menuBtn = document.createElement("button"); 28156 menuBtn.type = "button"; 28157 menuBtn.className = "absolute top-1 right-1 w-5 h-5 flex items-center justify-center bg-black/60 hover:bg-black/80 rounded text-white/70 hover:text-white opacity-0 group-hover/img:opacity-100 transition-opacity duration-200 cursor-pointer z-10"; 28158 menuBtn.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>'; 28159 imgWrap.appendChild(menuBtn); 28160 this._attachContextMenu(imgWrap, menuBtn, media, version, row, menuOpts); 28161 row.appendChild(imgWrap); 28162 return row; 28163 } 28164 _attachContextMenu(container, menuBtn, media, version, removeTarget, opts = {}) { 28165 const menu = document.createElement("div"); 28166 menu.className = "absolute top-7 right-1 bg-dark border border-white/10 rounded-lg shadow-xl py-1 z-20 hidden min-w-[120px]"; 28167 if (!opts.noModify) { 28168 const modifyItem = document.createElement("button"); 28169 modifyItem.type = "button"; 28170 modifyItem.className = "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition cursor-pointer"; 28171 modifyItem.innerHTML = '<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg><span>Modify</span>'; 28172 modifyItem.addEventListener("click", (e) => { 28173 e.stopPropagation(); 28174 menu.classList.add("hidden"); 28175 this.modifyMode = true; 28176 this.modifyVersion = version; 28177 this.modifyImage = { url: media.thumb_url || media.url, uuid: media.uuid }; 28178 this.openEditBar(); 28179 }); 28180 menu.appendChild(modifyItem); 28181 } 28182 const viewItem = document.createElement("button"); 28183 viewItem.type = "button"; 28184 viewItem.className = "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:text-white hover:bg-white/10 transition cursor-pointer"; 28185 viewItem.innerHTML = '<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg><span>View</span>'; 28186 viewItem.addEventListener("click", (e) => { 28187 e.stopPropagation(); 28188 menu.classList.add("hidden"); 28189 if (window.openMediaModal) window.openMediaModal(media.uuid); 28190 }); 28191 menu.appendChild(viewItem); 28192 if (!opts.noDelete) { 28193 const divider = document.createElement("div"); 28194 divider.className = "border-t border-white/10 my-1"; 28195 menu.appendChild(divider); 28196 const deleteItem = document.createElement("button"); 28197 deleteItem.type = "button"; 28198 deleteItem.className = "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400/70 hover:text-red-400 hover:bg-white/10 transition cursor-pointer"; 28199 deleteItem.innerHTML = '<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
28199<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg><span>Delete</span>'; 28200 deleteItem.addEventListener("click", (e) => { 28201 e.stopPropagation(); 28202 menu.classList.add("hidden"); 28203 const target = removeTarget || container.closest("[data-element-thumb]") || container; 28204 this.deleteMedia(media.uuid, target); 28205 }); 28206 menu.appendChild(deleteItem); 28207 } 28208 container.appendChild(menu); 28209 menu.setAttribute("data-element-ctx-menu", ""); 28210 menuBtn.addEventListener("click", (e) => { 28211 e.stopPropagation(); 28212 document.querySelectorAll("[data-element-ctx-menu]").forEach((m) => { 28213 if (m !== menu) m.classList.add("hidden"); 28214 }); 28215 menu.classList.toggle("hidden"); 28216 }); 28217 document.addEventListener("click", (e) => { 28218 if (!menu.contains(e.target) && e.target !== menuBtn) { 28219 menu.classList.add("hidden"); 28220 } 28221 }); 28222 } 28223 async deleteMedia(mediaUuid, row) { 28224 if (!mediaUuid) return; 28225 try { 28226 const response = await fetch(`/element/${this.uuid}/media/${mediaUuid}`, { 28227 method: "DELETE", 28228 headers: { "X-CSRF-Token": this.csrfToken } 28229 }); 28230 if (!response.ok) return; 28231 const bgImg = document.querySelector("[data-element-bg-img]"); 28232 if (bgImg && bgImg.dataset.mediaUuid === mediaUuid) { 28233 bgImg.remove(); 28234 } 28235 row.style.transition = "opacity 0.3s ease"; 28236 row.style.opacity = "0"; 28237 setTimeout(() => row.remove(), 300); 28238 } catch (err) { 28239 console.error("Failed to delete media:", err); 28240 } 28241 } 28242 renderVersionBar(versions, displayVersion) { 28243 const container = document.querySelector("[data-element-versions]"); 28244 if (!container) return; 28245 const label = container.querySelector("[data-element-version-label]"); 28246 if (label) label.textContent = `v${displayVersion}`; 28247 const menu = container.querySelector("[data-element-version-menu]"); 28248 if (menu) { 28249 menu.innerHTML = ""; 28250 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 28251 versions.forEach((v) => { 28252 const item = document.createElement("a"); 28253 const elType = document.querySelector("[data-element-init]")?.dataset?.elementType; 28254 const prefix = elType === "character" ? "casting" : elType === "object" ? "object" : elType === "set" ? "set" : "element"; 28255 item.href = `/${prefix}/${uuid2}/${v.version}`; 28256 item.setAttribute("data-turbo-action", "replace"); 28257 item.className = `flex items-center gap-2 px-3 py-1.5 text-[8px] uppercase transition duration-200 ${v.ver
28257sion === displayVersion ? "text-white bg-active/20" : "text-gray hover:text-white hover:bg-light/10"}`; 28258 item.innerHTML = `<span class="truncate">v${v.version}</span>`; 28259 menu.appendChild(item); 28260 }); 28261 const sep = document.createElement("div"); 28262 sep.className = "mx-2 border-t border-light/20 my-1"; 28263 menu.appendChild(sep); 28264 const newBtn = document.createElement("button"); 28265 newBtn.className = "flex items-center gap-1.5 w-full px-3 py-1.5 text-[8px] uppercase text-gray hover:text-white hover:bg-light/10 transition duration-200 cursor-pointer"; 28266 newBtn.innerHTML = `<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span>New version</span>`; 28267 newBtn.addEventListener("click", () => { 28268 menu.classList.add("hidden"); 28269 this.modifyMode = false; 28270 this.openEditBar(); 28271 }); 28272 menu.appendChild(newBtn); 28273 } 28274 if (versions && versions.length > 0) { 28275 container.classList.remove("hidden"); 28276 } else { 28277 container.classList.add("hidden"); 28278 } 28279 this.setupVersionDropdown(); 28280 } 28281 createThumbDiv(mediaData, width, aspectClass) { 28282 const div = document.createElement("div"); 28283 div.className = `rounded-md overflow-hidden ${aspectClass} border border-white/50 cursor-pointer group`; 28284 if (width) div.style.width = width; 28285 div.setAttribute("data-element-thumb", ""); 28286 div.setAttribute("data-full", mediaData.url); 28287 div.setAttribute("data-media-uuid", mediaData.uuid); 28288 div.innerHTML = `<img src="${mediaData.thumb_url || mediaData.url}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition duration-300" />`; 28289 div.addEventListener("click", () => this.setBgImage(mediaData.url, mediaData.uuid)); 28290 return div; 28291 } 28292 renderScenesStrip(data) { 28293 const strip = document.querySelector("[data-element-scenes-strip]"); 28294 if (!strip) return; 28295 const scroll = strip.firstElementChild; 28296 scroll.innerHTML = ""; 28297 const isProp = ["object"].includes(data.element_type); 28298 const hasFaces = data.faces && data.faces.length > 0; 28299 const isFirstGenerating = data.generating && !data.has_media; 28300 let scenes; 28301 if (data.element_type === "set") { 28302 const coverItem = data.cover ? [data.cover] : []; 28303 const hasCover2 = coverItem.length > 0; 28304 const viewItems = data.faces || []; 28305 scenes = [ 28306 { name: "Cover", items: isFirstGenerating && !hasCover2 ? [{ status: "generating" }] : coverItem }, 28307 { name: "Views", items: isFirstGenerating && viewItems.length === 0 && !hasCover2 ? [{ status: "generating" }] : viewItems, needsCover: true }, 28308 { name: "Weather", items: data.weather_generating && (!data.weather || data.weather.length === 0) ? Array(4).fill({ status: "generating" }) : data.weather || [], action: () => this.openWeatherPicker(), needsCover: true }, 28309 { name: "Variations", items: data.variations || [], action: () => this.openVariationModal(), needsCover: true }, 28310 { name: "Kling", items: data.customs || [], action: () => this.openCustomPresetModal(), needsCover: true }, 28311 { name: "Audio", items: [], action: () => this.openSoundGenerator(), needsCover: true } 28312 ]; 28313 } else if (isProp) { 28314 const allFaces = data.faces || []; 28315 const coverItem = allFaces.length > 0 ? [allFaces[0]] : []; 28316 const faceItems = allFaces.length > 0 ? allFaces.slice(0, 2) : []; 28317 scenes = [ 28318 { name: "Cover", items: isFirstGenerating && coverItem.length === 0 ? [{ status: "generating" }] : coverItem }, 28319 { name: "Faces", items: isFirstGenerating && faceItems.length === 0 && coverItem.length === 0 ? [{ status: "generating" }] : faceItems, needsFaces: true }, 28320 { name: "Variations", items: data.variations || [], action: () => this.openVariationModal(), needsFaces: true }, 28321 { name: "Kling", items: data.customs || [], action: () => this.openCustomPresetModal(), needsFaces: true }, 28322 { name: "Audio", items: [], action: () => this.openSoundGenerator(), needsFaces: true } 28323 ]; 28324 } else { 28325 const allFaces = data.faces || []; 28326 const coverItem = allFaces.length > 0 ? [allFaces[0]] : []; 28327 const faceItems = allFaces.length > 0 ? allFaces.slice(0, 2) : []; 28328 scenes = [ 28329 { name: "Cover", items: isFirstGenerating && coverItem.length === 0 ? [{ status: "generating" }] : coverItem }, 28330 { name: "Faces", items: isFirstGenerating && faceItems.length === 0 && coverItem.length === 0 ? [{ status: "generating" }] : faceItems, needsFaces: true }, 28331 { name: "Body", items: data.bodies || [], action: () => this.openBodyPicker(), needsFaces: true }, 28332 { name: "Wardrobes", items: data.wardrobes || [], action: () => this.openWardrobeModal(), needsFaces: true }, 28333 { name: "Expressions", items: data.expressions_generating && (!data.expressions || data.expressions.length === 0) ? Array(4).fill({ status: "generating" }) : data.expressions || [], action: () => this.openExpressionsPicker(), needsFaces: true }, 28334 { name: "Aging", items: data.aging_generating && (!data.aging || data.aging.length === 0) ? Array(4).fill({ status: "generating" }) : data.aging || [], action: () =>
28334 this.openAgingPicker(), needsFaces: true }, 28335 { name: "Variations", items: data.variations || [], action: () => this.openVariationModal(), needsFaces: true }, 28336 { name: "Kling", items: data.customs || [], action: () => this.openCustomPresetModal(), needsFaces: true }, 28337 { name: "Audio", items: [], action: () => this.openVoiceGenerator(), needsFaces: true } 28338 ]; 28339 } 28340 let firstShot = null; 28341 const hasCover = data.cover != null; 28342 scenes.forEach((scene) => { 28343 if (scene.needsFaces && !hasFaces) return; 28344 if (scene.needsCover && !hasCover) return; 28345 const block = document.createElement("div"); 28346 block.className = "flex flex-col gap-1 shrink-0"; 28347 const label = document.createElement("div"); 28348 label.className = "pt-[5px] text-[8px] text-gray uppercase whitespace-nowrap px-0.5"; 28349 label.textContent = scene.name; 28350 block.appendChild(label); 28351 const row = document.createElement("div"); 28352 row.className = "flex items-center gap-1.5"; 28353 if (scene.items.length > 0) { 28354 const sceneKey = scene.name.toLowerCase().replace(/\s+/g, "-").replace(/s$/, ""); 28355 scene.items.forEach((item, idx) => { 28356 const shot = document.createElement("div"); 28357 shot.className = sceneKey === "face" || sceneKey === "view" ? "shrink-0 cursor-pointer relative" : "shrink-0 cursor-pointer relative group/shot"; 28358 shot.setAttribute("data-element-shot", ""); 28359 const slug = sceneKey === "cover" ? sceneKey : item.slug || (idx === 0 ? sceneKey : `${sceneKey}-${idx + 1}`); 28360 shot.setAttribute("data-shot-slug", slug); 28361 shot.setAttribute("data-shot-scene", sceneKey); 28362 const inner = document.createElement("div"); 28363 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center transition duration-300 ease relative"; 28364 inner.style.height = "34px"; 28365 inner.style.width = "56px"; 28366 if (item.status === "generating" || item.status === "upscaling") { 28367 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 28368 } else { 28369 const thumbSrc = item.thumb_url || item.thumbnail_url || item.url; 28370 if (thumbSrc) { 28371 inner.innerHTML = `<img src="${thumbSrc}" alt="" class="w-full h-full object-cover object-top" />`; 28372 } 28373 } 28374 if (item.url && item.status !== "generating" && item.status !== "upscaling") { 28375 shot.setAttribute("data-media-url", item.url); 28376 shot.setAttribute("data-media-uuid", item.uuid); 28377 if (item.prompt) shot.setAttribute("data-media-prompt", item.prompt); 28378 shot.addEventListener("click", () => { 28379 this.setBgImage(item.url, item.uuid); 28380 this.positionScenesIndicator(shot); 28381 if (sceneKey === "cover") { 28382 history.replaceState(null, "", location.pathname); 28383 } else { 28384 history.replaceState(null, "", `#${slug}`); 28385 } 28386 this.updateLeftPanelForSlug(slug); 28387 if (sceneKey === "preset") { 28388 this.openCustomPresetModal(item); 28389 } 28390 }); 28391 } 28392 shot.appendChild(inner); 28393 if (sceneKey !== "cover" && sceneKey !== "face" && sceneKey !== "view" && sceneKey !== "preset" && item.url && item.status !== "generating" && item.status !== "upscaling") { 28394 const menuBtn = document.createElement("button"); 28395 menuBtn.className = "absolute top-0.5 right-0.5 w-4 h-4 rounded flex items-center justify-center bg-black/50 text-white/70 hover:text-white opacity-0 group-hover/shot:opacity-100 transition duration-200 cursor-pointer z-10"; 28396 menuBtn.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>'; 28397 menuBtn.addEventListener("click", (e) => { 28398 e.stopPropagation(); 28399 this.openShotContextMenu(shot, sceneKey, item, scene.action); 28400 }); 28401 shot.appendChild(menuBtn); 28402 } 28403 row.appendChild(shot); 28404 if (!firstShot) firstShot = shot; 28405 }); 28406 } 28407 if (scene.action) { 28408 const createBtn = document.createElement("button"); 28409 createBtn.className = "w-7 bg-inside text-gray/50 hover:text-white rounded flex items-center justify-center shrink-0 cursor-pointer transition duration-300 ease"; 28410 createBtn.style.height = "34px"; 28411 createBtn.innerHTML = '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'; 28412 createBtn.addEventListener("click", scene.action); 28413 row.appendChild(createBtn); 28414 } 28415 if (scene.items.length === 0 && !scene.action) { 28416 const empty = document.createElement("div"); 28417 empty.className = "shrink-0 cursor-pointer relative"; 28418 empty.setAttribute("data-element-shot", ""); 28419 const inner = document.createElement("div"); 28420 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center transition duration-300 ease relative"; 28421 inner.style.height = "34px"; 28422 inner.innerHTML = '<span class="text-[9px] text-gray/50 hover:text-white font-medium uppercase px-3 transition duration-300 ease">Create</span>'; 28423 inner.addEventListener("click", () => { 28424 this.modifyMode = false;
28425 this.openEditBar(); 28426 }); 28427 empty.appendChild(inner); 28428 row.appendChild(empty); 28429 if (!firstShot) firstShot = empty; 28430 } 28431 block.appendChild(row); 28432 scroll.appendChild(block); 28433 }); 28434 scroll.style.position = "relative"; 28435 let indicator = scroll.querySelector("[data-element-scenes-indicator]"); 28436 if (!indicator) { 28437 indicator = document.createElement("div"); 28438 indicator.className = "project-shot-indicator"; 28439 indicator.setAttribute("data-element-scenes-indicator", ""); 28440 scroll.appendChild(indicator); 28441 } 28442 if (data.has_media || data.generating && !data.has_media) { 28443 strip.classList.remove("hidden"); 28444 const hash = location.hash.replace("#", ""); 28445 const hashShot = hash ? scroll.querySelector(`[data-shot-slug="${hash}"]`) : null; 28446 const targetShot = hashShot || firstShot; 28447 if (targetShot) { 28448 requestAnimationFrame(() => this.positionScenesIndicator(targetShot)); 28449 } 28450 } 28451 } 28452 positionScenesIndicator(shotEl) { 28453 const strip = document.querySelector("[data-element-scenes-strip]"); 28454 if (!strip) return; 28455 const scroll = strip.firstElementChild; 28456 const indicator = scroll.querySelector("[data-element-scenes-indicator]"); 28457 if (!indicator || !shotEl) return; 28458 const containerRect = scroll.getBoundingClientRect(); 28459 const elRect = shotEl.getBoundingClientRect(); 28460 const left = elRect.left - containerRect.left + scroll.scrollLeft + 2; 28461 const width = elRect.width - 4; 28462 indicator.style.left = `${left}px`; 28463 indicator.style.width = `${width}px`; 28464 indicator.style.opacity = "1"; 28465 } 28466 selectMediaBySlug(slug) { 28467 const shot = document.querySelector(`[data-shot-slug="${slug}"]`); 28468 if (!shot) return; 28469 const url = shot.getAttribute("data-media-url"); 28470 const uuid2 = shot.getAttribute("data-media-uuid"); 28471 if (url) this.setBgImage(url, uuid2); 28472 this.positionScenesIndicator(shot); 28473 this.updateLeftPanelForSlug(slug); 28474 } 28475 async switchVersion(v) { 28476 const initEl = document.querySelector("[data-element-init]"); 28477 if (!initEl) return; 28478 const currentV = parseInt(initEl.dataset.displayVersion, 10); 28479 if (v === currentV) return; 28480 try { 28481 const response = await fetch(`/element/${this.uuid}/data/${v}`); 28482 if (!response.ok) return; 28483 const data = await response.json(); 28484 this.populatePage(data); 28485 } catch (err) { 28486 console.error("Failed to switch version:", err); 28487 } 28488 } 28489 setBgImage(url, mediaUuid) { 28490 const bgContainer = document.querySelector("[data-element-bg]"); 28491 const bgImg = bgContainer?.querySelector("img"); 28492 if (!bgImg || bgImg.src === url) return; 28493 let loader = bgContainer.querySelector("[data-element-bg-loader]"); 28494 if (!loader) { 28495 loader = document.createElement("div"); 28496 loader.setAttribute("data-element-bg-loader", ""); 28497 loader.className = "absolute inset-0 flex items-center justify-center z-10"; 28498 loader.innerHTML = '<div class="w-6 h-6 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>'; 28499 bgContainer.appendChild(loader); 28500 } 28501 loader.classList.remove("hidden"); 28502 const preload = new Image(); 28503 preload.onload = () => { 28504 bgImg.src = url; 28505 bgImg.dataset.mediaUuid = mediaUuid || ""; 28506 const isPortrait = preload.naturalHeight > preload.naturalWidth * 1.2; 28507 if (isPortrait) { 28508 bgImg.className = "w-full h-auto"; 28509 } else { 28510 bgImg.className = "w-full h-full object-cover"; 28511 } 28512 bgContainer.scrollTop = 0; 28513 loader.classList.add("hidden"); 28514 }; 28515 preload.onerror = () => loader.classList.add("hidden"); 28516 preload.src = url; 28517 } 28518 openModal(id, { autoFocus = true } = {}) { 28519 const modal = document.getElementById(id); 28520 if (!modal) return; 28521 modal.classList.remove("hidden"); 28522 document.body.classList.add("modal-open"); 28523 requestAnimationFrame(() => modal.classList.add("modal-showing")); 28524 const escHandler = (e) => { 28525 if (e.key === "Escape") this.closeModal(id); 28526 }; 28527 document.addEventListener("keydown", escHandler); 28528 modal._escHandler = escHandler; 28529 if (autoFocus) { 28530 const input = modal.querySelector('input[type="text"]'); 28531 if (input) setTimeout(() => input.focus(), 100); 28532 } 28533 } 28534 closeModal(id) { 28535 const modal = document.getElementById(id); 28536 if (!modal) return; 28537 if (modal._escHandler) { 28538 document.removeEventListener("keydown", modal._escHandler); 28539 modal._escHandler = null; 28540 } 28541 modal.classList.remove("modal-showing"); 28542 document.body.classList.remove("modal-open"); 28543 setTimeout(() => modal.classList.add("hidden"), 200); 28544 } 28545 async createElement(form) { 28546 const formData = new FormData(form); 28547 const name = formData.get("name")?.trim(); 28548 if (!name) return; 28549 const element_type = formData.get("element_type") || "character"; 28550 const style = formData.get("style") || "realistic"; 28551 const category = formData.get("category") || null; 28552 try { 28553 const response = await fetch("/elements", { 28554 method: "POST", 28555 headers: { 28556 "Content-Type": "application/json", 28557 "Accept": "application/json", 28558 "X-CSRF-Token": this.csrfToken 28559 }, 28560 body: JSON.stringify({ name, element_type, style, category }) 28561 }); 28562 if (response.ok) { 28563 const data = await response.json(); 28564 this.closeModal("create-element-modal"); 28565 window.location.href = data.redirect_to; 28566 } else { 28567 window.Toast?.show("Failed to create element", "alert"); 28568 } 28569 } catch (err) { 28570 console.error("Failed to create element:", err); 28571 window.Toast?.show("Failed to create element", "alert"); 28572 } 28573 } 28574 async deleteElementAndRedirect() { 28575 if (!this.deleteElementUuid) return; 28576 try { 28577 const response = await fetch(`/element/${this.deleteElementUuid}`, { 28578 method: "DELETE", 28579 headers: { "X-CSRF-Token": this.csrfToken, "Accept": "application/json" } 28580 }); 28581 if (response.ok) { 28582 const elType = document.querySelector("[data-element-init]")?.dataset.elementType; 28583 const redirectMap = { "character": "/casting", "object": "/objects", "set": "/sets" }; 28584 window.location.href = redirectMap[elType] || "/casting"; 28585 } else { 28586 window.Toast?.show("Failed to delete element", "alert"); 28587 } 28588 } catch (err) { 28589 console.error("Failed to delete element:", err); 28590 window.Toast?.show("Failed to delete element", "alert"); 28591 } 28592 } 28593 handleEmptyUpload() { 28594 const input = document.createElement("input"); 28595 input.type = "file"; 28596 input.accept = "image/jpeg,image/jpg,image/png,image/webp"; 28597 input.onchange = async (e) => { 28598 const file = e.target.files[0]; 28599 if (!file) return;
28600 this.showFirstGenerationLoading(); 28601 const initEl = document.querySelector("[data-element-init]"); 28602 const uuid2 = initEl?.dataset?.elementUuid; 28603 if (!uuid2) return; 28604 try { 28605 const formData = new FormData(); 28606 formData.append("file", file); 28607 const response = await fetch(`/element/${uuid2}/upload`, { 28608 method: "POST", 28609 headers: { "X-CSRF-Token": this.csrfToken }, 28610 body: formData 28611 }); 28612 const data = await response.json(); 28613 if (response.ok && data.file_hash) { 28614 const subjectMap = { 28615 character: "Extract the main person from the reference photo and reproduce them exactly", 28616 object: "Extract the main object from the reference photo and reproduce it exactly", 28617 set: "Reproduce this exact location from the reference photo with high fidelity" 28618 }; 28619 const prompt2 = subjectMap[data.element_type] || subjectMap.character; 28620 const genResponse = await fetch(`/element/${uuid2}/generate`, { 28621 method: "POST", 28622 headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken }, 28623 body: JSON.stringify({ prompt: prompt2, reference_hashes: [data.file_hash] }) 28624 }); 28625 if (genResponse.ok) { 28626 await this.fetchData(); 28627 } else if (genResponse.status === 402) { 28628 const genData = await genResponse.json().catch(() => ({})); 28629 window.Toast?.show(genData.error || "Insufficient credits", "alert"); 28630 window.location.reload(); 28631 } else { 28632 window.Toast?.show("Generation failed", "alert"); 28633 window.location.reload(); 28634 } 28635 } else if (response.ok) { 28636 await this.fetchData(); 28637 } else { 28638 window.Toast?.show("Upload failed", "alert"); 28639 await this.fetchData(); 28640 } 28641 } catch (error) { 28642 console.error("Upload error:", error); 28643 window.Toast?.show("Upload failed", "alert"); 28644 await this.fetchData(); 28645 } 28646 }; 28647 input.click(); 28648 } 28649 // Edit bar methods are provided by BarMixin (see elements/bar.js) 28650 showCanvasLoading(message) { 28651 const canvas = document.querySelector("[data-element-canvas]"); 28652 if (!canvas) return; 28653 canvas.classList.remove("hidden"); 28654 canvas.innerHTML = ` 28655 <div class="text-center"> 28656 <div class="animate-spin w-8 h-8 border-2 border-white/20 border-t-white rounded-full mx-auto mb-3"></div> 28657 <p class="text-sm text-white/50">${message}</p> 28658 </div> 28659 `; 28660 } 28661 showCanvasError(message) { 28662 const canvas = document.querySelector("[data-element-canvas]"); 28663 if (!canvas) return; 28664 canvas.classList.remove("hidden"); 28665 canvas.style.display = "flex"; 28666 canvas.style.alignItems = "center"; 28667 canvas.style.justifyContent = "center"; 28668 const uuid2 = window.location.pathname.split("/").pop(); 28669 canvas.innerHTML = ` 28670 <div class="text-center"> 28671 <svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-red-400/60 mx-auto mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> 28672 <p class="text-sm text-white/50 mb-4">${message}</p> 28673 <button data-element-retry-generate class="btn-secondary text-xs px-4">Retry</button> 28674 </div> 28675 `; 28676 canvas.querySelector("[data-element-retry-generate]")?.addEventListener("click", () => { 28677 canvas.classList.add("hidden"); 28678 canvas.innerHTML = ""; 28679 this.modifyMode = false; 28680 this.openEditBar(); 28681 }); 28682 } 28683 pollGenerationStatus(uuid2) { 28684 this.pollInterval = setInterval(async () => { 28685 try { 28686 const response = await fetch(`/element/${uuid2}/status`); 28687 if (!response.ok) return; 28688 const data = await response.json(); 28689 switch (data.status) { 28690 case "generating": 28691 if (this.currentData?.has_media) { 28692 if (data.step === "upscaling") { 28693 this.showCanvasLoading("Upscaling..."); 28694 } else { 28695 this.showCanvasLoading("Generating..."); 28696 } 28697 } 28698 break; 28699 case "completed": 28700 this.stopPolling(); 28701 this.resetGenerateState(); 28702 const canvas = document.querySelector("[data-element-canvas]"); 28703 if (canvas) { 28704 canvas.classList.add("hidden"); 28705 canvas.innerHTML = ""; 28706 } 28707 const bgImgEl = document.querySelector("[data-element-bg-img]"); 28708 if (bgImgEl) { 28709 bgImgEl.style.transition = "opacity 0.3s ease"; 28710 bgImgEl.style.opacity = ""; 28711 } 28712 document.querySelector("[data-element-left-panel]")?.style.setProperty("opacity", ""); 28713 await this.fetchData(); 28714 window.Toast?.show("Element generated", "success"); 28715 break; 28716 case "failed": { 28717 this.stopPolling(); 28718 const failMsg = data.error || "Generation failed"; 28719 this.showCanvasError(failMsg); 28720 this.resetGenerateState(); 28721 window.Toast?.show(failMsg, "alert"); 28722 break; 28723 } 28724 case "idle": 28725 break; 28726 } 28727 } catch (err) { 28728 console.error("Status poll error:", err); 28729 } 28730 }, 2e3); 28731 } 28732 addVersionThumb(v, thumbUrl) { 28733 const container = document.querySelector("[data-element-versions]"); 28734 if (!container) return; 28735 const menu = container.querySelector("[data-element-version-menu]"); 28736 if (!menu) return; 28737 if (menu.querySelector(`[data-element-version="${v}"]`)) return; 28738 const item = document.createElement("a"); 28739 item.href = `#${v}`; 28740 item.setAttribute("data-element-version", v); 28741 item.className = "flex items-center px-3 py-1.5 text-[8px] uppercase transition duration-200 text-gray hover:text-white hover:bg-light/10"; 28742 item.innerHTML = `<span class="truncate">v${v}</span>`;
28743 item.addEventListener("click", (e) => { 28744 e.preventDefault(); 28745 location.hash = v; 28746 menu.classList.add("hidden"); 28747 }); 28748 menu.insertBefore(item, menu.firstChild); 28749 container.classList.remove("hidden"); 28750 } 28751 addVersionLoadingBlock() { 28752 const container = document.querySelector("[data-element-versions]"); 28753 if (!container) return; 28754 const menu = container.querySelector("[data-element-version-menu]"); 28755 if (!menu) return; 28756 menu.querySelector("[data-version-loading]")?.remove(); 28757 const loader = document.createElement("div"); 28758 loader.setAttribute("data-version-loading", ""); 28759 loader.className = "flex items-center gap-2 px-3 py-1.5 text-[8px] uppercase text-gray"; 28760 loader.innerHTML = '<div class="w-2.5 h-2.5 border-2 border-white/20 border-t-white rounded-full animate-spin shrink-0"></div><span>Generating...</span>'; 28761 menu.insertBefore(loader, menu.firstChild); 28762 container.classList.remove("hidden"); 28763 } 28764 removeVersionLoadingBlock() { 28765 document.querySelector("[data-version-loading]")?.remove(); 28766 } 28767 createVersionThumbEl(v, thumbUrl, uuid2) { 28768 const link = document.createElement("a"); 28769 link.href = `#${v}`; 28770 link.setAttribute("data-element-version", v); 28771 link.className = "block w-14 rounded overflow-hidden transition duration-300"; 28772 link.style.height = "34px"; 28773 if (thumbUrl) { 28774 link.innerHTML = `<img src="${thumbUrl}" alt="v${v}" class="w-full h-full object-cover" />`; 28775 } else { 28776 link.innerHTML = `<div class="w-full h-full bg-inside flex items-center justify-center text-[8px] text-gray">v${v}</div>`; 28777 } 28778 link.addEventListener("click", (e) => { 28779 e.preventDefault(); 28780 location.hash = v; 28781 }); 28782 return link; 28783 } 28784 showFirstGenerationLoading() { 28785 const emptyNode = document.querySelector("[data-element-empty-node]"); 28786 if (emptyNode) emptyNode.classList.add("hidden"); 28787 const strip = document.querySelector("[data-element-scenes-strip]"); 28788 if (strip) { 28789 strip.classList.remove("hidden"); 28790 const scroll = strip.firstElementChild; 28791 if (!scroll.querySelector("[data-element-shot][data-media-url]")) { 28792 const initEl = document.querySelector("[data-element-init]"); 28793 const elType = initEl?.dataset?.elementType || "character"; 28794 const sceneName = "Cover"; 28795 const sceneKey = "cover"; 28796 scroll.innerHTML = ""; 28797 const block = document.createElement("div"); 28798 block.className = "flex flex-col gap-1 shrink-0"; 28799 const label = document.createElement("div"); 28800 label.className = "pt-[5px] text-[8px] text-gray uppercase whitespace-nowrap px-0.5"; 28801 label.textContent = sceneName; 28802 block.appendChild(label); 28803 const row = document.createElement("div"); 28804 row.className = "flex items-center gap-1.5"; 28805 const shot = document.createElement("div"); 28806 shot.className = "shrink-0 relative"; 28807 shot.setAttribute("data-element-shot", ""); 28808 shot.setAttribute("data-shot-slug", sceneKey); 28809 const inner = document.createElement("div"); 28810 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 28811 inner.style.height = "34px"; 28812 inner.style.width = "56px"; 28813 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 28814 shot.appendChild(inner); 28815 row.appendChild(shot); 28816 block.appendChild(row); 28817 scroll.appendChild(block); 28818 } 28819 } 28820 } 28821 stopPolling() { 28822 if (this.pollInterval) { 28823 clearInterval(this.pollInterval); 28824 this.pollInterval = null; 28825 } 28826 } 28827 resetGenerateState() { 28828 this.generating = false; 28829 this.modifyMode = false; 28830 this.modifyVersion = null; 28831 const generateBtn = document.querySelector("[data-element-edit-generate]"); 28832 if (generateBtn && this._generateBtnOriginalHTML) { 28833 generateBtn.disabled = false; 28834 generateBtn.classList.remove("opacity-50"); 28835 generateBtn.innerHTML = this._generateBtnOriginalHTML; 28836 } 28837 this.removeVersionLoadingBlock(); 28838 const bgImg = document.querySelector("[data-element-bg-img]"); 28839 if (bgImg) { 28840 bgImg.style.transition = "opacity 0.3s ease"; 28841 bgImg.style.opacity = ""; 28842 } 28843 document.querySelector("[data-element-left-panel]")?.style.setProperty("opacity", ""); 28844 } 28845 // --- Reference Image Picker --- 28846 populateRefPicker(modalType) { 28847 const picker = document.querySelector(`[data-ref-picker="${modalType}"]`); 28848 if (!picker) return; 28849 const strip = picker.querySelector("[data-ref-picker-strip]"); 28850 if (!strip) return; 28851 const excludeRoles = { 28852 expressions: ["expression_cell"], 28853 aging: ["aging_cell"], 28854 weather: ["weather_cell"] 28855 }; 28856 const exclude = excludeRoles[modalType] || []; 28857 const images = (this.currentData?.reference_images || []).filter((img) => !exclude.includes(img.role)); 28858 if (images.length <= 1) { 28859 picker.classList.add("hidden"); 28860 return; 28861 } 28862 picker.classList.remove("hidden"); 28863 strip.innerHTML = ""; 28864 this._selectedRef = this._selectedRef || {}; 28865 this._selectedRef[modalType] = null;
28866 images.forEach((img, i) => { 28867 const btn = document.createElement("button"); 28868 btn.type = "button"; 28869 btn.className = `shrink-0 w-[4.5rem] h-[4.5rem] rounded-lg overflow-hidden transition duration-200 cursor-pointer ${i === 0 ? "outline-2 outline-offset-2 outline-active" : ""}`; 28870 btn.innerHTML = `<img src="${img.thumb_url || img.url}" alt="${img.role}" class="w-full h-full object-cover transition duration-200 hover:scale-110" />`; 28871 btn.dataset.refUuid = img.uuid; 28872 btn.addEventListener("click", () => { 28873 strip.querySelectorAll("button").forEach((b) => { 28874 b.classList.remove("outline-2", "outline-offset-2", "outline-active"); 28875 }); 28876 btn.classList.add("outline-2", "outline-offset-2", "outline-active"); 28877 btn.blur(); 28878 this._selectedRef[modalType] = i === 0 ? null : img.uuid; 28879 }); 28880 strip.appendChild(btn); 28881 }); 28882 } 28883 getSelectedRefUuid(modalType) { 28884 return this._selectedRef?.[modalType] || null; 28885 } 28886 openExpressionsPicker() { 28887 this.exprSelections = []; 28888 const modal = document.getElementById("expressions-picker-modal"); 28889 if (!modal) return; 28890 modal.querySelectorAll("[data-expr-option]").forEach((btn) => { 28891 btn.classList.remove("border-active", "bg-active/10", "text-white"); 28892 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 28893 btn.querySelector("[data-expr-check]")?.classList.add("hidden"); 28894 }); 28895 const counter = modal.querySelector("[data-expr-picker-counter]"); 28896 if (counter) counter.textContent = "0/4 selected"; 28897 const genBtn = modal.querySelector("[data-expr-picker-generate]"); 28898 if (genBtn) genBtn.disabled = true; 28899 this.populateRefPicker("expressions"); 28900 this.openModal("expressions-picker-modal"); 28901 if (!this.exprPickerBound) { 28902 this.bindExpressionsPicker(); 28903 this.exprPickerBound = true; 28904 } 28905 } 28906 bindExpressionsPicker() { 28907 const modal = document.getElementById("expressions-picker-modal"); 28908 if (!modal) return; 28909 modal.querySelectorAll("[data-expr-picker-close]").forEach((btn) => { 28910 btn.addEventListener("click", () => this.closeModal("expressions-picker-modal")); 28911 }); 28912 modal.addEventListener("click", (e) => { 28913 if (e.target === modal) this.closeModal("expressions-picker-modal"); 28914 }); 28915 modal.querySelectorAll("[data-expr-option]").forEach((btn) => { 28916 btn.addEventListener("click", () => { 28917 btn.blur(); 28918 const value = btn.dataset.exprOption; 28919 const idx = this.exprSelections.indexOf(value); 28920 if (idx > -1) { 28921 this.exprSelections.splice(idx, 1); 28922 btn.classList.remove("border-active", "bg-active/10", "text-white"); 28923 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 28924 btn.querySelector("[data-expr-check]")?.classList.add("hidden"); 28925 } else { 28926 if (this.exprSelections.length >= 4) { 28927 const oldest = this.exprSelections.shift(); 28928 const oldBtn = modal.querySelector(`[data-expr-option="${oldest}"]`); 28929 if (oldBtn) { 28930 oldBtn.classList.remove("border-active", "bg-active/10", "text-white"); 28931 oldBtn.classList.add("border-light/10", "bg-inside", "text-gray"); 28932 oldBtn.querySelector("[data-expr-check]")?.classList.add("hidden"); 28933 } 28934 } 28935 this.exprSelections.push(value); 28936 btn.classList.add("border-active", "bg-active/10", "text-white"); 28937 btn.classList.remove("border-light/10", "bg-inside", "text-gray"); 28938 btn.querySelector("[data-expr-check]")?.classList.remove("hidden"); 28939 } 28940 const counter = modal.querySelector("[data-expr-picker-counter]"); 28941 if (counter) counter.textContent = `${this.exprSelections.length}/4 selected`; 28942 const genBtn = modal.querySelector("[data-expr-picker-generate]"); 28943 if (genBtn) genBtn.disabled = this.exprSelections.length !== 4; 28944 }); 28945 }); 28946 modal.querySelector("[data-expr-picker-generate]")?.addEventListener("click", () => { 28947 if (this.exprSelections.length !== 4) return; 28948 this.closeModal("expressions-picker-modal"); 28949 this.generateExpressions(); 28950 }); 28951 }
28952 openAgingPicker() { 28953 this.agingSelections = []; 28954 const modal = document.getElementById("aging-picker-modal"); 28955 if (!modal) return; 28956 modal.querySelectorAll("[data-aging-option]").forEach((btn) => { 28957 btn.classList.remove("border-active", "bg-active/10", "text-white"); 28958 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 28959 btn.querySelector("[data-aging-check]")?.classList.add("hidden"); 28960 }); 28961 const counter = modal.querySelector("[data-aging-picker-counter]"); 28962 if (counter) counter.textContent = "0/4 selected"; 28963 const genBtn = modal.querySelector("[data-aging-picker-generate]"); 28964 if (genBtn) genBtn.disabled = true; 28965 this.populateRefPicker("aging"); 28966 this.openModal("aging-picker-modal"); 28967 if (!this.agingPickerBound) { 28968 this.bindAgingPicker(); 28969 this.agingPickerBound = true; 28970 } 28971 } 28972 bindAgingPicker() { 28973 const modal = document.getElementById("aging-picker-modal"); 28974 if (!modal) return; 28975 modal.querySelectorAll("[data-aging-picker-close]").forEach((btn) => { 28976 btn.addEventListener("click", () => this.closeModal("aging-picker-modal")); 28977 }); 28978 modal.addEventListener("click", (e) => { 28979 if (e.target === modal) this.closeModal("aging-picker-modal"); 28980 }); 28981 modal.querySelectorAll("[data-aging-option]").forEach((btn) => { 28982 btn.addEventListener("click", () => { 28983 btn.blur(); 28984 const value = btn.dataset.agingOption; 28985 const idx = this.agingSelections.indexOf(value); 28986 if (idx > -1) { 28987 this.agingSelections.splice(idx, 1); 28988 btn.classList.remove("border-active", "bg-active/10", "text-white"); 28989 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 28990 btn.querySelector("[data-aging-check]")?.classList.add("hidden"); 28991 } else { 28992 if (this.agingSelections.length >= 4) { 28993 const oldest = this.agingSelections.shift(); 28994 const oldBtn = modal.querySelector(`[data-aging-option="${oldest}"]`); 28995 if (oldBtn) { 28996 oldBtn.classList.remove("border-active", "bg-active/10", "text-white"); 28997 oldBtn.classList.add("border-light/10", "bg-inside", "text-gray"); 28998 oldBtn.querySelector("[data-aging-check]")?.classList.add("hidden"); 28999 } 29000 } 29001 this.agingSelections.push(value); 29002 btn.classList.add("border-active", "bg-active/10", "text-white"); 29003 btn.classList.remove("border-light/10", "bg-inside", "text-gray"); 29004 btn.querySelector("[data-aging-check]")?.classList.remove("hidden"); 29005 } 29006 const counter = modal.querySelector("[data-aging-picker-counter]"); 29007 if (counter) counter.textContent = `${this.agingSelections.length}/4 selected`; 29008 const genBtn = modal.querySelector("[data-aging-picker-generate]"); 29009 if (genBtn) genBtn.disabled = this.agingSelections.length !== 4; 29010 }); 29011 }); 29012 modal.querySelector("[data-aging-picker-generate]")?.addEventListener("click", () => { 29013 if (this.agingSelections.length !== 4) return; 29014 this.closeModal("aging-picker-modal"); 29015 this.generateAging(); 29016 }); 29017 } 29018 async generateExpressions() { 29019 if (this.expressionsGenerating) return; 29020 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29021 if (!uuid2) return; 29022 this.expressionsGenerating = true; 29023 this.showExpressionsLoading(); 29024 try { 29025 const response = await fetch(`/element/${uuid2}/expressions`, { 29026 method: "POST", 29027 headers: { 29028 "Content-Type": "application/json", 29029 "X-CSRF-Token": this.csrfToken 29030 }, 29031 body: JSON.stringify({ selections: this.exprSelections || [], version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, reference_media_uuid: this.getSelectedRefUuid("expressions") }) 29032 }); 29033 if (response.ok) { 29034 this.pollExpressionsStatus(uuid2); 29035 } else if (response.status === 402) { 29036 const data = await response.json(); 29037 window.Toast?.show(data.error || "Insufficient credits", "alert"); 29038 this.resetExpressionsState(); 29039 } else { 29040 const data = await response.json().catch(() => ({})); 29041 window.Toast?.show(data.error || "Failed to start generation", "alert"); 29042 this.resetExpressionsState(); 29043 } 29044 } catch (err) { 29045 console.error("Failed to generate expressions:", err); 29046 window.Toast?.show("Failed to start generation", "alert"); 29047 this.resetExpressionsState(); 29048 } 29049 } 29050 showExpressionsLoading() { 29051 const strip = document.querySelector("[data-element-scenes-strip]"); 29052 if (!strip) return; 29053 const scroll = strip.firstElementChild; 29054 if (!scroll) return; 29055 const blocks = scroll.querySelectorAll(":scope > div"); 29056 let exprRow = null; 29057 blocks.forEach((block) => { 29058 const label = block.querySelector("div"); 29059 if (label && label.textContent.trim() === "Expressions") { 29060 exprRow = block.querySelector(".flex.items-center"); 29061 } 29062 }); 29063 if (!exprRow) return; 29064 const addBtn = Array.from(exprRow.children).find((el) => el.tagName === "BUTTON"); 29065 for (let i = 0; i < 4; i++) { 29066 const shot = document.createElement("div"); 29067 shot.className = "shrink-0 relative"; 29068 shot.setAttribute("data-expressions-loading-shot", ""); 29069 const inner = document.createElement("div"); 29070 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 29071 inner.style.height = "34px"; 29072 inner.style.width = "56px"; 29073 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 29074 shot.appendChild(inner); 29075 if (addBtn) { 29076 exprRow.insertBefore(shot, addBtn); 29077 } else { 29078 exprRow.appendChild(shot); 29079 } 29080 } 29081 } 29082 pollExpressionsStatus(uuid2) { 29083 this.expressionsPollInterval = setInterval(async () => {
29084 try { 29085 const response = await fetch(`/element/${uuid2}/expressions-status`); 29086 if (!response.ok) return; 29087 const data = await response.json(); 29088 if (data.status === "completed") { 29089 this.stopExpressionsPoll(); 29090 this.resetExpressionsState(); 29091 await this.fetchData(); 29092 window.Toast?.show("Expressions generated", "success"); 29093 } else if (data.status === "failed") { 29094 this.stopExpressionsPoll(); 29095 this.resetExpressionsState(); 29096 window.Toast?.show(data.error || "Expressions generation failed", "alert"); 29097 } 29098 } catch (err) { 29099 console.error("Expressions poll error:", err); 29100 } 29101 }, 2e3); 29102 } 29103 stopExpressionsPoll() { 29104 if (this.expressionsPollInterval) { 29105 clearInterval(this.expressionsPollInterval); 29106 this.expressionsPollInterval = null; 29107 } 29108 } 29109 resetExpressionsState() { 29110 this.expressionsGenerating = false; 29111 document.querySelectorAll("[data-expressions-loading-shot]").forEach((el) => el.remove()); 29112 } 29113 // --- Aging --- 29114 async generateAging() { 29115 if (this.agingGenerating) return; 29116 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29117 if (!uuid2) return; 29118 this.agingGenerating = true; 29119 this.showAgingLoading(); 29120 try { 29121 const response = await fetch(`/element/${uuid2}/aging`, { 29122 method: "POST", 29123 headers: { 29124 "Content-Type": "application/json", 29125 "X-CSRF-Token": this.csrfToken 29126 }, 29127 body: JSON.stringify({ selections: this.agingSelections || [], version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, reference_media_uuid: this.getSelectedRefUuid("aging") }) 29128 }); 29129 if (response.ok) { 29130 this.pollAgingStatus(uuid2); 29131 } else if (response.status === 402) { 29132 const data = await response.json(); 29133 window.Toast?.show(data.error || "Insufficient credits", "alert"); 29134 this.resetAgingState(); 29135 } else { 29136 const data = await response.json().catch(() => ({})); 29137 window.Toast?.show(data.error || "Failed to start generation", "alert"); 29138 this.resetAgingState(); 29139 } 29140 } catch (err) { 29141 console.error("Failed to generate aging:", err); 29142 window.Toast?.show("Failed to start generation", "alert"); 29143 this.resetAgingState(); 29144 } 29145 } 29146 showAgingLoading() { 29147 const strip = document.querySelector("[data-element-scenes-strip]"); 29148 if (!strip) return; 29149 const scroll = strip.firstElementChild; 29150 if (!scroll) return; 29151 const blocks = scroll.querySelectorAll(":scope > div"); 29152 let agingRow = null; 29153 blocks.forEach((block) => { 29154 const label = block.querySelector("div"); 29155 if (label && label.textContent.trim() === "Aging") { 29156 agingRow = block.querySelector(".flex.items-center"); 29157 } 29158 }); 29159 if (!agingRow) return; 29160 const addBtn = Array.from(agingRow.children).find((el) => el.tagName === "BUTTON"); 29161 for (let i = 0; i < 4; i++) { 29162 const shot = document.createElement("div"); 29163 shot.className = "shrink-0 relative"; 29164 shot.setAttribute("data-aging-loading-shot", ""); 29165 const inner = document.createElement("div"); 29166 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 29167 inner.style.height = "34px"; 29168 inner.style.width = "56px"; 29169 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 29170 shot.appendChild(inner); 29171 if (addBtn) { 29172 agingRow.insertBefore(shot, addBtn); 29173 } else { 29174 agingRow.appendChild(shot); 29175 } 29176 } 29177 } 29178 pollAgingStatus(uuid2) { 29179 this.agingPollInterval = setInterval(async () => { 29180 try { 29181 const response = await fetch(`/element/${uuid2}/aging-status`); 29182 if (!response.ok) return; 29183 const data = await response.json(); 29184 if (data.status === "completed") {
29185 this.stopAgingPoll(); 29186 this.resetAgingState(); 29187 await this.fetchData(); 29188 window.Toast?.show("Aging generated", "success"); 29189 } else if (data.status === "failed") { 29190 this.stopAgingPoll(); 29191 this.resetAgingState(); 29192 window.Toast?.show(data.error || "Aging generation failed", "alert"); 29193 } 29194 } catch (err) { 29195 console.error("Aging poll error:", err); 29196 } 29197 }, 2e3); 29198 } 29199 stopAgingPoll() { 29200 if (this.agingPollInterval) { 29201 clearInterval(this.agingPollInterval); 29202 this.agingPollInterval = null; 29203 } 29204 } 29205 resetAgingState() { 29206 this.agingGenerating = false; 29207 document.querySelectorAll("[data-aging-loading-shot]").forEach((el) => el.remove()); 29208 } 29209 // --- Weather --- 29210 openWeatherPicker() { 29211 this.weatherSelections = []; 29212 const modal = document.getElementById("weather-picker-modal"); 29213 if (!modal) return; 29214 modal.querySelectorAll("[data-weather-option]").forEach((btn) => { 29215 btn.classList.remove("border-active", "bg-active/10", "text-white"); 29216 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 29217 btn.querySelector("[data-weather-check]")?.classList.add("hidden"); 29218 }); 29219 const counter = modal.querySelector("[data-weather-picker-counter]"); 29220 if (counter) counter.textContent = "0/4 selected"; 29221 const genBtn = modal.querySelector("[data-weather-picker-generate]"); 29222 if (genBtn) genBtn.disabled = true; 29223 this.populateRefPicker("weather"); 29224 this.openModal("weather-picker-modal"); 29225 if (!this.weatherPickerBound) { 29226 this.bindWeatherPicker(); 29227 this.weatherPickerBound = true; 29228 } 29229 } 29230 bindWeatherPicker() { 29231 const modal = document.getElementById("weather-picker-modal"); 29232 if (!modal) return; 29233 modal.querySelectorAll("[data-weather-picker-close]").forEach((btn) => { 29234 btn.addEventListener("click", () => this.closeModal("weather-picker-modal")); 29235 }); 29236 modal.addEventListener("click", (e) => { 29237 if (e.target === modal) this.closeModal("weather-picker-modal"); 29238 }); 29239 modal.querySelectorAll("[data-weather-option]").forEach((btn) => { 29240 btn.addEventListener("click", () => { 29241 btn.blur(); 29242 const value = btn.dataset.weatherOption; 29243 const idx = this.weatherSelections.indexOf(value); 29244 if (idx > -1) { 29245 this.weatherSelections.splice(idx, 1); 29246 btn.classList.remove("border-active", "bg-active/10", "text-white"); 29247 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 29248 btn.querySelector("[data-weather-check]")?.classList.add("hidden"); 29249 } else { 29250 if (this.weatherSelections.length >= 4) { 29251 const oldest = this.weatherSelections.shift(); 29252 const oldBtn = modal.querySelector(`[data-weather-option="${oldest}"]`); 29253 if (oldBtn) { 29254 oldBtn.classList.remove("border-active", "bg-active/10", "text-white"); 29255 oldBtn.classList.add("border-light/10", "bg-inside", "text-gray"); 29256 oldBtn.querySelector("[data-weather-check]")?.classList.add("hidden"); 29257 } 29258 } 29259 this.weatherSelections.push(value); 29260 btn.classList.add("border-active", "bg-active/10", "text-white"); 29261 btn.classList.remove("border-light/10", "bg-inside", "text-gray"); 29262 btn.querySelector("[data-weather-check]")?.classList.remove("hidden"); 29263 } 29264 const counter = modal.querySelector("[data-weather-picker-counter]"); 29265 if (counter) counter.textContent = `${this.weatherSelections.length}/4 selected`; 29266 const genBtn = modal.querySelector("[data-weather-picker-generate]"); 29267 if (genBtn) genBtn.disabled = this.weatherSelections.length !== 4; 29268 }); 29269 }); 29270 modal.querySelector("[data-weather-picker-generate]")?.addEventListener("click", () => { 29271 if (this.weatherSelections.length !== 4) return; 29272 this.closeModal("weather-picker-modal"); 29273 this.generateWeather(); 29274 }); 29275 } 29276 async generateWeather() { 29277 if (this.weatherGenerating) return; 29278 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29279 if (!uuid2) return; 29280 this.weatherGenerating = true; 29281 this.showWeatherLoading(); 29282 try { 29283 const response = await fetch(`/element/${uuid2}/weather`, { 29284 method: "POST", 29285 headers: { 29286 "Content-Type": "application/json", 29287 "X-CSRF-Token": this.csrfToken 29288 }, 29289 body: JSON.stringify({ selections: this.weatherSelections || [], version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, reference_media_uuid: this.get
29289SelectedRefUuid("weather") }) 29290 }); 29291 if (response.ok) { 29292 this.pollWeatherStatus(uuid2); 29293 } else if (response.status === 402) { 29294 const data = await response.json(); 29295 window.Toast?.show(data.error || "Insufficient credits", "alert"); 29296 this.resetWeatherState(); 29297 } else { 29298 const data = await response.json().catch(() => ({})); 29299 window.Toast?.show(data.error || "Failed to start generation", "alert"); 29300 this.resetWeatherState(); 29301 } 29302 } catch (err) { 29303 console.error("Failed to generate weather:", err); 29304 window.Toast?.show("Failed to start generation", "alert"); 29305 this.resetWeatherState(); 29306 } 29307 } 29308 showWeatherLoading() { 29309 const strip = document.querySelector("[data-element-scenes-strip]"); 29310 if (!strip) return; 29311 const scroll = strip.firstElementChild; 29312 if (!scroll) return; 29313 const blocks = scroll.querySelectorAll(":scope > div"); 29314 let weatherRow = null; 29315 blocks.forEach((block) => { 29316 const label = block.querySelector("div"); 29317 if (label && label.textContent.trim() === "Weather") { 29318 weatherRow = block.querySelector(".flex.items-center"); 29319 } 29320 }); 29321 if (!weatherRow) return; 29322 const addBtn = Array.from(weatherRow.children).find((el) => el.tagName === "BUTTON"); 29323 for (let i = 0; i < 4; i++) { 29324 const shot = document.createElement("div"); 29325 shot.className = "shrink-0 relative"; 29326 shot.setAttribute("data-weather-loading-shot", ""); 29327 const inner = document.createElement("div"); 29328 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 29329 inner.style.height = "34px"; 29330 inner.style.width = "56px"; 29331 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 29332 shot.appendChild(inner); 29333 if (addBtn) { 29334 weatherRow.insertBefore(shot, addBtn); 29335 } else { 29336 weatherRow.appendChild(shot); 29337 } 29338 } 29339 } 29340 pollWeatherStatus(uuid2) { 29341 this.weatherPollInterval = setInterval(async () => { 29342 try { 29343 const response = await fetch(`/element/${uuid2}/weather-status`); 29344 if (!response.ok) return; 29345 const data = await response.json(); 29346 if (data.status === "completed") { 29347 this.stopWeatherPoll(); 29348 this.resetWeatherState(); 29349 await this.fetchData(); 29350 window.Toast?.show("Weather generated", "success"); 29351 } else if (data.status === "failed") { 29352 this.stopWeatherPoll(); 29353 this.resetWeatherState(); 29354 document.querySelectorAll("[data-weather-loading-shot]").forEach((el) => el.remove()); 29355 window.Toast?.show(data.error || "Weather generation failed", "alert"); 29356 } 29357 } catch (err) { 29358 console.error("Weather poll error:", err); 29359 } 29360 }, 2e3); 29361 } 29362 stopWeatherPoll() { 29363 if (this.weatherPollInterval) { 29364 clearInterval(this.weatherPollInterval); 29365 this.weatherPollInterval = null; 29366 } 29367 } 29368 resetWeatherState() { 29369 this.weatherGenerating = false; 29370 } 29371 // --- Wardrobe --- 29372 openWardrobeModal() { 29373 const modal = document.getElementById("wardrobe-modal"); 29374 if (!modal) return; 29375 const textarea = modal.querySelector("[data-wardrobe-outfit]"); 29376 if (textarea) textarea.value = ""; 29377 this.wardrobeUploadHashes = []; 29378 modal.querySelectorAll("[data-wardrobe-upload-slot]").forEach((slot) => { 29379 slot.innerHTML = '<svg class="w-4 h-4 text-white/20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>'; 29380 slot.classList.remove("border-solid", "border-active"); 29381 slot.classList.add("border-dashed", "border-light/20"); 29382 }); 29383 this.populateRefPicker("wardrobe"); 29384 this.openModal("wardrobe-modal"); 29385 if (!this.wardrobeBound) { 29386 this.bindWardrobeModal(); 29387 this.wardrobeBound = true; 29388 } 29389 } 29390 bindWardrobeModal() { 29391 const modal = document.getElementById("wardrobe-modal"); 29392 if (!modal) return; 29393 modal.querySelectorAll("[data-wardrobe-modal-close]").forEach((btn) => { 29394 btn.addEventListener("click", () => this.closeModal("wardrobe-modal")); 29395 }); 29396 modal.addEventListener("click", (e) => { 29397 if (e.target === modal) this.closeModal("wardrobe-modal"); 29398 }); 29399 modal.querySelectorAll("[data-wardrobe-upload-slot]").forEach((slot) => { 29400 slot.addEventListener("click", () => { 29401 const slotIndex = parseInt(slot.dataset.wardrobeUploadSlot); 29402 const input = document.createElement("input"); 29403 input.type = "file"; 29404 input.accept = "image/*"; 29405 input.onchange = async (e) => { 29406 const file = e.target.files?.[0]; 29407 if (!file) return; 29408 slot.innerHTML = '<div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>'; 29409 try { 29410 const formData = new FormData(); 29411 formData.append("file", file); 29412 const response = await fetch(`/element/${this.uuid}/upload`, { 29413 method: "POST", 29414 headers: { "X-CSRF-Token": this.csrfToken }, 29415 body: formData 29416 }); 29417 if (response.ok) { 29418 const data = await response.json(); 29419 if (!this.wardrobeUploadHashes) this.wardrobeUploadHashes = []; 29420 this.wardrobeUploadHashes[slotIndex] = data.file_hash; 29421 const img = document.createElement("img"); 29422 img.src = URL.createObjectURL(file); 29423 img.className = "w-full h-full object-cover"; 29424 slot.innerHTML = ""; 29425 slot.appendChild(img); 29426 slot.classList.remove("border-dashed", "border-light/20"); 29427 slot.classList.add("border-solid", "border-active"); 29428 } else { 29429 window.Toast?.show("Upload failed", "alert"); 29430 slot.innerHTML = '<svg class="w-4 h-4 text-white/20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>'; 29431 } 29432 } catch (err) { 29433 console.error("Wardrobe upload error:", err); 29434 window.Toast?.show("Upload failed", "alert"); 29435 slot.innerHTML = '<svg class="w-4 h-4 text-white/20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>'; 29436 } 29437 }; 29438 input.click(); 29439 }); 29440 }); 29441 modal.querySelector("[data-wardrobe-generate]")?.addEventListener("click", (e) => { 29442 e.target.closest("button")?.blur(); 29443 const outfit = modal.querySelector("[data-wardrobe-outfit]")?.value?.trim() || "casual everyday outfit"; 29444 this.closeModal("wardrobe-modal"); 29445 this.generateWardrobe(outfit); 29446 }); 29447 } 29448 async generateWardrobe(outfit) { 29449 if (this.wardrobeGenerating) return; 29450 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29451 if (!uuid2) return; 29452 this.wardrobeGenerating = true; 29453 this.showWardrobeLoading(); 29454 try { 29455 const response = await fetch(`/element/${uuid2}/wardrobe`, { 29456 method: "POST", 29457 headers: { 29458 "Content-Type": "application/json", 29459 "X-CSRF-Token": this.csrfToken 29460 }, 29461 body: JSON.stringify({ 29462 version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, 29463 outfit,
29464 reference_media_uuid: this.getSelectedRefUuid("wardrobe"), 29465 inspiration_hashes: (this.wardrobeUploadHashes || []).filter(Boolean) 29466 }) 29467 }); 29468 if (response.ok) { 29469 this.pollWardrobeStatus(uuid2); 29470 } else if (response.status === 402) { 29471 const data = await response.json(); 29472 window.Toast?.show(data.error || "Insufficient credits", "alert"); 29473 this.resetWardrobeState(); 29474 } else { 29475 const data = await response.json().catch(() => ({})); 29476 window.Toast?.show(data.error || "Failed to start generation", "alert"); 29477 this.resetWardrobeState(); 29478 } 29479 } catch (err) { 29480 console.error("Failed to generate wardrobe:", err); 29481 window.Toast?.show("Failed to start generation", "alert"); 29482 this.resetWardrobeState(); 29483 } 29484 } 29485 showWardrobeLoading() { 29486 const strip = document.querySelector("[data-element-scenes-strip]"); 29487 if (!strip) return; 29488 const scroll = strip.firstElementChild; 29489 if (!scroll) return; 29490 const blocks = scroll.querySelectorAll(":scope > div"); 29491 let wardrobeRow = null; 29492 blocks.forEach((block) => { 29493 const label = block.querySelector("div"); 29494 if (label && label.textContent.trim() === "Wardrobes") { 29495 wardrobeRow = block.querySelector(".flex.items-center"); 29496 } 29497 }); 29498 if (!wardrobeRow) return; 29499 const addBtn = Array.from(wardrobeRow.children).find((el) => el.tagName === "BUTTON"); 29500 const shot = document.createElement("div"); 29501 shot.className = "shrink-0 relative"; 29502 shot.setAttribute("data-wardrobe-loading-shot", ""); 29503 const inner = document.createElement("div"); 29504 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 29505 inner.style.height = "34px"; 29506 inner.style.width = "56px"; 29507 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 29508 shot.appendChild(inner); 29509 if (addBtn) { 29510 wardrobeRow.insertBefore(shot, addBtn); 29511 } else { 29512 wardrobeRow.appendChild(shot); 29513 } 29514 } 29515 pollWardrobeStatus(uuid2) { 29516 this.wardrobePollInterval = setInterval(async () => { 29517 try { 29518 const response = await fetch(`/element/${uuid2}/wardrobe-status`); 29519 if (!response.ok) return; 29520 const data = await response.json(); 29521 if (data.status === "completed") { 29522 this.stopWardrobePoll(); 29523 this.resetWardrobeState(); 29524 await this.fetchData(); 29525 window.Toast?.show("Wardrobe generated", "success"); 29526 } else if (data.status === "failed") { 29527 this.stopWardrobePoll(); 29528 this.resetWardrobeState(); 29529 window.Toast?.show(data.error || "Wardrobe generation failed", "alert"); 29530 } 29531 } catch (err) { 29532 console.error("Wardrobe poll error:", err); 29533 } 29534 }, 2e3); 29535 } 29536 updateWardrobeDisplay(data) { 29537 const area = document.querySelector("[data-wardrobe-area]"); 29538 if (!area || !data.wardrobe_url) return; 29539 const loader = area.querySelector("[data-wardrobe-loader]"); 29540 if (loader) loader.remove(); 29541 const scroll = area.querySelector("[data-wardrobe-scroll]"); 29542 if (!scroll) return; 29543 const cell = document.createElement("div"); 29544 cell.className = "flex-shrink-0 w-[60px] rounded-sm overflow-y-auto overflow-x-hidden border border-white/30 curs
29544or-pointer"; 29545 cell.style.height = "106px"; 29546 cell.style.scrollbarWidth = "none"; 29547 cell.setAttribute("data-element-thumb", ""); 29548 cell.setAttribute("data-full", data.wardrobe_url); 29549 cell.innerHTML = `<img src="${data.wardrobe_url}" alt="" class="w-full h-auto block" />`; 29550 cell.addEventListener("click", () => this.setBgImage(data.wardrobe_url)); 29551 scroll.prepend(cell); 29552 } 29553 stopWardrobePoll() { 29554 if (this.wardrobePollInterval) { 29555 clearInterval(this.wardrobePollInterval); 29556 this.wardrobePollInterval = null; 29557 } 29558 } 29559 resetWardrobeState() { 29560 this.wardrobeGenerating = false; 29561 document.querySelectorAll("[data-wardrobe-loading-shot]").forEach((el) => el.remove()); 29562 } 29563 // --- Variation --- 29564 openVariationModal(opts) { 29565 const modal = document.getElementById("variation-modal"); 29566 if (!modal) return; 29567 const nameInput = modal.querySelector("[data-variation-name]"); 29568 const promptInput = modal.querySelector("[data-variation-prompt]"); 29569 if (opts) { 29570 if (nameInput) nameInput.value = ""; 29571 if (promptInput) promptInput.value = opts.prompt || ""; 29572 this._variationModifyUuid = opts.mediaUuid || null; 29573 } else { 29574 if (nameInput) nameInput.value = ""; 29575 if (promptInput) promptInput.value = ""; 29576 this._variationModifyUuid = null; 29577 } 29578 this.populateRefPicker("variation"); 29579 let scrollTarget = null; 29580 if (opts?.mediaUuid) { 29581 const strip = document.querySelector('[data-ref-picker="variation"] [data-ref-picker-strip]'); 29582 if (strip) { 29583 const buttons = strip.querySelectorAll("button"); 29584 buttons.forEach((btn) => { 29585 btn.classList.remove("outline-2", "outline-offset-2", "outline-active"); 29586 if (btn.dataset.refUuid === opts.mediaUuid) { 29587 btn.classList.add("outline-2", "outline-offset-2", "outline-active"); 29588 this._selectedRef = this._selectedRef || {}; 29589 this._selectedRef["variation"] = opts.mediaUuid; 29590 scrollTarget = btn; 29591 } 29592 }); 29593 } 29594 } 29595 this.openModal("variation-modal"); 29596 if (scrollTarget) { 29597 requestAnimationFrame(() => { 29598 scrollTarget.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" }); 29599 }); 29600 } 29601 if (!this.variationBound) { 29602 this.bindVariationModal(); 29603 this.variationBound = true; 29604 } 29605 } 29606 openCustomPresetModal(editItem) { 29607 const data = this.currentData; 29608 if (!data) return; 29609 const refImages = (data.reference_images || []).filter( 29610 (img) => ["face", "cover", "body", "expression_cell", "aging_cell", "weather_cell", "wardrobe", "variation"].includes(img.role) 29611 ); 29612 if (refImages.length === 0) { 29613 window.Toast?.show("No media available for presets", "alert"); 29614 return; 29615 } 29616 const isEdit = !!editItem; 29617 let sel; 29618 if (isEdit) { 29619 sel = { 29620 tag: editItem.tag || "", 29621 frontal: editItem.uuid || null, 29622 references: [editItem.uuid, ...editItem.reference_media_uuids || []].filter(Boolean) 29623 }; 29624 if (sel.frontal && !sel.references.includes(sel.frontal)) { 29625 sel.references.unshift(sel.frontal); 29626 } 29627 } else { 29628 const defaultFrontal = refImages[0]?.uuid || null; 29629 sel = { 29630 tag: "", 29631 frontal: defaultFrontal, 29632 references: defaultFrontal ? [defaultFrontal] : [] 29633 }; 29634 } 29635 const prev = document.getElementById("element-custom-modal"); 29636 if (prev) prev.remove(); 29637 const modal = document.createElement("div"); 29638 modal.id = "element-custom-modal"; 29639 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base"; 29640 modal.style.cssText = "align-items:flex-start;padding-top:8vh;z-index:10000;"; 29641 const renderBody = () => { 29642 const itemsHtml = refImages.map((img) => { 29643 const isRef = sel.references.includes(img.uuid); 29644 const isFrontal = sel.frontal === img.uuid; 29645 const thumbSrc = img.thumb_url || img.url || ""; 29646 const label = img.slug ? img.slug.replace(/^.*-v\d+-/, "") : img.role; 29647 return ` 29648 <div class="mention-popup-item" data-toggle-uuid="${img.uuid}" style="padding:6px 8px;cursor:pointer;">
29649 <div class="mention-custom-check ${isRef ? "mention-custom-checked" : ""}"> 29650 ${isRef ? "✓" : ""} 29651 </div> 29652 <div class="mention-popup-thumbnail mention-popup-thumbnail-sm">${thumbSrc ? `<img src="${thumbSrc}" alt="${label}">` : `<div class="w-full h-full flex items-center justify-center text-gray text-[10px]">${img.role.charAt(0).toUpperCase()}</div>`}</div> 29653 <div class="mention-popup-info" style="flex:1;min-width:0;"> 29654 <span class="mention-popup-label">${label}</span> 29655 <span class="mention-popup-prompt">${img.role}${img.tag ? ` \xB7 ${img.tag}` : ""}</span> 29656 </div> 29657 <div class="mention-custom-star ${isFrontal ? "mention-custom-star-active" : ""}" style="margin-left:auto;" data-action="frontal" data-uuid="${img.uuid}" title="Set as frontal">★</div> 29658 </div> 29659 `; 29660 }).join(""); 29661 const frontalImg = refImages.find((i) => i.uuid === sel.frontal); 29662 const frontalHtml = frontalImg ? `<div class="mb-4"> 29663 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">Frontal</div> 29664 <img src="${frontalImg.thumbnail_url || frontalImg.url}" class="w-full rounded-lg object-cover" style="max-height:200px;"> 29665 </div>` : '<div class="text-[10px] text-gray text-center py-8">No frontal selected</div>'; 29666 const refImgs = refImages.filter((i) => sel.references.includes(i.uuid) && i.uuid !== sel.frontal); 29667 const refsHtml = refImgs.length > 0 ? `<div> 29668 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">References ${refImgs.length}/6</div> 29669 <div class="grid grid-cols-3 gap-2">${refImgs.map( 29670 (i) => `<div> 29671 <img src="${i.thumbnail_url || i.url}" class="w-full aspect-square rounded object-cover"> 29672 <div class="text-[9px] text-gray mt-0.5 text-center truncate">${i.slug ? i.slug.replace(/^.*-v\d+-/, "") : i.role}</div> 29673 </div>` 29674 ).join("")}</div> 29675 </div>` : ""; 29676 return ` 29677 <div class="modal-content bg-box rounded-lg p-5 max-w-3xl w-full mx-4 shadow-xl" style="max-height:80vh;display:flex;flex-direction:column;"> 29678 <div class="flex items-center justify-between mb-4"> 29679 <div class="text-sm font-medium text-white">${isEdit ? "Edit" : "Create"} Kling Element</div> 29680 <button type="button" data-close class="cursor-pointer text-gray hover:text-white transition-colors"> 29681 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 29682 </button> 29683 </div> 29684 <div class="mb-3"> 29685 <input type="text" data-tag-input placeholder="Preset name (e.g. angry, suit, winter)" value="${sel.tag}" ${isEdit ? "disabled" : ""} class="w-full bg-dark border border-light rounded px-3 py-1.5 text-xs text-white placeholder-gray focus:outline-none focus:border-active ${isEdit ? "opacity-50" : ""}" /> 29686 </div> 29687 <div class="flex gap-5 flex-1 min-h-0"> 29688 <div class="overflow-y-auto" style="width:33.333%;min-width:0;"> 29689 <div class="text-[10px] text-gray uppercase tracking-wide mb-2">Select Media</div> 29690 ${itemsHtml} 29691 </div> 29692 <div class="overflow-y-auto" style="width:66.666%;"> 29693 ${frontalHtml} 29694 ${refsHtml} 29695 </div> 29696 </div> 29697 <div class="flex gap-2 mt-4 justify-end"> 29698 ${isEdit ? '<button type="button" data-delete class="px-3 py-1.5 text-xs text-destructive hover:text-white bg-transparent border border-destructive/30 rounded cursor-pointer transition-colors mr-auto">Delete</button>' : ""} 29699 <button type="button" data-cancel class="px-3 py-1.5 text-xs text-gray hover:text-white bg-transparent border border-light rounded cursor-pointer transition-colors">Cancel</button> 29700 <button type="button" data-create class="px-3 py-1.5 text-xs text-white bg-active rounded cursor-pointer hover:bg-active/80 transition-colors">${isEdit ? "Save" : "Create"}</button> 29701 </div> 29702 </div> 29703 `; 29704 }; 29705 const updateModal = () => { 29706 modal.innerHTML = renderBody(); 29707 bindModalEvents(); 29708 }; 29709 const bindModalEvents = () => { 29710 modal.querySelector("[data-close]")?.addEventListener("click", () => modal.remove()); 29711 modal.querySelector("[data-cancel]")?.addEventListener("click", () => modal.remove()); 29712 modal.addEventListener("click", (e) => { 29713 if (e.target === modal) modal.remove(); 29714 }); 29715 modal.querySelector("[data-tag-input]")?.addEventListener("input", (e) => { 29716 sel.tag = e.target.value; 29717 }); 29718 modal.querySelectorAll("[data-toggle-uuid]").forEach((row) => { 29719 row.addEventListener("click", (e) => { 29720 if (e.target.closest('[data-action="frontal"]')) return; 29721 e.stopPropagation(); 29722 const uuid2 = row.dataset.toggleUuid; 29723 const idx = sel.references.indexOf(uuid2); 29724 if (idx >= 0) { 29725 sel.references.splice(idx, 1); 29726 if (sel.frontal === uuid2) sel.frontal = sel.references[0] || null; 29727 } else { 29728 if (sel.references.length >= 6) return; 29729 sel.references.push(uuid2); 29730 } 29731 updateModal(); 29732 }); 29733 }); 29734 modal.querySelectorAll('[data-action="frontal"]').forEach((el) => { 29735 el.addEventListener("click", (e) => { 29736 e.stopPropagation(); 29737 const uuid2 = el.dataset.uuid; 29738 sel.frontal = uuid2; 29739 if (!sel.references.includes(uuid2)) { 29740 if (sel.references.length >= 6) return; 29741 sel.references.push(uuid2); 29742 } 29743 updateModal(); 29744 }); 29745 }); 29746 modal.querySelector("[data-create]")?.addEventListener("click", async () => { 29747 if (!isEdit) { 29748 const tag = sel.tag.trim(); 29749 if (!tag) { 29750 window.Toast?.show("Please enter a preset name", "alert"); 29751 return; 29752 } 29753 } 29754 if (!sel.frontal) { 29755 window.Toast?.show("Please select a frontal image", "alert"); 29756 return; 29757 } 29758 const refUuids = sel.references.filter((r) => r !== sel.frontal); 29759 try { 29760 const url = isEdit ? `/element/${this.uuid}/custom/${editItem.em_uuid}` : `/element/${this.uuid}/custom`; 29761 const resp = await fetch(url, { 29762 method: isEdit ? "PATCH" : "POST", 29763 headers: { 29764 "Content-Type": "application/json", 29765 "X-CSRF-Token": this.csrfToken 29766 }, 29767 body: JSON.stringify({ 29768 tag: sel.tag.trim(), 29769 frontal_media_uuid: sel.frontal, 29770 reference_media_uuids: refUuids 29771 }) 29772 }); 29773 const result = await resp.json(); 29774 if (!resp.ok) { 29775 window.Toast?.show(result.error || `Failed to ${isEdit ? "update" : "create"} preset`, "alert"); 29776 return; 29777 } 29778 modal.remove(); 29779 window.Toast?.show(`Custom preset ${isEdit ? "updated" : "created"}`, "success"); 29780 this.fetchData(); 29781 } catch (err) { 29782 window.Toast?.show(`Failed to ${isEdit ? "update" : "create"} preset`, "alert"); 29783 } 29784 }); 29785 modal.querySelector("[data-delete]")?.addEventListener("click", async () => { 29786 try { 29787 const resp = await fetch(`/element/${this.uuid}/custom/${editItem.em_uuid}`, { 29788 method: "DELETE", 29789 headers: { "X-CSRF-Token": this.csrfToken } 29790 }); 29791 if (!resp.ok) { 29792 window.Toast?.show("Failed to delete preset", "alert"); 29793 return; 29794 } 29795 modal.remove(); 29796 window.Toast?.show("Custom preset deleted", "success"); 29797 this.fetchData(); 29798 } catch (err) { 29799 window.Toast?.show("Failed to delete preset", "alert"); 29800 } 29801 }); 29802 }; 29803 modal.innerHTML = renderBody(); 29804 document.body.appendChild(modal); 29805 requestAnimationFrame(() => modal.classList.add("modal-showing")); 29806 bindModalEvents(); 29807 const escHandler = (e) => { 29808 if (e.key === "Escape") { 29809 modal.remove(); 29810 document.removeEventListener("keydown", escHandler); 29811 } 29812 }; 29813 document.addEventListener("keydown", escHandler); 29814 } 29815 bindVariationModal() { 29816 const modal = document.getElementById("variation-modal"); 29817 if (!modal) return; 29818 modal.querySelectorAll("[data-variation-modal-close]").forEach((btn) => { 29819 btn.addEventListener("click", () => this.closeModal("variation-modal")); 29820 }); 29821 modal.addEventListener("click", (e) => { 29822 if (e.target === modal) this.closeModal("variation-modal"); 29823 }); 29824 modal.querySelector("[data-variation-generate]")?.addEventListener("click", (e) => { 29825 e.target.closest("button")?.blur(); 29826 const name = modal.querySelector("[data-variation-name]")?.value?.trim();
29827 const prompt2 = modal.querySelector("[data-variation-prompt]")?.value?.trim(); 29828 if (!name) { 29829 window.Toast?.show("Please enter a name", "alert"); 29830 return; 29831 } 29832 if (!prompt2) { 29833 window.Toast?.show("Please enter a prompt", "alert"); 29834 return; 29835 } 29836 this.closeModal("variation-modal"); 29837 this.generateVariation(name, prompt2, this._variationModifyUuid); 29838 }); 29839 } 29840 async generateVariation(name, prompt2, modifyMediaUuid) { 29841 if (this.variationGenerating) return; 29842 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29843 if (!uuid2) return; 29844 this.variationGenerating = true; 29845 this.showVariationLoading(); 29846 const body = { 29847 version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, 29848 name, 29849 prompt: prompt2, 29850 reference_media_uuid: this.getSelectedRefUuid("variation") 29851 }; 29852 if (modifyMediaUuid) body.replace_media_uuid = modifyMediaUuid; 29853 try { 29854 const response = await fetch(`/element/${uuid2}/variation`, { 29855 method: "POST", 29856 headers: { 29857 "Content-Type": "application/json", 29858 "X-CSRF-Token": this.csrfToken 29859 }, 29860 body: JSON.stringify(body) 29861 }); 29862 if (response.ok) { 29863 this.variationGenerating = false; 29864 } else if (response.status === 402) { 29865 const data = await response.json(); 29866 window.Toast?.show(data.error || "Insufficient credits", "alert"); 29867 this.resetVariationState(); 29868 } else { 29869 const data = await response.json().catch(() => ({})); 29870 window.Toast?.show(data.error || "Failed to start generation", "alert"); 29871 this.resetVariationState(); 29872 } 29873 } catch (err) { 29874 console.error("Failed to generate variation:", err); 29875 window.Toast?.show("Failed to start generation", "alert"); 29876 this.resetVariationState(); 29877 } 29878 } 29879 showVariationLoading() { 29880 const strip = document.querySelector("[data-element-scenes-strip]"); 29881 if (!strip) return; 29882 const scroll = strip.firstElementChild; 29883 if (!scroll) return; 29884 const blocks = scroll.querySelectorAll(":scope > div"); 29885 let variationRow = null; 29886 blocks.forEach((block) => { 29887 const label = block.querySelector("div"); 29888 if (label && label.textContent.trim() === "Variations") { 29889 variationRow = block.querySelector(".flex.items-center"); 29890 } 29891 }); 29892 if (!variationRow) return; 29893 const addBtn = Array.from(variationRow.children).find((el) => el.tagName === "BUTTON"); 29894 const shot = document.createElement("div"); 29895 shot.className = "shrink-0 relative"; 29896 shot.setAttribute("data-variation-loading-shot", ""); 29897 const inner = document.createElement("div"); 29898 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 29899 inner.style.height = "34px"; 29900 inner.style.width = "56px"; 29901 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 29902 shot.appendChild(inner); 29903 if (addBtn && addBtn.parentNode === variationRow) { 29904 variationRow.insertBefore(shot, addBtn); 29905 } else { 29906 variationRow.appendChild(shot); 29907 } 29908 } 29909 stopVariationPoll() { 29910 if (this.variationPollInterval) { 29911 clearInterval(this.variationPollInterval); 29912 this.variationPollInterval = null; 29913 } 29914 } 29915 resetVariationState() { 29916 this.variationGenerating = false; 29917 } 29918 // --- Body --- 29919 openBodyPicker() { 29920 const meta = this.currentMetadata || {}; 29921 this.bodySelections = { build: meta.build || null, height: meta.height || null }; 29922 const modal = document.getElementById("body-picker-modal"); 29923 if (!modal) return; 29924 modal.querySelectorAll("[data-body-option]").forEach((btn) => { 29925 const group = btn.closest("[data-body-picker-group]")?.dataset?.bodyPickerGroup; 29926 const value = btn.dataset.bodyOption; 29927 if (group === "build" && value === this.bodySelections.build || group === "height" && value === this.bodySelections.height) { 29928 btn.classList.remove("bg-inside", "border-light/10", "text-gray"); 29929 btn.classList.add("bg-active/40", "border-active", "text-white"); 29930 } else { 29931 btn.classList.remove("bg-active/40", "border-active", "text-white"); 29932 btn.classList.add("bg-inside", "border-light/10", "text-gray"); 29933 } 29934 }); 29935 this.updateBodyPreviewImages(this.bodySelections.height || "average"); 29936 this.populateRefPicker("body"); 29937 this.openModal("body-picker-modal"); 29938 if (!this.bodyPickerBound) { 29939 this.bindBodyPicker(); 29940 this.bodyPickerBound = true; 29941 } 29942 } 29943 bindBodyPicker() { 29944 const modal = document.getElementById("body-picker-modal"); 29945 if (!modal) return;
29946 modal.querySelectorAll("[data-body-picker-close]").forEach((btn) => { 29947 btn.addEventListener("click", () => this.closeModal("body-picker-modal")); 29948 }); 29949 modal.addEventListener("click", (e) => { 29950 if (e.target === modal) this.closeModal("body-picker-modal"); 29951 }); 29952 modal.querySelectorAll("[data-body-picker-group]").forEach((group) => { 29953 const groupName = group.dataset.bodyPickerGroup; 29954 group.addEventListener("click", (e) => { 29955 const btn = e.target.closest("[data-body-option]"); 29956 if (!btn) return; 29957 btn.blur(); 29958 const value = btn.dataset.bodyOption; 29959 group.querySelectorAll("[data-body-option]").forEach((b) => { 29960 b.classList.remove("bg-active/40", "border-active", "text-white"); 29961 b.classList.add("bg-inside", "border-light/10", "text-gray"); 29962 }); 29963 if (this.bodySelections[groupName] === value) { 29964 this.bodySelections[groupName] = null; 29965 if (groupName === "height") this.updateBodyPreviewImages("average"); 29966 return; 29967 } 29968 this.bodySelections[groupName] = value; 29969 btn.classList.remove("bg-inside", "border-light/10", "text-gray"); 29970 btn.classList.add("bg-active/40", "border-active", "text-white"); 29971 if (groupName === "height") { 29972 this.updateBodyPreviewImages(value); 29973 } 29974 }); 29975 }); 29976 modal.querySelector("[data-body-picker-generate]")?.addEventListener("click", (e) => { 29977 e.target.closest("button")?.blur(); 29978 this.closeModal("body-picker-modal"); 29979 this.generateBody(); 29980 }); 29981 } 29982 updateBodyPreviewImages(height) { 29983 const modal = document.getElementById("body-picker-modal"); 29984 if (!modal) return; 29985 modal.querySelectorAll("[data-body-preview]").forEach((img) => { 29986 const build = img.dataset.bodyPreview; 29987 img.src = `/elements/body-template-${build}-${height}.png`; 29988 }); 29989 } 29990 async generateBody() { 29991 if (this.bodyGenerating) return; 29992 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 29993 if (!uuid2) return; 29994 this.bodyGenerating = true; 29995 this.showBodyLoading(); 29996 try { 29997 const response = await fetch(`/element/${uuid2}/body`, { 29998 method: "POST", 29999 headers: { 30000 "Content-Type": "application/json", 30001 "X-CSRF-Token": this.csrfToken 30002 }, 30003 body: JSON.stringify({ 30004 version: document.querySelector("[data-element-init]")?.dataset?.displayVersion, 30005 build: this.bodySelections?.build, 30006 height: this.bodySelections?.height, 30007 reference_media_uuid: this.getSelectedRefUuid("body") 30008 }) 30009 }); 30010 if (response.ok) { 30011 this.pollBodyStatus(uuid2); 30012 } else if (response.status === 402) { 30013 const data = await response.json(); 30014 window.Toast?.show(data.error || "Insufficient credits", "alert"); 30015 this.resetBodyState(); 30016 } else { 30017 const data = await response.json().catch(() => ({})); 30018 window.Toast?.show(data.error || "Failed to start generation", "alert"); 30019 this.resetBodyState(); 30020 } 30021 } catch (err) { 30022 console.error("Failed to generate body:", err); 30023 window.Toast?.show("Failed to start generation", "alert"); 30024 this.resetBodyState(); 30025 } 30026 } 30027 showBodyLoading() { 30028 const strip = document.querySelector("[data-element-scenes-strip]"); 30029 if (!strip) return; 30030 const scroll = strip.firstElementChild; 30031 if (!scroll) return; 30032 const blocks = scroll.querySelectorAll(":scope > div"); 30033 let bodyRow = null; 30034 blocks.forEach((block) => { 30035 const label = block.querySelector("div"); 30036 if (label && label.textContent.trim() === "Body") { 30037 bodyRow = block.querySelector(".flex.items-center"); 30038 } 30039 }); 30040 if (!bodyRow) return; 30041 const addBtn = Array.from(bodyRow.children).find((el) => el.tagName === "BUTTON"); 30042 const shot = document.createElement("div"); 30043 shot.className = "shrink-0 relative"; 30044 shot.setAttribute("data-body-loading-shot", ""); 30045 const inner = document.createElement("div"); 30046 inner.className = "bg-inside rounded overflow-hidden flex items-center justify-center"; 30047 inner.style.height = "34px"; 30048 inner.style.width = "56px"; 30049 inner.innerHTML = '<div class="w-3 h-3 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>'; 30050 shot.appendChild(inner); 30051 if (addBtn) { 30052 bodyRow.insertBefore(shot, addBtn); 30053 } else { 30054 bodyRow.appendChild(shot); 30055 } 30056 } 30057 pollBodyStatus(uuid2) { 30058 this.bodyPollInterval = setInterval(async () => {
30059 try { 30060 const response = await fetch(`/element/${uuid2}/body-status`); 30061 if (!response.ok) return; 30062 const data = await response.json(); 30063 if (data.status === "completed") { 30064 this.stopBodyPoll(); 30065 this.resetBodyState(); 30066 document.querySelectorAll("[data-body-loading-shot]").forEach((el) => el.remove()); 30067 await this.fetchData(); 30068 window.Toast?.show("Body generated", "success"); 30069 } else if (data.status === "failed") { 30070 this.stopBodyPoll(); 30071 this.resetBodyState(); 30072 document.querySelectorAll("[data-body-loading-shot]").forEach((el) => el.remove()); 30073 window.Toast?.show(data.error || "Body generation failed", "alert"); 30074 } 30075 } catch (err) { 30076 console.error("Body poll error:", err); 30077 } 30078 }, 2e3); 30079 } 30080 stopBodyPoll() { 30081 if (this.bodyPollInterval) { 30082 clearInterval(this.bodyPollInterval); 30083 this.bodyPollInterval = null; 30084 } 30085 } 30086 resetBodyState() { 30087 this.bodyGenerating = false; 30088 document.querySelectorAll("[data-body-loading-shot]").forEach((el) => el.remove()); 30089 } 30090 openCharacterCreator() { 30091 this.openModal("character-creator-modal"); 30092 if (!this.ccBound) { 30093 this.bindCharacterCreator(); 30094 this.ccBound = true; 30095 } 30096 } 30097 bindCharacterCreator() { 30098 this.ccSelections = {}; 30099 this.ccNeutralOutfit = true; 30100 document.querySelectorAll("[data-cc-close]").forEach((btn) => { 30101 btn.addEventListener("click", () => this.closeModal("character-creator-modal")); 30102 }); 30103 const modal = document.getElementById("character-creator-modal"); 30104 if (modal) { 30105 modal.addEventListener("click", (e) => { 30106 if (e.target === modal) this.closeModal("character-creator-modal"); 30107 }); 30108 } 30109 document.querySelectorAll("[data-cc-group]").forEach((group) => { 30110 const groupName = group.dataset.ccGroup; 30111 const isSwatch = ["hair_color", "eye_color"].includes(groupName); 30112 group.addEventListener("click", (e) => { 30113 const btn = e.target.closest("[data-cc-value]"); 30114 if (!btn) return; 30115 const value = btn.dataset.ccValue; 30116 group.querySelectorAll("[data-cc-value]").forEach((b) => { 30117 if (isSwatch) { 30118 b.classList.remove("ring-2", "ring-active", "ring-offset-2", "ring-offset-box"); 30119 } else { 30120 b.classList.remove("bg-active/40", "border-active", "text-white"); 30121 b.classList.add("bg-inside", "border-light/10", "text-gray"); 30122 } 30123 }); 30124 if (this.ccSelections[groupName] === value) { 30125 delete this.ccSelections[groupName]; 30126 return; 30127 } 30128 this.ccSelections[groupName] = value; 30129 if (isSwatch) { 30130 btn.classList.add("ring-2", "ring-active", "ring-offset-2", "ring-offset-box"); 30131 } else { 30132 btn.classList.remove("bg-inside", "border-light/10", "text-gray"); 30133 btn.classList.add("bg-active/40", "border-active", "text-white"); 30134 } 30135 }); 30136 }); 30137 const toggleBtn = document.querySelector('[data-cc-toggle="outfit"]'); 30138 if (toggleBtn) { 30139 toggleBtn.addEventListener("click", () => { 30140 this.ccNeutralOutfit = !this.ccNeutralOutfit; 30141 const knob = toggleBtn.querySelector("span"); 30142 const customArea = document.querySelector("[data-cc-custom-outfit]"); 30143 if (this.ccNeutralOutfit) { 30144 toggleBtn.classList.add("bg-active"); 30145 toggleBtn.classList.remove("bg-gray/30"); 30146 knob.style.left = "20px"; 30147 if (customArea) customArea.classList.add("hidden"); 30148 } else { 30149 toggleBtn.classList.remove("bg-active"); 30150 toggleBtn.classList.add("bg-gray/30"); 30151 knob.style.left = "2px"; 30152 if (customArea) customArea.classList.remove("hidden"); 30153 } 30154 }); 30155 } 30156 document.querySelectorAll("[data-cc-generate]").forEach((btn) => { 30157 btn.addEventListener("click", () => this.generateFromCharacterCreator()); 30158 }); 30159 } 30160 async generateFromCharacterCreator() { 30161 if (this.generating) return; 30162 if (!this.ccSelections.gender) { 30163 window.Toast?.show("Please select a gender", "alert"); 30164 return; 30165 } 30166 if (!this.ccSelections.appearance) { 30167 window.Toast?.show("Please select an appearance", "alert"); 30168 return; 30169 } 30170 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 30171 if (!uuid2) return; 30172 const character = { ...this.ccSelections };
30173 if (this.ccNeutralOutfit) { 30174 character.outfit = "neutral"; 30175 } else { 30176 const customOutfit = document.querySelector("[data-cc-outfit-text]")?.value?.trim(); 30177 character.outfit = customOutfit || "neutral"; 30178 } 30179 const unique = document.querySelector("[data-cc-unique]")?.value?.trim(); 30180 if (unique) character.unique_details = unique; 30181 this.closeModal("character-creator-modal"); 30182 this.generating = true; 30183 this.showFirstGenerationLoading(); 30184 try { 30185 const response = await fetch(`/element/${uuid2}/generate`, { 30186 method: "POST", 30187 headers: { 30188 "Content-Type": "application/json", 30189 "X-CSRF-Token": this.csrfToken 30190 }, 30191 body: JSON.stringify({ character }) 30192 }); 30193 if (response.ok) { 30194 this.pollGenerationStatus(uuid2); 30195 } else if (response.status === 402) { 30196 const data = await response.json(); 30197 window.Toast?.show(data.error || "Insufficient credits", "alert"); 30198 this.resetGenerateState(); 30199 } else { 30200 const data = await response.json().catch(() => ({})); 30201 window.Toast?.show(data.error || "Failed to start generation", "alert"); 30202 this.resetGenerateState(); 30203 } 30204 } catch (err) { 30205 console.error("Failed to generate from character creator:", err); 30206 window.Toast?.show("Failed to start generation", "alert"); 30207 this.resetGenerateState(); 30208 } 30209 } 30210 async duplicateElementShow(uuid2) { 30211 try { 30212 const response = await fetch(`/element/${uuid2}/duplicate`, { 30213 method: "POST", 30214 headers: { 30215 "X-CSRF-Token": this.csrfToken, 30216 "Accept": "application/json" 30217 } 30218 }); 30219 if (response.ok) { 30220 const data = await response.json(); 30221 if (data.redirect_to) { 30222 window.location.href = data.redirect_to; 30223 } else { 30224 window.Toast?.show("Element duplicated", "success"); 30225 } 30226 } else { 30227 window.Toast?.show("Failed to duplicate element", "alert"); 30228 } 30229 } catch (err) { 30230 console.error("Failed to duplicate element:", err); 30231 window.Toast?.show("Failed to duplicate element", "alert"); 30232 } 30233 } 30234 async renameElementShow(uuid2) { 30235 const currentName = document.querySelector("[data-element-show-name]")?.textContent?.trim() || ""; 30236 const name = prompt("Rename element:", currentName); 30237 if (!name || name.trim() === "" || name.trim() === currentName) return; 30238 try { 30239 const response = await fetch(`/element/${uuid2}/rename`, { 30240 method: "PATCH", 30241 headers: { 30242 "Content-Type": "application/json", 30243 "X-CSRF-Token": this.csrfToken 30244 }, 30245 body: JSON.stringify({ name: name.trim() }) 30246 }); 30247 if (response.ok) { 30248 const nameEl = document.querySelector("[data-element-show-name]"); 30249 if (nameEl) nameEl.textContent = name.trim(); 30250 const h1 = document.querySelector("[data-element-init] h1"); 30251 if (h1) h1.textContent = name.trim(); 30252 window.Toast?.show("Element renamed", "success"); 30253 } else { 30254 window.Toast?.show("Failed to rename element", "alert"); 30255 } 30256 } catch (err) { 30257 console.error("Failed to rename element:", err); 30258 window.Toast?.show("Failed to rename element", "alert"); 30259 } 30260 } 30261 // ââ Shot context menu (Modify / Delete) ââââââââââââââââââââââ 30262 openShotContextMenu(shotEl, sceneKey, item, sceneAction) { 30263 this.closeShotContextMenu(); 30264 const menu = document.createElement("div"); 30265 menu.setAttribute("data-element-shot-menu", ""); 30266 menu.className = "w-30 bg-box rounded-sm shadow-lg z-100"; 30267 menu.style.cssText = "position: fixed; z-index: 9999;"; 30268 const inner = document.createElement("div"); 30269 inner.className = "py-1.5 text-xs"; 30270 if (sceneAction) { 30271 const modifyBtn = document.createElement("button"); 30272 modifyBtn.className = "cursor-pointer flex items-center w-full px-3 py-1.5 text-gray hover:text-white transition duration-300 ease"; 30273 modifyBtn.innerHTML = '<svg class="w-3 h-3 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>Edit'; 30274 modifyBtn.addEventListener("click", (e) => { 30275 e.stopPropagation(); 30276 this.closeShotContextMenu(); 30277 if (sceneKey === "preset") { 30278 this.openCustomPresetModal(item); 30279 } else if (sceneKey === "variation" || sceneKey === "expression" || sceneKey === "aging" || sceneKey === "weather") { 30280 this.openVariationModal({ 30281 mediaUuid: item.uuid, 30282 url: item.url, 30283 thumbnailUrl: item.thumbnail_url || item.thumb_url || item.url, 30284 name: item.slug || "", 30285 prompt: item.prompt || "" 30286 }); 30287 } else { 30288 sceneAction(); 30289 } 30290 }); 30291 inner.appendChild(modifyBtn); 30292 const divider = document.createElement("div"); 30293 divider.className = "mx-3 border-t border-light/20 my-1"; 30294 inner.appendChild(divider); 30295 } 30296 const deleteBtn = document.createElement("button"); 30297 deleteBtn.className = "cursor-pointer flex items-center w-full px-3 py-1.5 text-destructive hover:text-rose-500 transition duration-300 ease"; 30298 deleteBtn.innerHTML = '<svg class="w-3 h-3 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
30298<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>Delete'; 30299 deleteBtn.addEventListener("click", async (e) => { 30300 e.stopPropagation(); 30301 this.closeShotContextMenu(); 30302 if (sceneKey === "preset" && item.em_uuid) { 30303 try { 30304 const resp = await fetch(`/element/${this.uuid}/custom/${item.em_uuid}`, { 30305 method: "DELETE", 30306 headers: { "X-CSRF-Token": this.csrfToken } 30307 }); 30308 if (resp.ok) { 30309 window.Toast?.show("Custom preset deleted", "success"); 30310 this.fetchData(); 30311 } else { 30312 window.Toast?.show("Failed to delete preset", "alert"); 30313 } 30314 } catch { 30315 window.Toast?.show("Failed to delete preset", "alert"); 30316 } 30317 } else { 30318 this.deleteShotMedia(item.uuid); 30319 } 30320 }); 30321 inner.appendChild(deleteBtn); 30322 menu.appendChild(inner); 30323 document.body.appendChild(menu); 30324 const rect = shotEl.getBoundingClientRect(); 30325 menu.style.top = `${rect.bottom + 4}px`; 30326 menu.style.left = `${rect.left}px`; 30327 this._shotMenuCloseHandler = (e) => { 30328 if (!menu.contains(e.target)) this.closeShotContextMenu(); 30329 }; 30330 setTimeout(() => document.addEventListener("click", this._shotMenuCloseHandler), 0); 30331 } 30332 closeShotContextMenu() { 30333 const menu = document.querySelector("[data-element-shot-menu]"); 30334 if (menu) menu.remove(); 30335 if (this._shotMenuCloseHandler) { 30336 document.removeEventListener("click", this._shotMenuCloseHandler); 30337 this._shotMenuCloseHandler = null; 30338 } 30339 } 30340 async deleteShotMedia(mediaUuid) { 30341 const uuid2 = document.querySelector("[data-element-init]")?.dataset?.elementUuid; 30342 if (!uuid2 || !mediaUuid) return; 30343 try { 30344 const response = await fetch(`/element/${uuid2}/media/${mediaUuid}`, { 30345 method: "DELETE", 30346 headers: { "X-CSRF-Token": this.csrfToken } 30347 }); 30348 if (response.ok) { 30349 this.loadElementData(); 30350 } else { 30351 window.Toast?.show("Failed to delete", "alert"); 30352 } 30353 } catch (err) { 30354 console.error("Failed to delete media:", err); 30355 window.Toast?.show("Failed to delete", "alert"); 30356 } 30357 } 30358}; 30359Object.assign(ElementsIndex.prototype, BarMixin); 30360var elementsIndex = null; 30361function initElements() { 30362 if (document.getElementById("elements-grid") || document.querySelector("[data-create-element]") || document.querySelector("[data-element-init]")) { 30363 elementsIndex = new ElementsIndex(); 30364 } 30365} 30366function cleanupElements() { 30367 if (elementsIndex) { 30368 elementsIndex.stopPolling(); 30369 elementsIndex.stopExpressionsPoll(); 30370 elementsIndex.stopAgingPoll(); 30371 elementsIndex.stopBodyPoll(); 30372 elementsIndex.stopWardrobePoll(); 30373 elementsIndex.stopVariationPoll(); 30374 } 30375 elementsIndex = null; 30376} 30377document.addEventListener("turbo:load", initElements); 30378document.addEventListener("turbo:before-cache", cleanupElements); 30379
30380// app/javascript/stock.js 30381var StockManager = class { 30382 constructor() { 30383 this.bindFilters(); 30384 this.bindSearch(); 30385 this.bindPurchase(); 30386 this.bindPublish(); 30387 this.bindPreview(); 30388 } 30389 bindFilters() { 30390 document.querySelectorAll("[data-stock-filter]").forEach((btn) => { 30391 btn.addEventListener("click", (e) => { 30392 const type = e.currentTarget.dataset.stockFilter; 30393 const url = new URL(window.location); 30394 if (type) { 30395 url.searchParams.set("type", type); 30396 } else { 30397 url.searchParams.delete("type"); 30398 } 30399 url.searchParams.delete("q"); 30400 this.loadGrid(url); 30401 this.updateActiveFilter(type); 30402 }); 30403 }); 30404 } 30405 bindSearch() { 30406 const input = document.querySelector("[data-stock-search]"); 30407 if (!input) return; 30408 let timeout; 30409 input.addEventListener("input", () => { 30410 clearTimeout(timeout); 30411 timeout = setTimeout(() => { 30412 const url = new URL("/stock/search", window.location.origin); 30413 const type = document.querySelector( 30414 "[data-stock-filter].bg-active\\/20" 30415 )?.dataset?.stockFilter; 30416 if (type) url.searchParams.set("type", type); 30417 if (input.value.trim()) url.searchParams.set("q", input.value.trim()); 30418 this.loadGrid(url); 30419 }, 300); 30420 }); 30421 } 30422 bindPurchase() { 30423 document.querySelectorAll("[data-stock-purchase]").forEach((btn) => { 30424 btn.addEventListener("click", async (e) => { 30425 const uuid2 = e.currentTarget.dataset.stockPurchase; 30426 const button = e.currentTarget; 30427 if (!confirm( 30428 "Purchase this element? Credits will be deducted from your balance." 30429 )) 30430 return; 30431 button.disabled = true; 30432 button.innerHTML = '<span class="animate-pulse">Processing...</span>'; 30433 try { 30434 const response = await fetch(`/stock/${uuid2}/purchase`, { 30435 method: "POST", 30436 headers: { 30437 "Content-Type": "application/json", 30438 "X-CSRF-Token": document.querySelector( 30439 'meta[name="csrf-token"]' 30440 )?.content 30441 } 30442 }); 30443 const data = await response.json(); 30444 if (response.ok) { 30445 window.Toast?.show("Element added to your collection!", "success"); 30446 const typePath = data.element_type === "character" ? "casting" : data.element_type === "object" ? "object" : data.element_type === "set" ? "set" : "element"; 30447 setTimeout(() => { 30448 window.location.href = `/${typePath}/${data.cloned_element_uuid}`; 30449 }, 1e3); 30450 } else { 30451 window.Toast?.show(data.error || "Purchase failed", "alert"); 30452 button.disabled = false; 30453 button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Get Element`; 30454 } 30455 } catch { 30456 window.Toast?.show("Something went wrong", "alert"); 30457 button.disabled = false; 30458 button.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Get Element`; 30459 } 30460 }); 30461 }); 30462 } 30463 bindPublish() { 30464 document.querySelectorAll("[data-stock-publish]").forEach((btn) => { 30465 btn.addEventListener("click", async (e) => { 30466 const elementUuid = e.currentTarget.dataset.stockPublish; 30467 const button = e.currentTarget; 30468 try { 30469 const response = await fetch(`/stock/publish/${elementUuid}`, { 30470 method: "POST", 30471 headers: { 30472 "Content-Type": "application/json", 30473 "X-CSRF-Token": document.querySelector( 30474 'meta[name="csrf-token"]' 30475 )?.content 30476 } 30477 }); 30478 const data = await response.json(); 30479 if (response.ok) { 30480 window.Toast?.show("Published to Stock!", "success"); 30481 button.outerHTML = `<button type="button" data-stock-unpublish="${data.uuid}" class="text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
30481<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg> Remove from Stock</button>`; 30482 this.bindUnpublish(); 30483 } else { 30484 window.Toast?.show(data.error || "Failed to publish", "alert"); 30485 } 30486 } catch { 30487 window.Toast?.show("Something went wrong", "alert"); 30488 } 30489 }); 30490 }); 30491 this.bindUnpublish(); 30492 } 30493 bindUnpublish() { 30494 document.querySelectorAll("[data-stock-unpublish]").forEach((btn) => { 30495 btn.addEventListener("click", async (e) => { 30496 const uuid2 = e.currentTarget.dataset.stockUnpublish; 30497 const button = e.currentTarget; 30498 if (!confirm("Remove this element from Stock?")) return; 30499 try { 30500 const response = await fetch(`/stock/${uuid2}`, { 30501 method: "DELETE", 30502 headers: { 30503 "Content-Type": "application/json", 30504 "X-CSRF-Token": document.querySelector( 30505 'meta[name="csrf-token"]' 30506 )?.content 30507 } 30508 }); 30509 if (response.ok) { 30510 window.Toast?.show("Removed from Stock", "success"); 30511 button.outerHTML = `<button type="button" data-stock-publish="${button.closest("[data-element-uuid]")?.dataset?.elementUuid || ""}" class="text-xs text-active hover:text-white transition-colors cursor-pointer flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/></svg> Publish to Stock</button>`; 30512 this.bindPublish(); 30513 } else { 30514 const data = await response.json(); 30515 window.Toast?.show(data.error || "Failed to remove", "alert"); 30516 } 30517 } catch { 30518 window.Toast?.show("Something went wrong", "alert"); 30519 } 30520 }); 30521 }); 30522 } 30523 updateActiveFilter(type) { 30524 document.querySelectorAll("[data-stock-filter]").forEach((btn) => { 30525 if (btn.dataset.stockFilter === type) { 30526 btn.classList.add("bg-active/20", "text-active"); 30527 btn.classList.remove("text-gray", "hover:text-white"); 30528 } else { 30529 btn.classList.remove("bg-active/20", "text-active"); 30530 btn.classList.add("text-gray", "hover:text-white"); 30531 } 30532 }); 30533 } 30534 async loadGrid(url) { 30535 try { 30536 const response = await fetch(url, { 30537 headers: { "X-Requested-With": "XMLHttpRequest" } 30538 }); 30539 if (response.ok) { 30540 const html = await response.text(); 30541 const grid = document.getElementById("stock-grid"); 30542 if (grid) grid.innerHTML = html; 30543 this.bindPurchase(); 30544 } 30545 } catch { 30546 } 30547 } 30548 bindPreview() { 30549 this.modal = document.querySelector("[data-stock-preview-modal]"); 30550 if (!this.modal) return; 30551 this.previewImg = this.modal.querySelector("[data-stock-preview-img]"); 30552 this.previewCounter = this.modal.querySelector( 30553 "[data-stock-preview-counter]" 30554 ); 30555 const closeBtn = this.modal.querySelector("[data-stock-preview-close]"); 30556 const prevBtn = this.modal.querySelector("[data-stock-preview-prev]"); 30557 const nextBtn = this.modal.querySelector("[data-stock-preview-next]"); 30558 this.previewUrls = []; 30559 this.previewIndex = 0; 30560 const triggers = document.querySelectorAll("[data-stock-preview-trigger]"); 30561 triggers.forEach((el, i) => { 30562 const url = el.dataset.stockPreviewUrl || el.querySelector("img")?.src || el.querySelector("video")?.src; 30563 if (url) this.previewUrls.push(url); 30564 el.addEventListener("click", () => { 30565 this.previewIndex = i; 30566 this.showPreviewAt(this.previewIndex); 30567 this.modal.classList.remove("hidden"); 30568 document.body.style.overflow = "hidden"; 30569 }); 30570 }); 30571 this.bindCards(); 30572 if (prevBtn) { 30573 prevBtn.addEventListener("click", (e) => { 30574 e.stopPropagation(); 30575 this.previewIndex = (this.previewIndex - 1 + this.previewUrls.length) % this.previewUrls.length; 30576 this.showPreviewAt(this.previewIndex); 30577 }); 30578 } 30579 if (nextBtn) { 30580 nextBtn.addEventListener("click", (e) => { 30581 e.stopPropagation(); 30582 this.previewIndex = (this.previewIndex + 1) % this.previewUrls.length; 30583 this.showPreviewAt(this.previewIndex); 30584 }); 30585 } 30586 if (closeBtn) { 30587 closeBtn.addEventListener("click", () => this.closePreview()); 30588 } 30589 this.modal.addEventListener("click", (e) => { 30590 if (e.target === this.modal) this.closePreview(); 30591 }); 30592 this._escHandler = (e) => { 30593 if (!this.modal || this.modal.classList.contains("hidden")) return; 30594 if (e.key === "Escape") this.closePreview(); 30595 if (e.key === "ArrowLeft" && prevBtn) prevBtn.click(); 30596 if (e.key === "ArrowRight" && nextBtn) nextBtn.click(); 30597 }; 30598 document.addEventListener("keydown", this._escHandler); 30599 } 30600 showPreviewAt(index) { 30601 const url = this.previewUrls[index]; 30602 if (!url || !this.previewImg) return; 30603 this.previewImg.src = url; 30604 if (this.previewCounter && this.previewUrls.length > 1) { 30605 this.previewCounter.textContent = `${index + 1} / ${this.previewUrls.length}`; 30606 } 30607 } 30608 bindCards() { 30609 const grid = document.getElementById("stock-grid"); 30610 if (!grid) return; 30611 grid.addEventListener("click", (e) => { 30612 const card = e.target.closest("[data-stock-card]"); 30613 if (card) this.openPreview(card); 30614 }); 30615 } 30616 openPreview(card) { 30617 if (!this.modal) return; 30618 const d = card.dataset; 30619 const isVideo = d.stockCardIsVideo === "true"; 30620 if (isVideo && this.previewVideo) { 30621 this.previewImg.classList.add("hidden"); 30622 this.previewVideo.classList.remove("hidden"); 30623 this.previewVideo.src = d.stockCardVideo; 30624 } else if (this.previewImg) { 30625 this.previewVideo?.classList.add("hidden"); 30626 this.previewImg.classList.remove("hidden"); 30627 this.previewImg.src = d.stockCardImg; 30628 } 30629 const nameEl = this.modal.querySelector("[data-stock-preview-name]"); 30630 const typeEl = this.modal.querySelector("[data-stock-preview-type]"); 30631 const priceEl = this.modal.querySelector("[data-stock-preview-price]"); 30632 const priceLabelEl = this.modal.querySelector( 30633 "[data-stock-preview-price-label]" 30634 ); 30635 const sellerEl = this.modal.querySelector("[data-stock-preview-seller]"); 30636 const salesEl = this.modal.querySelector("[data-stock-preview-sales]"); 30637 const actionEl = this.modal.querySelector("[data-stock-preview-action]"); 30638 if (nameEl) nameEl.textContent = d.stockCardName; 30639 if (typeEl) { 30640 const style = d.stockCardStyle; 30641 typeEl.textContent = d.stockCardType + (style ? ` \xB7 ${style}` : ""); 30642 } 30643 if (priceEl) priceEl.textContent = d.stockCardPrice; 30644 if (priceLabelEl) 30645 priceLabelEl.textContent = parseInt(d.stockCardPrice) > 1 ? "credits" : "credit"; 30646 if (sellerEl) { 30647 sellerEl.textContent = d.stockCardSeller; 30648 sellerEl.href = `/user/${d.stockCardSellerId}`; 30649 } 30650 if (salesEl) { 30651 const count = parseInt(d.stockCardSales); 30652 salesEl.textContent = count > 0 ? `${count} ${count === 1 ? "sale" : "sales"}` : ""; 30653 } 30654 if (actionEl) { 30655 const uuid2 = d.stockCardUuid; 30656 const isOwner = d.stockCardOwner === "true"; 30657 const isPurchased = d.stockCardPurchased === "true"; 30658 const csrfToken = document.querySelector( 30659 'meta[name="csrf-token"]' 30660 )?.content; 30661 if (isOwner) { 30662 actionEl.innerHTML = `<div class="text-xs text-gray text-center py-2">This is your element</div>`; 30663 } else if (isPurchased) { 30664 actionEl.innerHTML = `<div class="flex items-center justify-center gap-2 text-xs text-green-400 py-2"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> Already in your collection</div>`; 30665 } else if (!csrfToken) { 30666 actionEl.innerHTML = `<button type="button" onclick="loadSigninModal()" class="btn-primary w-full flex items-center justify-center gap-2"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg> Sign in to purchase</button>`; 30667 } else { 30668 actionEl.innerHTML = `<button type="button" data-stock-purchase="${uuid2}" class="btn-primary w-full flex items-center justify-center gap-2 cursor-pointer"><svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Get Element</button>`; 30669 this.bindPurchase(); 30670 } 30671 } 30672 this.modal.classList.remove("hidden"); 30673 document.body.style.overflow = "hidden"; 30674 } 30675 closePreview() { 30676 if (!this.modal) return; 30677 this.modal.classList.add("hidden"); 30678 document.body.style.overflow = ""; 30679 if (this.previewImg) this.previewImg.src = ""; 30680 if (this.previewVideo) { 30681 this.previewVideo.pause(); 30682 this.previewVideo.src = ""; 30683 } 30684 } 30685 destroy() { 30686 if (this._escHandler) { 30687 document.removeEventListener("keydown", this._escHandler); 30688 } 30689 } 30690}; 30691var stockInstance;
30692document.addEventListener("turbo:load", () => { 30693 if (document.querySelector("[data-stock-filters]") || document.querySelector("[data-stock-card]") || document.querySelector("[data-stock-preview-trigger]") || document.querySelector("[data-stock-purchase]") || document.querySelector("[data-stock-publish]")) { 30694 stockInstance = new StockManager(); 30695 } 30696}); 30697document.addEventListener("turbo:before-cache", () => { 30698 if (stockInstance) { 30699 stockInstance.destroy(); 30700 stockInstance = null; 30701 } 30702}); 30703
30704// app/javascript/project/groups.js 30705var GroupsMixin = { 30706 GROUP_COLORS: [ 30707 "#a78bfa", 30708 "#818cf8", 30709 "#7dd3fc", 30710 "#67e8f9", 30711 "#5eead4", 30712 "#6ee7b7", 30713 "#86efac", 30714 "#bef264", 30715 "#fde047", 30716 "#fdba74", 30717 "#fca5a5", 30718 "#f9a8d4", 30719 "#f0abfc", 30720 "#c4b5fd", 30721 "#cbd5e1" 30722 ], 30723 initGroups() { 30724 this.groups = []; 30725 this.selectedNodeUuids = /* @__PURE__ */ new Set(); 30726 this.selectedGroupUuids = /* @__PURE__ */ new Set(); 30727 this.isLassoSelecting = false; 30728 this.lassoStart = { x: 0, y: 0 }; 30729 this.lassoBox = null; 30730 this.GROUP_PADDING = 32; 30731 this.GROUP_HEADER_HEIGHT = 64; 30732 this.GROUP_NODE_GAP = 60; 30733 this.GROUP_ROW_GAP = 60; 30734 this.DEFAULT_ITEMS_PER_ROW = 2; 30735 this.bindSelectionEvents(); 30736 }, 30737 destroyGroups() { 30738 this.groups = []; 30739 this.selectedNodeUuids = /* @__PURE__ */ new Set(); 30740 this.selectedGroupUuids = /* @__PURE__ */ new Set(); 30741 this.isLassoSelecting = false; 30742 this.lassoBox = null; 30743 this.hideGroupActionBar(); 30744 }, 30745 // --- Lasso selection events --- 30746 bindSelectionEvents() { 30747 this.addListener(this.container, "mousedown", (e) => { 30748 if (e.button !== 0 || e.shiftKey) return; 30749 if (this.isPanning) return; 30750 const target = e.target; 30751 if (target === this.container || target === this.viewport || target === this.nodesContainer || target === this.svgLayer || target.id === "project-dots") { 30752 this.startLassoSelect(e); 30753 } 30754 }); 30755 this.addListener(document, "mousemove", (e) => { 30756 if (this.isLassoSelecting) this.updateLassoSelect(e); 30757 }); 30758 this.addListener(document, "mouseup", (e) => { 30759 if (this.isLassoSelecting) this.endLassoSelect(e); 30760 }); 30761 this.addListener(document, "mousedown", (e) => { 30762 if (this.selectedNodeUuids.size === 0 && this.selectedGroupUuids.size === 0) return; 30763 if (e.shiftKey) return; 30764 const actionBar = document.getElementById("project-group-action-bar"); 30765 if (actionBar && actionBar.contains(e.target)) return; 30766 if (e.target.closest(".modal-base")) return; 30767 this.clearSelection(); 30768 }); 30769 }, 30770 startLassoSelect(e) { 30771 if (!e.shiftKey) { 30772 this.clearSelection(); 30773 } 30774 this.isLassoSelecting = true; 30775 this.container.style.cursor = "crosshair"; 30776 const rect = this.container.getBoundingClientRect(); 30777 const mouseX = e.clientX - rect.left; 30778 const mouseY = e.clientY - rect.top; 30779 this.lassoStart = { 30780 x: (mouseX - this.panX) / this.zoom, 30781 y: (mouseY - this.panY) / this.zoom 30782 }; 30783 this.lassoBox = document.createElement("div"); 30784 this.lassoBox.style.cssText = ` 30785 position: absolute; 30786 left: ${this.lassoStart.x}px; 30787 top: ${this.lassoStart.y}px; 30788 width: 0px; 30789 height: 0px; 30790 border: 2px dashed #a78bfa; 30791 border-radius: 6px; 30792 background: rgba(99, 102, 241, 0.08); 30793 pointer-events: none; 30794 z-index: 9999; 30795 `;
30796 this.nodesContainer.appendChild(this.lassoBox); 30797 }, 30798 updateLassoSelect(e) { 30799 if (!this.lassoBox) return; 30800 const rect = this.container.getBoundingClientRect(); 30801 const mouseX = e.clientX - rect.left; 30802 const mouseY = e.clientY - rect.top; 30803 const currentX = (mouseX - this.panX) / this.zoom; 30804 const currentY = (mouseY - this.panY) / this.zoom; 30805 const left = Math.min(this.lassoStart.x, currentX); 30806 const top = Math.min(this.lassoStart.y, currentY); 30807 const width = Math.abs(currentX - this.lassoStart.x); 30808 const height = Math.abs(currentY - this.lassoStart.y); 30809 this.lassoBox.style.left = `${left}px`; 30810 this.lassoBox.style.top = `${top}px`; 30811 this.lassoBox.style.width = `${width}px`; 30812 this.lassoBox.style.height = `${height}px`; 30813 if (width > 10 || height > 10) { 30814 this.highlightNodesInRect(left, top, width, height); 30815 } 30816 }, 30817 endLassoSelect(e) { 30818 if (!this.lassoBox) return; 30819 const rect = this.container.getBoundingClientRect(); 30820 const mouseX = e.clientX - rect.left; 30821 const mouseY = e.clientY - rect.top; 30822 const currentX = (mouseX - this.panX) / this.zoom; 30823 const currentY = (mouseY - this.panY) / this.zoom; 30824 const left = Math.min(this.lassoStart.x, currentX); 30825 const top = Math.min(this.lassoStart.y, currentY); 30826 const width = Math.abs(currentX - this.lassoStart.x); 30827 const height = Math.abs(currentY - this.lassoStart.y); 30828 this.lassoBox.remove(); 30829 this.lassoBox = null; 30830 this.isLassoSelecting = false; 30831 this.container.style.cursor = ""; 30832 if (width > 10 && height > 10) { 30833 this.selectNodesInRect(left, top, width, height); 30834 } 30835 }, 30836 highlightNodesInRect(left, top, width, height) { 30837 const nodes = this.nodesContainer.querySelectorAll(".project-node:not([data-in-group])"); 30838 nodes.forEach((nodeEl) => { 30839 const nodeLeft = parseFloat(nodeEl.style.left) || 0; 30840 const nodeTop = parseFloat(nodeEl.style.top) || 0; 30841 const nodeWidth = nodeEl.offsetWidth; 30842 const nodeHeight = nodeEl.offsetHeight; 30843 const overlaps = !(nodeLeft + nodeWidth < left || nodeLeft > left + width || nodeTop + nodeHeight < top || nodeTop > top + height); 30844 if (overlaps) { 30845 nodeEl.style.outline = "2px solid #a78bfa"; 30846 nodeEl.style.outlineOffset = "4px"; 30847 } else if (!this.selectedNodeUuids.has(nodeEl.dataset.projectNodeId)) { 30848 nodeEl.style.outline = ""; 30849 nodeEl.style.outlineOffset = ""; 30850 } 30851 }); 30852 const groups = this.nodesContainer.querySelectorAll(".project-group"); 30853 groups.forEach((groupEl) => { 30854 const gLeft = parseFloat(groupEl.style.left) || 0; 30855 const gTop = parseFloat(groupEl.style.top) || 0; 30856 const gWidth = groupEl.offsetWidth; 30857 const gHeight = groupEl.offsetHeight; 30858 const overlaps = !(gLeft + gWidth < left || gLeft > left + width || gTop + gHeight < top || gTop > top + height); 30859 if (overlaps) { 30860 groupEl.style.outline = "2px solid #a78bfa"; 30861 groupEl.style.outlineOffset = "4px"; 30862 } else if (!this.selectedGroupUuids.has(groupEl.dataset.projectGroupId)) { 30863 groupEl.style.outline = ""; 30864 groupEl.style.outlineOffset = ""; 30865 } 30866 }); 30867 }, 30868 selectNodesInRect(left, top, width, height) { 30869 const nodes = this.nodesContainer.querySelectorAll(".project-node:not([data-in-group])"); 30870 nodes.forEach((nodeEl) => { 30871 const nodeLeft = parseFloat(nodeEl.style.left) || 0; 30872 const nodeTop = parseFloat(nodeEl.style.top) || 0; 30873 const nodeWidth = nodeEl.offsetWidth; 30874 const nodeHeight = nodeEl.offsetHeight; 30875 const overlaps = !(nodeLeft + nodeWidth < left || nodeLeft > left + width || nodeTop + nodeHeight < top || nodeTop > top + height); 30876 if (overlaps) { 30877 const uuid2 = nodeEl.dataset.projectNodeId; 30878 if (!uuid2) return; 30879 this.selectedNodeUuids.add(uuid2); 30880 nodeEl.style.outline = "2px solid #a78bfa"; 30881 nodeEl.style.outlineOffset = "4px"; 30882 } 30883 }); 30884 const groups = this.nodesContainer.querySelectorAll(".project-group"); 30885 groups.forEach((groupEl) => { 30886 const gLeft = parseFloat(groupEl.style.left) || 0; 30887 const gTop = parseFloat(groupEl.style.top) || 0; 30888 const gWidth = groupEl.offsetWidth; 30889 const gHeight = groupEl.offsetHeight; 30890 const overlaps = !(gLeft + gWidth < left || gLeft > left + width || gTop + gHeight < top || gTop > top + height); 30891 if (overlaps) { 30892 const uuid2 = groupEl.dataset.projectGroupId; 30893 if (!uuid2) return; 30894 this.selectedGroupUuids.add(uuid2); 30895 groupEl.style.outline = "2px solid #a78bfa"; 30896 groupEl.style.outlineOffset = "4px"; 30897 } 30898 }); 30899 if (this.selectedNodeUuids.size > 0 || this.selectedGroupUuids.size > 0) { 30900 this.showGroupActionBar(); 30901 } 30902 }, 30903 // --- Node selection (shift+click) --- 30904 toggleNodeSelection(nodeUuid) {
30905 const nodeEl = this.nodesContainer.querySelector(`[data-project-node-id="${nodeUuid}"]`); 30906 if (!nodeEl || nodeEl.hasAttribute("data-in-group")) return; 30907 if (this.selectedNodeUuids.has(nodeUuid)) { 30908 this.selectedNodeUuids.delete(nodeUuid); 30909 nodeEl.style.outline = ""; 30910 nodeEl.style.outlineOffset = ""; 30911 } else { 30912 this.selectedNodeUuids.add(nodeUuid); 30913 nodeEl.style.outline = "2px solid #a78bfa"; 30914 nodeEl.style.outlineOffset = "4px"; 30915 } 30916 if (this.selectedNodeUuids.size > 0) { 30917 this.showGroupActionBar(); 30918 } else { 30919 this.hideGroupActionBar(); 30920 } 30921 }, 30922 clearSelection() { 30923 this.selectedNodeUuids.forEach((uuid2) => { 30924 const el = this.nodesContainer.querySelector(`[data-project-node-id="${uuid2}"]`); 30925 if (el) { 30926 el.style.outline = ""; 30927 el.style.outlineOffset = ""; 30928 } 30929 }); 30930 this.selectedNodeUuids.clear(); 30931 this.selectedGroupUuids.forEach((uuid2) => { 30932 const el = this.nodesContainer.querySelector(`[data-project-group-id="${uuid2}"]`); 30933 if (el) { 30934 el.style.outline = ""; 30935 el.style.outlineOffset = ""; 30936 } 30937 }); 30938 this.selectedGroupUuids.clear(); 30939 if (this.currentShot?.selected_media_id) {
30940 const mediaEl = this.nodesContainer.querySelector(`[data-project-media="${this.currentShot.selected_media_id}"]`); 30941 const selNode = mediaEl?.closest(".project-node"); 30942 if (selNode) { 30943 selNode.style.outline = "2px solid var(--color-active)"; 30944 selNode.style.outlineOffset = "4px"; 30945 } 30946 } 30947 this.hideGroupActionBar(); 30948 }, 30949 // --- Floating group action bar --- 30950 showGroupActionBar() { 30951 let bar = document.getElementById("project-group-action-bar"); 30952 if (!bar) { 30953 bar = document.createElement("div"); 30954 bar.id = "project-group-action-bar"; 30955 bar.style.cssText = ` 30956 position: fixed; 30957 bottom: 24px; 30958 left: 50%; 30959 transform: translateX(-50%); 30960 z-index: 10000; 30961 display: flex; 30962 align-items: center; 30963 gap: 12px; 30964 background: rgba(30, 30, 40, 0.95); 30965 backdrop-filter: blur(12px); 30966 border: 1px solid rgba(255,255,255,0.1); 30967 border-radius: 12px; 30968 padding: 8px 16px; 30969 box-shadow: 0 8px 32px rgba(0,0,0,0.4); 30970 `; 30971 const countLabel2 = document.createElement("span"); 30972 countLabel2.id = "project-group-action-count"; 30973 countLabel2.style.cssText = "font-size: 13px; color: #a78bfa; font-weight: 500;"; 30974 bar.appendChild(countLabel2); 30975 const groupBtn = document.createElement("button"); 30976 groupBtn.style.cssText = ` 30977 display: flex; align-items: center; gap: 6px; 30978 background: #a78bfa; color: white; border: none; border-radius: 8px; 30979 padding: 6px 14px; font-size: 12px; font-weight: 600; cursor: pointer; 30980 transition: background 0.2s; 30981 `; 30982 groupBtn.innerHTML = `${this.icon("group", "w-3.5 h-3.5")} Group`; 30983 groupBtn.addEventListener("mouseenter", () => groupBtn.style.background = "#8b5cf6"); 30984 groupBtn.addEventListener("mouseleave", () => groupBtn.style.background = "#a78bfa"); 30985 groupBtn.addEventListener("click", () => this.createGroupFromSelection()); 30986 bar.appendChild(groupBtn); 30987 const deleteBtn = document.createElement("button"); 30988 deleteBtn.style.cssText = ` 30989 display: flex; align-items: center; gap: 6px; 30990 background: rgba(239, 68, 68, 0.15); color: #f87171; border: none; border-radius: 8px; 30991 padding: 6px 14px; font-size: 12px; font-weight: 600; cursor: pointer; 30992 transition: all 0.2s; 30993 `; 30994 deleteBtn.innerHTML = `${this.icon("trash-2", "w-3.5 h-3.5")} Delete`; 30995 deleteBtn.addEventListener("mouseenter", () => { 30996 deleteBtn.style.background = "rgba(239, 68, 68, 0.3)"; 30997 deleteBtn.style.color = "#fca5a5"; 30998 }); 30999 deleteBtn.addEventListener("mouseleave", () => { 31000 deleteBtn.style.background = "rgba(239, 68, 68, 0.15)"; 31001 deleteBtn.style.color = "#f87171"; 31002 }); 31003 deleteBtn.addEventListener("click", () => this.confirmDeleteSelectedNodes()); 31004 bar.appendChild(deleteBtn); 31005 const cancelBtn = document.createElement("button"); 31006 cancelBtn.style.cssText = ` 31007 display: flex; align-items: center; gap: 4px; 31008 background: none; color: rgba(255,255,255,0.5); border: none; 31009 padding: 6px 8px; font-size: 12px; cursor: pointer; 31010 transition: color 0.2s; 31011 `; 31012 cancelBtn.textContent = "Cancel"; 31013 cancelBtn.addEventListener("mouseenter", () => cancelBtn.style.color = "white"); 31014 cancelBtn.addEventListener("mouseleave", () => cancelBtn.style.color = "rgba(255,255,255,0.5)"); 31015 cancelBtn.addEventListener("click", () => this.clearSelection()); 31016 bar.appendChild(cancelBtn); 31017 document.body.appendChild(bar); 31018 } 31019 const countLabel = document.getElementById("project-group-action-count"); 31020 const totalCount = this.selectedNodeUuids.size + this.selectedGroupUuids.size; 31021 if (countLabel) countLabel.textContent = `${totalCount}
31021 selected`; 31022 bar.style.display = "flex"; 31023 }, 31024 hideGroupActionBar() { 31025 const bar = document.getElementById("project-group-action-bar"); 31026 if (bar) bar.style.display = "none"; 31027 }, 31028 // --- Create group from selection --- 31029 async createGroupFromSelection() { 31030 if (this.selectedNodeUuids.size === 0) return; 31031 const shotUuid = this.currentShotUuid; 31032 if (!shotUuid) return; 31033 let minX = Infinity, minY = Infinity; 31034 const nodeUuids = [...this.selectedNodeUuids]; 31035 nodeUuids.forEach((uuid2) => { 31036 const el = this.nodesContainer.querySelector(`[data-project-node-id="${uuid2}"]`); 31037 if (el) { 31038 const x = parseFloat(el.style.left) || 0; 31039 const y = parseFloat(el.style.top) || 0; 31040 if (x < minX) minX = x; 31041 if (y < minY) minY = y; 31042 } 31043 }); 31044 const posX = minX - this.GROUP_PADDING; 31045 const posY = minY - this.GROUP_HEADER_HEIGHT - this.GROUP_PADDING; 31046 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 31047 try { 31048 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/groups`, { 31049 method: "POST", 31050 headers: { 31051 "Content-Type": "application/json", 31052 "X-CSRF-Token": csrfToken 31053 }, 31054 body: JSON.stringify({ 31055 position_x: posX, 31056 position_y: posY, 31057 node_uuids: nodeUuids, 31058 name: "Group", 31059 color: "#a78bfa" 31060 }) 31061 }); 31062 if (!response.ok) throw new Error("Failed to create group"); 31063 const data = await response.json(); 31064 if (data.success) { 31065 this.clearSelection(); 31066 this.loadShot(this.currentShotUuid); 31067 } 31068 } catch (error) { 31069 console.error("[ProjectCanvas] Create group error:", error); 31070 } 31071 }, 31072 // --- Delete selected nodes --- 31073 confirmDeleteSelectedNodes() { 31074 const nodeCount = this.selectedNodeUuids.size; 31075 const groupCount = this.selectedGroupUuids.size; 31076 if (nodeCount === 0 && groupCount === 0) return; 31077 let groupedNodeCount = 0; 31078 this.selectedGroupUuids.forEach((uuid2) => { 31079 const group = this.groups.find((g) => g.uuid === uuid2); 31080 if (group?.node_uuids) groupedNodeCount += group.node_uuids.length; 31081 }); 31082 const totalItems = nodeCount + groupedNodeCount; 31083 const description = groupCount > 0 ? `${totalItems} node${totalItems !== 1 ? "s" : ""} and ${groupCount} group${groupCount !== 1 ? "s" : ""}` : `${nodeCount} node${nodeCount !== 1 ? "s" : ""}`; 31084 const modal = document.createElement("div"); 31085 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 31086 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10001;"; 31087 modal.innerHTML = ` 31088 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 31089 <div class="flex items-start justify-between mb-3"> 31090 <h2 class="text-14 font-bold text-white">Delete ${description}</h2> 31091 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 31092 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 31093 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 31094 </svg> 31095 </button> 31096 </div> 31097 <p class="text-neutral-300 mb-6 text-sm">Are you sure you want to delete ${description}?<br>This action cannot be undone.</p> 31098 <div class="flex gap-3 justify-end"> 31099 <button data-modal-close class="btn-secondary">Cancel</button> 31100 <button data-delete-confirm class="btn-danger">Delete</button> 31101 </div> 31102 </div> 31103 `; 31104 document.body.appendChild(modal); 31105 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 31106 btn.addEventListener("click", () => modal.remove()); 31107 }); 31108 modal.addEventListener("click", (e) => { 31109 if (e.target === modal) modal.remove(); 31110 }); 31111 modal.querySelector("[data-delete-confirm]").addEventListener("click", () => { 31112 modal.remove(); 31113 this.deleteSelectedNodes(); 31114 }); 31115 }, 31116 deleteSelectedNodes() { 31117 const nodeUuids = [...this.selectedNodeUuids]; 31118 const groupUuids = [...this.selectedGroupUuids]; 31119 let deletedCount = 0; 31120 nodeUuids.forEach((uuid2) => { 31121 if (this.currentShot) { 31122 const node = this.currentShot.nodes.find((n) => n.uuid === uuid2); 31123 if (node && node.media_id && node.media_id === this.currentShot.selected_media_id) { 31124 this.unselectShot(this.currentShotUuid); 31125 } 31126 this.currentShot.nodes = this.currentShot.nodes.filter((n) => n.uuid !== uuid2); 31127 } 31128 this.deleteProcessingNode(uuid2); 31129 deletedCount++; 31130 }); 31131 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 31132 groupUuids.forEach((groupUuid) => { 31133 const group = this.groups.find((g) => g.uuid === groupUuid); 31134 if (group?.node_uuids) { 31135 group.node_uuids.forEach((nodeUuid) => { 31136 if (this.currentShot) { 31137 const node = this.currentShot.nodes.find((n) => n.uuid === nodeUuid); 31138 if (node && node.media_id && node.media_id === this.currentShot.selected_media_id) { 31139 this.unselectShot(this.currentShotUuid); 31140 } 31141 this.currentShot.nodes = this.currentShot.nodes.filter((n) => n.uuid !== nodeUuid); 31142 } 31143 this.deleteProcessingNode(nodeUuid); 31144 deletedCount++; 31145 }); 31146 }
31147 const groupEl = this.nodesContainer.querySelector(`[data-project-group-id="${groupUuid}"]`); 31148 if (groupEl) groupEl.remove(); 31149 this.groups = this.groups.filter((g) => g.uuid !== groupUuid); 31150 fetch(`${this.baseUrl()}/groups/${groupUuid}`, { 31151 method: "DELETE", 31152 headers: { "X-CSRF-Token": csrfToken } 31153 }).catch((err) => console.error("[ProjectCanvas] Delete group error:", err)); 31154 }); 31155 this.clearSelection(); 31156 if (this.currentShot && this.currentShot.nodes.length === 0) { 31157 this.renderEmptyNode(); 31158 this.updateShotStatusBar(this.currentShotUuid, "draft"); 31159 } 31160 if (window.Toast) window.Toast.show(`${deletedCount} node${deletedCount !== 1 ? "s" : ""} deleted`, "success"); 31161 }, 31162 // --- Render groups --- 31163 renderGroups(groups) { 31164 this.groups = groups; 31165 groups.forEach((g) => this.renderGroup(g)); 31166 }, 31167 renderGroup(groupData) { 31168 const el = document.createElement("div"); 31169 el.className = "project-group"; 31170 el.dataset.projectGroupId = groupData.uuid; 31171 el.style.cssText = ` 31172 position: absolute; 31173 left: ${groupData.position_x}px; 31174 top: ${groupData.position_y}px; 31175 z-index: 1; 31176 `; 31177 const header = document.createElement("div"); 31178 header.className = "project-group-header"; 31179 header.style.cssText = ` 31180 display: flex; 31181 align-items: center; 31182 gap: 6px; 31183 padding: 6px ${this.GROUP_PADDING}px; 31184 user-select: none; 31185 white-space: nowrap; 31186 height: ${this.GROUP_HEADER_HEIGHT}px; 31187 cursor: grab; 31188 `; 31189 const label = document.createElement("div"); 31190 label.className = "project-group-label"; 31191 label.style.cssText = ` 31192 font-size: 11px; 31193 color: ${groupData.color}; 31194 font-weight: 600; 31195 pointer-events: none; 31196 `; 31197 label.textContent = groupData.name; 31198 header.appendChild(label); 31199 const spacer = document.createElement("div"); 31200 spacer.style.flex = "1"; 31201 header.appendChild(spacer); 31202 const actions = document.createElement("div"); 31203 actions.className = "project-group-actions"; 31204 actions.style.cssText = ` 31205 display: flex; 31206 align-items: center; 31207 gap: 6px; 31208 opacity: 0; 31209 transition: opacity 0.15s; 31210 flex-shrink: 0; 31211 `; 31212 const wrapWithTooltip = (child, text) => { 31213 const wrapper = document.createElement("div"); 31214 wrapper.style.cssText = "position: relative; display: flex; align-items: center;"; 31215 wrapper.appendChild(child); 31216 const tip = document.createElement("div"); 31217 tip.textContent = text; 31218 tip.style.cssText = ` 31219 position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); 31220 margin-bottom: 6px; padding: 3px 8px; border-radius: 4px; 31221 background: rgba(20, 20, 30, 0.95); color: white; 31222 font-size: 10px; white-space: nowrap; pointer-events: none; 31223 opacity: 0; transition: opacity 0.15s; 31224 `; 31225 wrapper.appendChild(tip); 31226 wrapper.addEventListener("mouseenter", () => { 31227 tip.style.opacity = "1"; 31228 }); 31229 wrapper.addEventListener("mouseleave", () => { 31230 tip.style.opacity = "0"; 31231 }); 31232 return wrapper; 31233 }; 31234 const nodeCount = groupData.node_uuids ? groupData.node_uuids.length : 4; 31235 const maxPerRow = Math.min(Math.max(nodeCount, 1), 4); 31236 const currentItemsPerRow = groupData.items_per_row || this.DEFAULT_ITEMS_PER_ROW; 31237 const iprContainer = document.createElement("div"); 31238 iprContainer.style.cssText = "display: flex; align-items: center; gap: 2px;"; 31239 for (let i = 1; i <= maxPerRow; i++) { 31240 const btn = document.createElement("button"); 31241 btn.dataset.iprBtn = i; 31242 const isActive = i === currentItemsPerRow; 31243 btn.style.cssText = ` 31244 width: 18px; height: 18px; border-radius: 4px; border: none; 31245 display: flex; align-items: center; justify-content: center; 31246 font-size: 9px; font-weight: 600; cursor: pointer; 31247 transition: all 0.15s; 31248 background: ${isActive ? groupData.color + "40" : "transparent"}; 31249 color: ${isActive ? "white" : groupData.color}; 31250 `; 31251 btn.textContent = i; 31252 btn.addEventListener("mouseenter", () => { 31253 if (!btn.classList.contains("ipr-active")) btn.style.color = "white"; 31254 }); 31255 btn.addEventListener("mouseleave", () => { 31256 if (!btn.classList.contains("ipr-active")) btn.style.color = groupData.color; 31257 }); 31258 if (isActive) btn.classList.add("ipr-active"); 31259 iprContainer.appendChild(btn); 31260 } 31261 actions.appendChild(wrapWithTooltip(iprContainer, "Items per row")); 31262 const divider = document.createElement("div"); 31263 divider.style.cssText = `width: 1px; height: 12px; background: ${groupData.color}33; flex-shrink: 0;`; 31264 actions.appendChild(divider); 31265 const renameBtn = document.createElement("div"); 31266 renameBtn.className = "project-group-rename"; 31267 renameBtn.style.cssText = `cursor: pointer; color: ${groupData.color}; display: flex; align-items: center;`; 31268 renameBtn.innerHTML = this.icon("square-pen", "w-3 h-3"); 31269 actions.appendChild(wrapWithTooltip(renameBtn, "Rename")); 31270 const ungroupBtn = document.createElement("div"); 31271 ungroupBtn.className = "project-group-ungroup"; 31272 ungroupBtn.style.cssText = `cursor: pointer; color: ${groupData.color}; display: flex; align-items: center;`; 31273 ungroupBtn.innerHTML = this.icon("ungroup", "w-3 h-3"); 31274 actions.appendChild(wrapWithTooltip(ungroupBtn, "Ungroup")); 31275 const divider2 = document.createElement("div"); 31276 divider2.style.cssText = `width: 1px; height: 12px; background: ${groupData.color}33; flex-shrink: 0;`; 31277 actions.appendChild(divider2); 31278 const colorDot = document.createElement("div"); 31279 colorDot.className = "project-group-color"; 31280 colorDot.style.cssText = `
31281 width: 10px; 31282 height: 10px; 31283 border-radius: 50%; 31284 background: ${groupData.color}; 31285 cursor: pointer; 31286 flex-shrink: 0; 31287 `; 31288 actions.appendChild(wrapWithTooltip(colorDot, "Color")); 31289 header.appendChild(actions); 31290 el.appendChild(header); 31291 const gridBody = document.createElement("div"); 31292 gridBody.className = "project-group-grid"; 31293 const itemsPerRow = groupData.items_per_row || this.DEFAULT_ITEMS_PER_ROW; 31294 gridBody.style.cssText = ` 31295 display: grid; 31296 grid-template-columns: repeat(${itemsPerRow}, auto); 31297 column-gap: ${this.GROUP_NODE_GAP}px; 31298 row-gap: ${this.GROUP_ROW_GAP}px; 31299 padding: 28px ${this.GROUP_PADDING}px 36px; 31300 `; 31301 el.appendChild(gridBody); 31302 this.renderGroupedNodes(gridBody, groupData); 31303 el.style.background = `${groupData.color}0d`; 31304 el.style.border = `2px solid ${groupData.color}33`; 31305 el.style.borderRadius = "8px"; 31306 el.addEventListener("mouseenter", () => { 31307 actions.style.opacity = "1"; 31308 }); 31309 el.addEventListener("mouseleave", () => { 31310 actions.style.opacity = "0"; 31311 }); 31312 iprContainer.querySelectorAll("[data-ipr-btn]").forEach((btn) => { 31313 btn.addEventListener("click", (e) => { 31314 e.stopPropagation(); 31315 const count = parseInt(btn.dataset.iprBtn); 31316 this.setGroupItemsPerRow(groupData.uuid, count); 31317 iprContainer.querySelectorAll("[data-ipr-btn]").forEach((b) => { 31318 const active = parseInt(b.dataset.iprBtn) === count; 31319 b.style.background = active ? groupData.color + "40" : "transparent"; 31320 b.style.color = active ? "white" : groupData.color; 31321 active ? b.classList.add("ipr-active") : b.classList.remove("ipr-active"); 31322 }); 31323 }); 31324 }); 31325 renameBtn.addEventListener("click", (e) => { 31326 e.stopPropagation(); 31327 this.startGroupRename(groupData.uuid, label); 31328 }); 31329 ungroupBtn.addEventListener("click", (e) => { 31330 e.stopPropagation(); 31331 this.ungroupAll(groupData.uuid); 31332 }); 31333 colorDot.addEventListener("click", (e) => { 31334 e.stopPropagation(); 31335 this.openGroupColorMenu(groupData.uuid, e.clientX, e.clientY); 31336 }); 31337 this.bindGroupDrag(el, groupData.uuid); 31338 this.nodesContainer.appendChild(el); 31339 }, 31340 renderGroupedNodes(gridBody, groupData) { 31341 if (!groupData.node_uuids || !this.currentShot?.nodes) return; 31342 const allNodes = this.currentShot.nodes; 31343 groupData.node_uuids.forEach((nodeUuid) => { 31344 const nodeData = allNodes.find((n) => n.uuid === nodeUuid); 31345 if (!nodeData) return; 31346 if (nodeData.status === "processing") { 31347 this.renderGroupedProcessingNode(gridBody, nodeData, groupData); 31348 this.pollNodeStatus(nodeData.uuid); 31349 return; 31350 } 31351 const nodeHeight = this.getNodeHeight(nodeData.ratio); 31352 const modelLabel = nodeData.model || ""; 31353 const isUpscale = nodeData.model === "seedvr2"; 31354 const isVideo = nodeData.node_type === "video" || nodeData.media_url && nodeData.media_url.includes(".mp4"); 31355 const isGrid = nodeData.node_type === "grid"; 31356 const canRegenerate = !isVideo && (nodeData.source === "generated" || nodeData.source === "app"); 31357 const sourceLabel = isGrid ? nodeData.app_name || "Grid" : isUpscale ? "Upscale" : isVideo ? "Video" : nodeData.source === "app" ? nodeData.app_name || "App" : nodeData.source === "generated" ? "Image" : nodeData.source === "reference" ? "Reference" : "Upload"; 31358 const sourceIcon = isGrid ? "layout-dashboard" : isUpscale ? "maximize-2" : isVideo ? "video" : nodeData.source === "upload" ? "upload" : nodeData.source === "app" ? "layout-dashboard" : nodeData.source === "generated" ? "image" : "image"; 31359 const isSelected = nodeData.media_id && this.currentShot?.selected_media_id === nodeData.media_id; 31360 const nodeWidth = this.getNodeWidthForRatio(nodeData.ratio); 31361 const card = document.createElement("div"); 31362 card.className = "project-node group bg-inside rounded-md"; 31363 card.dataset.projectNodeId = nodeData.uuid; 31364 card.dataset.inGroup = groupData.uuid; 31365 card.style.cssText = `
31366 position: relative; 31367 width: ${nodeWidth}px; 31368 height: ${nodeHeight}px; 31369 `; 31370 if (isSelected) card.classList.add("ring-2", "ring-active"); 31371 const mediaEl = isVideo ? `<video src="${nodeData.media_url}" ${nodeData.thumbnail_url ? `poster="${nodeData.thumbnail_url}"` : ""} autoplay loop muted playsinline class="w-full h-full object-cover${nodeData.media_id ? " cursor-pointer" : ""}" data-project-media="${nodeData.media_id || ""}"></video>` : `<img src="${nodeData.thumbnail_url || nodeData.media_url}" alt="" class="w-full h-full object-cover${nodeData.media_id ? " cursor-pointer" : ""}" data-project-media="${nodeData.media_id || ""}" />`; 31372 const actionBarHtml = isGrid ? "" : isVideo ? ` 31373 <div class="relative group/upscale"> 31374 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="upscale"> 31375 ${this.icon("maximize-2", "w-4.5 h-4.5")} 31376 </button> 31377 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/upscale:opacity-100 transition-opacity duration-200"> 31378 <div class="text-[11px] font-medium">Upscale</div> 31379 <div class="text-gray text-[10px] -mt-[1px]">Enhance quality</div> 31380 </div> 31381 </div> 31382 <div class="relative group/frame"> 31383 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="extract-frame"> 31384 ${this.icon("image", "w-4.5 h-4.5")} 31385 </button> 31386 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/frame:opacity-100 transition-opacity duration-200"> 31387 <div class="text-[11px] font-medium">Last Frame</div> 31388 <div class="text-gray text-[10px] -mt-[1px]">Extract as image</div> 31389 </div> 31390 </div> 31391 ` : ` 31392 <div class="relative group/edit"> 31393 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="edit"> 31394 ${this.icon("square-pen", "w-4.5 h-4.5")} 31395 </button> 31396 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/edit:opacity-100 transition-opacity duration-200"> 31397 <div class="text-[11px] font-medium">Edit</div> 31398 <div class="text-gray text-[10px] -mt-[1px]">Generate image</div> 31399 </div> 31400 </div> 31401 <div class="relative group/apps"> 31402 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="apps"> 31403 ${this.icon("layout-dashboard", "w-4.5 h-4.5")} 31404 </button> 31405 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/apps:opacity-100 transition-opacity duration-200"> 31406 <div class="text-[11px] font-medium">Modify</div> 31407 <div class="text-gray text-[10px] -mt-[1px]">With AI Apps</div> 31408 </div> 31409 </div> 31410 <div class="relative group/animate"> 31411 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="animate"> 31412 ${this.icon("clapperboard", "w-4.5 h-4.5")} 31413 </button> 31414 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/animate:opacity-100 transition-opacity duration-200"> 31415 <div class="text-[11px] font-medium">Animate</div> 31416 <div class="text-gray text-[10px] -mt-[1px]">Generate video</div> 31417 </div> 31418 </div> 31419 <div class="relative group/world"> 31420 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="world"> 31421 ${this.icon("rotate-3d", "w-4.5 h-4.5")} 31422 </button> 31423 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/world:opacity-100 transition-opacity duration-200"> 31424 <div class="text-[11px] font-medium">3D World</div> 31425 <div class="text-gray text-[10px] -mt-[1px]">Explore in 3D</div> 31426 </div> 31427 </div> 31428 `; 31429 card.innerHTML = ` 31430 <div class="relative h-full flex flex-col"> 31431 <!-- TOP BAR - Info label left --> 31432 <div class="absolute bottom-full left-1 mb-2 flex items-end gap-1.5 max-w-[220px]"> 31433 <div class="flex items-end gap-1.5 text-[10px] text-gray min-w-0">
31434 <span class="flex-shrink-0">${this.icon(sourceIcon, "w-4 h-4")}</span> 31435 <span class="text-gray-300 font-medium truncate">${sourceLabel}</span> 31436 <span class="flex-shrink-0">${modelLabel}</span> 31437 </div> 31438 </div> 31439 31440 <!-- Hover bridge for top bar buttons --> 31441 <div class="absolute bottom-full left-0 right-0 h-6 pointer-events-auto z-0"></div> 31442 31443 <!-- Top bar right - remove from group button --> 31444 <div class="absolute bottom-full right-1 mb-1 flex items-center gap-2 opacity-0 translate-y-2 -translate-x-3 scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-10" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 31445 <div class="relative group/remove"> 31446 <button class="cursor-pointer p-1 text-gray/50 hover:text-white transition duration-300 ease" data-remove-from-group="${nodeData.uuid}"> 31447 ${this.icon("x", "w-4.5 h-4.5")} 31448 </button> 31449 <div class="absolute bottom-full right-0 mb-1 px-2 py-1 bg-box rounded text-[10px] text-white whitespace-nowrap opacity-0 pointer-events-none group-hover/remove:opacity-100 transition-opacity duration-200">Remove from group</div> 31450 </div> 31451 </div> 31452 31453 <!-- Media container --> 31454 <div class="w-full flex-1 rounded-md overflow-hidden relative z-10"> 31455 ${mediaEl} 31456 ${canRegenerate ? ` 31457 <div class="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-20" style="background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);"> 31458 <div class="flex items-end justify-end min-h-6"> 31459 <div class="relative group/regen"> 31460 <button class="cursor-pointer w-6 h-6 bg-white/10 text-white hover:bg-white/20 rounded-full flex items-center justify-center transition duration-300 ease pointer-events-auto" data-project-action="regenerate"> 31461 ${this.icon("refresh-cw", "w-3.5 h-3.5")} 31462 </button> 31463 <span class="absolute bottom-full right-0 mb-1 px-2 py-1 bg-box text-white text-[10px] rounded whitespace-nowrap opacity-0 pointer-events-none group-hover/regen:opacity-100 transition-opacity duration-200">Regenerate</span> 31464 </div> 31465 </div> 31466 </div> 31467 ` : ""} 31468 </div> 31469 31470 ${isGrid ? "" : ` 31471 <!-- Hover bridge for action bar --> 31472 <div class="absolute left-full top-0 bottom-0 w-14 pointer-events-auto z-0"></div> 31473 31474 <!-- ACTION BAR - right side on hover --> 31475 <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2.5 flex flex-col gap-2 opacity-0 -translate-x-[8px] scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-0" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); filter: drop-shadow(0 0 20px rgb(0, 0, 0));"> 31476 ${actionBarHtml} 31477 </div> 31478 31479 <!-- BOTTOM BAR - below node on hover --> 31480 <div class="absolute top-full left-0 right-0 flex justify-center gap-3 select-none opacity-0 translate-y-[-8px] scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:scale-100 group-hover:pointer-events-auto z-50" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 31481 <button class="cursor-pointer py-3 px-2 text-xs ${isSelected ? "text-active" : "text-gray/50"} hover:text-white transition duration-300 ease flex items-center gap-1.5" data-project-action="select-shot"> 31482 <span>${this.icon("check", "w-3.5 h-3.5")}</span> 31483 <span class="font-medium" data-select-label>${isSelected ? "SELECTED" : "SELECT SHOT"}</span> 31484 </button> 31485 <button class="cursor-pointer py-3 px-2 text-xs text-gray/50 hover:text-white transition duration-300 ease flex items-center gap-1.5" data-project-action="duplicate-to-shot">
31486 <span class="font-medium">DUPLICATE TO...</span> 31487 <span>${this.icon("arrow-right", "w-3.5 h-3.5")}</span> 31488 </button> 31489 </div> 31490 `} 31491 </div> 31492 `; 31493 gridBody.appendChild(card); 31494 card.addEventListener("mouseenter", () => { 31495 card.style.zIndex = "10"; 31496 }); 31497 card.addEventListener("mouseleave", () => { 31498 card.style.zIndex = ""; 31499 }); 31500 const isVideoNode = nodeData.node_type === "video"; 31501 const mediaClickEl = card.querySelector("[data-project-media]"); 31502 if (mediaClickEl && nodeData.media_id) { 31503 mediaClickEl.addEventListener("click", (e) => { 31504 e.stopPropagation(); 31505 if (this.selectedNodeUuids.size > 0 || this.selectedGroupUuids.size > 0) return; 31506 this.openMediaViewer(nodeData.media_id); 31507 }); 31508 } 31509 const editBtn = card.querySelector('[data-project-action="edit"]'); 31510 if (editBtn) { 31511 editBtn.addEventListener("click", (e) => { 31512 e.stopPropagation(); 31513 this.openEditBar(nodeData, "image"); 31514 }); 31515 } 31516 const appsBtn = card.querySelector('[data-project-action="apps"]'); 31517 if (appsBtn) { 31518 appsBtn.addEventListener("click", (e) => { 31519 e.stopPropagation(); 31520 this.openAppsModal(nodeData); 31521 }); 31522 } 31523 const animateBtn = card.querySelector('[data-project-action="animate"]'); 31524 if (animateBtn) { 31525 animateBtn.addEventListener("click", (e) => { 31526 e.stopPropagation(); 31527 this.openEditBar(nodeData, "video"); 31528 }); 31529 } 31530 const worldBtn = card.querySelector('[data-project-action="world"]'); 31531 if (worldBtn) { 31532 worldBtn.addEventListener("click", (e) => { 31533 e.stopPropagation(); 31534 this.openWorldModal(nodeData); 31535 }); 31536 } 31537 const upscaleBtn = card.querySelector('[data-project-action="upscale"]'); 31538 if (upscaleBtn) { 31539 upscaleBtn.addEventListener("click", (e) => { 31540 e.stopPropagation(); 31541 this.handleUpscaleClick(nodeData); 31542 }); 31543 } 31544 const extractFrameBtn = card.querySelector('[data-project-action="extract-frame"]'); 31545 if (extractFrameBtn) { 31546 extractFrameBtn.addEventListener("click", (e) => { 31547 e.stopPropagation(); 31548 this.handleExtractFrame(nodeData); 31549 }); 31550 } 31551 const duplicateBtn = card.querySelector('[data-project-action="duplicate-to-shot"]'); 31552 if (duplicateBtn) { 31553 duplicateBtn.addEventListener("click", (e) => { 31554 e.stopPropagation(); 31555 this.openDuplicateToShotModal(nodeData); 31556 }); 31557 } 31558 const selectBtn = card.querySelector('[data-project-action="select-shot"]'); 31559 if (selectBtn && nodeData.media_id) { 31560 selectBtn.addEventListener("click", (e) => { 31561 e.stopPropagation(); 31562 this.selectMediaForShot(nodeData.media_id, card); 31563 }); 31564 } 31565 const regenBtn = card.querySelector('[data-project-action="regenerate"]'); 31566 if (regenBtn) { 31567 regenBtn.addEventListener("click", (e) => { 31568 e.stopPropagation(); 31569 this.handleRegenerate(nodeData); 31570 }); 31571 } 31572 const removeBtn = card.querySelector(`[data-remove-from-group="${nodeData.uuid}"]`); 31573 if (removeBtn) { 31574 removeBtn.addEventListener("click", (e) => { 31575 e.stopPropagation(); 31576 this.removeNodeFromGroup(nodeData.uuid); 31577 }); 31578 } 31579 this.bindGroupedNodeDrag(card, nodeData.uuid, groupData); 31580 }); 31581 }, 31582 // --- Grouped processing node --- 31583 renderGroupedProcessingNode(gridBody, nodeData, groupData) { 31584 const nodeHeight = this.getNodeHeight(nodeData.ratio); 31585 const isUpscale = nodeData.model === "seedvr2"; 31586 const label = isUpscale ? "Upscaling" : "Processing"; 31587 const iconName = isUpscale ? "maximize-2" : "sparkles"; 31588 const card = document.createElement("div"); 31589 card.className = "project-node group"; 31590 card.dataset.projectNodeId = nodeData.uuid; 31591 card.dataset.projectProcessing = "true"; 31592 card.dataset.inGroup = groupData.uuid; 31593 const nodeWidth = this.getNodeWidthForRatio(nodeData.ratio); 31594 card.style.cssText = `
31595 position: relative; 31596 width: ${nodeWidth}px; 31597 height: ${nodeHeight}px; 31598 `; 31599 card.innerHTML = ` 31600 <!-- Top bar - info label left --> 31601 <div class="absolute bottom-full left-1 mb-2 flex items-end gap-1.5"> 31602 <div class="flex items-end gap-1.5 text-[10px] text-gray min-w-0"> 31603 <span class="flex-shrink-0">${this.icon(iconName, "w-4 h-4")}</span> 31604 <span class="text-gray-300 font-medium">${label}</span> 31605 </div> 31606 </div> 31607 31608 <!-- Hover bridge for top bar buttons --> 31609 <div class="absolute bottom-full left-0 right-0 h-6 pointer-events-auto z-0"></div> 31610 31611 <!-- Top bar right - remove from group button --> 31612 <div class="absolute bottom-full right-1 mb-1 flex items-center gap-2 opacity-0 translate-y-2 -translate-x-3 scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-10" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 31613 <button class="cursor-pointer p-1 text-gray/50 hover:text-white transition duration-300 ease" data-remove-from-group="${nodeData.uuid}" title="Remove from group"> 31614 ${this.icon("x", "w-4.5 h-4.5")} 31615 </button> 31616 </div> 31617 31618 <!-- Magic card wrapper --> 31619 <div class="magic-card w-full h-full"> 31620 <div class="magic-card-inner flex-col items-center justify-center gap-3 overflow-hidden"> 31621 <div class="relative flex items-center justify-center z-10"> 31622 <svg width="48" height="48" class="transform -rotate-90 animate-spin" style="animation-duration: 3s;"> 31623 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 0.2)" stroke-width="2.5" fill="none" /> 31624 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 1)" stroke-width="2.5" fill="none" 31625 stroke-dasharray="31.4 94.26" stroke-linecap="round" /> 31626 </svg> 31627 </div> 31628 <span class="text-[11px] text-gray z-10">${label}...</span> 31629 </div> 31630 </div> 31631 `; 31632 gridBody.appendChild(card); 31633 const removeBtn = card.querySelector(`[data-remove-from-group="${nodeData.uuid}"]`); 31634 if (removeBtn) { 31635 removeBtn.addEventListener("click", (e) => { 31636 e.stopPropagation(); 31637 this.removeNodeFromGroup(nodeData.uuid); 31638 }); 31639 } 31640 }, 31641 // --- Group drag --- 31642 bindGroupDrag(el, groupUuid) { 31643 const header = el.querySelector(".project-group-header"); 31644 if (!header) return; 31645 let isDragging = false; 31646 let startMouseX = 0; 31647 let startMouseY = 0; 31648 let startNodeX = 0; 31649 let startNodeY = 0; 31650 let multiDragStarts = null; 31651 const onMouseDown = (e) => { 31652 if (e.button !== 0 || e.shiftKey) return; 31653 if (e.target.closest(".project-group-actions")) return; 31654 const isMultiSelect = this.selectedNodeUuids.size > 0 || this.selectedGroupUuids.size > 0; 31655 if (!isMultiSelect && !header.contains(e.target)) return; 31656 isDragging = true; 31657 startMouseX = e.clientX; 31658 startMouseY = e.clientY; 31659 startNodeX = parseFloat(el.style.left) || 0; 31660 startNodeY = parseFloat(el.style.top) || 0; 31661 multiDragStarts = null; 31662 el.style.cursor = "grabbing"; 31663 e.preventDefault(); 31664 e.stopPropagation(); 31665 }; 31666 const onMouseMove = (e) => { 31667 if (!isDragging) return; 31668 const deltaX = (e.clientX - startMouseX) / this.zoom; 31669 const deltaY = (e.clientY - startMouseY) / this.zoom; 31670 const isMulti = this.selectedGroupUuids.has(groupUuid) && (this.selectedNodeUuids.size > 0 || this.selectedGroupUuids.size > 1); 31671 if (isMulti) { 31672 if (!multiDragStarts) { 31673 multiDragStarts = /* @__PURE__ */ new Map(); 31674 this.selectedNodeUuids.forEach((uuid2) => { 31675 const nodeEl = this.nodesContainer.querySelector(`.project-node[data-
31675project-node-id="${uuid2}"]:not([data-in-group])`); 31676 if (nodeEl) multiDragStarts.set(uuid2, { el: nodeEl, x: parseFloat(nodeEl.style.left) || 0, y: parseFloat(nodeEl.style.top) || 0, type: "node" }); 31677 }); 31678 this.selectedGroupUuids.forEach((uuid2) => { 31679 const gEl = this.nodesContainer.querySelector(`[data-project-group-id="${uuid2}"]`); 31680 if (gEl) multiDragStarts.set("group_" + uuid2, { el: gEl, x: parseFloat(gEl.style.left) || 0, y: parseFloat(gEl.style.top) || 0, type: "group", groupUuid: uuid2 }); 31681 }); 31682 } 31683 multiDragStarts.forEach(({ el: dragEl, x, y }) => { 31684 dragEl.style.left = `${Math.max(0, x + deltaX)}px`; 31685 dragEl.style.top = `${Math.max(0, y + deltaY)}px`; 31686 }); 31687 } else { 31688 el.style.left = `${Math.max(0, startNodeX + deltaX)}px`; 31689 el.style.top = `${Math.max(0, startNodeY + deltaY)}px`; 31690 } 31691 }; 31692 const onMouseUp = () => { 31693 if (!isDragging) return; 31694 isDragging = false; 31695 el.style.cursor = ""; 31696 if (multiDragStarts) { 31697 multiDragStarts.forEach(({ el: dragEl, x, y, type, groupUuid: gUuid }, key) => { 31698 const { x: snappedX, y: snappedY } = this.snapElementToGrid(dragEl); 31699 if (snappedX !== x || snappedY !== y) { 31700 if (type === "group") { 31701 this.updateGroupData(gUuid, { position_x: snappedX, position_y: snappedY }); 31702 } else { 31703 this.saveNodePosition(key, snappedX, snappedY); 31704 } 31705 } 31706 }); 31707 multiDragStarts = null; 31708 } else { 31709 const { x: snappedX, y: snappedY } = this.snapElementToGrid(el); 31710 if (snappedX !== startNodeX || snappedY !== startNodeY) { 31711 this.updateGroupData(groupUuid, { position_x: snappedX, position_y: snappedY }); 31712 } 31713 } 31714 }; 31715 el.addEventListener("mousedown", onMouseDown); 31716 this.addListener(window, "mousemove", onMouseMove); 31717 this.addListener(window, "mouseup", onMouseUp); 31718 }, 31719 // --- Grouped node drag (reorder / ungroup) --- 31720 bindGroupedNodeDrag(card, nodeUuid, groupData) { 31721 let isDragging = false; 31722 let hasMoved = false; 31723 let startX = 0; 31724 let startY = 0; 31725 let ghost = null; 31726 let placeholder = null; 31727 const DRAG_THRESHOLD = 6; 31728 const onMouseDown = (e) => { 31729 if (e.button !== 0) return; 31730 if (e.target.closest("button") || e.target.closest("[data-project-action]")) return; 31731 if (this.selectedNodeUuids.size > 0 || this.selectedGroupUuids.size > 0) { 31732 e.preventDefault(); 31733 return; 31734 } 31735 isDragging = true; 31736 hasMoved = false; 31737 startX = e.clientX; 31738 startY = e.clientY; 31739 e.preventDefault(); 31740 }; 31741 const onMouseMove = (e) => { 31742 if (!isDragging) return; 31743 const dx = e.clientX - startX; 31744 const dy = e.clientY - startY; 31745 if (!hasMoved) { 31746 if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return; 31747 hasMoved = true; 31748 const cardW = card.offsetWidth; 31749 const cardH = card.offsetHeight; 31750 const ghostW = cardW * this.zoom; 31751 const ghostH = cardH * this.zoom; 31752 const mediaEl = card.querySelector("[data-project-media]"); 31753 ghost = document.createElement("div"); 31754 if (mediaEl) { 31755 const img = mediaEl.cloneNode(true); 31756 img.style.cssText = "width: 100%; height: 100%; object-fit: cover; display: block;"; 31757 ghost.appendChild(img); 31758 } 31759 ghost.style.cssText = ` 31760 position: fixed; 31761 width: ${ghostW}px; 31762 height: ${ghostH}px; 31763 pointer-events: none; 31764 z-index: 100000; 31765 opacity: 0.85; 31766 border-radius: ${6 * this.zoom}px; 31767 overflow: hidden; 31768 box-shadow: 0 20px 40px rgba(0,0,0,0.5); 31769 `; 31770 document.body.appendChild(ghost); 31771 placeholder = document.createElement("div"); 31772 placeholder.style.cssText = ` 31773 width: ${cardW}px; 31774 height: ${cardH}px; 31775 border: 2px dashed ${groupData.color}55; 31776 border-radius: 6px; 31777 background: ${groupData.color}0a; 31778 `; 31779 card.parentNode.insertBefore(placeholder, card); 31780 card.style.display = "none"; 31781 } 31782 if (!ghost) return; 31783 const gw = parseFloat(ghost.style.width) || 0; 31784 const gh = parseFloat(ghost.style.height) || 0; 31785 ghost.style.left = `${e.clientX - gw / 2}px`; 31786 ghost.style.top = `${e.clientY - gh / 2}px`;
31787 const groupEl = this.nodesContainer.querySelector(`[data-project-group-id="${groupData.uuid}"]`); 31788 if (!groupEl) return; 31789 const groupRect = groupEl.getBoundingClientRect(); 31790 const isInsideGroup = e.clientX >= groupRect.left && e.clientX <= groupRect.right && e.clientY >= groupRect.top && e.clientY <= groupRect.bottom; 31791 if (isInsideGroup) { 31792 const gridBody = groupEl.querySelector(".project-group-grid"); 31793 if (!gridBody) return; 31794 const cards = [...gridBody.querySelectorAll(".project-node[data-project-node-id]")]; 31795 let closestCard = null; 31796 let closestDist = Infinity; 31797 let insertBefore = true; 31798 cards.forEach((c) => { 31799 if (c === card) return; 31800 const rect = c.getBoundingClientRect(); 31801 const cx = rect.left + rect.width / 2; 31802 const cy = rect.top + rect.height / 2; 31803 const dist = Math.hypot(e.clientX - cx, e.clientY - cy); 31804 if (dist < closestDist) { 31805 closestDist = dist; 31806 closestCard = c; 31807 insertBefore = e.clientX < cx || Math.abs(e.clientX - cx) < rect.width / 4 && e.clientY < cy; 31808 } 31809 }); 31810 if (closestCard && placeholder.parentNode === gridBody) { 31811 if (insertBefore) { 31812 gridBody.insertBefore(placeholder, closestCard); 31813 } else { 31814 gridBody.insertBefore(placeholder, closestCard.nextSibling); 31815 } 31816 } 31817 ghost.style.opacity = "0.85"; 31818 } 31819 }; 31820 const onMouseUp = async (e) => { 31821 if (!isDragging) return; 31822 isDragging = false; 31823 if (!hasMoved) return; 31824 if (ghost) { 31825 ghost.remove(); 31826 ghost = null; 31827 }
31828 const groupEl = this.nodesContainer.querySelector(`[data-project-group-id="${groupData.uuid}"]`); 31829 const groupRect = groupEl?.getBoundingClientRect(); 31830 const isInsideGroup = groupRect && e.clientX >= groupRect.left && e.clientX <= groupRect.right && e.clientY >= groupRect.top && e.clientY <= groupRect.bottom; 31831 if (placeholder) { 31832 const gridBody = groupEl?.querySelector(".project-group-grid"); 31833 if (gridBody) { 31834 card.style.display = ""; 31835 gridBody.insertBefore(card, placeholder); 31836 placeholder.remove(); 31837 placeholder = null; 31838 const newOrder = [...gridBody.querySelectorAll(".project-node[data-project-node-id]")].map((c) => c.dataset.projectNodeId); 31839 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 31840 fetch(`${this.baseUrl()}/groups/${groupData.uuid}/reorder_nodes`, { 31841 method: "POST", 31842 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, 31843 body: JSON.stringify({ node_uuids: newOrder }) 31844 }).catch((err) => console.error("[ProjectCanvas] Reorder error:", err)); 31845 } else { 31846 if (placeholder) { 31847 placeholder.remove(); 31848 placeholder = null; 31849 } 31850 card.style.display = ""; 31851 } 31852 } 31853 }; 31854 card.addEventListener("mousedown", onMouseDown); 31855 this.addListener(window, "mousemove", onMouseMove); 31856 this.addListener(window, "mouseup", onMouseUp); 31857 }, 31858 // --- Group rename --- 31859 startGroupRename(groupUuid, labelEl) { 31860 const currentName = labelEl.textContent; 31861 const input = document.createElement("input"); 31862 input.type = "text"; 31863 input.value = currentName; 31864 input.style.cssText = ` 31865 font-size: 11px; 31866 font-weight: 500; 31867 background: rgba(0,0,0,0.8); 31868 color: white; 31869 border: 1px solid ${labelEl.style.color}; 31870 border-radius: 4px; 31871 padding: 2px 6px; 31872 outline: none; 31873 width: ${Math.max(60, currentName.length * 8)}px; 31874 `; 31875 labelEl.textContent = ""; 31876 labelEl.appendChild(input); 31877 labelEl.style.pointerEvents = "auto"; 31878 input.focus(); 31879 input.select(); 31880 const save = () => { 31881 const newName = input.value.trim() || "Group"; 31882 labelEl.textContent = newName; 31883 labelEl.style.pointerEvents = "none"; 31884 if (newName !== currentName) { 31885 this.updateGroupData(groupUuid, { name: newName }); 31886 const group = this.groups.find((g) => g.uuid === groupUuid); 31887 if (group) group.name = newName; 31888 } 31889 }; 31890 input.addEventListener("blur", save); 31891 input.addEventListener("keydown", (e) => { 31892 if (e.key === "Enter") { 31893 e.preventDefault(); 31894 input.blur(); 31895 } 31896 if (e.key === "Escape") { 31897 input.value = currentName; 31898 input.blur(); 31899 } 31900 }); 31901 }, 31902 // --- Group color menu --- 31903 openGroupColorMenu(groupUuid, clientX, clientY) { 31904 const existing = document.querySelector("[data-group-context-menu]"); 31905 if (existing) existing.remove(); 31906 const group = this.groups.find((g) => g.uuid === groupUuid); 31907 const currentColor = group?.color || "#a78bfa"; 31908 const menu = document.createElement("div"); 31909 menu.dataset.groupContextMenu = groupUuid; 31910 menu.className = "bg-box rounded-lg shadow-lg overflow-hidden"; 31911 menu.style.cssText = `position: fixed; z-index: 10000; left: ${clientX}px; top: ${clientY}px;`; 31912 let html = '<div class="p-2.5">'; 31913 html += '<div class="grid grid-cols-5 gap-1.5">'; 31914 for (const color of this.GROUP_COLORS) { 31915 const isActive = currentColor === color; 31916 html += `<button data-group-color="${color}" class="w-5 h-5 rounded cursor-pointer transition duration-200 ${isActive ? "ring-2 ring-white ring-offset-1 ring-offset-black" : "hover:scale-110"}" style="background: ${color}"></button>`; 31917 } 31918 html += "</div></div>"; 31919 menu.innerHTML = html; 31920 document.body.appendChild(menu); 31921 menu.querySelectorAll("[data-group-color]").forEach((btn) => { 31922 btn.addEventListener("click", (e) => { 31923 e.stopPropagation(); 31924 this.changeGroupColor(groupUuid, btn.dataset.groupColor); 31925 menu.remove(); 31926 }); 31927 }); 31928 const closeHandler = (e) => { 31929 if (!menu.contains(e.target)) { 31930 menu.remove(); 31931 document.removeEventListener("click", closeHandler); 31932 } 31933 }; 31934 setTimeout(() => document.addEventListener("click", closeHandler), 0); 31935 }, 31936 changeGroupColor(groupUuid, color) {
31937 const el = this.nodesContainer.querySelector(`[data-project-group-id="${groupUuid}"]`); 31938 if (el) { 31939 el.style.borderColor = `${color}33`; 31940 el.style.background = `${color}0d`; 31941 const label = el.querySelector(".project-group-label"); 31942 if (label) label.style.color = color; 31943 const colorDot = el.querySelector(".project-group-color"); 31944 if (colorDot) colorDot.style.background = color; 31945 el.querySelectorAll(".project-group-rename, .project-group-ungroup").forEach((btn) => { 31946 btn.style.color = color; 31947 }); 31948 el.querySelectorAll("[data-ipr-btn]").forEach((btn) => { 31949 const isActive = btn.classList.contains("ipr-active"); 31950 btn.style.background = isActive ? color + "40" : "transparent"; 31951 btn.style.color = isActive ? "white" : color; 31952 }); 31953 el.querySelectorAll('.project-group-actions > div[style*="width: 1px"]').forEach((d) => { 31954 d.style.background = `${color}33`; 31955 }); 31956 } 31957 const group = this.groups.find((g) => g.uuid === groupUuid); 31958 if (group) group.color = color; 31959 this.updateGroupData(groupUuid, { color }); 31960 }, 31961 setGroupItemsPerRow(groupUuid, count) { 31962 const group = this.groups.find((g) => g.uuid === groupUuid); 31963 if (group) group.items_per_row = count;
31964 const groupEl = this.nodesContainer.querySelector(`[data-project-group-id="${groupUuid}"]`); 31965 if (groupEl) { 31966 const grid = groupEl.querySelector(".project-group-grid"); 31967 if (grid) { 31968 grid.style.gridTemplateColumns = `repeat(${count}, auto)`; 31969 } 31970 } 31971 this.updateGroupData(groupUuid, { items_per_row: count }); 31972 }, 31973 // --- Add node to group --- 31974 async addNodeToGroup(nodeUuid, groupUuid) { 31975 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 31976 try { 31977 const response = await fetch(`${this.baseUrl()}/groups/${groupUuid}`, { 31978 method: "PATCH", 31979 headers: { 31980 "Content-Type": "application/json", 31981 "X-CSRF-Token": csrfToken 31982 }, 31983 body: JSON.stringify({ add_node_uuid: nodeUuid }) 31984 }); 31985 if (!response.ok) throw new Error("Failed to add node to group"); 31986 this.loadShot(this.currentShotUuid); 31987 } catch (error) { 31988 console.error("[ProjectCanvas] Add to group error:", error); 31989 } 31990 }, 31991 // --- Remove node from group --- 31992 async removeNodeFromGroup(nodeUuid) { 31993 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 31994 try { 31995 const response = await fetch(`${this.baseUrl()}/nodes/${nodeUuid}/remove_from_group`, { 31996 method: "PATCH", 31997 headers: { "X-CSRF-Token": csrfToken } 31998 }); 31999 if (!response.ok) throw new Error("Failed to remove node from group"); 32000 this.loadShot(this.currentShotUuid); 32001 } catch (error) { 32002 console.error("[ProjectCanvas] Remove from group error:", error); 32003 } 32004 }, 32005 // --- Ungroup all --- 32006 async ungroupAll(groupUuid) { 32007 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32008 try { 32009 const response = await fetch(`${this.baseUrl()}/groups/${groupUuid}/ungroup`, { 32010 method: "DELETE", 32011 headers: { "X-CSRF-Token": csrfToken } 32012 }); 32013 if (!response.ok) throw new Error("Failed to ungroup"); 32014 this.loadShot(this.currentShotUuid); 32015 } catch (error) { 32016 console.error("[ProjectCanvas] Ungroup error:", error); 32017 } 32018 }, 32019 // --- Reorder group nodes --- 32020 async reorderGroupNodes(groupUuid, nodeUuids) { 32021 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32022 try { 32023 const response = await fetch(`${this.baseUrl()}/groups/${groupUuid}/reorder_nodes`, { 32024 method: "POST", 32025 headers: { 32026 "Content-Type": "application/json", 32027 "X-CSRF-Token": csrfToken 32028 }, 32029 body: JSON.stringify({ node_uuids: nodeUuids }) 32030 }); 32031 if (!response.ok) throw new Error("Failed to reorder group nodes"); 32032 } catch (error) { 32033 console.error("[ProjectCanvas] Reorder group nodes error:", error); 32034 } 32035 }, 32036 // --- Server communication --- 32037 updateGroupData(groupUuid, updates) { 32038 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32039 fetch(`${this.baseUrl()}/groups/${groupUuid}`, { 32040 method: "PATCH", 32041 headers: { 32042 "Content-Type": "application/json", 32043 "X-CSRF-Token": csrfToken 32044 }, 32045 body: JSON.stringify(updates) 32046 }).catch((err) => console.error("[ProjectCanvas] Update group error:", err)); 32047 }, 32048 // --- Create group via API --- 32049 async createGroup(nodeUuids) { 32050 const shotUuid = this.currentShotUuid; 32051 if (!shotUuid || !nodeUuids || nodeUuids.length === 0) return null; 32052 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32053 try { 32054 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/groups`, { 32055 method: "POST", 32056 headers: { 32057 "Content-Type": "application/json", 32058 "X-CSRF-Token": csrfToken 32059 }, 32060 body: JSON.stringify({ 32061 node_uuids: nodeUuids, 32062 name: "Group", 32063 color: "#a78bfa" 32064 }) 32065 }); 32066 if (!response.ok) throw new Error("Failed to create group"); 32067 const data = await response.json(); 32068 if (data.success) { 32069 this.loadShot(this.currentShotUuid); 32070 return data; 32071 } 32072 return null; 32073 } catch (error) { 32074 console.error("[ProjectCanvas] Create group error:", error); 32075 return null; 32076 } 32077 }, 32078 // --- Update group via API --- 32079 async updateGroup(groupUuid, updates) { 32080 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32081 try { 32082 const response = await fetch(`${this.baseUrl()}/groups/${groupUuid}`, { 32083 method: "PATCH", 32084 headers: { 32085 "Content-Type": "application/json", 32086 "X-CSRF-Token": csrfToken 32087 }, 32088 body: JSON.stringify(updates) 32089 }); 32090 if (!response.ok) throw new Error("Failed to update group"); 32091 const data = await response.json(); 32092 return data; 32093 } catch (error) { 32094 console.error("[ProjectCanvas] Update group error:", error); 32095 return null; 32096 } 32097 }, 32098 // --- Delete group via API --- 32099 async deleteGroup(groupUuid) { 32100 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32101 try { 32102 const response = await fetch(`${this.baseUrl()}/groups/${groupUuid}`, { 32103 method: "DELETE", 32104 headers: { "X-CSRF-Token": csrfToken } 32105 }); 32106 if (!response.ok) throw new Error("Failed to delete group"); 32107 this.loadShot(this.currentShotUuid); 32108 } catch (error) { 32109 console.error("[ProjectCanvas] Delete group error:", error); 32110 } 32111 } 32112}; 32113
32114// app/javascript/project/nodes.js 32115var NodesMixin2 = { 32116 renderNodes(nodes) { 32117 nodes.forEach((nodeData) => { 32118 if (nodeData.group_uuid) return; 32119 if (nodeData.status === "ready" && nodeData.media_url) { 32120 this.renderImageNode(nodeData); 32121 } else if (nodeData.status === "processing") { 32122 this.renderProcessingNode(nodeData); 32123 this.pollNodeStatus(nodeData.uuid); 32124 } 32125 }); 32126 }, 32127 renderImageNode(nodeData) { 32128 this.revealFacesDependents(); 32129 const nodeWidth = this.getNodeWidthForRatio(nodeData.ratio); 32130 const nodeHeight = this.getNodeHeight(nodeData.ratio); 32131 let defaultX, defaultY; 32132 if (this.nextNodePosition) { 32133 defaultX = this.nextNodePosition.x; 32134 defaultY = this.nextNodePosition.y; 32135 this.nextNodePosition = null; 32136 } else { 32137 defaultX = (this.emptyNodeCenter?.x || 1e4) - nodeWidth / 2; 32138 defaultY = (this.emptyNodeCenter?.y || 1e4) - nodeHeight / 2; 32139 } 32140 const posX = nodeData.position_x != null ? nodeData.position_x : defaultX; 32141 const posY = nodeData.position_y != null ? nodeData.position_y : defaultY;
32142 const existing = this.nodesContainer.querySelector(`[data-project-node-id="${nodeData.uuid}"]`); 32143 if (existing) existing.remove(); 32144 const node = document.createElement("div"); 32145 node.className = "project-node group bg-inside rounded-md"; 32146 node.dataset.projectNodeId = nodeData.uuid; 32147 if (nodeData.thumb_url) node.dataset.thumbUrl = nodeData.thumb_url; 32148 node.style.cssText = ` 32149 position: absolute; 32150 left: ${posX}px; 32151 top: ${posY}px; 32152 width: ${nodeWidth}px; 32153 height: ${nodeHeight}px; 32154 cursor: grab; 32155 `; 32156 const modelLabel = nodeData.model || ""; 32157 const isUpscale = nodeData.model === "seedvr2"; 32158 const isVideo = nodeData.node_type === "video" || nodeData.media_url && nodeData.media_url.includes(".mp4"); 32159 const isGrid = nodeData.node_type === "grid"; 32160 const isWorld = nodeData.node_type === "world"; 32161 const canRegenerate = !isVideo && !isWorld && (nodeData.source === "generated" || nodeData.source === "app"); 32162 const sourceLabel = isWorld ? "3D World" : isGrid ? nodeData.app_name || "Grid" : isUpscale ? "Upscale" : isVideo ? "Video" : nodeData.source === "app" ? nodeData.app_name || "App" : nodeData.source === "generated" ? "Image" : nodeData.source === "reference" ? "Reference" : nodeData.source === "library" ? "Library" : "Upload"; 32163 const sourceIcon = isWorld ? "rotate-3d" : isGrid ? "layout-dashboard" : isUpscale ? "maximize-2" : isVideo ? "video" : nodeData.source === "upload" ? "upload" : nodeData.source === "app" ? "layout-dashboard" : nodeData.source === "generated" ? "image" : "image"; 32164 const isSelected = nodeData.media_id && this.currentShot?.selected_media_id === nodeData.media_id; 32165 if (isSelected) { 32166 node.style.outline = "2px solid var(--color-active)"; 32167 node.style.outlineOffset = "4px"; 32168 } 32169 const worldClickable = isWorld && nodeData.world_id; 32170 const mediaEl = isVideo ? `<video src="${nodeData.media_url}" ${nodeData.thumbnail_url ? `poster="${nodeData.thumbnail_url}"` : ""} autoplay loop muted playsinline class="w-full h-full object-cover${nodeData.media_id ? " cursor-pointer" : ""}" data-project-media="${nodeData.media_id || ""}"></video>` : `<img src="${nodeData.thumbnail_url || nodeData.media_url}" alt="" class="w-full h-full object-cover${nodeData.media_id || worldClickable ? " cursor-pointer" : ""}" data-project-media="${nodeData.media_id || nodeData.uuid}" />`; 32171 const actionBarHtml = isGrid || isWorld ? "" : isVideo ? ` 32172 <div class="relative group/upscale"> 32173 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="upscale"> 32174 ${this.icon("maximize-2", "w-4.5 h-4.5")} 32175 </button> 32176 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/upscale:opacity-100 transition-opacity duration-200"> 32177 <div class="text-[11px] font-medium">Upscale</div> 32178 <div class="text-gray text-[10px] -mt-[1px]">Enhance quality</div> 32179 </div> 32180 </div> 32181 <div class="relative group/frame"> 32182 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="extract-frame"> 32183 ${this.icon("image", "w-4.5 h-4.5")} 32184 </button> 32185 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/frame:opacity-100 transition-opacity duration-200"> 32186 <div class="text-[11px] font-medium">Last Frame</div> 32187 <div class="text-gray text-[10px] -mt-[1px]">Extract as image</div> 32188 </div> 32189 </div> 32190 ` : ` 32191 <div class="relative group/edit"> 32192 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="edit"> 32193 ${this.icon("square-pen", "w-4.5 h-4.5")} 32194 </button> 32195 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/edit:opacity-100 transition-opacity duration-200"> 32196 <div class="text-[11px] font-medium">Edit</div> 32197 <div class="text-gray text-[10px] -mt-[1px]">Generate image</div> 32198 </div> 32199 </div> 32200 <div class="relative group/apps"> 32201 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="apps"> 32202 ${this.icon("layout-dashboard", "w-4.5 h-4.5")} 32203 </button> 32204 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/apps:opacity-100 transition-opacity duration-200"> 32205 <div class="text-[11px] font-medium">Modify</div> 32206 <div class="text-gray text-[10px] -mt-[1px]">With AI Apps</div> 32207 </div> 32208 </div> 32209 <div class="relative group/animate"> 32210 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="animate"> 32211 ${this.icon("clapperboard", "w-4.5 h-4.5")} 32212 </button> 32213 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/animate:opacity-100 transition-opacity duration-200"> 32214 <div class="text-[11px] font-medium">Animate</div> 32215 <div class="text-gray text-[10px] -mt-[1px]">Generate video</div> 32216 </div> 32217 </div> 32218 <div class="relative group/world"> 32219 <button class="cursor-pointer w-10 h-10 bg-inside rounded-lg flex items-center justify-center text-white/70 hover:text-white transition duration-300 ease" data-project-action="world"> 32220 ${this.icon("rotate-3d", "w-4.5 h-4.5")} 32221 </button> 32222 <div class="absolute left-full ml-1.5 top-1/2 -translate-y-1/2 px-2 py-1.5 bg-box rounded text-xs whitespace-nowrap opacity-0 pointer-events-none group-hover/world:opacity-100 transition-opacity duration-200"> 32223 <div class="text-[11px] font-medium">3D World</div> 32224 <div class="text-gray text-[10px] -mt-[1px]">Explore in 3D</div> 32225 </div> 32226 </div> 32227 `; 32228 node.innerHTML = ` 32229 <div class="relative h-full flex flex-col"> 32230 <!-- TOP BAR - Info label left --> 32231 <div class="absolute bottom-full left-1 mb-2 flex items-end gap-1.5 max-w-[220px]"> 32232 <div class="flex items-end gap-1.5 text-[10px] text-gray min-w-0">
32233 <span class="flex-shrink-0">${this.icon(sourceIcon, "w-4 h-4")}</span> 32234 <span class="text-gray-300 font-medium truncate">${sourceLabel}</span> 32235 <span class="flex-shrink-0">${modelLabel}</span> 32236 </div> 32237 </div> 32238 32239 <!-- Hover bridge for top bar buttons --> 32240 <div class="absolute bottom-full left-0 right-0 h-6 pointer-events-auto z-0"></div> 32241 32242 <!-- Top bar right - delete button --> 32243 <div class="absolute bottom-full right-1 mb-1 flex items-center gap-2 opacity-0 translate-y-2 -translate-x-3 scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-10" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 32244 <button class="cursor-pointer p-1 text-gray/50 hover:text-white transition duration-300 ease" data-project-delete="${nodeData.uuid}"> 32245 ${this.icon("x", "w-4.5 h-4.5")} 32246 </button> 32247 </div> 32248 32249 <!-- Media container --> 32250 <div class="w-full flex-1 rounded-md overflow-hidden relative z-10"> 32251 ${mediaEl} 32252 ${isWorld && nodeData.world_id ? ` 32253 <div class="absolute inset-0 bg-black/20 flex items-center justify-center z-20 cursor-pointer" data-project-action="explore-world"> 32254 <button class="px-4 py-2 rounded-lg text-white text-xs font-medium flex items-center gap-2 transition cursor-pointer" style="background: rgba(255,255,255,0.12); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.15);"> 32255 ${this.icon("rotate-3d", "w-4 h-4")} 32256 Explore 3D World 32257 </button> 32258 </div> 32259 ` : ""} 32260 ${canRegenerate ? ` 32261 <div class="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-20" style="background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent);"> 32262 <div class="flex items-end justify-end min-h-6"> 32263 <div class="relative group/regen"> 32264 <button class="cursor-pointer w-6 h-6 bg-white/10 text-white hover:bg-white/20 rounded-full flex items-center justify-center transition duration-300 ease pointer-events-auto" data-project-action="regenerate"> 32265 ${this.icon("refresh-cw", "w-3.5 h-3.5")} 32266 </button> 32267 <span class="absolute bottom-full right-0 mb-1 px-2 py-1 bg-box text-white text-[10px] rounded whitespace-nowrap opacity-0 pointer-events-none group-hover/regen:opacity-100 transition-opacity duration-200">Regenerate</span> 32268 </div> 32269 </div> 32270 </div> 32271 ` : ""} 32272 </div> 32273 32274 ${isGrid || isWorld ? "" : ` 32275 <!-- Hover bridge for action bar --> 32276 <div class="absolute left-full top-0 bottom-0 w-14 pointer-events-auto z-0"></div> 32277 32278 <!-- ACTION BAR - right side on hover --> 32279 <div class="absolute left-full top-1/2 -translate-y-1/2 ml-2.5 flex flex-col gap-2 opacity-0 -translate-x-[8px] scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-0" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); filter: drop-shadow(0 0 20px rgb(0, 0, 0));"> 32280 ${actionBarHtml} 32281 </div> 32282 32283 <!-- BOTTOM BAR - below node on hover --> 32284 <div class="absolute top-full left-0 right-0 flex justify-center gap-3 select-none opacity-0 translate-y-[-8px] scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:scale-100 group-hover:pointer-events-auto z-50" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 32285 <button class="cursor-pointer py-3 px-2 text-xs ${isSelected ? "text-active" : "text-gray/50"} hover:text-white transition duration-300 ease flex items-center gap-1.5" data-project-action="select-shot">
32286 <span>${this.icon("check", "w-3.5 h-3.5")}</span> 32287 <span class="font-medium" data-select-label>${isSelected ? "SELECTED" : "SELECT SHOT"}</span> 32288 </button> 32289 <button class="cursor-pointer py-3 px-2 text-xs text-gray/50 hover:text-white transition duration-300 ease flex items-center gap-1.5" data-project-action="duplicate-to-shot"> 32290 <span class="font-medium">DUPLICATE TO...</span> 32291 <span>${this.icon("arrow-right", "w-3.5 h-3.5")}</span> 32292 </button> 32293 </div> 32294 `} 32295 </div> 32296 `; 32297 this.nodesContainer.appendChild(node); 32298 this.bindNodeDrag(node, nodeData.uuid); 32299 const isVideoNode = nodeData.node_type === "video"; 32300 const editBtn = node.querySelector('[data-project-action="edit"]'); 32301 if (editBtn) { 32302 editBtn.addEventListener("click", (e) => { 32303 e.stopPropagation(); 32304 this.openEditBar(nodeData, isVideoNode ? "video" : "image"); 32305 }); 32306 } 32307 const appsBtn = node.querySelector('[data-project-action="apps"]'); 32308 if (appsBtn) { 32309 appsBtn.addEventListener("click", (e) => { 32310 e.stopPropagation(); 32311 this.openAppsModal(nodeData); 32312 }); 32313 } 32314 const animateBtn = node.querySelector('[data-project-action="animate"]'); 32315 if (animateBtn) { 32316 animateBtn.addEventListener("click", (e) => { 32317 e.stopPropagation(); 32318 this.openEditBar(nodeData, "video"); 32319 }); 32320 } 32321 const worldBtn = node.querySelector('[data-project-action="world"]'); 32322 if (worldBtn) { 32323 worldBtn.addEventListener("click", (e) => { 32324 e.stopPropagation(); 32325 this.openWorldModal(nodeData); 32326 }); 32327 } 32328 const upscaleBtn = node.querySelector('[data-project-action="upscale"]'); 32329 if (upscaleBtn) { 32330 upscaleBtn.addEventListener("click", (e) => { 32331 e.stopPropagation(); 32332 this.handleUpscaleClick(nodeData); 32333 }); 32334 } 32335 const extractFrameBtn = node.querySelector('[data-project-action="extract-frame"]'); 32336 if (extractFrameBtn) { 32337 extractFrameBtn.addEventListener("click", (e) => { 32338 e.stopPropagation(); 32339 this.handleExtractFrame(nodeData); 32340 }); 32341 } 32342 const regenBtn = node.querySelector('[data-project-action="regenerate"]'); 32343 if (regenBtn) { 32344 regenBtn.addEventListener("click", (e) => { 32345 e.stopPropagation(); 32346 this.handleRegenerate(nodeData); 32347 }); 32348 } 32349 const duplicateBtn = node.querySelector('[data-project-action="duplicate-to-shot"]'); 32350 if (duplicateBtn) { 32351 duplicateBtn.addEventListener("click", (e) => { 32352 e.stopPropagation(); 32353 this.openDuplicateToShotModal(nodeData); 32354 }); 32355 } 32356 const worldOverlay = node.querySelector('[data-project-action="explore-world"]'); 32357 if (worldOverlay) { 32358 worldOverlay.addEventListener("click", (e) => { 32359 e.stopPropagation(); 32360 if (this._didDrag) { 32361 this._didDrag = false; 32362 return; 32363 } 32364 this.openWorldViewer(nodeData.world_id, nodeData.source_url, nodeData); 32365 }); 32366 } 32367 const mediaClickEl = node.querySelector("[data-project-media]"); 32368 if (mediaClickEl) { 32369 if (nodeData.node_type === "world") { 32370 } else if (nodeData.media_id) { 32371 mediaClickEl.addEventListener("click", (e) => { 32372 e.stopPropagation(); 32373 if (this._didDrag) { 32374 this._didDrag = false; 32375 return; 32376 } 32377 this.openMediaViewer(nodeData.media_id); 32378 }); 32379 } 32380 } 32381 const selectBtn = node.querySelector('[data-project-action="select-shot"]'); 32382 if (selectBtn && nodeData.media_id) { 32383 selectBtn.addEventListener("click", (e) => { 32384 e.stopPropagation(); 32385 this.selectMediaForShot(nodeData.media_id, node); 32386 }); 32387 }
32388 const deleteBtn = node.querySelector(`[data-project-delete="${nodeData.uuid}"]`); 32389 if (deleteBtn) { 32390 deleteBtn.addEventListener("click", (e) => { 32391 e.stopPropagation(); 32392 this.confirmDeleteNode(nodeData.uuid); 32393 }); 32394 } 32395 if (nodeData.position_x == null) { 32396 this.saveNodePosition(nodeData.uuid, posX, posY); 32397 } 32398 }, 32399 renderEmptyNode() { 32400 const cx = 1e4; 32401 const cy = 1e4; 32402 this.emptyNodeCenter = { x: cx, y: cy }; 32403 const node = document.createElement("div"); 32404 node.className = "project-node"; 32405 node.style.cssText = ` 32406 position: absolute; 32407 left: ${cx}px; 32408 top: ${cy}px; 32409 transform: translate(-50%, -50%); 32410 `; 32411 node.innerHTML = ` 32412 <div class="bg-box rounded-md shadow-lg p-2"> 32413 <div class="grid grid-cols-4 gap-2"> 32414 <button data-empty-action="upload" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 32415 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 32416 <path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> 32417 </svg> 32418 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Upload</span> 32419 </button> 32420 <button data-empty-action="element" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 32421 <svg class="w-3.5 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 32422 <circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-4 8"/> 32423 </svg> 32424 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Element</span> 32425 </button> 32426 <button data-empty-action="favs" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 32427 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 32428 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 32429 </svg> 32430 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Favs</span> 32431 </button> 32432 <button data-empty-action="create" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 32433 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 32434 <path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> 32435 </svg> 32436 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Create</span> 32437 </button> 32438 </div> 32439 </div> 32440 `; 32441 this.nodesContainer.appendChild(node); 32442 this.animateToZoom(cx, cy, 1, 92); 32443 const uploadBtn = node.querySelector('[data-empty-action="upload"]'); 32444 if (uploadBtn) { 32445 uploadBtn.addEventListener("click", (e) => { 32446 e.stopPropagation(); 32447 this.handleUploadClick(); 32448 }); 32449 } 32450 const createBtn = node.querySelector('[data-empty-action="create"]'); 32451 if (createBtn) { 32452 createBtn.addEventListener("click", (e) => { 32453 e.stopPropagation(); 32454 this.openEditBar(null, "image"); 32455 }); 32456 } 32457 const elementBtn = node.querySelector('[data-empty-action="element"]'); 32458 if (elementBtn) { 32459 elementBtn.addEventListener("click", (e) => { 32460 e.stopPropagation(); 32461 this.openElementsPicker(); 32462 }); 32463 } 32464 const favsBtn = node.querySelector('[data-empty-action="favs"]'); 32465 if (favsBtn) { 32466 favsBtn.addEventListener("click", (e) => { 32467 e.stopPropagation(); 32468 this.openFavsPicker(); 32469 }); 32470 } 32471 const viewportCenterX = this.container.clientWidth / 2; 32472 const viewportCenterY = (this.container.clientHeight + 60) / 2; 32473 this.panX = viewportCenterX - cx * this.zoom; 32474 this.panY = viewportCenterY - cy * this.zoom; 32475 this.updateTransform(); 32476 }, 32477 // --- Drag & Drop --- 32478 bindNodeDrag(nodeEl, nodeUuid) { 32479 let isDragging = false;
32480 let startMouseX = 0; 32481 let startMouseY = 0; 32482 let startNodeX = 0; 32483 let startNodeY = 0; 32484 const onMouseDown = (e) => { 32485 if (e.button !== 0) return; 32486 if (e.shiftKey) { 32487 e.preventDefault(); 32488 e.stopPropagation(); 32489 this.toggleNodeSelection(nodeUuid); 32490 return; 32491 } 32492 if (e.target.closest("button, a, input, [data-project-action], [data-project-delete]")) return; 32493 isDragging = true; 32494 startMouseX = e.clientX; 32495 startMouseY = e.clientY; 32496 startNodeX = parseFloat(nodeEl.style.left) || 0; 32497 startNodeY = parseFloat(nodeEl.style.top) || 0; 32498 nodeEl.style.cursor = "grabbing"; 32499 nodeEl.style.zIndex = "100"; 32500 e.preventDefault(); 32501 e.stopPropagation(); 32502 }; 32503 const onMouseMove = (e) => { 32504 if (!isDragging) return; 32505 const deltaX = (e.clientX - startMouseX) / this.zoom; 32506 const deltaY = (e.clientY - startMouseY) / this.zoom; 32507 if (!this._didDrag && (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3)) { 32508 this._didDrag = true; 32509 } 32510 const hasSelectedGroups = this.selectedGroupUuids && this.selectedGroupUuids.size > 0; 32511 const isSelected = this.selectedNodeUuids && this.selectedNodeUuids.has(nodeUuid) && (this.selectedNodeUuids.size > 1 || hasSelectedGroups); 32512 if (isSelected) { 32513 if (!this._multiDragStarts) { 32514 this._multiDragStarts = /* @__PURE__ */ new Map(); 32515 this.selectedNodeUuids.forEach((uuid2) => { 32516 const el = this.nodesContainer.querySelector(`.project-node[data-
32516project-node-id="${uuid2}"]:not([data-in-group])`); 32517 if (el) this._multiDragStarts.set(uuid2, { el, x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, type: "node" }); 32518 }); 32519 if (this.selectedGroupUuids) { 32520 this.selectedGroupUuids.forEach((uuid2) => { 32521 const el = this.nodesContainer.querySelector(`[data-project-group-id="${uuid2}"]`); 32522 if (el) this._multiDragStarts.set("group_" + uuid2, { el, x: parseFloat(el.style.left) || 0, y: parseFloat(el.style.top) || 0, type: "group", groupUuid: uuid2 }); 32523 }); 32524 } 32525 } 32526 this._multiDragStarts.forEach(({ el, x, y }) => { 32527 el.style.left = `${Math.max(0, x + deltaX)}px`; 32528 el.style.top = `${Math.max(0, y + deltaY)}px`; 32529 }); 32530 } else { 32531 const newX = startNodeX + deltaX; 32532 const newY = startNodeY + deltaY; 32533 const clampedX = Math.max(0, Math.min(2e4 - nodeEl.offsetWidth, newX)); 32534 const clampedY = Math.max(0, Math.min(2e4 - nodeEl.offsetHeight, newY)); 32535 nodeEl.style.left = `${clampedX}px`; 32536 nodeEl.style.top = `${clampedY}px`; 32537 } 32538 }; 32539 const onMouseUp = () => { 32540 if (!isDragging) return; 32541 isDragging = false; 32542 nodeEl.style.cursor = "grab"; 32543 nodeEl.style.zIndex = ""; 32544 if (this._multiDragStarts) { 32545 this._multiDragStarts.forEach(({ el, x, y, type, groupUuid }, key) => { 32546 const { x: snappedX, y: snappedY } = this.snapElementToGrid(el); 32547 if (snappedX !== x || snappedY !== y) { 32548 if (type === "group") { 32549 this.updateGroupData(groupUuid, { position_x: snappedX, position_y: snappedY }); 32550 } else { 32551 this.saveNodePosition(key, snappedX, snappedY); 32552 } 32553 } 32554 }); 32555 this._multiDragStarts = null; 32556 } else { 32557 const { x: snappedX, y: snappedY } = this.snapElementToGrid(nodeEl); 32558 if (snappedX !== startNodeX || snappedY !== startNodeY) { 32559 this.saveNodePosition(nodeUuid, snappedX, snappedY); 32560 } 32561 } 32562 }; 32563 nodeEl.addEventListener("mousedown", onMouseDown); 32564 this.addListener(window, "mousemove", onMouseMove); 32565 this.addListener(window, "mouseup", onMouseUp); 32566 }, 32567 snapElementToGrid(el) { 32568 const rawX = parseFloat(el.style.left) || 0; 32569 const rawY = parseFloat(el.style.top) || 0; 32570 const snappedX = Math.round(rawX / this.GRID_SNAP) * this.GRID_SNAP; 32571 const snappedY = Math.round(rawY / this.GRID_SNAP) * this.GRID_SNAP; 32572 if (snappedX === rawX && snappedY === rawY) return { x: snappedX, y: snappedY }; 32573 el.style.transition = "left 0.15s ease, top 0.15s ease"; 32574 el.style.left = `${snappedX}px`; 32575 el.style.top = `${snappedY}px`; 32576 const cleanup = () => { 32577 el.style.transition = ""; 32578 el.removeEventListener("transitionend", cleanup); 32579 }; 32580 el.addEventListener("transitionend", cleanup); 32581 return { x: snappedX, y: snappedY }; 32582 }, 32583 saveNodePosition(nodeUuid, x, y) { 32584 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32585 fetch(`${this.baseUrl()}/nodes/${nodeUuid}/position`, { 32586 method: "PATCH", 32587 headers: { 32588 "Content-Type": "application/json", 32589 "X-CSRF-Token": csrfToken 32590 }, 32591 body: JSON.stringify({ position_x: x, position_y: y }) 32592 }).catch((err) => console.error("[ProjectCanvas] Failed to save position:", err)); 32593 }, 32594 // --- Processing node --- 32595 renderProcessingNode(nodeData) { 32596 this.revealFacesDependents(); 32597 const nodeWidth = this.getNodeWidthForRatio(nodeData.ratio); 32598 const nodeHeight = this.getNodeHeight(nodeData.ratio); 32599 let defaultX, defaultY; 32600 if (this.nextNodePosition) { 32601 defaultX = this.nextNodePosition.x; 32602 defaultY = this.nextNodePosition.y; 32603 this.nextNodePosition = null; 32604 } else { 32605 defaultX = (this.emptyNodeCenter?.x || 1e4) - nodeWidth / 2; 32606 defaultY = (this.emptyNodeCenter?.y || 1e4) - nodeHeight / 2; 32607 } 32608 const posX = nodeData.position_x != null ? nodeData.position_x : defaultX; 32609 const posY = nodeData.position_y != null ? nodeData.position_y : defaultY; 32610 const emptyNodes = this.nodesContainer.querySelectorAll(".project-node:not([data-project-node-id])");
32611 emptyNodes.forEach((el) => el.remove()); 32612 const existing = this.nodesContainer.querySelector(`[data-project-node-id="${nodeData.uuid}"]`); 32613 if (existing) existing.remove(); 32614 const isUpscale = nodeData.model === "seedvr2"; 32615 const label = isUpscale ? "Upscaling" : "Processing"; 32616 const iconName = isUpscale ? "maximize-2" : "sparkles"; 32617 const node = document.createElement("div"); 32618 node.className = "project-node group"; 32619 node.dataset.projectNodeId = nodeData.uuid; 32620 node.dataset.projectProcessing = "true"; 32621 node.style.cssText = ` 32622 position: absolute; 32623 left: ${posX}px; 32624 top: ${posY}px; 32625 width: ${nodeWidth}px; 32626 height: ${nodeHeight}px; 32627 `; 32628 node.innerHTML = ` 32629 <!-- Top bar - info label left --> 32630 <div class="absolute bottom-full left-1 mb-2 flex items-end gap-1.5"> 32631 <div class="flex items-end gap-1.5 text-[10px] text-gray min-w-0"> 32632 <span class="flex-shrink-0">${this.icon(iconName, "w-4 h-4")}</span> 32633 <span class="text-gray-300 font-medium">${label}</span> 32634 </div> 32635 </div> 32636 32637 <!-- Hover bridge for top bar buttons --> 32638 <div class="absolute bottom-full left-0 right-0 h-6 pointer-events-auto z-0"></div> 32639 32640 <!-- Top bar right - delete button --> 32641 <div class="absolute bottom-full right-1 mb-1 flex items-center gap-2 opacity-0 translate-y-2 -translate-x-3 scale-90 transition-all duration-300 ease pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:translate-x-0 group-hover:scale-100 group-hover:pointer-events-auto z-10" style="transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);"> 32642 <button class="cursor-pointer p-1 text-gray/50 hover:text-white transition duration-300 ease" data-project-delete-processing="${nodeData.uuid}"> 32643 ${this.icon("x", "w-4.5 h-4.5")} 32644 </button> 32645 </div> 32646 32647 <!-- Magic card wrapper --> 32648 <div class="magic-card w-full h-full"> 32649 <div class="magic-card-inner flex-col items-center justify-center gap-3 overflow-hidden"> 32650 <div class="relative flex items-center justify-center z-10"> 32651 <svg width="48" height="48" class="transform -rotate-90 animate-spin" style="animation-duration: 3s;"> 32652 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 0.2)" stroke-width="2.5" fill="none" /> 32653 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 1)" stroke-width="2.5" fill="none" 32654 stroke-dasharray="31.4 94.26" stroke-linecap="round" /> 32655 </svg> 32656 </div> 32657 <span class="text-[11px] text-gray z-10">${label}...</span> 32658 </div> 32659 </div> 32660 `; 32661 this.nodesContainer.appendChild(node); 32662 const centerX = posX + nodeWidth / 2; 32663 const centerY = posY + nodeHeight / 2; 32664 this.animateToZoom(centerX, centerY, 1, 92); 32665 this.bindNodeDrag(node, nodeData.uuid);
32666 const deleteBtn = node.querySelector(`[data-project-delete-processing="${nodeData.uuid}"]`); 32667 if (deleteBtn) { 32668 deleteBtn.addEventListener("click", (e) => { 32669 e.stopPropagation(); 32670 this.deleteProcessingNode(nodeData.uuid); 32671 }); 32672 } 32673 }, 32674 pollNodeStatus(nodeUuid) { 32675 if (!this._processingNodes) this._processingNodes = /* @__PURE__ */ new Set(); 32676 this._processingNodes.add(nodeUuid); 32677 this._startBatchPolling(); 32678 }, 32679 _startBatchPolling() { 32680 if (this._batchPollTimer) return; 32681 const poll = async () => { 32682 this._batchPollTimer = null; 32683 if (!this._processingNodes || this._processingNodes.size === 0) return; 32684 for (const uuid2 of [...this._processingNodes]) { 32685 if (!this.nodesContainer.querySelector(`[data-project-node-id="${uuid2}"]`)) { 32686 this._processingNodes.delete(uuid2); 32687 } 32688 } 32689 if (this._processingNodes.size === 0) return; 32690 try { 32691 const uuids = [...this._processingNodes].join(","); 32692 const response = await fetch(`${this.baseUrl()}/nodes/status?uuids=${uuids}`); 32693 if (!response.ok) return; 32694 const nodes = await response.json(); 32695 let hasGroupChange = false; 32696 for (const data of nodes) { 32697 if (data.status === "ready" && data.media_url) { 32698 this._processingNodes.delete(data.uuid); 32699 const processingNode = this.nodesContainer.querySelector(`[data-
32699project-node-id="${data.uuid}"]`); 32700 const isInGroup = processingNode?.hasAttribute("data-in-group"); 32701 if (isInGroup) { 32702 hasGroupChange = true; 32703 } else { 32704 const posX = processingNode ? parseFloat(processingNode.style.left) : null; 32705 const posY = processingNode ? parseFloat(processingNode.style.top) : null; 32706 if (processingNode) processingNode.remove(); 32707 this.renderImageNode({ 32708 uuid: data.uuid, 32709 node_type: data.node_type || "image", 32710 status: "ready", 32711 source: data.source || "upload", 32712 ratio: data.ratio, 32713 model: data.model, 32714 media_url: data.media_url, 32715 thumbnail_url: data.thumbnail_url, 32716 thumb_url: data.thumb_url, 32717 media_id: data.media_id, 32718 app_name: data.app_name, 32719 world_id: data.world_id, 32720 position_x: posX, 32721 position_y: posY 32722 }); 32723 } 32724 } else if (data.status === "failed") { 32725 this._processingNodes.delete(data.uuid); 32726 const processingNode = this.nodesContainer.querySelector(`[data-
32726project-node-id="${data.uuid}"]`); 32727 if (processingNode) processingNode.remove(); 32728 if (window.Toast) window.Toast.show(data.error_message || "Generation failed", "alert"); 32729 } 32730 } 32731 if (hasGroupChange) { 32732 this.loadShot(this.currentShotUuid, { skipZoom: true }); 32733 } 32734 } catch (error) { 32735 console.error("[ProjectCanvas] Batch poll error:", error); 32736 } 32737 if (this._processingNodes.size > 0) { 32738 this._batchPollTimer = setTimeout(poll, 3e3); 32739 } 32740 }; 32741 this._batchPollTimer = setTimeout(poll, 1e3); 32742 }, 32743 // --- Delete nodes --- 32744 confirmDeleteNode(nodeUuid) { 32745 const modal = document.createElement("div"); 32746 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 32747 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10000;"; 32748 modal.innerHTML = ` 32749 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 32750 <div class="flex items-start justify-between mb-3"> 32751 <h2 class="text-14 font-bold text-white">Delete Node</h2> 32752 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 32753 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 32754 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 32755 </svg> 32756 </button> 32757 </div> 32758 <p class="text-neutral-300 mb-6 text-sm">Are you sure you want to delete this node?<br>This action cannot be undone.</p> 32759 <div class="flex gap-3 justify-end"> 32760 <button data-modal-close class="btn-secondary">Cancel</button> 32761 <button data-delete-confirm class="btn-danger">Delete</button> 32762 </div> 32763 </div> 32764 `; 32765 document.body.appendChild(modal); 32766 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 32767 btn.addEventListener("click", () => modal.remove()); 32768 }); 32769 modal.addEventListener("click", (e) => { 32770 if (e.target === modal) modal.remove(); 32771 }); 32772 modal.querySelector("[data-delete-confirm]").addEventListener("click", () => { 32773 modal.remove(); 32774 if (this.currentShot) { 32775 const deletedNode = this.currentShot.nodes.find((n) => n.uuid === nodeUuid); 32776 if (deletedNode && deletedNode.media_id && deletedNode.media_id === this.currentShot.selected_media_id) { 32777 this.unselectShot(this.currentShotUuid); 32778 } 32779 this.currentShot.nodes = this.currentShot.nodes.filter((n) => n.uuid !== nodeUuid); 32780 } 32781 this.deleteProcessingNode(nodeUuid); 32782 if (this.currentShot && this.currentShot.nodes.length === 0) { 32783 this.renderEmptyNode(); 32784 this.updateShotStatusBar(this.currentShotUuid, "draft"); 32785 } 32786 }); 32787 }, 32788 deleteProcessingNode(nodeUuid) { 32789 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || "";
32790 const el = this.nodesContainer.querySelector(`[data-project-node-id="${nodeUuid}"]`); 32791 if (el) el.remove(); 32792 fetch(`${this.baseUrl()}/nodes/${nodeUuid}`, { 32793 method: "DELETE", 32794 headers: { "X-CSRF-Token": csrfToken } 32795 }).catch((err) => console.error("[ProjectCanvas] Delete error:", err)); 32796 }, 32797 // --- Add library item as node --- 32798 async addLibraryNode(mediaUrl, fileHash, posX, posY, isVideo) { 32799 const shotUuid = this.currentShotUuid; 32800 if (!shotUuid) return; 32801 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 32802 const emptyNodes = this.nodesContainer.querySelectorAll(".project-node:not([data-project-node-id])"); 32803 emptyNodes.forEach((el) => el.remove()); 32804 try { 32805 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/add-library`, { 32806 method: "POST", 32807 headers: { 32808 "Content-Type": "application/json", 32809 "X-CSRF-Token": csrfToken 32810 }, 32811 body: JSON.stringify(Object.assign( 32812 { media_url: mediaUrl, file_hash: fileHash, is_video: !!isVideo }, 32813 posX != null && posY != null ? { position_x: posX, position_y: posY } : {} 32814 )) 32815 }); 32816 if (!response.ok) { 32817 if (window.Toast) window.Toast.show("Failed to add library item", "alert"); 32818 return; 32819 } 32820 const data = await response.json(); 32821 if (data.success && data.node) { 32822 this.renderImageNode(data.node); 32823 const nw = this.getNodeWidthForRatio(data.node.ratio); 32824 const nh = this.getNodeHeight(data.node.ratio); 32825 const nx = data.node.position_x != null ? data.node.position_x : parseFloat(this.nodesContainer.querySelector(`[data-
32825project-node-id="${data.node.uuid}"]`)?.style.left || 0); 32826 const ny = data.node.position_y != null ? data.node.position_y : parseFloat(this.nodesContainer.querySelector(`[data-project-node-id="${data.node.uuid}"]`)?.style.top || 0); 32827 this.animateToZoom(nx + nw / 2, ny + nh / 2, 1, 92); 32828 if (data.shot_status) { 32829 this.updateShotStatusBar(this.currentShotUuid, data.shot_status); 32830 } 32831 } 32832 } catch (error) { 32833 console.error("[ProjectCanvas] Add library error:", error); 32834 if (window.Toast) window.Toast.show("Failed to add library item", "alert"); 32835 } 32836 }, 32837 // --- Elements picker --- 32838 async openElementsPicker() { 32839 if (!window.Modal) return; 32840 const handler = (e) => { 32841 document.removeEventListener("items-selected", handler); 32842 const { hash, url, isVideo } = e.detail; 32843 const pos = this.nextNodePosition; 32844 this.nextNodePosition = null; 32845 this.addLibraryNode(url, hash, pos?.x ?? null, pos?.y ?? null, isVideo); 32846 }; 32847 document.addEventListener("items-selected", handler); 32848 window.Modal.showElementsPicker(); 32849 }, 32850 // --- Favs picker --- 32851 async openFavsPicker() { 32852 const handler = (e) => { 32853 document.removeEventListener("items-selected", handler); 32854 const { hash, url, isVideo } = e.detail; 32855 const pos = this.nextNodePosition; 32856 this.nextNodePosition = null; 32857 this.addLibraryNode(url, hash, pos?.x ?? null, pos?.y ?? null, isVideo); 32858 }; 32859 document.addEventListener("items-selected", handler); 32860 this._openFavsModal(); 32861 }, 32862 // --- Upload handling --- 32863 handleUploadClick() { 32864 const fileInput = document.createElement("input"); 32865 fileInput.type = "file"; 32866 fileInput.accept = "image/jpeg,image/jpg,image/png,image/webp,image/avif"; 32867 fileInput.multiple = false; 32868 fileInput.onchange = (e) => { 32869 const file = e.target.files[0]; 32870 if (file) this.processUpload(file); 32871 }; 32872 fileInput.click(); 32873 }, 32874 async processUpload(file) { 32875 const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/avif"]; 32876 if (!validTypes.includes(file.type)) { 32877 if (window.Toast) window.Toast.show("Only JPG, PNG, WebP, and AVIF images are allowed", "alert"); 32878 return; 32879 } 32880 if (file.size > 20 * 1024 * 1024) { 32881 if (window.Toast) window.Toast.show("File must be under 20MB", "alert"); 32882 return; 32883 } 32884 let uploadFile = file; 32885 if (file.type === "image/webp" || file.type === "image/avif") { 32886 try { 32887 uploadFile = await this.convertImageToPNG(file); 32888 } catch (error) { 32889 console.error("[ProjectCanvas] Conversion error:", error); 32890 if (window.Toast) window.Toast.show("Failed to convert image", "alert"); 32891 return; 32892 } 32893 } 32894 const dims = await this.extractImageDimensions(uploadFile); 32895 const ratio = dims ? this.calculateRatio(dims.width, dims.height) : "1:1"; 32896 const nodeHeight = this.getNodeHeight(ratio); 32897 this.renderLoadingNode(ratio, nodeHeight); 32898 try { 32899 const result = await this.uploadToServer(uploadFile, ratio); 32900 if (result.success) { 32901 const loadingNode = this.nodesContainer.querySelector("[data-project-loading]"); 32902 if (loadingNode) loadingNode.remove(); 32903 this.renderImageNode(result.node); 32904 if (result.shot_status) { 32905 this.updateShotStatusBar(this.currentShotUuid, result.shot_status); 32906 } 32907 } else { 32908 if (window.Toast) window.Toast.show("Upload failed", "alert"); 32909 const loadingNode = this.nodesContainer.querySelector("[data-project-loading]"); 32910 if (loadingNode) loadingNode.remove(); 32911 if (!this.nodesContainer.querySelector(".project-node[data-
32911project-node-id]")) { 32912 this.renderEmptyNode(); 32913 } 32914 } 32915 } catch (error) { 32916 console.error("[ProjectCanvas] Upload error:", error); 32917 if (window.Toast) window.Toast.show("Upload failed", "alert"); 32918 const loadingNode = this.nodesContainer.querySelector("[data-project-loading]"); 32919 if (loadingNode) loadingNode.remove(); 32920 if (!this.nodesContainer.querySelector(".project-node[data-project-node-id]")) { 32921 this.renderEmptyNode(); 32922 } 32923 } 32924 }, 32925 convertImageToPNG(file) { 32926 return new Promise((resolve, reject) => { 32927 const img = new Image(); 32928 const url = URL.createObjectURL(file); 32929 img.onload = () => { 32930 try { 32931 const canvas = document.createElement("canvas"); 32932 canvas.width = img.width; 32933 canvas.height = img.height; 32934 const ctx = canvas.getContext("2d"); 32935 ctx.drawImage(img, 0, 0); 32936 canvas.toBlob((blob) => { 32937 URL.revokeObjectURL(url); 32938 if (blob) { 32939 resolve(new File([blob], file.name.replace(/\.(webp|avif)$/i, ".png"), { type: "image/png", lastModified: Date.now() })); 32940 } else { 32941 reject(new Error("Failed to convert image to PNG")); 32942 } 32943 }, "image/png", 0.95); 32944 } catch (error) { 32945 URL.revokeObjectURL(url); 32946 reject(error); 32947 } 32948 }; 32949 img.onerror = () => { 32950 URL.revokeObjectURL(url); 32951 reject(new Error("Failed to load image")); 32952 }; 32953 img.src = url; 32954 }); 32955 }, 32956 extractImageDimensions(file) { 32957 return new Promise((resolve) => { 32958 const img = new Image(); 32959 const url = URL.createObjectURL(file); 32960 img.onload = () => { 32961 URL.revokeObjectURL(url); 32962 resolve({ width: img.width, height: img.height }); 32963 }; 32964 img.onerror = () => { 32965 URL.revokeObjectURL(url); 32966 resolve(null); 32967 }; 32968 img.src = url; 32969 }); 32970 }, 32971 calculateRatio(width, height) { 32972 if (!width || !height) return "1:1"; 32973 const ratioFloat = width / height; 32974 const commonRatios = [ 32975 { target: 16 / 9, label: "16:9" }, 32976 { target: 9 / 16, label: "9:16" }, 32977 { target: 4 / 3, label: "4:3" }, 32978 { target: 3 / 4, label: "3:4" }, 32979 { target: 1, label: "1:1" } 32980 ]; 32981 for (const { target, label } of commonRatios) { 32982 if (Math.abs(ratioFloat - target) / target < 0.02) return label; 32983 } 32984 const gcd = (a, b) => b === 0 ? a : gcd(b, a % b); 32985 const divisor = gcd(width, height); 32986 return `${width / divisor}:${height / divisor}`; 32987 }, 32988 getNodeWidthForRatio(ratio) { 32989 if (!ratio) return 300; 32990 const [w, h] = ratio.split(":").map(Number); 32991 if (!w || !h) return 300; 32992 const baseHeight = w > h && ratio === "16:9" ? 400 : 300; 32993 return w >= h ? Math.round(baseHeight * w / h) : baseHeight; 32994 }, 32995 getNodeHeight(ratio) { 32996 if (!ratio) return 300; 32997 const [w, h] = ratio.split(":").map(Number); 32998 if (!w || !h) return 300; 32999 if (ratio === "16:9") return 400; 33000 return h >= w ? Math.round(300 * h / w) : 300; 33001 }, 33002 // --- Loading node (magic-card style) --- 33003 renderLoadingNode(ratio, nodeHeight) { 33004 const nodeWidth = this.getNodeWidthForRatio(ratio); 33005 let posX, posY; 33006 if (this.nextNodePosition) { 33007 posX = this.nextNodePosition.x; 33008 posY = this.nextNodePosition.y; 33009 } else { 33010 const viewportCenter = this.getViewportCenter(); 33011 const cx = this.emptyNodeCenter?.x || viewportCenter.x; 33012 const cy = this.emptyNodeCenter?.y || viewportCenter.y; 33013 posX = cx - nodeWidth / 2; 33014 posY = cy - nodeHeight / 2; 33015 } 33016 const emptyNodes = this.nodesContainer.querySelectorAll(".project-node:not([data-project-node-id])"); 33017 emptyNodes.forEach((el) => el.remove()); 33018 const node = document.createElement("div"); 33019 node.className = "project-node group"; 33020 node.dataset.projectLoading = "true"; 33021 node.style.cssText = ` 33022 position: absolute; 33023 left: ${posX}px; 33024 top: ${posY}px; 33025 width: ${nodeWidth}px; 33026 height: ${nodeHeight}px; 33027 `; 33028 node.innerHTML = ` 33029 <!-- Top bar - info label --> 33030 <div class="absolute bottom-full left-1 mb-2 flex items-end gap-1.5"> 33031 <div class="flex items-end gap-1.5 text-[10px] text-gray min-w-0">
33032 <span class="flex-shrink-0">${this.icon("sparkles", "w-4 h-4")}</span> 33033 <span class="text-gray-300 font-medium">Uploading</span> 33034 <span data-upload-progress-label>0%</span> 33035 </div> 33036 </div> 33037 33038 <!-- Magic card wrapper --> 33039 <div class="magic-card w-full h-full"> 33040 <div class="magic-card-inner flex-col items-center justify-center gap-3 overflow-hidden"> 33041 <!-- Progress fill --> 33042 <div class="absolute bottom-0 left-0 right-0 bg-gradient-to-r from-active/10 to-indigo-400/10 transition-all duration-500 ease" data-upload-progress-fill style="height: 0%;"></div> 33043 33044 <div class="relative flex items-center justify-center z-10"> 33045 <svg width="48" height="48" class="transform -rotate-90"> 33046 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 0.2)" stroke-width="2.5" fill="none" /> 33047 <circle cx="24" cy="24" r="20" stroke="rgba(99, 102, 241, 1)" stroke-width="2.5" fill="none" 33048 stroke-dasharray="125.66" stroke-dashoffset="125.66" stroke-linecap="round" data-upload-progress-circle /> 33049 </svg> 33050 <div class="absolute inset-0 flex items-center justify-center"> 33051 <span class="text-xs font-medium text-indigo-400" data-upload-progress-text>0%</span> 33052 </div> 33053 </div> 33054 <span class="text-[11px] text-gray z-10">Uploading...</span> 33055 </div> 33056 </div> 33057 `; 33058 this.nodesContainer.appendChild(node); 33059 const centerX = posX + nodeWidth / 2; 33060 const centerY = posY + nodeHeight / 2; 33061 this.animateToZoom(centerX, centerY, 1, 92); 33062 }, 33063 updateUploadProgress(percentage) { 33064 const circle = this.nodesContainer.querySelector("[data-upload-progress-circle]"); 33065 const text = this.nodesContainer.querySelector("[data-upload-progress-text]"); 33066 const label = this.nodesContainer.querySelector("[data-upload-progress-label]"); 33067 const fill = this.nodesContainer.querySelector("[data-upload-progress-fill]"); 33068 if (circle) { 33069 const circumference = 125.66; 33070 const offset = circumference - circumference * percentage / 100; 33071 circle.style.strokeDashoffset = offset; 33072 } 33073 const rounded = Math.round(percentage); 33074 if (text) text.textContent = `${rounded}%`; 33075 if (label) label.textContent = `${rounded}%`; 33076 if (fill) fill.style.height = `${rounded}%`; 33077 }, 33078 // --- Upload to server --- 33079 uploadToServer(file, ratio) { 33080 const shotUuid = this.currentShotUuid; 33081 if (!shotUuid) return Promise.reject(new Error("Missing context")); 33082 const formData = new FormData(); 33083 formData.append("file", file); 33084 formData.append("ratio", ratio); 33085 const csrfToken = document.querySelector('[name="csrf-token"]'); 33086 return new Promise((resolve, reject) => { 33087 const xhr = new XMLHttpRequest(); 33088 xhr.upload.addEventListener("progress", (e) => { 33089 if (e.lengthComputable) { 33090 const percent = Math.round(e.loaded / e.total * 100); 33091 this.updateUploadProgress(percent); 33092 } 33093 }); 33094 xhr.addEventListener("load", () => { 33095 if (xhr.status >= 200 && xhr.status < 300) { 33096 try { 33097 resolve(JSON.parse(xhr.response)); 33098 } catch { 33099 reject(new Error("Invalid response")); 33100 } 33101 } else { 33102 reject(new Error(`HTTP ${xhr.status}`)); 33103 } 33104 }); 33105 xhr.addEventListener("error", () => reject(new Error("Network error"))); 33106 xhr.open("POST", `${this.baseUrl()}/shots/${shotUuid}/upload`); 33107 if (csrfToken) xhr.setRequestHeader("X-CSRF-Token", csrfToken.content); 33108 xhr.send(formData); 33109 }); 33110 }, 33111 // --- World modal --- 33112 async openWorldModal(nodeData) { 33113 try { 33114 const response = await fetch("/modal/world"); 33115 if (!response.ok) throw new Error("Failed to load world modal"); 33116 const html = await response.text(); 33117 const existingModal = document.getElementById("world-modal"); 33118 if (existingModal) existingModal.remove(); 33119 document.body.insertAdjacentHTML("beforeend", html); 33120 const modal = document.getElementById("world-modal"); 33121 if (!modal) return; 33122 const preview = modal.querySelector("#world-modal-preview"); 33123 if (preview) preview.src = nodeData.thumbnail_url || nodeData.media_url; 33124 const promptInput = modal.querySelector("#world-modal-prompt"); 33125 if (promptInput && nodeData.prompt) promptInput.value = nodeData.prompt;
33126 if (window.Modal) window.Modal.open("world-modal"); 33127 const generateBtn = modal.querySelector("#world-modal-generate"); 33128 generateBtn.addEventListener("click", async () => { 33129 generateBtn.disabled = true; 33130 generateBtn.innerHTML = "<span>Generating...</span>"; 33131 try { 33132 const shotUuid = this.currentShotUuid; 33133 const prompt2 = promptInput?.value?.trim() || ""; 33134 const res = await fetch(`${this.baseUrl()}/shots/${shotUuid}/generate-world`, { 33135 method: "POST", 33136 headers: { 33137 "Content-Type": "application/json", 33138 "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content 33139 }, 33140 body: JSON.stringify({ 33141 source_node_uuid: nodeData.uuid, 33142 prompt: prompt2 33143 }) 33144 }); 33145 const data = await res.json(); 33146 if (data.error) { 33147 if (window.Toast) window.Toast.show(data.error, "alert"); 33148 generateBtn.disabled = false; 33149 generateBtn.innerHTML = "<span>Generate 3D World</span>"; 33150 return; 33151 } 33152 if (window.Modal) window.Modal.close("world-modal"); 33153 setTimeout(() => modal.remove(), 300); 33154 this.renderProcessingNode(data.node); 33155 this.pollNodeStatus(data.node.uuid); 33156 } catch (err) { 33157 if (window.Toast) window.Toast.show("Failed to generate world", "alert"); 33158 generateBtn.disabled = false; 33159 generateBtn.innerHTML = "<span>Generate 3D World</span>"; 33160 } 33161 }); 33162 } catch (error) { 33163 console.error("[ProjectCanvas] Error loading world modal:", error); 33164 if (window.Toast) window.Toast.show("Failed to load world modal", "alert"); 33165 } 33166 }, 33167 openWorldViewer(worldId, sourceUrl, nodeData = {}) { 33168 const existing = document.getElementById("world-viewer-modal"); 33169 if (existing) existing.remove(); 33170 if (!document.querySelector('script[type="importmap"]')) { 33171 const map = document.createElement("script"); 33172 map.type = "importmap"; 33173 map.textContent = JSON.stringify({ imports: { 33174 "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js", 33175 "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/", 33176 "@sparkjsdev/spark": "https://sparkjs.dev/releases/spark/0.1.10/spark.module.js" 33177 } }); 33178 document.head.appendChild(map); 33179 } 33180 const overlay = document.createElement("div"); 33181 overlay.id = "world-viewer-modal"; 33182 overlay.setAttribute("data-modal", "world-viewer-modal"); 33183 overlay.className = "fixed inset-0 bg-black/90 flex justify-center items-center hidden modal-base p-4"; 33184 overlay.style.zIndex = "9999"; 33185 overlay.innerHTML = ` 33186 <div class="bg-box rounded-lg max-w-5xl w-full modal-content shadow-xl overflow-hidden relative"> 33187 <div class="flex items-center justify-between px-5 py-3"> 33188 <div class="flex items-center gap-4"> 33189 <h2 class="text-sm font-bold text-white">3D World</h2> 33190 <div class="flex items-center gap-3 text-[10px] text-gray"> 33191 <span class="flex items-center gap-1"><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">W</kbd><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">A</kbd><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">S</kbd><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">D</kbd> <span class="text-gray/50">move</span></span> 33192 <span class="flex items-center gap-1"><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">Space</kbd> <span class="text-gray/50">up</span></span>
33193 <span class="flex items-center gap-1"><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">Shift</kbd> <span class="text-gray/50">down</span></span> 33194 <span class="flex items-center gap-1"><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">Scroll</kbd> <span class="text-gray/50">zoom</span></span> 33195 <span class="flex items-center gap-1"><kbd class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded border border-gray/30 bg-white/5 text-[9px] font-mono text-gray-300 leading-none">Drag</kbd> <span class="text-gray/50">look</span></span> 33196 </div> 33197 </div> 33198 <div class="flex items-center gap-2"> 33199 <button id="wv-reset" class="text-[10px] text-gray hover:text-white transition cursor-pointer">Reset Position</button> 33200 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300 ml-2"> 33201 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 33202 </button> 33203 </div> 33204 </div> 33205 <div id="wv-container" class="relative" style="height: 70vh;"> 33206 <div id="wv-loading" class="absolute inset-0 flex items-center justify-center z-10"> 33207 <div class="flex items-center gap-3 text-sm text-gray"> 33208 <svg class="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 33209 <circle cx="12" cy="12" r="10" stroke-opacity="0.25"/> 33210 <path d="M12 2a10 10 0 0 1 10 10" stroke-opacity="0.75"/> 33211 </svg> 33212 <span>Loading world...</span> 33213 </div> 33214 </div> 33215 <canvas id="wv-canvas" class="w-full h-full rounded-b-lg"></canvas> 33216 <div class="absolute bottom-6 left-1/2 -translate-x-1/2 z-20"> 33217 <button id="wv-capture" class="bg-white hover:bg-white/90 text-black rounded-full px-5 py-2 text-sm font-medium transition cursor-pointer shadow-lg flex items-center gap-2"> 33218 <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg> 33219 Capture 33220 </button> 33221 <span id="wv-capture-status" class="hidden text-xs text-white/70 text-center mt-2 block"></span> 33222 </div> 33223 </div> 33224 </div> 33225 `; 33226 window.__worldViewerCtx = { 33227 worldId, 33228 sourceUrl: sourceUrl || "", 33229 captureUrl: `${this.baseUrl()}/shots/${this.currentShotUuid}/capture-world`, 33230 worldNodeUuid: nodeData.uuid || "", 33231 csrfToken: document.querySelector('meta[name="csrf-token"]')?.content || "" 33232 }; 33233 document.body.appendChild(overlay); 33234 if (window.Modal) window.Modal.open("world-viewer-modal"); 33235 const script = document.createElement("script"); 33236 script.type = "module"; 33237 script.textContent = ` 33238import * as THREE from "three"; 33239import { SplatMesh, SparkRenderer } from "@sparkjsdev/spark"; 33240 33241const worldId = "${worldId}"; 33242const container = document.getElementById('wv-container'); 33243const canvas = document.getElementById('wv-canvas'); 33244const loading = document.getElementById('wv-loading'); 33245if (!container || !canvas) throw new Error('World viewer DOM not found'); 33246 33247// Load world data 33248let worldData; 33249try { 33250 const res = await fetch('/world/result/' + worldId); 33251 worldData = await res.json(); 33252 if (worldData.error) { loading.innerHTML = '<span class="text-red-400 text-sm">' + worldData.error + '</span>'; throw new Error('stop'); } 33253} catch (err) { 33254 if (err.message !== 'stop') loading.innerHTML = '<span class="text-red-400 text-sm">' + err.message + '</span>'; 33255 throw err; 33256} 33257loading.classList.add('hidden'); 33258 33259// Three.js setup 33260const w = container.clientWidth, h = container.clientHeight; 33261const renderer = new THREE.WebGLRenderer({ canvas, antialias: false }); 33262renderer.setSize(w, h); 33263renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 33264 33265const scene = new THREE.Scene(); 33266scene.background = new THREE.Color(0x111111); 33267 33268const camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 1000); 33269camera.position.set(0, 0, 0); 33270scene.add(camera); 33271 33272const spark = new SparkRenderer({ renderer, maxStdDev: Math.sqrt(9), blurAmount: 0.3, focalAdjustment: 1.0, falloff: 1.0, maxPixelRadius: 1024, clipXY: 1.4 }); 33273camera.add(spark); 33274 33275let yaw = Math.PI, pitch = 0; 33276const initialYaw = Math.PI, initialPitch = 0; 33277function updateRot() { camera.quaternion.setFromEuler(new THREE.Euler(pitch, yaw, 0, 'YXZ')); } 33278updateRot(); 33279 33280// Mouse drag 33281let dragging = false;
33282canvas.addEventListener('mousedown', () => { dragging = true; }); 33283const onUp = () => { dragging = false; }; 33284const onMove = (e) => { if (!dragging) return; yaw += e.movementX * 0.003; pitch = Math.max(-Math.PI/2, Math.min(Math.PI/2, pitch + e.movementY * 0.003)); updateRot(); }; 33285window.addEventListener('mouseup', onUp); 33286window.addEventListener('mousemove', onMove); 33287 33288// Scroll zoom 33289canvas.addEventListener('wheel', (e) => { e.preventDefault(); const d = new THREE.Vector3(); camera.getWorldDirection(d); camera.position.addScaledVector(d, -e.deltaY * 0.002); }, { passive: false }); 33290 33291// Resize 33292const onResize = () => { const cw = container.clientWidth, ch = container.clientHeight; camera.aspect = cw / ch; camera.updateProjectionMatrix(); renderer.setSize(cw, ch); }; 33293window.addEventListener('resize', onResize); 33294 33295// Keyboard 33296const keys = {}; 33297const velocity = new THREE.Vector3(); 33298const onKD = (e) => { if (e.code === 'Space') e.preventDefault(); keys[e.code] = true; }; 33299const onKU = (e) => { keys[e.code] = false; }; 33300window.addEventListener('keydown', onKD); 33301window.addEventListener('keyup', onKU); 33302 33303// Splats 33304let currentSplat = null; 33305function loadSplat(q) { 33306 const spz = worldData.assets?.splats?.spz_urls; if (!spz) return; 33307 const url = spz[q] || spz['500k'] || spz['full_res']; if (!url) return; 33308 if (currentSplat) { scene.remove(currentSplat); currentSplat.dispose?.(); } 33309 currentSplat = new SplatMesh({ url }); currentSplat.scale.set(1, -1, 1); scene.add(currentSplat); 33310} 33311loadSplat('full_res'); 33312 33313document.getElementById('wv-reset')?.addEventListener('click', () => { camera.position.set(0,0,0); yaw = initialYaw; pitch = initialPitch; velocity.set(0,0,0); updateRot(); }); 33314 33315// Render loop 33316const accel = 0.004, fric = 0.9; 33317renderer.setAnimationLoop(() => { 33318 const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize(); 33319 const right = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize(); 33320 if (keys['ArrowUp']||keys['KeyW']) velocity.addScaledVector(fwd, accel); 33321 if (keys['ArrowDown']||keys['KeyS']) velocity.addScaledVector(fwd, -accel); 33322 if (keys['ArrowLeft']||keys['KeyA']) velocity.addScaledVector(right, -accel); 33323 if (keys['ArrowRight']||keys['KeyD']) velocity.addScaledVector(right, accel); 33324 if (keys['Space']) velocity.y += accel; 33325 if (keys['ShiftLeft']||keys['ShiftRight']) velocity.y -= accel; 33326 velocity.multiplyScalar(fric); camera.position.add(velocity); 33327 renderer.render(scene, camera); 33328}); 33329 33330// Capture button 33331const captureBtn = document.getElementById('wv-capture'); 33332const captureStatus = document.getElementById('wv-capture-status'); 33333const ctx = window.__worldViewerCtx || {}; 33334if (captureBtn && ctx.captureUrl) { 33335 captureBtn.addEventListener('click', async () => { 33336 captureBtn.disabled = true; 33337 captureBtn.innerHTML = 'Capturing...'; 33338 captureStatus.classList.remove('hidden'); 33339 captureStatus.textContent = 'Taking screenshot...'; 33340 33341 // Render at 2x for quality 33342 const cw = container.clientWidth * 2, ch = container.clientHeight * 2; 33343 renderer.setSize(cw, ch); renderer.setPixelRatio(1); 33344 renderer.render(scene, camera); 33345 const blob = await new Promise(r => canvas.toBlob(r, 'image/png')); 33346 renderer.setSize(container.clientWidth, container.clientHeight); 33347 renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 33348 33349 captureStatus.textContent = 'Uploading & generating...'; 33350 const fd = new FormData(); 33351 fd.append('screenshot', blob, 'capture.png'); 33352 fd.append('source_url', ctx.sourceUrl || worldData.assets?.imagery?.pano_url || ''); 33353 fd.append('source_node_uuid', ctx.worldNodeUuid || ''); 33354 try { 33355 const res = await fetch(ctx.captureUrl, { 33356 method: 'POST', 33357 headers: { 'X-CSRF-Token': ctx.csrfToken }, 33358 body: fd 33359 }); 33360 const data = await res.json(); 33361 if (data.error) { 33362 captureStatus.textContent = 'Error: ' + data.error; 33363 } else { 33364 captureStatus.textContent = 'Processing image...'; 33365 // Dispatch event so the canvas can render the processing node 33366 window.dispatchEvent(new CustomEvent('world-capture-node', { detail: data.node })); 33367 } 33368 } catch (err) { captureStatus.textContent = 'Error: ' + err.message; } 33369 captureBtn.disabled = false;
33370 captureBtn.innerHTML = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg> Capture'; 33371 }); 33372} 33373 33374// Cleanup on modal close 33375const overlay = document.getElementById('world-viewer-modal'); 33376const obs = new MutationObserver(() => { 33377 if (!overlay || overlay.classList.contains('hidden') || !document.body.contains(overlay)) { 33378 obs.disconnect(); 33379 renderer.setAnimationLoop(null); renderer.dispose(); 33380 if (currentSplat) currentSplat.dispose?.(); 33381 window.removeEventListener('mouseup', onUp); window.removeEventListener('mousemove', onMove); 33382 window.removeEventListener('resize', onResize); 33383 window.removeEventListener('keydown', onKD); window.removeEventListener('keyup', onKU); 33384 overlay?.remove(); 33385 } 33386}); 33387if (overlay) obs.observe(overlay, { attributes: true, attributeFilter: ['class'] }); 33388`; 33389 document.body.appendChild(script); 33390 const onCaptureNode = (e) => { 33391 const nodeData2 = e.detail; 33392 if (nodeData2) { 33393 this.renderProcessingNode(nodeData2); 33394 this.pollNodeStatus(nodeData2.uuid); 33395 } 33396 }; 33397 window.addEventListener("world-capture-node", onCaptureNode, { once: false }); 33398 const cleanupListener = new MutationObserver(() => { 33399 if (overlay.classList.contains("hidden") || !document.body.contains(overlay)) { 33400 cleanupListener.disconnect(); 33401 window.removeEventListener("world-capture-node", onCaptureNode); 33402 delete window.__worldViewerCtx; 33403 } 33404 }); 33405 cleanupListener.observe(overlay, { attributes: true, attributeFilter: ["class"] }); 33406 }, 33407 // --- Apps modal --- 33408 async openAppsModal(nodeData) { 33409 try { 33410 const response = await fetch("/modal/apps"); 33411 if (!response.ok) throw new Error("Failed to load apps modal"); 33412 const html = await response.text(); 33413 const existingModal = document.getElementById("apps-modal"); 33414 if (existingModal) existingModal.remove(); 33415 document.body.insertAdjacentHTML("beforeend", html); 33416 const modal = document.getElementById("apps-modal"); 33417 if (!modal) return; 33418 if (window.Modal) window.Modal.open("apps-modal"); 33419 modal.querySelectorAll("[data-app-slug]").forEach((card) => { 33420 card.addEventListener("click", (e) => { 33421 e.preventDefault(); 33422 const slug = card.dataset.appSlug; 33423 if (window.Modal) window.Modal.close("apps-modal"); 33424 setTimeout(() => { 33425 modal.remove(); 33426 this.openAppDetailModal(slug, nodeData); 33427 }, 250); 33428 }); 33429 }); 33430 } catch (error) { 33431 console.error("[ProjectCanvas] Error loading apps modal:", error); 33432 if (window.Toast) window.Toast.show("Failed to load apps", "alert"); 33433 } 33434 }, 33435 async openAppDetailModal(slug, nodeData) { 33436 try { 33437 const response = await fetch(`/modal/app?slug=${slug}`); 33438 if (!response.ok) throw new Error("Failed to load app modal"); 33439 const html = await response.text(); 33440 const existingModal = document.getElementById("flow-app-modal"); 33441 if (existingModal) existingModal.remove(); 33442 document.body.insertAdjacentHTML("beforeend", html); 33443 const modal = document.getElementById("flow-app-modal"); 33444 if (!modal) return; 33445 const imgEl = modal.querySelector("#flow-app-image"); 33446 if (imgEl) imgEl.src = nodeData.media_url; 33447 if (window.Modal) window.Modal.open("flow-app-modal"); 33448 modal.querySelectorAll(".flow-app-model-btn").forEach((btn) => { 33449 btn.addEventListener("click", () => { 33450 modal.querySelectorAll(".flow-app-model-btn").forEach((b) => { 33451 b.classList.remove("border-active", "bg-active/10"); 33452 b.classList.add("border-light/40"); 33453 }); 33454 btn.classList.remove("border-light/40"); 33455 btn.classList.add("border-active", "bg-active/10"); 33456 const generateBtn2 = modal.querySelector("#flow-app-generate-btn"); 33457 const costEl = modal.querySelector("#flow-app-cost"); 33458 if (generateBtn2) generateBtn2.dataset.model = btn.dataset.model; 33459 if (costEl) costEl.textContent = btn.dataset.cost; 33460 }); 33461 }); 33462 modal.querySelectorAll(".flow-app-option-btn").forEach((btn) => { 33463 btn.addEventListener("click", () => { 33464 const group = btn.closest("[data-param]"); 33465 if (group) { 33466 group.querySelectorAll(".flow-app-option-btn").forEach((b) => { 33467 b.classList.remove("border-active", "bg-active/10", "text-white"); 33468 b.classList.add("border-light/40", "text-gray"); 33469 }); 33470 } 33471 btn.classList.remove("border-light/40", "text-gray"); 33472 btn.classList.add("border-active", "bg-active/10", "text-white"); 33473 const input = group?.querySelector('input[type="hidden"]'); 33474 if (input) input.value = btn.dataset.value; 33475 }); 33476 }); 33477 const randomGridContainer = modal.querySelector(".locations-grid-container"); 33478 if (randomGridContainer) { 33479 const randomUrl = randomGridContainer.dataset.randomUrl || "/locations/random"; 33480 const hidden = randomGridContainer.previousElementSibling; 33481 const updateHidden = () => { 33482 const values = [...randomGridContainer.querySelectorAll(".locations-input")].map((i) => i.value); 33483 if (hidden) hidden.value = JSON.stringify(values); 33484 }; 33485 randomGridContainer.querySelectorAll(".locations-input").forEach((input) => { 33486 input.addEventListener("input", updateHidden); 33487 }); 33488 randomGridContainer.querySelectorAll(".locations-dice").forEach((btn) => { 33489 btn.addEventListener("click", async () => { 33490 const idx = parseInt(btn.dataset.index); 33491 const input = randomGridContainer.querySelector(`.locations-input[data-index="${idx}"]`); 33492 const current = [...randomGridContainer.querySelectorAll(".locations-input")].map((i) => i.value); 33493 btn.classList.add("animate-spin"); 33494 try { 33495 const res = await fetch(`${randomUrl}?exclude=${encodeURIComponent(JSON.stringify(current))}`); 33496 const data = await res.json(); 33497 if (data.location) { 33498 input.value = data.location; 33499 updateHidden(); 33500 } 33501 } catch (e) { 33502 console.error("Failed to fetch random item:", e); 33503 } 33504 btn.classList.remove("animate-spin"); 33505 }); 33506 }); 33507 } 33508 const perspGrid = modal.querySelector(".perspectives-grid"); 33509 if (perspGrid) { 33510 const perspHidden = perspGrid.previousElementSibling; 33511 const perspCount = modal.querySelector(".perspectives-count"); 33512 const maxPersp = parseInt(perspGrid.dataset.max || "9"); 33513 const checkSvg = '<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M5 13l4 4L19 7"/></svg>'; 33514 const getSelected = () => [...perspGrid.querySelectorAll(".perspective-checkbox.checked")].map((l) => l.dataset.perspective); 33515 perspGrid.querySelectorAll(".perspective-checkbox").forEach((label) => { 33516 label.addEventListener("click", (e) => { 33517 e.preventDefault(); 33518 const box = label.querySelector(".perspective-box"); 33519 const txt = label.querySelector(".perspective-label"); 33520 const isChecked = label.classList.contains("checked"); 33521 if (isChecked) { 33522 label.classList.remove("checked"); 33523 box.classList.remove("bg-active", "border-active"); 33524 box.classList.add("border-light/40"); 33525 box.innerHTML = ""; 33526 txt.classList.remove("text-white"); 33527 txt.classList.add("text-gray"); 33528 } else { 33529 if (getSelected().length >= maxPersp) { 33530 if (window.Toast) window.Toast.show(`Maximum ${maxPersp} perspectives`, "alert"); 33531 return; 33532 } 33533 label.classList.add("checked"); 33534 box.classList.add("bg-active", "border-active"); 33535 box.classList.remove("border-light/40"); 33536 box.innerHTML = checkSvg; 33537 txt.classList.add("text-white"); 33538 txt.classList.remove("text-gray"); 33539 } 33540 const selected = getSelected(); 33541 if (perspHidden) perspHidden.value = JSON.stringify(selected); 33542 if (perspCount) perspCount.textContent = `${selected.length}
33542/${maxPersp} selected`; 33543 }); 33544 }); 33545 } 33546 const generateBtn = modal.querySelector("#flow-app-generate-btn"); 33547 if (generateBtn) { 33548 generateBtn.addEventListener("click", () => { 33549 this.generateFromApp(modal, slug, nodeData); 33550 }); 33551 } 33552 } catch (error) { 33553 console.error("[ProjectCanvas] Error loading app modal:", error); 33554 if (window.Toast) window.Toast.show("Failed to load app", "alert"); 33555 } 33556 }, 33557 async generateFromApp(modal, slug, nodeData) { 33558 const generateBtn = modal.querySelector("#flow-app-generate-btn"); 33559 if (!generateBtn) return; 33560 const model = generateBtn.dataset.model; 33561 const shotUuid = this.currentShotUuid; 33562 if (!shotUuid) return; 33563 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 33564 const appParams = {}; 33565 modal.querySelectorAll(".flow-app-param").forEach((input) => { 33566 if (input.name && input.value) appParams[input.name] = input.value; 33567 }); 33568 if (window.Modal) window.Modal.close("flow-app-modal"); 33569 setTimeout(() => modal.remove(), 250); 33570 try { 33571 const viewportCenter = this.getViewportCenter(); 33572 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/generate-app`, { 33573 method: "POST", 33574 headers: { 33575 "Content-Type": "application/json", 33576 "X-CSRF-Token": csrfToken 33577 }, 33578 body: JSON.stringify({ 33579 app_slug: slug, 33580 model, 33581 image_url: nodeData.media_url, 33582 source_node_uuid: nodeData.uuid, 33583 viewport_center_x: viewportCenter.x, 33584 viewport_center_y: viewportCenter.y, 33585 ...appParams 33586 }) 33587 }); 33588 if (!response.ok) { 33589 const error = await response.json().catch(() => ({})); 33590 if (window.Toast) window.Toast.show(error.error || "Generation failed", "alert"); 33591 return; 33592 } 33593 const data = await response.json(); 33594 if (data.success && data.node) { 33595 this.renderProcessingNode(data.node); 33596 this.pollNodeStatus(data.node.uuid); 33597 } 33598 } catch (error) { 33599 console.error("[ProjectCanvas] App generation error:", error); 33600 if (window.Toast) window.Toast.show("Generation failed", "alert"); 33601 } 33602 }, 33603 // --- Media viewer modal --- 33604 async openMediaViewer(mediaId) { 33605 try { 33606 const response = await fetch(`/media/${mediaId}`, { 33607 headers: { 33608 Accept: "text/html", 33609 "X-Requested-With": "XMLHttpRequest" 33610 } 33611 }); 33612 if (!response.ok) throw new Error("Failed to load media viewer"); 33613 const html = await response.text(); 33614 const existingModal = document.getElementById("media-modal"); 33615 if (existingModal) existingModal.remove(); 33616 document.body.insertAdjacentHTML("beforeend", html); 33617 if (window.Modal) window.Modal.open("media-modal"); 33618 this.setupMultishotSelection(); 33619 } catch (error) { 33620 console.error("[ProjectCanvas] Error loading media viewer:", error); 33621 if (window.Toast) window.Toast.show("Failed to load media viewer", "alert"); 33622 } 33623 }, 33624 setupMultishotSelection() { 33625 const cells = document.querySelectorAll(".multishot-cell"); 33626 if (cells.length === 0) return; 33627 const upscaleBar = document.getElementById("multishot-upscale-bar"); 33628 const res2k = document.getElementById("multishot-res-2k"); 33629 const res4k = document.getElementById("multishot-res-4k"); 33630 const upscaleBtn = document.getElementById("multishot-upscale-btn"); 33631 const costSpan = document.getElementById("multishot-upscale-cost"); 33632 const selectedCells = /* @__PURE__ */ new Set(); 33633 const COST_2K = 8; 33634 const COST_4K = 12; 33635 let selectedResolution = "2K"; 33636 const starSvg = '<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L14.5 8.5L22 11L14.5 13.5L12 21L9.5 13.5L2 11L9.5 8.5L12 1Z"/></svg>'; 33637 const updateCost = () => { 33638 if (selectedCells.size > 0) { 33639 upscaleBar?.classList.remove("hidden"); 33640 const cost = selectedResolution === "4K" ? COST_4K : COST_2K; 33641 costSpan.innerHTML = `${starSvg} ${selectedCells.size * cost}`; 33642 } else { 33643 upscaleBar?.classList.add("hidden"); 33644 costSpan.innerHTML = ""; 33645 } 33646 }; 33647 const setActiveRes = (btn) => {
33648 [res2k, res4k].forEach((b) => { 33649 b.classList.remove("text-white", "bg-active/40"); 33650 b.classList.add("text-gray", "hover:text-white"); 33651 }); 33652 btn.classList.add("text-white", "bg-active/40"); 33653 btn.classList.remove("text-gray", "hover:text-white"); 33654 }; 33655 res2k?.addEventListener("click", () => { 33656 selectedResolution = "2K"; 33657 setActiveRes(res2k); 33658 updateCost(); 33659 }); 33660 res4k?.addEventListener("click", () => { 33661 selectedResolution = "4K"; 33662 setActiveRes(res4k); 33663 updateCost(); 33664 }); 33665 cells.forEach((cell) => { 33666 cell.addEventListener("click", () => { 33667 const cellIndex = cell.dataset.cell; 33668 const checkIcon = cell.querySelector(".multishot-check"); 33669 if (selectedCells.has(cellIndex)) { 33670 selectedCells.delete(cellIndex); 33671 cell.classList.remove("ring-2", "ring-active"); 33672 if (checkIcon) checkIcon.classList.add("opacity-0"); 33673 } else { 33674 selectedCells.add(cellIndex); 33675 cell.classList.add("ring-2", "ring-active"); 33676 if (checkIcon) checkIcon.classList.remove("opacity-0"); 33677 } 33678 updateCost(); 33679 }); 33680 }); 33681 upscaleBtn?.addEventListener("click", async () => { 33682 if (selectedCells.size === 0) return; 33683 const indices = Array.from(selectedCells).map(Number).sort((a, b) => a - b); 33684 const modal = document.getElementById("media-modal"); 33685 const sourceHash = modal?.dataset.mediaHash; 33686 if (!sourceHash) { 33687 if (window.Toast) window.Toast.show("Could not find source image", "alert"); 33688 return; 33689 } 33690 const shotUuid = this.currentShotUuid; 33691 if (!shotUuid) return; 33692 upscaleBtn.disabled = true; 33693 upscaleBtn.innerHTML = '<span class="animate-pulse">Upscaling...</span>'; 33694 try { 33695 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 33696 const viewportCenter = this.getViewportCenter(); 33697 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/upscale-multishot`, { 33698 method: "POST", 33699 headers: { 33700 "Content-Type": "application/json", 33701 "X-CSRF-Token": csrfToken 33702 }, 33703 body: JSON.stringify({ 33704 source_hash: sourceHash, 33705 cell_indices: indices, 33706 viewport_center_x: viewportCenter.x, 33707 viewport_center_y: viewportCenter.y, 33708 resolution: selectedResolution 33709 }) 33710 }); 33711 const data = await response.json(); 33712 if (!response.ok) { 33713 if (window.Toast) window.Toast.show(data.error || "Upscale failed", "alert"); 33714 return; 33715 } 33716 if (window.Toast) window.Toast.show(`Upscaling ${indices.length} ${indices.length === 1 ? "shot" : "shots"} to ${selectedResolution}...`, "success"); 33717 if (window.Modal) window.Modal.close("media-modal"); 33718 this.loadShot(this.currentShotUuid); 33719 if (data.balance !== void 0) { 33720 const balanceEl = document.querySelector("[data-balance]"); 33721 if (balanceEl) balanceEl.textContent = data.balance.toLocaleString(); 33722 } 33723 } catch (error) { 33724 console.error("[ProjectCanvas] Upscale multishot error:", error); 33725 if (window.Toast) window.Toast.show("Upscale failed", "alert"); 33726 } finally { 33727 upscaleBtn.disabled = false; 33728 upscaleBtn.innerHTML = '<span>Upscale</span><span class="flex items-center gap-1 text-white" id="multishot-upscale-cost"></span>'; 33729 updateCost(); 33730 } 33731 }); 33732 }, 33733 async selectMediaForShot(mediaId, nodeEl) { 33734 const shotUuid = this.currentShotUuid; 33735 if (!shotUuid) return; 33736 const isAlreadySelected = this.currentShot?.selected_media_id === mediaId; 33737 const newMediaId = isAlreadySelected ? null : mediaId; 33738 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 33739 try { 33740 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/select`, { 33741 method: "PATCH", 33742 headers: { 33743 "Content-Type": "application/json", 33744 "X-CSRF-Token": csrfToken 33745 }, 33746 body: JSON.stringify({ media_id: newMediaId }) 33747 }); 33748 if (!response.ok) throw new Error("Failed to update selection"); 33749 if (this.currentShot) this.currentShot.selected_media_id = newMediaId;
33750 this.nodesContainer.querySelectorAll(".project-node[data-project-node-id]").forEach((el) => { 33751 el.style.outline = ""; 33752 el.style.outlineOffset = ""; 33753 const label = el.querySelector("[data-select-label]"); 33754 const btn = el.querySelector('[data-project-action="select-shot"]'); 33755 if (label) label.textContent = "SELECT SHOT"; 33756 if (btn) { 33757 btn.classList.remove("text-active"); 33758 btn.classList.add("text-gray/50"); 33759 } 33760 }); 33761 const shotEl = document.querySelector(`[data-project-shot="${shotUuid}"]`); 33762 if (newMediaId) { 33763 nodeEl.style.outline = "2px solid var(--color-active)"; 33764 nodeEl.style.outlineOffset = "4px"; 33765 const label = nodeEl.querySelector("[data-select-label]"); 33766 const btn = nodeEl.querySelector('[data-project-action="select-shot"]'); 33767 if (label) label.textContent = "SELECTED"; 33768 if (btn) { 33769 btn.classList.remove("text-gray/50"); 33770 btn.classList.add("text-active"); 33771 } 33772 const thumbUrl = nodeEl.dataset.thumbUrl; 33773 const sourceMedia = nodeEl.querySelector("img, video"); 33774 const isVideoSource = sourceMedia?.tagName === "VIDEO"; 33775 const thumbDiv = shotEl?.querySelector(":scope > div:first-child"); 33776 if (thumbDiv && sourceMedia) { 33777 const menuBtn = thumbDiv.querySelector("[data-shot-menu]"); 33778 if (isVideoSource && thumbUrl?.endsWith(".mp4")) { 33779 thumbDiv.innerHTML = `<video src="${thumbUrl}" autoplay loop muted playsinline class="w-full h-full object-cover"></video>`; 33780 } else if (isVideoSource) { 33781 thumbDiv.innerHTML = `<video src="${sourceMedia.src}" autoplay loop muted playsinline class="w-full h-full object-cover"></video>`; 33782 } else { 33783 thumbDiv.innerHTML = `<img src="${thumbUrl || sourceMedia.src}" alt="" class="w-full h-full object-cover" />`; 33784 } 33785 if (menuBtn) thumbDiv.appendChild(menuBtn); 33786 } 33787 } else { 33788 const thumbDiv = shotEl?.querySelector(":scope > div:first-child"); 33789 if (thumbDiv) { 33790 const shotOrder = shotEl.dataset.projectShotOrder || ""; 33791 const menuBtn = thumbDiv.querySelector("[data-shot-menu]"); 33792 thumbDiv.innerHTML = `<span class="text-[9px] text-gray/50 group-hover/shot:text-white transition duration-300 ease">Shot ${shotOrder}</span>`; 33793 if (menuBtn) thumbDiv.appendChild(menuBtn); 33794 } 33795 } 33796 } catch (error) { 33797 console.error("[ProjectCanvas] Select error:", error); 33798 if (window.Toast) window.Toast.show("Failed to update selection", "alert"); 33799 } 33800 }, 33801 // --- Duplicate to shot --- 33802 async openDuplicateToShotModal(nodeData) { 33803 try { 33804 const response = await fetch(`${this.baseUrl()}/shots-tree`); 33805 if (!response.ok) throw new Error("Failed to load shots tree"); 33806 const data = await response.json(); 33807 const sequences = data.sequences || []; 33808 const modal = document.createElement("div"); 33809 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 33810 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 20vh; z-index: 10000;"; 33811 let treeHtml = ""; 33812 sequences.forEach((seq) => { 33813 treeHtml += `<div class="mb-3">`; 33814 treeHtml += `<div class="text-[11px] uppercase font-semibold text-gray mb-1.5 px-1">${this._escapeHtml(seq.name)}</div>`; 33815 seq.scenes.forEach((scene) => { 33816 treeHtml += `<div class="mb-2">`;
33817 treeHtml += `<div class="text-[10px] text-gray/70 mb-1 px-1">${this._escapeHtml(scene.name)}</div>`; 33818 treeHtml += `<div class="flex flex-wrap gap-1.5 px-1">`; 33819 scene.shots.forEach((shot) => { 33820 const isCurrent = shot.uuid === this.currentShotUuid; 33821 const thumbStyle = shot.thumbnail_url ? `background-image: url(${shot.thumbnail_url}); background-size: cover; background-position: center;` : ""; 33822 treeHtml += ` 33823 <button data-duplicate-target="${shot.uuid}" class="group/shot relative w-[72px] h-[52px] rounded-lg overflow-hidden border transition duration-200 ${isCurrent ? "border-active/50 opacity-50 cursor-not-allowed" : "border-light/20 hover:border-active cursor-pointer"}" style="${thumbStyle}" ${isCurrent ? "disabled" : ""}> 33824 ${!shot.thumbnail_url ? `<span class="absolute inset-0 flex items-center justify-center text-[9px] text-gray/50">Shot ${shot.order}</span>` : ""} 33825 ${isCurrent ? '<span class="absolute inset-0 flex items-center justify-center text-[8px] text-active font-medium">Current</span>' : ""} 33826 <div class="absolute inset-0 bg-active/0 group-hover/shot:bg-active/20 transition duration-200"></div> 33827 </button> 33828 `; 33829 }); 33830 treeHtml += `</div></div>`; 33831 }); 33832 treeHtml += `</div>`; 33833 }); 33834 modal.innerHTML = ` 33835 <div class="bg-box rounded-lg p-5 max-w-lg w-full mx-4 modal-content shadow-xl"> 33836 <div class="flex items-start justify-between mb-4"> 33837 <div> 33838 <h2 class="text-14 font-bold text-white">Duplicate to...</h2> 33839 <p class="text-[11px] text-gray mt-0.5">Select a shot to duplicate this node to</p> 33840 </div> 33841 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 33842 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 33843 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 33844 </svg> 33845 </button> 33846 </div> 33847 <div class="max-h-[50vh] overflow-y-auto pr-1"> 33848 ${treeHtml} 33849 </div> 33850 </div> 33851 `; 33852 document.body.appendChild(modal); 33853 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 33854 btn.addEventListener("click", () => modal.remove()); 33855 }); 33856 modal.addEventListener("click", (e) => { 33857 if (e.target === modal) modal.remove(); 33858 }); 33859 modal.querySelectorAll("[data-duplicate-target]:not([disabled])").forEach((btn) => { 33860 btn.addEventListener("click", async () => { 33861 const targetShotUuid = btn.dataset.duplicateTarget; 33862 modal.remove(); 33863 await this.duplicateNodeToShot(nodeData.uuid, targetShotUuid); 33864 }); 33865 }); 33866 } catch (error) { 33867 console.error("[ProjectCanvas] Error loading shots tree:", error); 33868 if (window.Toast) window.Toast.show("Failed to load shots", "alert"); 33869 } 33870 }, 33871 async duplicateNodeToShot(nodeUuid, targetShotUuid) { 33872 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 33873 try { 33874 const response = await fetch(`${this.baseUrl()}/nodes/${nodeUuid}/duplicate_to_shot`, { 33875 method: "POST", 33876 headers: { 33877 "Content-Type": "application/json", 33878 "X-CSRF-Token": csrfToken 33879 }, 33880 body: JSON.stringify({ target_shot_uuid: targetShotUuid }) 33881 }); 33882 if (!response.ok) { 33883 const error = await response.json().catch(() => ({})); 33884 if (window.Toast) window.Toast.show(error.error || "Duplication failed", "alert"); 33885 return; 33886 } 33887 if (window.Toast) window.Toast.show("Duplicated to shot", "success"); 33888 } catch (error) { 33889 console.error("[ProjectCanvas] Duplicate error:", error); 33890 if (window.Toast) window.Toast.show("Duplication failed", "alert"); 33891 } 33892 }, 33893 _escapeHtml(text) { 33894 const div = document.createElement("div"); 33895 div.textContent = text; 33896 return div.innerHTML; 33897 }, 33898 // --- Regenerate handling --- 33899 async handleRegenerate(nodeData) { 33900 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; 33901 try { 33902 const response = await fetch(`${this.baseUrl()}/nodes/${nodeData.uuid}/regenerate`, { 33903 method: "POST", 33904 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken } 33905 }); 33906 const data = await response.json(); 33907 if (!response.ok) { 33908 if (window.Toast) window.Toast.show(data.error || "Regeneration failed", "alert"); 33909 return; 33910 } 33911 this.loadShot(this.currentShotUuid, { skipZoom: true });
33912 if (data.balance !== void 0) { 33913 const balanceEl = document.querySelector("[data-balance]"); 33914 if (balanceEl) balanceEl.textContent = data.balance.toLocaleString(); 33915 } 33916 } catch (error) { 33917 console.error("[ProjectCanvas] Regenerate error:", error); 33918 if (window.Toast) window.Toast.show("Regeneration failed", "alert"); 33919 } 33920 }, 33921 // --- Upscale handling --- 33922 async handleUpscaleClick(nodeData) { 33923 const isVideo = nodeData.node_type === "video"; 33924 if (isVideo) { 33925 await this.openUpscaleModal(nodeData); 33926 } else { 33927 await this.submitUpscale(nodeData.uuid); 33928 } 33929 }, 33930 async openUpscaleModal(nodeData) { 33931 try { 33932 const response = await fetch("/modal/upscale"); 33933 if (!response.ok) throw new Error("Failed to load upscale modal"); 33934 const html = await response.text(); 33935 const existing = document.getElementById("upscale-modal"); 33936 if (existing) existing.remove(); 33937 document.body.insertAdjacentHTML("beforeend", html); 33938 const modal = document.getElementById("upscale-modal"); 33939 if (!modal) return; 33940 const costPerSecond = parseFloat(modal.dataset.costPerSecond) || 10; 33941 const durationSeconds = 5; 33942 const durationEl = modal.querySelector("[data-upscale-duration]"); 33943 const costEl = modal.querySelector("[data-upscale-cost]"); 33944 if (durationEl) durationEl.textContent = durationSeconds; 33945 if (costEl) costEl.textContent = Math.floor(durationSeconds * costPerSecond); 33946 const factorButtons = modal.querySelectorAll("[data-upscale-factor]"); 33947 factorButtons.forEach((btn) => { 33948 btn.addEventListener("click", () => { 33949 factorButtons.forEach((b) => b.classList.remove("active")); 33950 btn.classList.add("active"); 33951 }); 33952 }); 33953 const confirmBtn = modal.querySelector("[data-upscale-confirm]"); 33954 if (confirmBtn) { 33955 confirmBtn.addEventListener("click", async () => { 33956 const factor = modal.querySelector("[data-upscale-factor].active")?.dataset.upscaleFactor || "2"; 33957 modal.classList.remove("modal-showing"); 33958 document.body.classList.remove("modal-open"); 33959 setTimeout(() => modal.remove(), 200); 33960 await this.submitUpscale(nodeData.uuid, parseInt(factor)); 33961 }); 33962 } 33963 const closeModal = () => { 33964 modal.classList.remove("modal-showing"); 33965 document.body.classList.remove("modal-open"); 33966 setTimeout(() => modal.remove(), 200); 33967 }; 33968 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 33969 btn.addEventListener("click", closeModal); 33970 }); 33971 modal.addEventListener("click", (e) => { 33972 if (e.target === modal) closeModal(); 33973 }); 33974 modal.classList.add("hidden"); 33975 modal.classList.remove("modal-showing"); 33976 modal.style.opacity = "0"; 33977 modal.offsetHeight; 33978 requestAnimationFrame(() => { 33979 requestAnimationFrame(() => { 33980 modal.style.opacity = ""; 33981 modal.classList.remove("hidden"); 33982 document.body.classList.add("modal-open"); 33983 requestAnimationFrame(() => { 33984 modal.classList.add("modal-showing"); 33985 }); 33986 }); 33987 }); 33988 } catch (error) { 33989 console.error("[ProjectCanvas] Upscale modal error:", error); 33990 if (window.Toast) window.Toast.show("Failed to open upscale modal", "alert"); 33991 } 33992 }, 33993 async submitUpscale(sourceNodeUuid, upscaleFactor) { 33994 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 33995 try { 33996 const viewportCenter = this.getViewportCenter(); 33997 const body = { viewport_center_x: viewportCenter.x, viewport_center_y: viewportCenter.y }; 33998 if (upscaleFactor) body.upscale_factor = upscaleFactor; 33999 const response = await fetch(`${this.baseUrl()}/nodes/${sourceNodeUuid}/upscale`, { 34000 method: "POST", 34001 headers: { 34002 "Content-Type": "application/json", 34003 "X-CSRF-Token": csrfToken 34004 }, 34005 body: JSON.stringify(body) 34006 }); 34007 if (!response.ok) { 34008 const error = await response.json().catch(() => ({})); 34009 if (window.Toast) window.Toast.show(error.error || "Upscale failed", "alert"); 34010 return; 34011 } 34012 const data = await response.json(); 34013 if (data.success && data.node) { 34014 this.renderProcessingNode(data.node); 34015 this.pollNodeStatus(data.node.uuid); 34016 } 34017 } catch (error) { 34018 console.error("[ProjectCanvas] Upscale error:", error); 34019 if (window.Toast) window.Toast.show("Upscale failed", "alert"); 34020 } 34021 }, 34022 // --- Extract last frame --- 34023 async handleExtractFrame(nodeData) { 34024 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 34025 if (window.Toast) window.Toast.show("Extracting last frame...", "notice"); 34026 try { 34027 const viewportCenter = this.getViewportCenter(); 34028 const response = await fetch(`${this.baseUrl()}/nodes/${nodeData.uuid}/extract_frame`, { 34029 method: "POST", 34030 headers: { 34031 "Content-Type": "application/json", 34032 "X-CSRF-Token": csrfToken 34033 }, 34034 body: JSON.stringify({ 34035 viewport_center_x: viewportCenter.x, 34036 viewport_center_y: viewportCenter.y 34037 }) 34038 }); 34039 if (!response.ok) { 34040 const error = await response.json().catch(() => ({})); 34041 if (window.Toast) window.Toast.show(error.error || "Extract frame failed", "alert"); 34042 return; 34043 } 34044 const data = await response.json(); 34045 if (data.success && data.node) { 34046 this.renderImageNode(data.node); 34047 if (window.Toast) window.Toast.show("Last frame extracted", "success"); 34048 } 34049 } catch (error) { 34050 console.error("[ProjectCanvas] Extract frame error:", error); 34051 if (window.Toast) window.Toast.show("Failed to extract frame", "alert"); 34052 } 34053 } 34054}; 34055
34056// app/javascript/project/icons.js 34057var IconsMixin = { 34058 icon(name, className = "") { 34059 const icons = { 34060 image: '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>', 34061 x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 34062 "square-pen": '<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/>', 34063 "maximize-2": '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>', 34064 clapperboard: '<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="m12.4 3.4 3.1 4"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>', 34065 bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>', 34066 bolt: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>', 34067 pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>', 34068 sparkles: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>', 34069 download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>', 34070 upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/>', 34071 check: '<path d="M20 6 9 17l-5-5"/>', 34072 "trash-2": '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>', 34073 ellipsis: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>', 34074 plus: '<path d="M5 12h14"/><path d="M12 5v14"/>', 34075 "layout-dashboard": '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>', 34076 "group": '<rect width="8" height="8" x="2" y="2" rx="1"/><rect width="8" height="8" x="14" y="2" rx="1"/><rect width="8" height="8" x="2" y="14" rx="1"/><rect width="8" height="8" x="14" y="14" rx="1"/>', 34077 "ungroup": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/><path d="M3 12h18"/>', 34078 "arrow-right": '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>', 34079 "settings": '<circle cx="12" cy="12" r="3"/><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>', 34080 "video": '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>', 34081 "refresh-cw": '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>', 34082 "film": '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>', 34083 "rotate-3d": '<path d="M16.466 7.5C15.643 4.237 13.952 2 12 2 9.239 2 7 6.477 7 12s2.239 10 5 10c.342 0 .677-.069 1-.2"/><path d="m15.194 13.707 3.814 1.86-1.86 3.814"/><path d="M19 15.57c-1.804.885-4.274 1.43-7 1.43-5.523 0-10-2.239-10-5s4.477-5 10-5c4.838 0 8.873 1.718 9.8 4"/>' 34084 }; 34085 const cls = className ? ` class="${className}"` : ""; 34086 return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"${cls}>${icons[name] || ""}</svg>`; 34087 } 34088}; 34089
34090// app/javascript/project/bar.js 34091var BarMixin2 = { 34092 // ââ Public API ââââââââââââââââââââââââââââââââââââââââââââââ 34093 async openEditBar(nodeData, mode = "image") { 34094 this.closeEditBar(true); 34095 try { 34096 const response = await fetch(`${this.baseUrl()}/edit-bar`); 34097 if (!response.ok) throw new Error("Failed to load edit bar"); 34098 const html = await response.text(); 34099 document.body.insertAdjacentHTML("beforeend", html); 34100 const bar = document.querySelector("[data-project-edit-bar]"); 34101 if (!bar) return; 34102 this._editBar = bar; 34103 this._editBarNodeData = nodeData; 34104 this._editBarMode = mode; 34105 this._syncGlobalsFromLocalStorage(); 34106 this.initEditBar(bar, nodeData, mode); 34107 } catch (error) { 34108 console.error("[ProjectCanvas] Error loading edit bar:", error); 34109 if (window.Toast) window.Toast.show("Failed to load edit bar", "alert"); 34110 } 34111 }, 34112 initEditBar(bar, nodeData, mode) { 34113 this._setEditBarMode(bar, mode); 34114 this.prefillReference(bar, nodeData, mode); 34115 this.setupEditBarEvents(bar); 34116 this._initEditBarCameraDefaults(bar); 34117 this._initProjectMultishot(bar); 34118 if (typeof this.initMentions === "function") { 34119 this.initMentions(); 34120 } 34121 const imagePromptEl = bar.querySelector("[data-project-edit-prompt]"); 34122 if (imagePromptEl && typeof this.bindMentions === "function") { 34123 this.bindMentions(imagePromptEl); 34124 } 34125 if (imagePromptEl) { 34126 const savedHtml = localStorage.getItem("project-image-prompt-html"); 34127 if (savedHtml) { 34128 imagePromptEl.innerHTML = savedHtml; 34129 if (typeof this._bindExistingMentions === "function") { 34130 this._bindExistingMentions(imagePromptEl); 34131 } 34132 } 34133 imagePromptEl.addEventListener("input", () => { 34134 localStorage.setItem("project-image-prompt-html", imagePromptEl.innerHTML); 34135 }); 34136 } 34137 const videoPromptEl = bar.querySelector("[data-project-edit-video-prompt]"); 34138 if (videoPromptEl && typeof this.bindMentions === "function") { 34139 this.bindMentions(videoPromptEl); 34140 } 34141 if (videoPromptEl) { 34142 videoPromptEl.addEventListener("input", () => { 34143 if (window.videoMultishot) { 34144 this._saveCurrentShot(bar); 34145 } 34146 }); 34147 } 34148 this._librarySelectHandler = (e) => { 34149 if (!e.detail?.url) return; 34150 const refSlot = this._pendingRefSlot; 34151 if (refSlot) { 34152 const addBtn = bar.querySelector(`[data-add-ref-btn="${refSlot}"]`); 34153 const preview = bar.querySelector(`[data-ref-preview="${refSlot}"]`); 34154 const img = bar.querySelector(`[data-ref-img="${refSlot}"]`); 34155 if (addBtn && preview && img) { 34156 img.src = e.detail.url; 34157 addBtn.classList.add("hidden"); 34158 preview.classList.remove("hidden"); 34159 } 34160 if (refSlot === 1) { 34161 this._editBarReferenceHash = e.detail.hash || null; 34162 this._editBarReferenceUrl = e.detail.url || null; 34163 } else { 34164 this._editBarReference2Hash = e.detail.hash || null; 34165 this._editBarReference2Url = e.detail.url || null; 34166 } 34167 this._pendingRefSlot = null; 34168 this._updateRefSlot2Visibility(bar); 34169 return; 34170 } 34171 const frameSlot = this._pendingFrameSlot; 34172 if (frameSlot) { 34173 this._setFramePreview(bar, frameSlot, e.detail.url); 34174 if (frameSlot === 1) { 34175 this._editBarStartFrameHash = e.detail.hash || null; 34176 this._editBarStartFrameUrl = e.detail.url || null; 34177 } else { 34178 this._editBarEndFrameHash = e.detail.hash || null; 34179 this._editBarEndFrameUrl = e.detail.url || null; 34180 } 34181 this._pendingFrameSlot = null; 34182 } 34183 }; 34184 document.addEventListener("items-selected", this._librarySelectHandler); 34185 requestAnimationFrame(() => { 34186 bar.style.opacity = "1"; 34187 const prompt2 = mode === "video" ? bar.querySelector("[data-project-edit-video-prompt]") : bar.querySelector("[data-project-edit-prompt]"); 34188 if (prompt2) prompt2.focus(); 34189 }); 34190 }, 34191 _setVideoPromptText(el, text) { 34192 if (!el) return; 34193 const highlighted = (text || "").replace(/@(\w+)/g, (match, slug) => { 34194 if (window.validElementSlugs?.has(slug.toLowerCase())) { 34195 return `<span class="text-active mention-tag" data-slug="${slug}" contenteditable="false">@${slug}</span>`;
34196 } 34197 return match; 34198 }); 34199 el.innerHTML = highlighted; 34200 }, 34201 _editBarHasContent() { 34202 if (!this._editBar) return false; 34203 const mode = this._editBarMode || "image"; 34204 if (mode === "video") { 34205 const videoPrompt = this._editBar.querySelector("[data-project-edit-video-prompt]"); 34206 return videoPrompt && videoPrompt.textContent.trim().length > 0; 34207 } 34208 const imagePrompt = this._editBar.querySelector("[data-project-edit-prompt]"); 34209 return imagePrompt && imagePrompt.textContent.trim().length > 0; 34210 }, 34211 closeEditBar(force = false) { 34212 if (!force && this._editBarHasContent()) return; 34213 if (typeof this.cleanupMentions === "function") { 34214 this.cleanupMentions(); 34215 } 34216 if (this._editBar) { 34217 this._editBar.remove(); 34218 this._editBar = null; 34219 this._editBarNodeData = null; 34220 this._editBarMode = null; 34221 } 34222 if (this._editBarEscHandler) { 34223 document.removeEventListener("keydown", this._editBarEscHandler); 34224 this._editBarEscHandler = null; 34225 } 34226 if (this._librarySelectHandler) { 34227 document.removeEventListener("items-selected", this._librarySelectHandler); 34228 this._librarySelectHandler = null; 34229 } 34230 this._pendingFrameSlot = null; 34231 }, 34232 // ââ Events ââââââââââââââââââââââââââââââââââââââââââââââââââ 34233 setupEditBarEvents(bar) { 34234 const closeBtn = bar.querySelector("[data-project-edit-bar-close]"); 34235 if (closeBtn) { 34236 closeBtn.addEventListener("click", (e) => { 34237 e.stopPropagation(); 34238 this.closeEditBar(true); 34239 }); 34240 } 34241 const backdrop = bar.querySelector("[data-project-edit-bar-backdrop]"); 34242 if (backdrop) { 34243 backdrop.addEventListener("click", (e) => { 34244 e.stopPropagation(); 34245 this.closeEditBar(); 34246 }); 34247 } 34248 this._editBarEscHandler = (e) => { 34249 if (e.key === "Escape") { 34250 if (this._mentionPopup && this._mentionPopup.style.display !== "none") return; 34251 this.closeEditBar(); 34252 } 34253 }; 34254 document.addEventListener("keydown", this._editBarEscHandler); 34255 bar.querySelectorAll("[data-studio-mode-toggle] button[data-mode]").forEach((btn) => { 34256 btn.addEventListener("click", () => { 34257 this._setEditBarMode(bar, btn.dataset.mode); 34258 this.prefillReference(bar, this._editBarNodeData, btn.dataset.mode); 34259 }); 34260 }); 34261 bar.querySelectorAll("[data-image-submode]").forEach((btn) => { 34262 btn.addEventListener("click", () => { 34263 this._setEditBarImageSubmode(bar, btn.dataset.imageSubmode); 34264 }); 34265 }); 34266 bar.querySelectorAll("[data-project-camera-btn]").forEach((btn) => { 34267 btn.addEventListener("click", () => { 34268 const category = btn.dataset.projectCameraBtn; 34269 if (typeof window.openCameraModal === "function") { 34270 window.openCameraModal(category); 34271 } 34272 }); 34273 }); 34274 const animStyleBtn = bar.querySelector("[data-project-animation-btn]"); 34275 if (animStyleBtn) { 34276 animStyleBtn.addEventListener("click", () => { 34277 if (typeof window.openAnimationModal === "function") { 34278 window.openAnimationModal(); 34279 } 34280 }); 34281 } 34282 bar.querySelectorAll("[data-video-frame]").forEach((frameBtn) => { 34283 frameBtn.addEventListener("click", () => { 34284 const frame = frameBtn.dataset.videoFrame; 34285 const slot = frame === "start" ? 1 : 2; 34286 this._showFrameContextMenu(bar, frameBtn, slot); 34287 }); 34288 }); 34289 const movementBtn = bar.querySelector("[data-project-movement-btn]"); 34290 if (movementBtn) { 34291 movementBtn.addEventListener("click", () => { 34292 if (typeof window.openMovementModal === "function") { 34293 window.openMovementModal(); 34294 } 34295 }); 34296 } 34297 const movementClear = bar.querySelector("[data-movement-clear]"); 34298 if (movementClear) { 34299 movementClear.addEventListener("click", (e) => { 34300 e.stopPropagation(); 34301 if (typeof window.clearMovement === "function") { 34302 window.clearMovement(); 34303 } 34304 }); 34305 } 34306 const slowmoBtn = bar.querySelector("[data-project-slowmo-btn]"); 34307 if (slowmoBtn) { 34308 slowmoBtn.addEventListener("click", () => { 34309 if (typeof window.toggleSlowMotion === "function") { 34310 window.toggleSlowMotion(slowmoBtn); 34311 } 34312 }); 34313 } 34314 const durationDecrease = bar.querySelector("[data-project-duration-decrease]"); 34315 const durationIncrease = bar.querySelector("[data-project-duration-increase]"); 34316 if (durationDecrease) { 34317 durationDecrease.addEventListener("click", () => { 34318 if (typeof window.decreaseDuration === "function") { 34319 window.decreaseDuration(); 34320 } 34321 this._updateProjectMultishotUI(bar); 34322 }); 34323 } 34324 if (durationIncrease) { 34325 durationIncrease.addEventListener("click", () => { 34326 if (typeof window.increaseDuration === "function") { 34327 window.increaseDuration(); 34328 } 34329 this._updateProjectMultishotUI(bar); 34330 }); 34331 } 34332 const audioBtn = bar.querySelector("[data-project-audio-btn]"); 34333 if (audioBtn) { 34334 audioBtn.addEventListener("click", () => { 34335 if (typeof window.toggleAudio === "function") { 34336 window.toggleAudio(audioBtn); 34337 } 34338 this._updateEditBarCost(); 34339 }); 34340 } 34341 const multishotToggle = bar.querySelector("[data-multishot-toggle]"); 34342 if (multishotToggle) { 34343 multishotToggle.addEventListener("click", () => { 34344 this._toggleProjectMultishot(bar); 34345 }); 34346 } 34347 const resSelector = bar.querySelector("[data-resolution-selector]"); 34348 if (resSelector) { 34349 resSelector.querySelectorAll("[data-resolution]").forEach((btn) => { 34350 btn.addEventListener("click", () => { 34351 this._editBarResolution = btn.dataset.resolution; 34352 resSelector.querySelectorAll("[data-resolution]").forEach((b) => { 34353 b.classList.remove("text-white", "bg-active/40"); 34354 b.classList.add("text-gray"); 34355 }); 34356 btn.classList.remove("text-gray"); 34357 btn.classList.add("text-white", "bg-active/40"); 34358 this._updateEditBarCost(); 34359 }); 34360 }); 34361 } 34362 ; 34363 [1, 2].forEach((slot) => { 34364 const addBtn = bar.querySelector(`[data-add-ref-btn="${slot}"]`); 34365 if (addBtn) { 34366 addBtn.addEventListener("click", () => { 34367 this._showRefContextMenu(bar, addBtn, slot); 34368 }); 34369 } 34370 const removeBtn = bar.querySelector(`[data-remove-ref-btn="${slot}"]`); 34371 if (removeBtn) { 34372 removeBtn.addEventListener("click", (e) => { 34373 e.stopPropagation(); 34374 this._editBarClearReference(bar, slot); 34375 }); 34376 } 34377 }); 34378 const elementsBtn = bar.querySelector("[data-elements-btn]"); 34379 if (elementsBtn) { 34380 elementsBtn.addEventListener("mousedown", (e) => e.preventDefault()); 34381 elementsBtn.addEventListener("click", () => { 34382 const mode = this._editBarMode || "image"; 34383 const editor = mode === "video" ? bar.querySelector("[data-project-edit-video-prompt]") : bar.querySelector("[data-project-edit-prompt]"); 34384 if (!editor) return; 34385 editor.focus(); 34386 const sel = window.getSelection(); 34387 const range = document.createRange(); 34388 range.selectNodeContents(editor); 34389 range.collapse(false); 34390 sel.removeAllRanges(); 34391 sel.addRange(range); 34392 document.execCommand("insertText", false, "@"); 34393 }); 34394 } 34395 const generateBtn = bar.querySelector("[data-project-edit-generate]"); 34396 if (generateBtn) { 34397 generateBtn.addEventListener("click", () => { 34398 this.generateFromEditBar(); 34399 }); 34400 } 34401 }, 34402 // ââ Reference / Start Frame Prefill âââââââââââââââââââââââââ 34403 prefillReference(bar, nodeData, mode) { 34404 if (!nodeData) return; 34405 if (mode === "image") { 34406 const addBtn = bar.querySelector('[data-add-ref-btn="1"]'); 34407 const preview = bar.querySelector('[data-ref-preview="1"]'); 34408 const img = bar.querySelector('[data-ref-img="1"]'); 34409 if (addBtn && preview && img && nodeData.thumbnail_url) { 34410 img.src = nodeData.thumbnail_url || nodeData.media_url; 34411 addBtn.classList.add("hidden"); 34412 preview.classList.remove("hidden"); 34413 this._editBarReferenceHash = nodeData.file_hash || null; 34414 this._editBarReferenceUrl = nodeData.media_url || null; 34415 this._updateRefSlot2Visibility(bar); 34416 } 34417 } else if (mode === "video") {
34418 const frameBtn = bar.querySelector('[data-video-frame="start"]'); 34419 if (frameBtn && nodeData.thumbnail_url) { 34420 frameBtn.innerHTML = `<img src="${nodeData.thumbnail_url || nodeData.media_url}" class="w-full h-full object-cover rounded-lg" />`; 34421 frameBtn.classList.add("ring-1", "ring-active/50"); 34422 this._editBarStartFrameHash = nodeData.file_hash || null; 34423 this._editBarStartFrameUrl = nodeData.media_url || null; 34424 } 34425 } 34426 }, 34427 // ââ Generate ââââââââââââââââââââââââââââââââââââââââââââââââ 34428 async generateFromEditBar() { 34429 const bar = this._editBar; 34430 if (!bar) return; 34431 const shotUuid = this.currentShotUuid; 34432 if (!shotUuid) return; 34433 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 34434 const mode = this._editBarMode || "image"; 34435 const hasExplicitPos = !!this.nextNodePosition; 34436 const spawnPos = this.nextNodePosition || this.getViewportCenter(); 34437 const params = { 34438 mode, 34439 source_node_uuid: this._editBarNodeData?.uuid, 34440 resolution: this._editBarResolution || "2k", 34441 ratio: this._getProjectRatio() 34442 }; 34443 if (hasExplicitPos || this._editBarNodeData?.uuid) { 34444 params.viewport_center_x = spawnPos.x; 34445 params.viewport_center_y = spawnPos.y; 34446 } 34447 if (mode === "image") { 34448 const promptEl = bar.querySelector("[data-project-edit-prompt]"); 34449 params.prompt = typeof this.getEditorTextContent === "function" ? this.getEditorTextContent(promptEl) || "" : promptEl?.textContent?.trim() || ""; 34450 const modelsDataEl = document.getElementById("project-models-data"); 34451 if (modelsDataEl) { 34452 try { 34453 const models = JSON.parse(modelsDataEl.textContent); 34454 if (models.length > 0) params.model = models[0].name; 34455 } catch (e) { 34456 } 34457 } 34458 const submode = this._editBarImageSubmode || "live"; 34459 if (submode === "live") { 34460 const cameraPrompts = {}; 34461 if (window.cameraSelections) { 34462 if (window.cameraSelections["color grade"]) cameraPrompts.grade = window.cameraSelections["color grade"].uuid; 34463 if (window.cameraSelections["device"]) cameraPrompts.device = window.cameraSelections["device"].uuid; 34464 if (window.cameraSelections["lens"]) cameraPrompts.lens = window.cameraSelections["lens"].uuid; 34465 if (window.cameraSelections["focal"]) cameraPrompts.focal = window.cameraSelections["focal"].uuid; 34466 if (window.cameraSelections["aperture"]) cameraPrompts.aperture = window.cameraSelections["aperture"].uuid; 34467 } 34468 if (Object.keys(cameraPrompts).length > 0) params.camera_prompts = cameraPrompts; 34469 } else if (submode === "animation") { 34470 const animStyle = window.animationSelections?.style; 34471 if (animStyle) { 34472 params.animation_prompts = { style: animStyle.uuid || animStyle }; 34473 } 34474 } 34475 const refHashes = [this._editBarReferenceHash, this._editBarReference2Hash].filter(Boolean); 34476 const refUrls = [this._editBarReferenceUrl, this._editBarReference2Url].filter(Boolean); 34477 if (refHashes.length > 0) params.reference_hashes = refHashes; 34478 if (refUrls.length > 0) params.reference_urls = refUrls; 34479 } else { 34480 const videoModelsEl = document.getElementById("project-video-models-data"); 34481 if (videoModelsEl) { 34482 try { 34483 const models = JSON.parse(videoModelsEl.textContent); 34484 if (models.length > 0) params.model = models[0].name; 34485 } catch (e) { 34486 } 34487 } 34488 params.audio = window.audioEnabled || false; 34489 if (this._editBarStartFrameHash) { 34490 params.start_frame = this._editBarStartFrameHash; 34491 } 34492 if (this._editBarStartFrameUrl) { 34493 params.start_frame_url = this._editBarStartFrameUrl; 34494 } 34495 if (this._editBarEndFrameHash) { 34496 params.end_frame = this._editBarEndFrameHash; 34497 } 34498 if (this._editBarEndFrameUrl) { 34499 params.end_frame_url = this._editBarEndFrameUrl; 34500 } 34501 params.multishot = window.videoMultishot === true; 34502 if (params.multishot && window.videoShots) { 34503 this._saveCurrentShot(bar); 34504 params.shots = window.videoShots.map((shot) => { 34505 const s = { 34506 prompt: shot.prompt || "", 34507 duration: (shot.duration || 3).toString(), 34508 slow_motion: shot.slow_motion || false 34509 }; 34510 if (shot.movement) s.movement = shot.movement; 34511 return s; 34512 }); 34513 } else { 34514 const videoPromptEl = bar.querySelector("[data-project-edit-video-prompt]"); 34515 params.prompt = typeof this.getEditorTextContent === "function" ? this.getEditorTextContent(videoPromptEl) || "" : videoPromptEl?.innerText?.trim() || ""; 34516 const durationEl = bar.querySelector("[data-setting-duration]"); 34517 params.duration = durationEl?.textContent?.trim() || "3s"; 34518 params.slow_motion = window.slowMotion || false; 34519 if (window.selectedMovement?.uuid) { 34520 params.movement_uuid = window.selectedMovement.uuid; 34521 } 34522 } 34523 } 34524 const generateBtn = bar.querySelector("[data-project-edit-generate]"); 34525 if (generateBtn) { 34526 generateBtn.disabled = true; 34527 generateBtn.classList.add("opacity-50"); 34528 generateBtn.textContent = "GENERATING..."; 34529 } 34530 try { 34531 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/generate`, { 34532 method: "POST", 34533 headers: { 34534 "Content-Type": "application/json", 34535 "X-CSRF-Token": csrfToken 34536 }, 34537 body: JSON.stringify(params) 34538 }); 34539 if (!response.ok) { 34540 const error = await response.json().catch(() => ({})); 34541 if (window.Toast) window.Toast.show(error.error || "Generation failed", "alert"); 34542 if (generateBtn) { 34543 generateBtn.disabled = false;
34544 generateBtn.classList.remove("opacity-50"); 34545 generateBtn.innerHTML = "GENERATE"; 34546 } 34547 return; 34548 } 34549 const data = await response.json(); 34550 if (data.success && data.node) { 34551 if (mode === "image") localStorage.removeItem("project-image-prompt-html"); 34552 this.closeEditBar(true); 34553 this.nextNodePosition = null; 34554 this.renderProcessingNode(data.node); 34555 this.pollNodeStatus(data.node.uuid); 34556 } 34557 } catch (error) { 34558 console.error("[ProjectCanvas] Edit bar generate error:", error); 34559 if (window.Toast) window.Toast.show("Generation failed", "alert"); 34560 if (generateBtn) { 34561 generateBtn.disabled = false; 34562 generateBtn.classList.remove("opacity-50"); 34563 generateBtn.innerHTML = "GENERATE"; 34564 } 34565 } 34566 }, 34567 // ââ Private helpers âââââââââââââââââââââââââââââââââââââââââ 34568 /** 34569 * Get the project ratio from the init element. 34570 * Subclass canvases should set data-project-ratio on their init element. 34571 */ 34572 _getProjectRatio() { 34573 const initEl = document.querySelector("[data-project-init]"); 34574 return initEl?.dataset.projectRatio || "16:9"; 34575 }, 34576 _setEditBarMode(bar, mode) { 34577 this._editBarMode = mode; 34578 const imageSubmode = bar.querySelector('[data-submode-tabs="image"]'); 34579 const videoSubmode = bar.querySelector('[data-submode-tabs="video"]'); 34580 if (imageSubmode) { 34581 if (mode === "image") { 34582 imageSubmode.classList.remove("hidden"); 34583 } else { 34584 imageSubmode.classList.add("hidden"); 34585 } 34586 } 34587 if (videoSubmode) { 34588 if (mode === "video") { 34589 videoSubmode.classList.remove("hidden"); 34590 } else { 34591 videoSubmode.classList.add("hidden"); 34592 } 34593 } 34594 const imagePromptArea = bar.querySelector('[data-for-mode="image"]'); 34595 const videoPromptArea = bar.querySelector('[data-for-mode="video"]'); 34596 if (mode === "image") { 34597 imagePromptArea?.classList.remove("hidden"); 34598 imagePromptArea?.classList.add("flex-1"); 34599 videoPromptArea?.classList.add("hidden"); 34600 videoPromptArea?.classList.remove("flex-1"); 34601 } else { 34602 imagePromptArea?.classList.add("hidden"); 34603 imagePromptArea?.classList.remove("flex-1"); 34604 videoPromptArea?.classList.remove("hidden"); 34605 videoPromptArea?.classList.add("flex-1"); 34606 } 34607 const cameraColumn = bar.querySelector("[data-image-camera-column]"); 34608 const multishotRow = bar.querySelector("[data-project-multishot-row]"); 34609 if (mode === "video") { 34610 cameraColumn?.classList.add("hidden"); 34611 multishotRow?.classList.remove("hidden"); 34612 multishotRow?.classList.add("flex"); 34613 } else { 34614 cameraColumn?.classList.remove("hidden"); 34615 multishotRow?.classList.add("hidden"); 34616 multishotRow?.classList.remove("flex"); 34617 } 34618 const resSelector = bar.querySelector("[data-resolution-selector]"); 34619 const audioBtn = bar.querySelector("[data-project-audio-btn]"); 34620 if (mode === "video") { 34621 resSelector?.classList.add("hidden"); 34622 audioBtn?.classList.remove("hidden"); 34623 } else { 34624 resSelector?.classList.remove("hidden"); 34625 audioBtn?.classList.add("hidden"); 34626 } 34627 this._updateProjectMultishotUI(bar); 34628 this._updateEditBarCost(); 34629 }, 34630 _setEditBarImageSubmode(bar, submode) { 34631 this._editBarImageSubmode = submode; 34632 bar.querySelectorAll("[data-image-submode]").forEach((btn) => { 34633 if (btn.dataset.imageSubmode === submode) { 34634 btn.classList.remove("text-gray", "hover:text-white", "bg-inside"); 34635 btn.classList.add("text-white", "bg-active/40"); 34636 } else { 34637 btn.classList.remove("text-white", "bg-active/40"); 34638 btn.classList.add("text-gray", "hover:text-white", "bg-inside"); 34639 } 34640 }); 34641 const liveGrid = bar.querySelector('[data-camera-grid="live"]'); 34642 const animGrid = bar.querySelector('[data-camera-grid="animation"]'); 34643 if (submode === "live") { 34644 liveGrid?.classList.remove("hidden"); 34645 animGrid?.classList.add("hidden"); 34646 } else { 34647 liveGrid?.classList.add("hidden"); 34648 animGrid?.classList.remove("hidden"); 34649 } 34650 }, 34651 _initEditBarCameraDefaults(bar) { 34652 this._editBarResolution = "2k"; 34653 this._editBarImageSubmode = "live"; 34654 this._updateEditBarCameraLabels(bar); 34655 this._loadMovementPreview(bar); 34656 }, 34657 _updateEditBarCameraLabels(bar) { 34658 if (!window.cameraSelections) return; 34659 const mapping = { 34660 "color grade": "[data-grade-button]", 34661 "camera": "[data-device-button]", 34662 "lens": "[data-lens-button]", 34663 "focal": "[data-focal-button]", 34664 "aperture": "[data-aperture-button]" 34665 }; 34666 for (const [category, selector] of Object.entries(mapping)) { 34667 const btn = bar.querySelector(selector); 34668 if (!btn) continue; 34669 const selection = window.cameraSelections[category === "camera" ? "device" : category]; 34670 if (selection?.preview) { 34671 btn.innerHTML = `<div class="w-full aspect-square rounded-md overflow-hidden bg-cover bg-center" style="background-image: url('${selection.preview}')"></div>`; 34672 btn.classList.add("bg-inside"); 34673 } else { 34674 btn.classList.remove("bg-inside"); 34675 } 34676 } 34677 const ratioDisplay = bar.querySelector("[data-setting-ratio]"); 34678 if (ratioDisplay) ratioDisplay.textContent = this._getProjectRatio(); 34679 }, 34680 _updateEditBarCost() { 34681 const bar = this._editBar; 34682 if (!bar) return; 34683 const costEl = bar.querySelector("[data-cost-value]"); 34684 if (!costEl) return; 34685 const mode = this._editBarMode || "image";
34686 let cost = 4; 34687 if (mode === "image") { 34688 const resolution = (this._editBarResolution || "2k").toLowerCase(); 34689 cost = resolution === "4k" ? 16 : 12; 34690 } else { 34691 let duration = parseInt(bar.querySelector("[data-setting-duration]")?.textContent) || 3; 34692 if (window.videoMultishot && window.videoShots) { 34693 duration = window.videoShots.reduce((sum, shot) => sum + (shot.duration || 3), 0); 34694 } 34695 const perSecond = window.audioEnabled ? 35 : 22; 34696 cost = duration * perSecond; 34697 } 34698 costEl.textContent = Math.round(cost); 34699 }, 34700 _editBarPickReference(slot = 1) { 34701 const fileInput = document.createElement("input"); 34702 fileInput.type = "file"; 34703 fileInput.accept = "image/jpeg,image/jpg,image/png,image/webp"; 34704 fileInput.multiple = false; 34705 fileInput.onchange = async (e) => { 34706 const file = e.target.files[0]; 34707 if (!file) return; 34708 const bar = this._editBar; 34709 if (!bar) return; 34710 const url = URL.createObjectURL(file); 34711 const addBtn = bar.querySelector(`[data-add-ref-btn="${slot}"]`); 34712 const preview = bar.querySelector(`[data-ref-preview="${slot}"]`); 34713 const img = bar.querySelector(`[data-ref-img="${slot}"]`); 34714 if (addBtn && preview && img) { 34715 img.src = url; 34716 addBtn.classList.add("hidden"); 34717 preview.classList.remove("hidden"); 34718 } 34719 try { 34720 const formData = new FormData(); 34721 formData.append("file", file); 34722 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 34723 const response = await fetch("/studio/upload", { 34724 method: "POST", 34725 headers: { "X-CSRF-Token": csrfToken }, 34726 body: formData 34727 }); 34728 if (response.ok) { 34729 const data = await response.json(); 34730 if (slot === 1) { 34731 this._editBarReferenceHash = data.hash || null; 34732 this._editBarReferenceUrl = data.url || null; 34733 this._updateRefSlot2Visibility(bar); 34734 } else { 34735 this._editBarReference2Hash = data.hash || null; 34736 this._editBarReference2Url = data.url || null; 34737 } 34738 } 34739 } catch (err) { 34740 console.error("[ProjectCanvas] Reference upload error:", err); 34741 } 34742 URL.revokeObjectURL(url); 34743 }; 34744 fileInput.click(); 34745 }, 34746 _editBarClearReference(bar, slot = 1) { 34747 const addBtn = bar.querySelector(`[data-add-ref-btn="${slot}"]`); 34748 const preview = bar.querySelector(`[data-ref-preview="${slot}"]`); 34749 const img = bar.querySelector(`[data-ref-img="${slot}"]`); 34750 if (addBtn) addBtn.classList.remove("hidden"); 34751 if (preview) preview.classList.add("hidden"); 34752 if (img) img.src = ""; 34753 if (slot === 1) { 34754 this._editBarReferenceHash = null; 34755 this._editBarReferenceUrl = null; 34756 this._editBarClearReference(bar, 2); 34757 } else { 34758 this._editBarReference2Hash = null; 34759 this._editBarReference2Url = null; 34760 } 34761 this._updateRefSlot2Visibility(bar); 34762 }, 34763 _updateRefSlot2Visibility(bar) { 34764 const slot2 = bar?.querySelector('[data-ref-slot="2"]'); 34765 if (!slot2) return; 34766 const hasRef1 = this._editBarReferenceHash || this._editBarReferenceUrl; 34767 if (hasRef1) { 34768 slot2.classList.remove("hidden"); 34769 } else { 34770 slot2.classList.add("hidden"); 34771 } 34772 }, 34773 _showRefContextMenu(bar, btnEl, slot) { 34774 const existing = bar.querySelector("[data-ref-context-menu]"); 34775 if (existing) { 34776 existing.remove(); 34777 return; 34778 } 34779 const menu = document.createElement("div"); 34780 menu.setAttribute("data-ref-context-menu", ""); 34781 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px]"; 34782 menu.style.cssText = "bottom: 100%; left: 0; margin-bottom: 8px;"; 34783 const uploadBtn = document.createElement("button"); 34784 uploadBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 34785 uploadBtn.innerHTML = ` 34786 <div class="flex items-start gap-3"> 34787 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 34788 <path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/> 34789 </svg> 34790 <div> 34791 <div class="font-medium text-white">Upload</div> 34792 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Image from your computer</div> 34793 </div> 34794 </div> 34795 `; 34796 uploadBtn.addEventListener("click", (e) => { 34797 e.stopPropagation(); 34798 menu.remove(); 34799 this._editBarPickReference(slot); 34800 }); 34801 menu.appendChild(uploadBtn); 34802 const generatedBtn = document.createElement("button"); 34803 generatedBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 34804 generatedBtn.innerHTML = ` 34805 <div class="flex items-start gap-3"> 34806 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 34807 <path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M14 3v4a2 2 0 0 0 2 2h4"/><path d="M14 3l7 7"/> 34808 </svg> 34809 <div> 34810 <div class="font-medium text-white">Generated</div> 34811 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Images from this shot</div> 34812 </div> 34813 </div> 34814 `; 34815 generatedBtn.addEventListener("click", (e) => { 34816 e.stopPropagation(); 34817 menu.remove(); 34818 this._pickRefFromShot(bar, slot); 34819 }); 34820 menu.appendChild(generatedBtn); 34821 const favsBtn = document.createElement("button"); 34822 favsBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 34823 favsBtn.innerHTML = ` 34824 <div class="flex items-start gap-3"> 34825 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 34826 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 34827 </svg> 34828 <div> 34829 <div class="font-medium text-white">Favs</div> 34830 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Favorites images</div> 34831 </div> 34832 </div> 34833 `; 34834 favsBtn.addEventListener("click", (e) => { 34835 e.stopPropagation(); 34836 menu.remove(); 34837 this._pendingRefSlot = slot; 34838 this._openFavsModal(); 34839 }); 34840 menu.appendChild(favsBtn); 34841 const slotContainer = btnEl.closest("[data-ref-slot]"); 34842 if (slotContainer) slotContainer.appendChild(menu); 34843 const dismiss = (e) => { 34844 if (!menu.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) { 34845 menu.remove(); 34846 document.removeEventListener("mousedown", dismiss); 34847 } 34848 }; 34849 setTimeout(() => document.addEventListener("mousedown", dismiss), 0); 34850 }, 34851 async _openFavsModal() { 34852 try { 34853 const resp = await fetch("/favs.json"); 34854 if (!resp.ok) throw new Error("Failed to load favs"); 34855 const data = await resp.json(); 34856 this._showFavsPickerModal(data.finals); 34857 } catch (err) { 34858 console.error("[Project] Favs picker error:", err); 34859 if (window.Toast) window.Toast.show("Failed to load favs", "alert"); 34860 } 34861 }, 34862 _showFavsPickerModal(finals) { 34863 const prev = document.getElementById("favs-picker-modal"); 34864 if (prev) prev.remove(); 34865 const modal = document.createElement("div"); 34866 modal.id = "favs-picker-modal"; 34867 modal.className = "fixed inset-0 bg-black/90 flex justify-center"; 34868 modal.style.cssText = "align-items: flex-start; padding-top: 10vh; z-index: 9999;"; 34869 const isEmpty = !finals || finals.length === 0; 34870 let bodyHTML = ""; 34871 if (isEmpty) { 34872 bodyHTML = ` 34873 <div class="flex flex-col items-center justify-center py-12 text-center">
34874 <svg class="w-12 h-12 text-gray mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 34875 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 34876 </svg> 34877 <h3 class="text-white font-medium mb-2">No Favs Yet</h3> 34878 <p class="text-gray text-sm max-w-md">Save images to your favs to use them here.</p> 34879 </div>`; 34880 } else { 34881 const grid = finals.map((f) => { 34882 const playIcon = `<div class="absolute inset-0 flex items-center justify-center pointer-events-none z-10 transition-opacity duration-300 group-hover:opacity-0"><svg class="w-6 h-6 text-white drop-shadow-lg" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg></div>`; 34883 const inner = f.is_video ? `${playIcon}<video src="${f.file_url}" muted playsinline loop preload="metadata" class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-110"></video>` : `<img src="${f.thumbnail_url}" alt="" class="w-full h-full object-cover object-top transition-transform duration-300 group-hover:scale-110" loading="lazy" />`; 34884 return `<button type="button" class="favs-media-node group relative rounded-md overflow-hidden bg-inside cursor-pointer aspect-square" data-media-url="${f.file_url}" data-media-hash="${f.file_hash}" data-media-is-video="${f.is_video}">${inner}</button>`; 34885 }).join(""); 34886 bodyHTML = ` 34887 <div class="overflow-y-auto" style="max-height: 60vh;"> 34888 <div class="grid grid-cols-4 gap-2">${grid}</div> 34889 </div>`; 34890 } 34891 modal.innerHTML = ` 34892 <div class="bg-box rounded-lg p-6 max-w-3xl w-full mx-4 shadow-xl max-h-[80vh] overflow-hidden flex flex-col"> 34893 <div class="flex items-start justify-between mb-4"> 34894 <div> 34895 <h2 class="text-14 font-bold text-white">Favs</h2> 34896 <p class="text-xs text-gray mt-1">Select from your favorites</p> 34897 </div> 34898 <button type="button" data-favs-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 34899 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> 34900 </button> 34901 </div> 34902 <div class="flex-1 overflow-hidden">${bodyHTML}</div> 34903 </div>`; 34904 document.body.appendChild(modal); 34905 const closeModal = () => modal.remove(); 34906 modal.querySelector("[data-favs-close]").addEventListener("click", closeModal); 34907 modal.addEventListener("click", (e) => { 34908 if (e.target === modal) closeModal(); 34909 }); 34910 modal.querySelectorAll(".favs-media-node").forEach((node) => { 34911 node.addEventListener("click", () => { 34912 const url = node.dataset.mediaUrl; 34913 const hash = node.dataset.mediaHash; 34914 const isVideo = node.dataset.mediaIsVideo === "true"; 34915 closeModal(); 34916 document.dispatchEvent(new CustomEvent("items-selected", { 34917 detail: { hash, url, isVideo } 34918 })); 34919 }); 34920 const video = node.querySelector("video"); 34921 if (video) { 34922 node.addEventListener("mouseenter", () => { 34923 video.currentTime = 0; 34924 video.play(); 34925 }); 34926 node.addEventListener("mouseleave", () => { 34927 video.pause(); 34928 video.currentTime = 0; 34929 }); 34930 } 34931 }); 34932 }, 34933 _pickRefFromShot(bar, slot) { 34934 const nodes = this.currentShot?.nodes || []; 34935 const imageNodes = nodes.filter((n) => n.node_type === "image" && n.media_url); 34936 if (imageNodes.length === 0) { 34937 if (window.Toast) window.Toast.show("No images in this shot", "notice"); 34938 return; 34939 } 34940 const modal = document.createElement("div"); 34941 modal.style.cssText = "position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center;"; 34942 const content = document.createElement("div"); 34943 content.className = "bg-box rounded-lg p-4 max-w-lg w-full mx-4 shadow-xl"; 34944 content.innerHTML = ` 34945 <div class="flex items-center justify-between mb-3"> 34946 <h3 class="text-sm font-medium text-white">Select an image</h3> 34947 <button data-close class="text-gray hover:text-white transition cursor-pointer"> 34948 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34948<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 34949 </button> 34950 </div> 34951 <div class="grid grid-cols-4 gap-2 max-h-[300px] overflow-y-auto"></div> 34952 `; 34953 modal.appendChild(content); 34954 const grid = content.querySelector(".grid"); 34955 imageNodes.forEach((node) => { 34956 const thumb = document.createElement("div"); 34957 thumb.className = "rounded-md overflow-hidden cursor-pointer hover:ring-2 hover:ring-active transition"; 34958 thumb.style.cssText = "aspect-ratio: 1/1;"; 34959 thumb.innerHTML = `<img src="${node.thumbnail_url || node.media_url}" class="w-full h-full object-cover" />`; 34960 thumb.addEventListener("click", () => { 34961 const addBtn = bar.querySelector(`[data-add-ref-btn="${slot}"]`); 34962 const preview = bar.querySelector(`[data-ref-preview="${slot}"]`); 34963 const img = bar.querySelector(`[data-ref-img="${slot}"]`); 34964 if (addBtn && preview && img) { 34965 img.src = node.thumbnail_url || node.media_url; 34966 addBtn.classList.add("hidden"); 34967 preview.classList.remove("hidden"); 34968 } 34969 if (slot === 1) { 34970 this._editBarReferenceHash = node.file_hash || null; 34971 this._editBarReferenceUrl = node.media_url || null; 34972 } else { 34973 this._editBarReference2Hash = node.file_hash || null; 34974 this._editBarReference2Url = node.media_url || null; 34975 } 34976 this._updateRefSlot2Visibility(bar); 34977 modal.remove(); 34978 }); 34979 grid.appendChild(thumb); 34980 }); 34981 content.querySelector("[data-close]").addEventListener("click", () => modal.remove()); 34982 modal.addEventListener("click", (e) => { 34983 if (e.target === modal) modal.remove(); 34984 }); 34985 document.body.appendChild(modal); 34986 }, 34987 // ââ localStorage sync ââââââââââââââââââââââââââââââââââââââ 34988 _syncGlobalsFromLocalStorage() { 34989 const get = (k) => localStorage.getItem(k); 34990 const json = (k) => { 34991 try { 34992 return JSON.parse(get(k)); 34993 } catch { 34994 return null; 34995 } 34996 }; 34997 const device = json("project-image-device"); 34998 const lens = json("project-image-lens"); 34999 const focal = json("project-image-focal"); 35000 const aperture = json("project-image-aperture"); 35001 const grade = json("project-image-grade"); 35002 if (device || lens || focal || aperture || grade) { 35003 window.cameraSelections = window.cameraSelections || {}; 35004 if (device) window.cameraSelections["device"] = device; 35005 if (lens) window.cameraSelections["lens"] = lens; 35006 if (focal) window.cameraSelections["focal"] = focal; 35007 if (aperture) window.cameraSelections["aperture"] = aperture; 35008 if (grade) window.cameraSelections["color grade"] = grade; 35009 } 35010 const ratio = get("project-image-ratio"); 35011 if (ratio) window.selectedRatio = ratio; 35012 const movement = json("project-video-movement"); 35013 if (movement) window.selectedMovement = movement; 35014 window.slowMotion = get("project-video-slowmo") === "true"; 35015 window.audioEnabled = get("project-video-audio") === "true"; 35016 const duration = get("project-video-duration"); 35017 window.selectedDuration = duration || "3s"; 35018 if (!window.videoMultishot && parseInt(window.selectedDuration) < 3) { 35019 window.selectedDuration = "3s"; 35020 localStorage.setItem("project-video-duration", "3s"); 35021 } 35022 const resolution = get("project-resolution"); 35023 if (resolution) window.selectedResolution = resolution; 35024 const submode = get("project-image-submode"); 35025 if (submode) window.selectedImageSubmode = submode; 35026 }, 35027 // ââ Project multishot UI (total duration + generate button) ââ 35028 // ââ Project multishot (self-contained, no studio.js dependency) ââ 35029 _initProjectMultishot(bar) { 35030 window.videoMultishot = localStorage.getItem("studio-video-multishot") === "true"; 35031 const savedShots = localStorage.getItem("studio-video-shots"); 35032 if (savedShots) { 35033 try { 35034 window.videoShots = JSON.parse(savedShots); 35035 } catch { 35036 window.videoShots = [{ prompt: "", duration: 3, slow_motion: false, movement: null }]; 35037 } 35038 } else { 35039 window.videoShots = [{ prompt: "", duration: 3, slow_motion: false, movement: null }]; 35040 } 35041 window.activeShot = 0; 35042 this._updateProjectMultishotToggleUI(bar); 35043 this._renderProjectShotTabs(bar); 35044 this._setupProjectShotTabListeners(bar); 35045 this._updateProjectMultishotUI(bar); 35046 }, 35047 _toggleProjectMultishot(bar) { 35048 window.videoMultishot = !window.videoMultishot; 35049 localStorage.setItem("studio-video-multishot", window.videoMultishot); 35050 if (!window.videoMultishot) { 35051 const current = parseInt(window.selectedDuration) || 3; 35052 if (current < 3) { 35053 window.selectedDuration = "3s"; 35054 bar.querySelectorAll("[data-setting-duration]").forEach((e) => e.textContent = "3s"); 35055 } 35056 } 35057 this._updateProjectMultishotToggleUI(bar); 35058 this._renderProjectShotTabs(bar); 35059 this._updateProjectMultishotUI(bar); 35060 }, 35061 _updateProjectMultishotToggleUI(bar) { 35062 const track = bar.querySelector("[data-multishot-track]"); 35063 const thumb = bar.querySelector("[data-multishot-thumb]"); 35064 const shotTabsContainer = bar.querySelector("[data-shot-tabs-container]"); 35065 if (window.videoMultishot) { 35066 track?.classList.remove("bg-dark"); 35067 track?.classList.add("bg-active"); 35068 thumb?.classList.remove("left-0.5", "bg-gray"); 35069 thumb?.classList.add("left-4.5", "bg-white"); 35070 shotTabsContainer?.classList.remove("hidden"); 35071 shotTabsContainer?.classList.add("flex"); 35072 } else { 35073 track?.classList.remove("bg-active"); 35074 track?.classList.add("bg-dark"); 35075 thumb?.classList.remove("left-4.5", "bg-white"); 35076 thumb?.classList.add("left-0.5", "bg-gray"); 35077 shotTabsContainer?.classList.add("hidden"); 35078 shotTabsContainer?.classList.remove("flex"); 35079 } 35080 }, 35081 _renderProjectShotTabs(bar) { 35082 const tabsContainer = bar.querySelector("[data-shot-tabs]"); 35083 if (!tabsContainer) return; 35084 const shots = window.videoShots || []; 35085 const activeIndex = window.activeShot || 0; 35086 let html = shots.map((shot, index) => { 35087 const isActive = index === activeIndex; 35088 const activeClasses = isActive ? "bg-active text-white" : "bg-inside text-gray hover:text-white"; 35089 const showDelete = isActive && shots.length > 1; 35090 return ` 35091 <div class="relative group" data-shot-tab="${index}"> 35092 <button type="button" class="px-4 py-1.5 rounded-md text-[9px] uppercase font-medium transition duration-300 cursor-pointer ${activeClasses}" data-shot-tab-btn="${index}"> 35093 Shot ${index + 1} 35094 </button> 35095 ${showDelete ? ` 35096 <button type="button" class="absolute -top-1 -right-1 w-3 h-3 rounded-sm bg-dark/80 hover:bg-red-500/80 flex items-center justify-center cursor-pointer" data-remove-sh
35096ot-btn="${index}" title="Remove shot"> 35097 <svg class="w-2 h-2 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 35098 </button> 35099 ` : ""} 35100 </div> 35101 `; 35102 }).join(""); 35103 if (shots.length < 7) { 35104 html += ` 35105 <button type="button" class="flex-shrink-0 w-6 h-6 rounded-md bg-inside hover:bg-active/40 flex items-center justify-center text-gray hover:text-white transition duration-300 cursor-pointer" data-add-shot-btn title="Add shot"> 35106 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg> 35107 </button> 35108 `; 35109 } 35110 tabsContainer.innerHTML = html; 35111 this._loadShotToPrompt(bar, activeIndex); 35112 }, 35113 _setupProjectShotTabListeners(bar) { 35114 const container = bar.querySelector("[data-shot-tabs-container]"); 35115 if (!container) return; 35116 container.addEventListener("click", (e) => { 35117 const tabBtn = e.target.closest("[data-shot-tab-btn]"); 35118 if (tabBtn) { 35119 const index = parseInt(tabBtn.dataset.shotTabBtn); 35120 this._switchProjectShot(bar, index); 35121 return; 35122 } 35123 const removeBtn = e.target.closest("[data-remove-shot-btn]"); 35124 if (removeBtn) { 35125 e.stopPropagation(); 35126 this._removeProjectShot(bar, parseInt(removeBtn.dataset.removeShotBtn)); 35127 return; 35128 } 35129 const addBtn = e.target.closest("[data-add-shot-btn]"); 35130 if (addBtn) { 35131 this._addProjectShot(bar); 35132 return; 35133 } 35134 }); 35135 }, 35136 _switchProjectShot(bar, index) { 35137 if (!window.videoShots || index < 0 || index >= window.videoShots.length) return; 35138 this._saveCurrentShot(bar); 35139 window.activeShot = index; 35140 this._renderProjectShotTabs(bar); 35141 this._updateProjectMultishotUI(bar); 35142 }, 35143 _addProjectShot(bar) { 35144 if (!window.videoShots) window.videoShots = []; 35145 if (window.videoShots.length >= 7) return; 35146 this._saveCurrentShot(bar); 35147 window.videoShots.push({ prompt: "", duration: 3, slow_motion: false, movement: null }); 35148 localStorage.setItem("studio-video-shots", JSON.stringify(window.videoShots)); 35149 window.activeShot = window.videoShots.length - 1; 35150 this._renderProjectShotTabs(bar); 35151 this._updateProjectMultishotUI(bar); 35152 }, 35153 _removeProjectShot(bar, index) { 35154 if (!window.videoShots || window.videoShots.length <= 1) return; 35155 window.videoShots.splice(index, 1); 35156 localStorage.setItem("studio-video-shots", JSON.stringify(window.videoShots)); 35157 if (window.activeShot >= window.videoShots.length) { 35158 window.activeShot = window.videoShots.length - 1; 35159 } else if (window.activeShot > index) { 35160 window.activeShot--; 35161 } 35162 this._renderProjectShotTabs(bar); 35163 this._updateProjectMultishotUI(bar); 35164 }, 35165 _saveCurrentShot(bar) { 35166 const el = bar.querySelector("[data-project-video-prompt-input]"); 35167 if (!el || !window.videoShots) return; 35168 const index = window.activeShot || 0; 35169 if (window.videoShots[index]) { 35170 window.videoShots[index].prompt = typeof this.getEditorTextContent === "function" ? this.getEditorTextContent(el) || "" : el.innerText?.trim() || ""; 35171 window.videoShots[index]._promptHtml = el.innerHTML || ""; 35172 window.videoShots[index].duration = parseInt(window.selectedDuration) || 3; 35173 window.videoShots[index].slow_motion = window.slowMotion || false; 35174 window.videoShots[index].movement = window.selectedMovement?.uuid || null; 35175 localStorage.setItem("studio-video-shots", JSON.stringify(window.videoShots)); 35176 } 35177 }, 35178 _loadShotToPrompt(bar, index) { 35179 const el = bar.querySelector("[data-project-video-prompt-input]"); 35180 if (!el || !window.videoShots) return; 35181 const shot = window.videoShots[index]; 35182 if (!shot) return; 35183 if (shot._promptHtml) { 35184 el.innerHTML = shot._promptHtml; 35185 } else { 35186 this._setVideoPromptText(el, shot.prompt || ""); 35187 } 35188 if (typeof this._bindExistingMentions === "function") { 35189 this._bindExistingMentions(el); 35190 } 35191 const duration = shot.duration || 3; 35192 window.selectedDuration = duration + "s"; 35193 bar.querySelectorAll("[data-setting-duration]").forEach((e) => e.textContent = window.selectedDuration); 35194 window.slowMotion = shot.slow_motion || false; 35195 bar.querySelectorAll("[data-setting-slowmo]").forEach((e) => e.textContent = window.slowMotion ? "On" : "Off"); 35196 if (shot.movement) { 35197 const movDataEl = document.getElementById("project-movement-prompts-data"); 35198 if (movDataEl) { 35199 try { 35200 const movData = JSON.parse(movDataEl.textContent); 35201 const mov = movData.find((m) => m.uuid === shot.movement); 35202 window.selectedMovement = mov ? { uuid: mov.uuid, name: mov.name, preview: mov.preview } : null; 35203 } catch { 35204 window.selectedMovement = null; 35205 } 35206 } 35207 } else { 35208 window.selectedMovement = null; 35209 } 35210 this._loadMovementPreview(bar); 35211 }, 35212 _updateProjectMultishotUI(bar) { 35213 if (!bar) bar = this._editBar; 35214 if (!bar) return; 35215 const isVideoMode = this._editBarMode === "video"; 35216 const totalDurationCell = bar.querySelector("[data-total-duration-cell]"); 35217 const totalDurationEl = bar.querySelector("[data-total-duration]"); 35218 const generateBtn = bar.querySelector("[data-project-edit-generate]"); 35219 if (window.videoMultishot && isVideoMode) { 35220 totalDurationCell?.classList.remove("hidden"); 35221 let total = 0; 35222 if (window.videoShots) { 35223 total = window.videoShots.reduce((sum, shot) => sum + (shot.duration || 3), 0); 35224 } 35225 if (totalDurationEl) totalDurationEl.textContent = total + "s"; 35226 const isInvalid = total < 3 || total > 15; 35227 if (totalDurationCell) { 35228 const spans = totalDurationCell.querySelectorAll("span"); 35229 if (isInvalid) { 35230 spans.forEach((s) => { 35231 s.classList.add("text-rose-600"); 35232 s.classList.remove("text-gray", "text-white"); 35233 }); 35234 } else { 35235 spans[0]?.classList.remove("text-rose-600"); 35236 spans[0]?.classList.add("text-gray"); 35237 spans[1]?.classList.remove("text-rose-600"); 35238 spans[1]?.classList.add("text-white"); 35239 } 35240 } 35241 if (generateBtn) { 35242 if (isInvalid) { 35243 generateBtn.disabled = true; 35244 generateBtn.classList.add("opacity-50", "cursor-not-allowed"); 35245 } else { 35246 generateBtn.disabled = false;
35247 generateBtn.classList.remove("opacity-50", "cursor-not-allowed"); 35248 } 35249 } 35250 } else { 35251 totalDurationCell?.classList.add("hidden"); 35252 const current = parseInt(window.selectedDuration) || 3; 35253 if (current < 3) { 35254 window.selectedDuration = "3s"; 35255 localStorage.setItem("project-video-duration", "3s"); 35256 const durationEl = bar.querySelector("[data-setting-duration]"); 35257 if (durationEl) durationEl.textContent = "3s"; 35258 } 35259 if (generateBtn) { 35260 generateBtn.disabled = false; 35261 generateBtn.classList.remove("opacity-50", "cursor-not-allowed"); 35262 } 35263 } 35264 this._updateEditBarCost(); 35265 }, 35266 // ââ Frame context menu (Upload / Generated / Library) âââââ 35267 _showFrameContextMenu(bar, frameBtn, slot) { 35268 const existing = bar.querySelector("[data-frame-context-menu]"); 35269 if (existing) { 35270 existing.remove(); 35271 return; 35272 } 35273 const menu = document.createElement("div"); 35274 menu.setAttribute("data-frame-context-menu", ""); 35275 menu.className = "absolute bg-inside rounded-md shadow-lg z-50 px-1 py-1.5 w-[200px]"; 35276 menu.style.cssText = "bottom: 100%; left: 0; margin-bottom: 8px;"; 35277 const uploadBtn = document.createElement("button"); 35278 uploadBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 35279 uploadBtn.innerHTML = ` 35280 <div class="flex items-start gap-3"> 35281 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 35282 <path d="M12 13v8"/><path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"/><path d="m8 17 4-4 4 4"/> 35283 </svg> 35284 <div> 35285 <div class="font-medium text-white">Upload</div> 35286 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Image from your computer</div> 35287 </div> 35288 </div> 35289 `; 35290 uploadBtn.addEventListener("click", (e) => { 35291 e.stopPropagation(); 35292 menu.remove(); 35293 this._uploadFrame(bar, slot); 35294 }); 35295 menu.appendChild(uploadBtn); 35296 const generatedBtn = document.createElement("button"); 35297 generatedBtn.className = "group w-full px-3 py-2.5 text-left text-xs hover:bg-light/20 rounded-md transition-colors cursor-pointer"; 35298 generatedBtn.innerHTML = ` 35299 <div class="flex items-start gap-3"> 35300 <svg class="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 35301 <path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M14 3v4a2 2 0 0 0 2 2h4"/><path d="M14 3l7 7"/> 35302 </svg> 35303 <div> 35304 <div class="font-medium text-white">Generated</div> 35305 <div class="text-[10px] text-gray group-hover:text-white leading-tight mt-0.5">Images from this shot</div> 35306 </div> 35307 </div> 35308 `; 35309 generatedBtn.addEventListener("click", (e) => { 35310 e.stopPropagation(); 35311 menu.remove(); 35312 this._pickFrameFromShot(bar, slot); 35313 }); 35314 menu.appendChild(generatedBtn); 35315 const slotContainer = frameBtn.closest("[data-video-frame-slot]"); 35316 if (slotContainer) slotContainer.appendChild(menu); 35317 const dismiss = (e) => { 35318 if (!menu.contains(e.target) && e.target !== frameBtn && !frameBtn.contains(e.target)) { 35319 menu.remove(); 35320 document.removeEventListener("mousedown", dismiss); 35321 } 35322 }; 35323 setTimeout(() => document.addEventListener("mousedown", dismiss), 0); 35324 }, 35325 _uploadFrame(bar, slot) { 35326 const fileInput = document.createElement("input"); 35327 fileInput.type = "file"; 35328 fileInput.accept = "image/jpeg,image/jpg,image/png,image/webp"; 35329 fileInput.multiple = false; 35330 fileInput.onchange = async (e) => { 35331 const file = e.target.files[0]; 35332 if (!file) return; 35333 const url = URL.createObjectURL(file); 35334 this._setFramePreview(bar, slot, url);
35335 try { 35336 const formData = new FormData(); 35337 formData.append("file", file); 35338 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 35339 const response = await fetch("/studio/upload", { 35340 method: "POST", 35341 headers: { "X-CSRF-Token": csrfToken }, 35342 body: formData 35343 }); 35344 if (response.ok) { 35345 const data = await response.json(); 35346 if (slot === 1) { 35347 this._editBarStartFrameHash = data.hash || null; 35348 } else { 35349 this._editBarEndFrameHash = data.hash || null; 35350 } 35351 } 35352 } catch (err) { 35353 console.error("[ProjectCanvas] Frame upload error:", err); 35354 } 35355 URL.revokeObjectURL(url); 35356 }; 35357 fileInput.click(); 35358 }, 35359 _pickFrameFromShot(bar, slot) { 35360 const nodes = this.currentShot?.nodes || []; 35361 const imageNodes = nodes.filter((n) => n.node_type === "image" && n.media_url); 35362 if (imageNodes.length === 0) { 35363 if (window.Toast) window.Toast.show("No images in this shot", "notice"); 35364 return; 35365 } 35366 const modal = document.createElement("div"); 35367 modal.style.cssText = "position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.85); display: flex; align-items: center; justify-content: center;"; 35368 const content = document.createElement("div"); 35369 content.className = "bg-box rounded-lg p-4 max-w-lg w-full mx-4 shadow-xl"; 35370 content.innerHTML = ` 35371 <div class="flex items-center justify-between mb-3"> 35372 <h3 class="text-sm font-medium text-white">Select an image</h3> 35373 <button data-close class="text-gray hover:text-white transition cursor-pointer"> 35374 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 35375 </button> 35376 </div> 35377 <div class="grid grid-cols-4 gap-2 max-h-[300px] overflow-y-auto"></div> 35378 `; 35379 modal.appendChild(content); 35380 const grid = content.querySelector(".grid"); 35381 imageNodes.forEach((node) => { 35382 const thumb = document.createElement("div"); 35383 thumb.className = "rounded-md overflow-hidden cursor-pointer hover:ring-2 hover:ring-active transition"; 35384 thumb.style.cssText = "aspect-ratio: 1/1;"; 35385 thumb.innerHTML = `<img src="${node.thumbnail_url || node.media_url}" class="w-full h-full object-cover" />`; 35386 thumb.addEventListener("click", () => { 35387 this._setFramePreview(bar, slot, node.thumbnail_url || node.media_url); 35388 if (slot === 1) { 35389 this._editBarStartFrameHash = node.file_hash || null; 35390 this._editBarStartFrameUrl = node.media_url || null; 35391 } else { 35392 this._editBarEndFrameHash = node.file_hash || null; 35393 this._editBarEndFrameUrl = node.media_url || null; 35394 } 35395 modal.remove(); 35396 }); 35397 grid.appendChild(thumb); 35398 }); 35399 content.querySelector("[data-close]").addEventListener("click", () => modal.remove()); 35400 modal.addEventListener("click", (e) => { 35401 if (e.target === modal) modal.remove(); 35402 }); 35403 document.body.appendChild(modal); 35404 }, 35405 _setFramePreview(bar, slot, imageUrl) { 35406 const frame = slot === 1 ? "start" : "end"; 35407 const frameBtn = bar.querySelector(`[data-video-frame="${frame}"]`); 35408 if (frameBtn) { 35409 frameBtn.innerHTML = ` 35410 <img src="${imageUrl}" class="w-full h-full object-cover rounded-lg" /> 35411 <div class="absolute top-0.5 right-0.5 w-4 h-4 bg-dark/80 hover:bg-red-500/80 rounded-md flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer z-10" data-remove-frame="${slot}"> 35412 <svg class="w-2.5 h-2.5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12"/></svg> 35413 </div> 35414 `; 35415 frameBtn.classList.add("ring-1", "ring-active/50", "group"); 35416 frameBtn.style.position = "relative"; 35417 const removeBtn = frameBtn.querySelector(`[data-remove-frame="${slot}"]`); 35418 if (removeBtn) { 35419 removeBtn.addEventListener("click", (e) => { 35420 e.stopPropagation();
35421 frameBtn.innerHTML = `${frame === "start" ? "Start" : "End"}<br>Frame`; 35422 frameBtn.classList.remove("ring-1", "ring-active/50", "group"); 35423 frameBtn.style.position = ""; 35424 if (slot === 1) { 35425 this._editBarStartFrameHash = null; 35426 this._editBarStartFrameUrl = null; 35427 } else { 35428 this._editBarEndFrameHash = null; 35429 this._editBarEndFrameUrl = null; 35430 } 35431 }); 35432 } 35433 } 35434 }, 35435 // ââ Default movement preview ââââââââââââââââââââââââââââââ 35436 _loadMovementPreview(bar) { 35437 const preview = bar.querySelector("[data-movement-preview]"); 35438 if (!preview) return; 35439 let previewUrl = window.selectedMovement?.preview; 35440 if (!previewUrl) { 35441 const movementDataEl = document.getElementById("project-movement-prompts-data"); 35442 if (movementDataEl) { 35443 try { 35444 const movementData = JSON.parse(movementDataEl.textContent); 35445 const staticMovement = movementData.find((m) => m.name.toLowerCase() === "static"); 35446 if (staticMovement?.preview) previewUrl = staticMovement.preview; 35447 } catch (e) { 35448 } 35449 } 35450 } 35451 if (previewUrl) { 35452 const isVideo = /\.(mp4|webm|mov)(\?|$)/i.test(previewUrl); 35453 if (isVideo) { 35454 preview.innerHTML = `<video src="${previewUrl}" class="w-full h-full object-cover" muted loop autoplay playsinline></video>`; 35455 } else { 35456 preview.innerHTML = `<img src="${previewUrl}" class="w-full h-full object-cover" />`; 35457 } 35458 } 35459 } 35460}; 35461
35462// app/javascript/project/canvas.js 35463var ProjectCanvas = class _ProjectCanvas { 35464 constructor(container, { projectUuid, sequenceId, ratio, projectType }) { 35465 this.container = container; 35466 this.projectUuid = projectUuid; 35467 this.sequenceId = sequenceId; 35468 this.ratio = ratio; 35469 this.projectType = projectType; 35470 this.GRID_SNAP = 20; 35471 this.panX = 0; 35472 this.panY = 0; 35473 this.zoom = 1; 35474 this.isPanning = false; 35475 this.lastPosX = 0; 35476 this.lastPosY = 0; 35477 this.MIN_ZOOM = 0.2; 35478 this.MAX_ZOOM = 1; 35479 this.ZOOM_STEP = 1.1; 35480 this.ANIMATION_DURATION = 500; 35481 this.NODE_WIDTH = this.getNodeWidthForRatio(this.ratio); 35482 this.listeners = []; 35483 this.currentShotUuid = null; 35484 this.currentShot = null; 35485 this.emptyNodeCenter = null; 35486 this.setup(); 35487 } 35488 baseUrl() { 35489 return `/${this.projectType}/${this.projectUuid}/${this.sequenceId}`; 35490 } 35491 setup() { 35492 this.updateScenesBarRight(); 35493 const width = this.container.clientWidth; 35494 const height = this.container.clientHeight; 35495 this.initialCenterX = width / 2; 35496 this.initialCenterY = height / 2; 35497 this.dotsElement = document.getElementById("project-dots"); 35498 this.viewport = document.createElement("div"); 35499 this.viewport.id = "project-viewport"; 35500 this.viewport.style.cssText = ` 35501 position: absolute; 35502 width: 20000px; 35503 height: 20000px; 35504 top: 0; 35505 left: 0; 35506 transform-origin: 0 0; 35507 will-change: transform; 35508 `; 35509 this.container.appendChild(this.viewport); 35510 this.svgLayer = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 35511 this.svgLayer.setAttribute("id", "project-connections"); 35512 this.svgLayer.setAttribute("width", "20000"); 35513 this.svgLayer.setAttribute("height", "20000"); 35514 this.svgLayer.style.cssText = ` 35515 position: absolute; 35516 inset: 0; 35517 pointer-events: none; 35518 overflow: visible; 35519 `; 35520 this.viewport.appendChild(this.svgLayer); 35521 this.nodesContainer = document.createElement("div"); 35522 this.nodesContainer.id = "project-nodes"; 35523 this.nodesContainer.style.cssText = ` 35524 position: absolute; 35525 inset: 0; 35526 `; 35527 this.viewport.appendChild(this.nodesContainer); 35528 this.setupPanZoom(); 35529 this.bindZoomControls(); 35530 this.setupShots(); 35531 this.setupSceneReorder(); 35532 this.setupShotReorder(); 35533 this.initGroups(); 35534 this.setupContextMenu(); 35535 if (this.projectType === "movie" || this.projectType === "short" || this.projectType === "element") { 35536 this.setupSequenceMenu(); 35537 } 35538 const previewBtn = document.querySelector("[data-project-preview]"); 35539 if (previewBtn) { 35540 this.addListener(previewBtn, "click", (e) => { 35541 e.stopPropagation(); 35542 this.showPreviewMenu(previewBtn); 35543 }); 35544 } 35545 this.updateTransform(); 35546 this.addListener(window, "resize", () => { 35547 this.updateTransform(); 35548 this.updateScenesBarRight(); 35549 }); 35550 } 35551 addListener(target, event, handler) { 35552 target.addEventListener(event, handler); 35553 this.listeners.push({ target, event, handler }); 35554 } 35555 revealFacesDependents() { 35556 document.querySelectorAll("[data-needs-faces]").forEach((el) => { 35557 el.classList.remove("hidden"); 35558 el.removeAttribute("data-needs-faces"); 35559 }); 35560 } 35561 updateScenesBarRight() { 35562 const topBar = document.getElementById("project-top-bar"); 35563 const userBox = document.querySelector('[data-dropdown-trigger="user"]')?.closest(".fixed"); 35564 if (topBar && userBox) { 35565 const userRect = userBox.getBoundingClientRect(); 35566 topBar.style.right = `${window.innerWidth - userRect.left + 10}px`; 35567 } 35568 } 35569 // --- Pan / Zoom --- 35570 setupPanZoom() { 35571 this.addListener(this.container, "mousedown", (e) => { 35572 if (e.button === 1 || e.button === 0 && e.shiftKey) { 35573 if (e.shiftKey && e.target.closest(".project-node")) return; 35574 this.isPanning = true; 35575 this.lastPosX = e.clientX; 35576 this.lastPosY = e.clientY; 35577 this.container.style.cursor = "grabbing"; 35578 e.preventDefault(); 35579 } 35580 }); 35581 this.addListener(this.container, "mousemove", (e) => { 35582 if (this.isPanning) { 35583 const deltaX = e.clientX - this.lastPosX; 35584 const deltaY = e.clientY - this.lastPosY; 35585 this.panX += deltaX; 35586 this.panY += deltaY; 35587 this.lastPosX = e.clientX; 35588 this.lastPosY = e.clientY; 35589 this.updateTransform(); 35590 } 35591 }); 35592 this.addListener(window, "mouseup", () => { 35593 if (this.isPanning) { 35594 this.isPanning = false;
35595 this.container.style.cursor = "default"; 35596 } 35597 }); 35598 this.addListener(this.container, "wheel", (e) => { 35599 if (e.ctrlKey || e.metaKey) { 35600 e.preventDefault(); 35601 const delta = e.deltaY; 35602 const zoomFactor = 0.999 ** delta; 35603 const newZoom = this.zoom * zoomFactor; 35604 if (newZoom > this.MAX_ZOOM || newZoom < this.MIN_ZOOM) return; 35605 const rect = this.container.getBoundingClientRect(); 35606 const mouseX = e.clientX - rect.left; 35607 const mouseY = e.clientY - rect.top; 35608 this.panX = mouseX - (mouseX - this.panX) * (newZoom / this.zoom); 35609 this.panY = mouseY - (mouseY - this.panY) * (newZoom / this.zoom); 35610 this.zoom = newZoom; 35611 this.updateTransform(); 35612 this.updateZoomDisplay(); 35613 } else { 35614 e.preventDefault(); 35615 this.panX -= e.deltaX; 35616 this.panY -= e.deltaY; 35617 this.updateTransform(); 35618 } 35619 }); 35620 } 35621 updateTransform() { 35622 if (!this.viewport) return; 35623 const vw = this.container.clientWidth; 35624 const vh = this.container.clientHeight; 35625 const maxX = vw * 0.5; 35626 const maxY = vh * 0.5; 35627 const minX = -(2e4 * this.zoom - vw * 0.5); 35628 const minY = -(2e4 * this.zoom - vh * 0.5); 35629 this.panX = Math.max(minX, Math.min(maxX, this.panX)); 35630 this.panY = Math.max(minY, Math.min(maxY, this.panY)); 35631 this.viewport.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`; 35632 this.updateGrid(); 35633 } 35634 getViewportCenter() { 35635 const rect = this.container.getBoundingClientRect(); 35636 return { 35637 x: (rect.width / 2 - this.panX) / this.zoom, 35638 y: (rect.height / 2 - this.panY) / this.zoom 35639 }; 35640 } 35641 updateGrid() { 35642 if (!this.dotsElement) return; 35643 let opacity = 0; 35644 if (this.zoom >= 1) { 35645 opacity = 0.2; 35646 } else if (this.zoom > 0.5) { 35647 opacity = (this.zoom - 0.5) / 0.5 * 0.2; 35648 } 35649 const gridSize = 20 * this.zoom; 35650 this.dotsElement.style.backgroundImage = `radial-gradient(circle, rgba(255, 255, 255, ${opacity}) 0.75px, transparent 0.75px)`; 35651 this.dotsElement.style.backgroundSize = `${gridSize}px ${gridSize}px`; 35652 this.dotsElement.style.backgroundPosition = `${this.panX}px ${this.panY}px`; 35653 this.dotsElement.style.webkitMask = "radial-gradient(circle, black 0%, rgba(0, 0, 0, 0.8) 30%, transparent 160%)"; 35654 this.dotsElement.style.mask = "radial-gradient(circle, black 0%, rgba(0, 0, 0, 0.8) 30%, transparent 160%)"; 35655 }
35656 snapToGrid(value) { 35657 return Math.round(value / this.GRID_SNAP) * this.GRID_SNAP; 35658 } 35659 snapElementToGrid(el) { 35660 const rawX = parseFloat(el.style.left) || 0; 35661 const rawY = parseFloat(el.style.top) || 0; 35662 const snappedX = this.snapToGrid(rawX); 35663 const snappedY = this.snapToGrid(rawY); 35664 if (snappedX === rawX && snappedY === rawY) return { x: snappedX, y: snappedY }; 35665 el.style.transition = "left 0.15s ease, top 0.15s ease"; 35666 el.style.left = `${snappedX}px`; 35667 el.style.top = `${snappedY}px`; 35668 const cleanup = () => { 35669 el.style.transition = ""; 35670 el.removeEventListener("transitionend", cleanup); 35671 }; 35672 el.addEventListener("transitionend", cleanup); 35673 return { x: snappedX, y: snappedY }; 35674 } 35675 // --- Zoom controls --- 35676 updateZoomDisplay() { 35677 const zoomDisplay = document.querySelector("[data-project-zoom-display]"); 35678 if (zoomDisplay) { 35679 zoomDisplay.textContent = Math.round(this.zoom * 100) + "%"; 35680 } 35681 } 35682 zoomIn() { 35683 let targetZoom = Math.round(this.zoom * 10 + 1) / 10; 35684 if (targetZoom > this.MAX_ZOOM) targetZoom = this.MAX_ZOOM; 35685 const viewportCenterX = this.container.clientWidth / 2; 35686 const viewportCenterY = this.container.clientHeight / 2; 35687 const worldX = (viewportCenterX - this.panX) / this.zoom; 35688 const worldY = (viewportCenterY - this.panY) / this.zoom; 35689 this.animateToZoom(worldX, worldY, targetZoom); 35690 } 35691 zoomOut() { 35692 let targetZoom = Math.round(this.zoom * 10 - 1) / 10; 35693 if (targetZoom < this.MIN_ZOOM) targetZoom = this.MIN_ZOOM; 35694 const viewportCenterX = this.container.clientWidth / 2; 35695 const viewportCenterY = this.container.clientHeight / 2; 35696 const worldX = (viewportCenterX - this.panX) / this.zoom; 35697 const worldY = (viewportCenterY - this.panY) / this.zoom; 35698 this.animateToZoom(worldX, worldY, targetZoom); 35699 } 35700 // Get top-level canvas elements (ungrouped nodes + groups, skip nodes inside groups) 35701 _getCanvasElements() { 35702 const elements = []; 35703 this.nodesContainer.querySelectorAll(":scope > .project-node, :scope > .project-group").forEach((el) => { 35704 let left = parseFloat(el.style.left) || 0; 35705 let top = parseFloat(el.style.top) || 0; 35706 const width = el.offsetWidth; 35707 const height = el.offsetHeight; 35708 if (el.style.transform?.includes("translate(-50%")) { 35709 left -= width / 2; 35710 top -= height / 2; 35711 } 35712 elements.push({ el, left, top, width, height }); 35713 }); 35714 return elements; 35715 } 35716 _findTargetElement() { 35717 const selectedMediaId = this.currentShot?.selected_media_id; 35718 if (selectedMediaId) { 35719 const allNodeEls = this.nodesContainer.querySelectorAll(".project-node[data-
35719project-node-id]"); 35720 for (const el of allNodeEls) { 35721 const mediaEl = el.querySelector(`[data-project-media="${selectedMediaId}"]`); 35722 if (mediaEl) { 35723 const groupEl = el.closest(".project-group"); 35724 const target = groupEl || el; 35725 const left = parseFloat(target.style.left) || 0; 35726 const top = parseFloat(target.style.top) || 0; 35727 return { left, top, width: target.offsetWidth, height: target.offsetHeight }; 35728 } 35729 } 35730 } 35731 const elements = this._getCanvasElements(); 35732 return elements.length > 0 ? elements[0] : null; 35733 } 35734 resetZoom() { 35735 const barOffset = 92; 35736 const target = this._findTargetElement(); 35737 if (target) { 35738 const centerX = target.left + target.width / 2; 35739 const centerY = target.top + target.height / 2; 35740 this.animateToZoom(centerX, centerY, 1, barOffset); 35741 } else { 35742 this.animateToZoom(this.initialCenterX, this.initialCenterY, 1, barOffset); 35743 } 35744 } 35745 fitToScreen() { 35746 const elements = this._getCanvasElements(); 35747 if (elements.length === 0) { 35748 this.animateToZoom(this.initialCenterX, this.initialCenterY, 1); 35749 return; 35750 } 35751 const minX = Math.min(...elements.map((n) => n.left)); 35752 const maxX = Math.max(...elements.map((n) => n.left + n.width)); 35753 const minY = Math.min(...elements.map((n) => n.top)); 35754 const maxY = Math.max(...elements.map((n) => n.top + n.height)); 35755 const contentWidth = maxX - minX; 35756 const contentHeight = maxY - minY; 35757 const viewportWidth = this.container.clientWidth; 35758 const barOffset = 92; 35759 const viewportHeight = this.container.clientHeight - barOffset; 35760 const paddingX = viewportWidth * 0.1; 35761 const paddingY = viewportHeight * 0.1; 35762 const zoomX = (viewportWidth - paddingX * 2) / contentWidth; 35763 const zoomY = (viewportHeight - paddingY * 2) / contentHeight; 35764 let targetZoom = Math.min(zoomX, zoomY); 35765 targetZoom = Math.max(this.MIN_ZOOM, Math.min(this.MAX_ZOOM, targetZoom)); 35766 const centerX = minX + contentWidth / 2; 35767 const centerY = minY + contentHeight / 2; 35768 this.animateToZoom(centerX, centerY, targetZoom, barOffset); 35769 } 35770 animateToZoom(targetWorldX, targetWorldY, targetZoom, topOffset = 0) { 35771 const startZoom = this.zoom; 35772 const startPanX = this.panX; 35773 const startPanY = this.panY; 35774 const viewportCenterX = this.container.clientWidth / 2; 35775 const viewportCenterY = topOffset + (this.container.clientHeight - topOffset) / 2; 35776 const targetPanX = viewportCenterX - targetWorldX * targetZoom; 35777 const targetPanY = viewportCenterY - targetWorldY * targetZoom; 35778 const startTime = Date.now(); 35779 const animate = () => { 35780 const elapsed = Date.now() - startTime; 35781 const progress = Math.min(elapsed / this.ANIMATION_DURATION, 1); 35782 const easeProgress = 1 - Math.pow(1 - progress, 3); 35783 this.zoom = startZoom + (targetZoom - startZoom) * easeProgress; 35784 this.panX = startPanX + (targetPanX - startPanX) * easeProgress; 35785 this.panY = startPanY + (targetPanY - startPanY) * easeProgress; 35786 this.updateTransform(); 35787 this.updateZoomDisplay(); 35788 if (progress < 1) { 35789 requestAnimationFrame(animate); 35790 } 35791 }; 35792 animate(); 35793 } 35794 bindZoomControls() { 35795 const zoomInBtn = document.querySelector("[data-project-zoom-in]"); 35796 const zoomOutBtn = document.querySelector("[data-project-zoom-out]"); 35797 const resetBtn = document.querySelector("[data-project-zoom-reset]"); 35798 const fitBtn = document.querySelector("[data-project-fit-screen]"); 35799 if (zoomInBtn) { 35800 this.addListener(zoomInBtn, "click", () => this.zoomIn()); 35801 } 35802 if (zoomOutBtn) { 35803 this.addListener(zoomOutBtn, "click", () => this.zoomOut()); 35804 } 35805 if (resetBtn) { 35806 this.addListener(resetBtn, "click", () => this.resetZoom()); 35807 } 35808 if (fitBtn) { 35809 this.addListener(fitBtn, "click", () => this.fitToScreen()); 35810 } 35811 this.updateZoomDisplay(); 35812 } 35813 // --- Sequence menu (movie only) --- 35814 setupSequenceMenu() { 35815 const renameModal = document.querySelector("[data-rename-sequence-modal]"); 35816 const renameForm = document.querySelector("[data-rename-sequence-form]"); 35817 const renameCloses = document.querySelectorAll("[data-rename-sequence-close]"); 35818 if (renameModal && renameForm) { 35819 this.addListener(renameForm, "submit", (e) => { 35820 e.preventDefault(); 35821 this.submitRenameSequence(); 35822 });
35823 renameCloses.forEach((btn) => { 35824 this.addListener(btn, "click", () => renameModal.classList.add("hidden")); 35825 }); 35826 this.addListener(renameModal, "click", (e) => { 35827 if (e.target === renameModal) renameModal.classList.add("hidden"); 35828 }); 35829 } 35830 const deleteModal = document.querySelector("[data-delete-sequence-modal]"); 35831 const deleteCloses = document.querySelectorAll("[data-delete-sequence-close]"); 35832 const deleteConfirm = document.querySelector("[data-delete-sequence-confirm]"); 35833 if (deleteModal) { 35834 deleteCloses.forEach((btn) => { 35835 this.addListener(btn, "click", () => deleteModal.classList.add("hidden")); 35836 }); 35837 this.addListener(deleteModal, "click", (e) => { 35838 if (e.target === deleteModal) deleteModal.classList.add("hidden"); 35839 }); 35840 if (deleteConfirm) { 35841 this.addListener(deleteConfirm, "click", () => this.submitDeleteSequence()); 35842 } 35843 } 35844 this._deleteSceneUuid = null; 35845 const deleteSceneModal = document.querySelector("[data-delete-scene-modal]"); 35846 const deleteSceneCloses = document.querySelectorAll("[data-delete-scene-close]"); 35847 const deleteSceneConfirm = document.querySelector("[data-delete-scene-confirm]"); 35848 if (deleteSceneModal) { 35849 deleteSceneCloses.forEach((btn) => { 35850 this.addListener(btn, "click", () => deleteSceneModal.classList.add("hidden")); 35851 }); 35852 this.addListener(deleteSceneModal, "click", (e) => { 35853 if (e.target === deleteSceneModal) deleteSceneModal.classList.add("hidden"); 35854 }); 35855 if (deleteSceneConfirm) { 35856 this.addListener(deleteSceneConfirm, "click", () => this.submitDeleteScene()); 35857 } 35858 } 35859 document.querySelectorAll("[data-delete-scene]").forEach((btn) => { 35860 this.addListener(btn, "click", (e) => { 35861 e.stopPropagation(); 35862 this._deleteSceneUuid = btn.dataset.deleteScene; 35863 if (deleteSceneModal) deleteSceneModal.classList.remove("hidden"); 35864 }); 35865 }); 35866 const seqBtn = document.querySelector("[data-sequence-selector-btn]"); 35867 const seqMenu = document.querySelector("[data-sequence-selector-menu]"); 35868 if (seqBtn && seqMenu) { 35869 this.addListener(seqBtn, "click", (e) => { 35870 e.stopPropagation(); 35871 seqMenu.classList.toggle("hidden"); 35872 }); 35873 this.addListener(document, "click", () => seqMenu.classList.add("hidden")); 35874 const newSeqBtn = seqMenu.querySelector("[data-sequence-selector-new]"); 35875 if (newSeqBtn) { 35876 this.addListener(newSeqBtn, "click", async (e) => { 35877 e.stopPropagation(); 35878 seqMenu.classList.add("hidden"); 35879 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 35880 try { 35881 const res = await fetch(`/${this.projectType}/${this.projectUuid}/sequences`, { 35882 method: "POST", 35883 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken }, 35884 body: JSON.stringify({}) 35885 }); 35886 const data = await res.json(); 35887 if (data.success && data.redirect_to) { 35888 window.Turbo ? Turbo.visit(data.redirect_to) : window.location.href = data.redirect_to; 35889 } 35890 } catch (err) { 35891 if (window.Toast) window.Toast.show("Failed to create sequence", "alert"); 35892 } 35893 }); 35894 } 35895 } 35896 } 35897 openRenameSequenceModal() { 35898 const modal = document.querySelector("[data-rename-sequence-modal]"); 35899 const input = document.querySelector("[data-rename-sequence-input]"); 35900 if (!modal || !input) return; 35901 const nameDisplay = document.querySelector("[data-sequence-name-display]"); 35902 if (nameDisplay) input.value = nameDisplay.textContent.trim(); 35903 modal.classList.remove("hidden"); 35904 setTimeout(() => { 35905 input.focus(); 35906 input.select(); 35907 }, 50); 35908 } 35909 async submitRenameSequence() { 35910 const input = document.querySelector("[data-rename-sequence-input]"); 35911 const modal = document.querySelector("[data-rename-sequence-modal]"); 35912 if (!input) return; 35913 const name = input.value.trim(); 35914 if (!name) return; 35915 try { 35916 const res = await fetch(`${this.baseUrl()}/rename`, { 35917 method: "PATCH", 35918 headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content }, 35919 body: JSON.stringify({ name }) 35920 }); 35921 const data = await res.json(); 35922 if (data.success) { 35923 const nameDisplay = document.querySelector("[data-sequence-name]"); 35924 if (nameDisplay) nameDisplay.textContent = data.name; 35925 if (modal) modal.classList.add("hidden"); 35926 } 35927 } catch (err) { 35928 console.error("Rename failed:", err); 35929 } 35930 } 35931 openDeleteSequenceModal() { 35932 const modal = document.querySelector("[data-delete-sequence-modal]"); 35933 if (modal) modal.classList.remove("hidden"); 35934 } 35935 async submitDeleteSequence() { 35936 try { 35937 const res = await fetch(`${this.baseUrl()}`, { 35938 method: "DELETE", 35939 headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content } 35940 }); 35941 const data = await res.json(); 35942 if (data.success) { 35943 window.location.href = "/projects"; 35944 } 35945 } catch (err) { 35946 console.error("Delete failed:", err); 35947 } 35948 } 35949 async submitDeleteScene() { 35950 const sceneUuid = this._deleteSceneUuid; 35951 if (!sceneUuid) return; 35952 const modal = document.querySelector("[data-delete-scene-modal]"); 35953 if (modal) modal.classList.add("hidden"); 35954 try { 35955 const res = await fetch(`${this.baseUrl()}/scenes/${sceneUuid}`, { 35956 method: "DELETE", 35957 headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content } 35958 }); 35959 const data = await res.json(); 35960 if (data.success) { 35961 const sceneEl = document.querySelector(`[data-scene-id="${sceneUuid}"]`); 35962 if (sceneEl) sceneEl.remove(); 35963 document.querySelectorAll("[data-scene-id]").forEach((el, i) => { 35964 const label = el.querySelector("[data-scene-label]"); 35965 if (label) label.textContent = `Scene ${i + 1}`; 35966 }); 35967 } 35968 } catch (err) { 35969 console.error("[ProjectCanvas] Delete scene failed:", err); 35970 if (window.Toast) window.Toast.show("Failed to delete scene", "alert"); 35971 } 35972 } 35973 // --- Shot selection & indicator --- 35974 setupShots() { 35975 this.currentShotUuid = null; 35976 const scenesBar = document.querySelector("[data-project-shot]")?.closest(".project-scenes-scroll"); 35977 if (scenesBar) { 35978 scenesBar.style.position = "relative"; 35979 this._shotIndicator = document.createElement("div"); 35980 this._shotIndicator.className = "project-shot-indicator";
35981 scenesBar.appendChild(this._shotIndicator); 35982 } 35983 const allShots = document.querySelectorAll("[data-project-shot]"); 35984 allShots.forEach((el) => { 35985 this.addListener(el, "click", (e) => { 35986 if (this._didDragShot) { 35987 this._didDragShot = false; 35988 return; 35989 } 35990 e.preventDefault(); 35991 this.selectShot(el.dataset.projectShot); 35992 }); 35993 }); 35994 document.querySelectorAll("[data-shot-menu]").forEach((btn) => { 35995 this.addListener(btn, "click", (e) => { 35996 e.stopPropagation(); 35997 e.preventDefault(); 35998 this.openShotMenu(btn.dataset.shotMenu, btn); 35999 }); 36000 }); 36001 this.addMenuSceneUuid = null; 36002 this.addMenuBtn = null; 36003 const addDropdown = document.getElementById("project-add-dropdown"); 36004 document.querySelectorAll("[data-add-menu]").forEach((btn) => { 36005 this.addListener(btn, "click", (e) => { 36006 e.stopPropagation(); 36007 this.closeShotMenu(); 36008 if (!addDropdown) return; 36009 const sceneUuid = btn.dataset.addMenu; 36010 if (!addDropdown.classList.contains("hidden") && this.addMenuSceneUuid === sceneUuid) { 36011 addDropdown.classList.add("hidden"); 36012 return; 36013 } 36014 this.addMenuSceneUuid = sceneUuid; 36015 this.addMenuBtn = btn; 36016 const rect = btn.getBoundingClientRect(); 36017 addDropdown.style.right = ""; 36018 addDropdown.style.left = `${rect.left - 3}px`; 36019 addDropdown.style.top = "67px"; 36020 addDropdown.classList.remove("hidden"); 36021 }); 36022 }); 36023 this.addListener(document, "click", () => { 36024 if (addDropdown) addDropdown.classList.add("hidden"); 36025 }); 36026 const addShotBtn = document.getElementById("project-add-shot-btn"); 36027 if (addShotBtn) { 36028 this.addListener(addShotBtn, "click", (e) => { 36029 e.stopPropagation(); 36030 if (addDropdown) addDropdown.classList.add("hidden"); 36031 if (this.addMenuSceneUuid) this.createShot(this.addMenuSceneUuid, this.addMenuBtn); 36032 }); 36033 } 36034 const addSceneBtn = document.getElementById("project-add-scene-btn"); 36035 if (addSceneBtn) { 36036 this.addListener(addSceneBtn, "click", async (e) => { 36037 e.stopPropagation(); 36038 if (addDropdown) addDropdown.classList.add("hidden"); 36039 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36040 const sceneName = addSceneBtn.dataset.sceneName || ""; 36041 try { 36042 const form = document.createElement("form"); 36043 form.method = "POST"; 36044 form.action = `${this.baseUrl()}/scenes`; 36045 const token = document.createElement("input"); 36046 token.type = "hidden"; 36047 token.name = "authenticity_token"; 36048 token.value = csrfToken; 36049 form.appendChild(token); 36050 if (sceneName) { 36051 const nameInput = document.createElement("input"); 36052 nameInput.type = "hidden"; 36053 nameInput.name = "scene_name"; 36054 nameInput.value = sceneName; 36055 form.appendChild(nameInput); 36056 } 36057 document.body.appendChild(form); 36058 form.submit(); 36059 } catch (err) { 36060 window.Toast?.show("Failed to create scene", "alert"); 36061 } 36062 }); 36063 } 36064 const weatherBtn = document.getElementById("project-weather-btn"); 36065 if (weatherBtn) { 36066 this.addListener(weatherBtn, "click", (e) => { 36067 e.stopPropagation(); 36068 if (addDropdown) addDropdown.classList.add("hidden"); 36069 this.openWeatherPicker(); 36070 }); 36071 } 36072 document.querySelectorAll("[data-add-shot]").forEach((btn) => { 36073 this.addListener(btn, "click", (e) => { 36074 e.stopPropagation(); 36075 this.createShot(btn.dataset.addShot, btn); 36076 }); 36077 }); 36078 document.querySelectorAll("[data-delete-scene]").forEach((btn) => { 36079 this.addListener(btn, "click", (e) => { 36080 e.stopPropagation(); 36081 this._deleteSceneUuid = btn.dataset.deleteScene; 36082 const modal = document.querySelector("[data-delete-scene-modal]"); 36083 if (modal) modal.classList.remove("hidden"); 36084 }); 36085 }); 36086 this.addListener(document, "click", () => this.closeShotMenu()); 36087 const hashShot = window.location.hash.substring(1);
36088 if (hashShot && document.querySelector(`[data-project-shot="${hashShot}"]`)) { 36089 this.selectShot(hashShot); 36090 } else if (allShots.length > 0) { 36091 this.currentShotUuid = allShots[0].dataset.projectShot; 36092 this.highlightShot(this.currentShotUuid); 36093 this.loadShot(this.currentShotUuid); 36094 } 36095 } 36096 selectShot(shotUuid) { 36097 if (shotUuid === this.currentShotUuid) return; 36098 this.currentShotUuid = shotUuid; 36099 window.history.replaceState(null, "", `#${shotUuid}`); 36100 this.highlightShot(shotUuid); 36101 this.loadShot(shotUuid); 36102 } 36103 highlightShot(shotUuid) { 36104 document.querySelectorAll("[data-project-shot]").forEach((el) => { 36105 const menuBtn = el.querySelector("[data-shot-menu]"); 36106 const shotLabel = el.querySelector("[data-shot-status-bar] > span"); 36107 if (el.dataset.projectShot === shotUuid) { 36108 if (menuBtn) { 36109 menuBtn.classList.remove("hidden"); 36110 } 36111 if (shotLabel) { 36112 shotLabel.classList.remove("text-gray/50"); 36113 shotLabel.classList.add("text-white"); 36114 } 36115 this.scrollShotIntoView(el); 36116 if (this._shotIndicator) { 36117 const scrollContainer = el.closest(".project-scenes-scroll"); 36118 if (scrollContainer) { 36119 const elRect = el.getBoundingClientRect(); 36120 const containerRect = scrollContainer.getBoundingClientRect(); 36121 const left = elRect.left - containerRect.left + scrollContainer.scrollLeft + 2; 36122 const width = elRect.width - 4; 36123 this._shotIndicator.style.left = `${left}px`; 36124 this._shotIndicator.style.width = `${width}px`; 36125 this._shotIndicator.style.opacity = "1"; 36126 } 36127 } 36128 } else { 36129 if (menuBtn) { 36130 menuBtn.classList.add("hidden"); 36131 } 36132 if (shotLabel) { 36133 shotLabel.classList.remove("text-white"); 36134 shotLabel.classList.add("text-gray/50"); 36135 } 36136 } 36137 }); 36138 } 36139 scrollShotIntoView(el) { 36140 const scrollContainer = el.closest(".project-scenes-scroll"); 36141 if (!scrollContainer) return; 36142 const elRect = el.getBoundingClientRect(); 36143 const containerRect = scrollContainer.getBoundingClientRect(); 36144 if (elRect.left < containerRect.left || elRect.right > containerRect.right) { 36145 const elCenter = el.offsetLeft - scrollContainer.offsetLeft + el.offsetWidth / 2; 36146 const scrollTarget = elCenter - scrollContainer.offsetWidth / 2; 36147 scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }); 36148 } 36149 } 36150 repositionShotIndicator() { 36151 if (!this._shotIndicator || !this.currentShotUuid) return;
36152 const el = document.querySelector(`[data-project-shot="${this.currentShotUuid}"]`); 36153 if (!el) return; 36154 const scrollContainer = el.closest(".project-scenes-scroll"); 36155 if (!scrollContainer) return; 36156 const elRect = el.getBoundingClientRect(); 36157 const containerRect = scrollContainer.getBoundingClientRect(); 36158 this._shotIndicator.style.left = `${elRect.left - containerRect.left + scrollContainer.scrollLeft + 2}px`; 36159 this._shotIndicator.style.width = `${elRect.width - 4}px`; 36160 } 36161 // --- Load shot data --- 36162 async loadShot(shotUuid, options = {}) { 36163 if (this._loadingShotUuid === shotUuid) return; 36164 this._loadingShotUuid = shotUuid; 36165 try { 36166 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}`); 36167 if (!response.ok) { 36168 this._loadingShotUuid = null; 36169 return; 36170 } 36171 if (this._loadingShotUuid !== shotUuid) return; 36172 const data = await response.json(); 36173 this.currentShot = data.shot; 36174 this._loadingShotUuid = null; 36175 this.nodesContainer.innerHTML = ""; 36176 if (data.shot.status) { 36177 this.updateShotStatusBar(shotUuid, data.shot.status); 36178 } 36179 if (data.shot.nodes && data.shot.nodes.length > 0) { 36180 this.renderNodes(data.shot.nodes); 36181 if (data.shot.groups && data.shot.groups.length > 0) { 36182 this.renderGroups(data.shot.groups); 36183 } 36184 if (!options.skipZoom) { 36185 if (data.shot.nodes.length <= 1) { 36186 this.resetZoom(); 36187 } else { 36188 this.fitToScreen(); 36189 } 36190 } 36191 } else { 36192 this.renderEmptyNode(); 36193 if (!options.skipZoom) { 36194 const barOffset = 92; 36195 this.animateToZoom(1e4, 1e4, 1, barOffset); 36196 } 36197 } 36198 } catch (error) { 36199 this._loadingShotUuid = null; 36200 console.error("[ProjectCanvas] Error loading shot:", error); 36201 } 36202 } 36203 // renderGroup / renderGroups provided by GroupsMixin 36204 // --- Empty node --- 36205 renderEmptyNode() { 36206 const cx = 1e4; 36207 const cy = 1e4; 36208 this.emptyNodeCenter = { x: cx, y: cy }; 36209 const node = document.createElement("div"); 36210 node.className = "project-node"; 36211 node.style.cssText = ` 36212 position: absolute; 36213 left: ${cx}px; 36214 top: ${cy}px; 36215 transform: translate(-50%, -50%); 36216 `; 36217 node.innerHTML = ` 36218 <div class="bg-box rounded-md shadow-lg p-2"> 36219 <div class="grid grid-cols-4 gap-2"> 36220 <button data-empty-action="upload" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36221 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36222 <path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> 36223 </svg> 36224 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Upload</span> 36225 </button> 36226 <button data-empty-action="element" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36227 <svg class="w-3.5 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 36228 <circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-4 8"/> 36229 </svg> 36230 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Element</span> 36231 </button> 36232 <button data-empty-action="favs" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36233 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36234 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 36235 </svg>
36236 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Favs</span> 36237 </button> 36238 <button data-empty-action="create" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36239 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36240 <path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> 36241 </svg> 36242 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Create</span> 36243 </button> 36244 </div> 36245 </div> 36246 `; 36247 this.nodesContainer.appendChild(node); 36248 this.animateToZoom(cx, cy, 1, 92); 36249 const uploadBtn = node.querySelector('[data-empty-action="upload"]'); 36250 if (uploadBtn) { 36251 uploadBtn.addEventListener("click", (e) => { 36252 e.stopPropagation(); 36253 this.handleUploadClick(); 36254 }); 36255 } 36256 const createBtn = node.querySelector('[data-empty-action="create"]'); 36257 if (createBtn) { 36258 createBtn.addEventListener("click", (e) => { 36259 e.stopPropagation(); 36260 this.openEditBar(null, "image"); 36261 }); 36262 } 36263 const elementBtn = node.querySelector('[data-empty-action="element"]'); 36264 if (elementBtn) { 36265 elementBtn.addEventListener("click", (e) => { 36266 e.stopPropagation(); 36267 this.openElementsPicker(); 36268 }); 36269 } 36270 const favsBtn = node.querySelector('[data-empty-action="favs"]'); 36271 if (favsBtn) { 36272 favsBtn.addEventListener("click", (e) => { 36273 e.stopPropagation(); 36274 this.openFavsPicker(); 36275 }); 36276 } 36277 } 36278 // --- Context menu empty node --- 36279 setupContextMenu() { 36280 this.addListener(this.container, "contextmenu", (e) => { 36281 const target = e.target; 36282 if (target.closest(".project-node") || target.closest(".project-group")) return; 36283 const isCanvasBg = target === this.container || target === this.viewport || target === this.nodesContainer || target === this.svgLayer || target.id === "project-dots"; 36284 if (!isCanvasBg) return; 36285 e.preventDefault(); 36286 if (!this.nodesContainer.querySelector("[data-project-node-id]")) return; 36287 const rect = this.container.getBoundingClientRect(); 36288 const menuWidth = 280; 36289 const screenX = Math.min(e.clientX - rect.left, rect.width - menuWidth); 36290 const screenY = e.clientY - rect.top; 36291 const canvasX = (e.clientX - rect.left - this.panX) / this.zoom; 36292 const canvasY = (e.clientY - rect.top - this.panY) / this.zoom; 36293 this.showContextEmptyNode(screenX, screenY, canvasX, canvasY); 36294 }); 36295 } 36296 showContextEmptyNode(screenX, screenY, canvasX, canvasY) { 36297 const prev = this.container.querySelector("[data-project-context-empty]"); 36298 if (prev) prev.remove(); 36299 const node = document.createElement("div"); 36300 node.dataset.projectContextEmpty = "true"; 36301 node.style.cssText = ` 36302 position: absolute; 36303 left: ${screenX}px; 36304 top: ${screenY}px; 36305 z-index: 200; 36306 `; 36307 node.innerHTML = ` 36308 <div class="bg-box rounded-md shadow-lg p-2"> 36309 <div class="grid grid-cols-4 gap-2"> 36310 <button data-ctx-action="upload" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36311 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36312 <path d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> 36313 </svg> 36314 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Upload</span> 36315 </button> 36316 <button data-ctx-action="element" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36317 <svg class="w-3.5 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 36318 <circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-4 8"/> 36319 </svg>
36320 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Element</span> 36321 </button> 36322 <button data-ctx-action="favs" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36323 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36324 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon> 36325 </svg> 36326 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Favs</span> 36327 </button> 36328 <button data-ctx-action="create" class="group flex-1 flex flex-col items-center justify-center gap-1.5 bg-inside hover:bg-inside/60 rounded-lg px-4 py-3 cursor-pointer transition duration-300 ease"> 36329 <svg class="w-4 h-4 text-gray group-hover:text-white transition-colors duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 36330 <path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> 36331 </svg> 36332 <span class="text-[9px] text-gray group-hover:text-white transition-colors duration-300 leading-tight text-center">Create</span> 36333 </button> 36334 </div> 36335 </div> 36336 `; 36337 this.container.appendChild(node); 36338 const uploadBtn = node.querySelector('[data-ctx-action="upload"]'); 36339 if (uploadBtn) { 36340 uploadBtn.addEventListener("click", (e) => { 36341 e.stopPropagation(); 36342 this.nextNodePosition = { x: canvasX, y: canvasY }; 36343 node.remove(); 36344 this.handleUploadClick(); 36345 }); 36346 } 36347 const createBtn = node.querySelector('[data-ctx-action="create"]'); 36348 if (createBtn) { 36349 createBtn.addEventListener("click", (e) => { 36350 e.stopPropagation(); 36351 this.nextNodePosition = { x: canvasX, y: canvasY }; 36352 node.remove(); 36353 this.openEditBar(null, "image"); 36354 }); 36355 } 36356 const elementBtn = node.querySelector('[data-ctx-action="element"]'); 36357 if (elementBtn) { 36358 elementBtn.addEventListener("click", (e) => { 36359 e.stopPropagation(); 36360 this.nextNodePosition = { x: canvasX, y: canvasY }; 36361 node.remove(); 36362 this.openElementsPicker(); 36363 }); 36364 } 36365 const favsBtn = node.querySelector('[data-ctx-action="favs"]'); 36366 if (favsBtn) { 36367 favsBtn.addEventListener("click", (e) => { 36368 e.stopPropagation(); 36369 this.nextNodePosition = { x: canvasX, y: canvasY }; 36370 node.remove(); 36371 this.openFavsPicker(); 36372 }); 36373 } 36374 const dismissHandler = (e) => { 36375 if (!node.contains(e.target)) { 36376 node.remove(); 36377 document.removeEventListener("mousedown", dismissHandler); 36378 } 36379 }; 36380 setTimeout(() => document.addEventListener("mousedown", dismissHandler), 0); 36381 } 36382 // --- Create shot --- 36383 async createShot(sceneUuid, addBtn) { 36384 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36385 try { 36386 const response = await fetch(`${this.baseUrl()}/scenes/${sceneUuid}/shots`, { 36387 method: "POST", 36388 headers: { 36389 "Content-Type": "application/json", 36390 "X-CSRF-Token": csrfToken 36391 } 36392 }); 36393 if (!response.ok) throw new Error("Failed to create shot"); 36394 const data = await response.json(); 36395 if (!data.success) throw new Error("Failed to create shot"); 36396 const shot = data.shot; 36397 const shotEl = document.createElement("div"); 36398 shotEl.dataset.projectShot = shot.uuid; 36399 shotEl.dataset.projectShotOrder = shot.order; 36400 shotEl.dataset.shotStatus = shot.status; 36401 shotEl.className = "group/shot shrink-0 cursor-pointer relative"; 36402 const newShotLabel = `${shot.order}`; 36403 shotEl.innerHTML = ` 36404 <div data-shot-status-bar class="bg-inside rounded overflow-hidden flex items-center justify-center transition duration-300 ease relative${this.projectType !== "element" ? " border-l-[3px] border-l-transparent" : ""}" style="height: 34px; width: 56px;">
36405 <span class="text-[9px] text-gray/50 group-hover/shot:text-white transition duration-300 ease text-center">${newShotLabel}</span> 36406 <button data-shot-menu="${shot.uuid}" class="hidden absolute top-0.5 right-0.5 w-4 h-4 rounded flex items-center justify-center bg-black/50 text-white/70 hover:text-white opacity-0 group-hover/shot:opacity-100 transition duration-200 ease cursor-pointer z-10" onclick="event.stopPropagation()"> 36407 ${this.icon("ellipsis", "w-3 h-3")} 36408 </button> 36409 </div> 36410 `; 36411 const combo = addBtn.closest(".flex.flex-row") || addBtn.closest(".flex.flex-col"); 36412 if (combo && combo.querySelector("[data-delete-scene]")) { 36413 const newAddBtn = document.createElement("button"); 36414 newAddBtn.dataset.addMenu = sceneUuid; 36415 newAddBtn.className = "w-7 bg-inside text-gray/50 hover:text-white rounded flex items-center justify-center shrink-0 cursor-pointer transition duration-300 ease"; 36416 newAddBtn.style.height = "34px"; 36417 newAddBtn.innerHTML = this.icon("plus", "w-3.5 h-3.5"); 36418 combo.replaceWith(newAddBtn); 36419 newAddBtn.addEventListener("click", (e) => { 36420 e.stopPropagation(); 36421 this.closeShotMenu(); 36422 const dropdown = document.getElementById("project-add-dropdown"); 36423 if (!dropdown) return; 36424 this.addMenuSceneUuid = sceneUuid; 36425 this.addMenuBtn = newAddBtn; 36426 const rect = newAddBtn.getBoundingClientRect(); 36427 dropdown.style.right = ""; 36428 dropdown.style.left = `${rect.left - 7}px`; 36429 dropdown.style.top = "67px"; 36430 dropdown.classList.remove("hidden"); 36431 }); 36432 newAddBtn.before(shotEl); 36433 } else { 36434 addBtn.before(shotEl); 36435 } 36436 this.addListener(shotEl, "click", (e) => { 36437 if (this._didDragShot) { 36438 this._didDragShot = false; 36439 return; 36440 } 36441 e.preventDefault(); 36442 this.selectShot(shot.uuid); 36443 }); 36444 const menuBtn = shotEl.querySelector("[data-shot-menu]"); 36445 if (menuBtn) { 36446 this.addListener(menuBtn, "click", (e) => { 36447 e.stopPropagation(); 36448 e.preventDefault(); 36449 this.openShotMenu(shot.uuid, menuBtn); 36450 }); 36451 } 36452 this.addListener(shotEl, "mousedown", (e) => this.onShotMouseDown(e, shotEl)); 36453 this.selectShot(shot.uuid); 36454 } catch (error) { 36455 console.error("[ProjectCanvas] Create shot error:", error); 36456 if (window.Toast) window.Toast.show("Failed to create shot", "alert"); 36457 } 36458 } 36459 // --- Shot context menu --- 36460 static SHOT_STATUSES = [ 36461 { key: "todo", label: "Todo", dot: "bg-white/50", bar: "border-l-transparent" }, 36462 { key: "draft", label: "Draft", dot: "bg-blue-500", bar: "border-l-blue-500" }, 36463 { key: "in_progress", label: "In Progress", dot: "bg-yellow-500", bar: "border-l-yellow-500" }, 36464 { key: "review", label: "Review", dot: "bg-purple-500", bar: "border-l-purple-500" }, 36465 { key: "rework", label: "Rework", dot: "bg-orange-500", bar: "border-l-orange-500" }, 36466 { key: "done", label: "Done", dot: "bg-green-500", bar: "border-l-green-500" }, 36467 { key: "archived", label: "Archived", dot: "bg-gray-500", bar: "border-l-gray-500" } 36468 ]; 36469 closeAddDropdown() { 36470 const d = document.getElementById("project-add-dropdown"); 36471 if (d) d.classList.add("hidden"); 36472 } 36473 openShotMenu(shotUuid, btnEl) { 36474 this.closeShotMenu(); 36475 this.closeAddDropdown(); 36476 if (this._shotIndicator) this._shotIndicator.style.opacity = "0";
36477 const shotEl = document.querySelector(`[data-project-shot="${shotUuid}"]`); 36478 if (!shotEl) return; 36479 const currentStatus = shotEl.dataset.shotStatus || "todo"; 36480 const thumbDiv = shotEl.querySelector(":scope > div:first-child"); 36481 const hasMedia = !!thumbDiv?.querySelector("img") || !!thumbDiv?.querySelector("video"); 36482 const menu = document.createElement("div"); 36483 menu.dataset.shotMenuDropdown = shotUuid; 36484 menu.className = "w-40 bg-box rounded-sm shadow-lg overflow-hidden"; 36485 menu.style.cssText = "position: fixed; z-index: 9999;"; 36486 let menuItems = '<div class="py-1">'; 36487 if (this.projectType !== "element") { 36488 menuItems += '<div class="px-3 pb-1"><span class="text-[9px] text-gray uppercase">Status</span></div>'; 36489 for (const s of _ProjectCanvas.SHOT_STATUSES) { 36490 const isActive = currentStatus === s.key; 36491 menuItems += ` 36492 <button data-shot-status-option="${s.key}" class="flex items-center w-full px-3 py-1.5 text-xs ${isActive ? "text-white" : "text-gray"} hover:text-white hover:bg-inside transition duration-300 cursor-pointer"> 36493 <span class="w-2 h-2 rounded-full ${s.dot} mr-2 shrink-0"></span> 36494 ${s.label} 36495 ${isActive ? '<span class="ml-auto text-[10px] text-gray">✓</span>' : ""} 36496 </button> 36497 `; 36498 } 36499 menuItems += '<div class="my-1 border-t border-white/5"></div>'; 36500 if (hasMedia) { 36501 menuItems += ` 36502 <button data-shot-action="unselect" class="flex items-center w-full px-3 py-2 text-xs text-gray hover:text-white hover:bg-inside transition duration-300 cursor-pointer"> 36503 ${this.icon("x", "w-3.5 h-3.5 mr-2")} 36504 Unselect 36505 </button> 36506 `; 36507 } 36508 } 36509 menuItems += ` 36510 <button data-shot-action="delete" class="flex items-center w-full px-3 py-2 text-xs text-red-400 hover:text-red-300 hover:bg-inside transition duration-300 cursor-pointer"> 36511 ${this.icon("trash-2", "w-3.5 h-3.5 mr-2")} 36512 Delete 36513 </button> 36514 `; 36515 menuItems += "</div>"; 36516 menu.innerHTML = menuItems; 36517 document.body.appendChild(menu); 36518 const shotRect = shotEl.getBoundingClientRect(); 36519 menu.style.top = "67px"; 36520 menu.style.left = `${shotRect.left - 7}px`; 36521 menu.querySelectorAll("[data-shot-status-option]").forEach((btn) => { 36522 btn.addEventListener("click", (e) => { 36523 e.stopPropagation(); 36524 this.closeShotMenu(); 36525 this.updateShotStatus(shotUuid, btn.dataset.shotStatusOption); 36526 }); 36527 }); 36528 const unselectBtn = menu.querySelector('[data-shot-action="unselect"]'); 36529 if (unselectBtn) { 36530 unselectBtn.addEventListener("click", (e) => { 36531 e.stopPropagation(); 36532 this.closeShotMenu(); 36533 this.unselectShot(shotUuid); 36534 }); 36535 } 36536 const deleteBtn = menu.querySelector('[data-shot-action="delete"]'); 36537 if (deleteBtn) { 36538 deleteBtn.addEventListener("click", (e) => { 36539 e.stopPropagation(); 36540 this.closeShotMenu(); 36541 this.confirmDeleteShot(shotUuid); 36542 }); 36543 } 36544 menu.addEventListener("click", (e) => e.stopPropagation()); 36545 } 36546 async updateShotStatus(shotUuid, status) { 36547 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36548 try { 36549 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/status`, { 36550 method: "PATCH", 36551 headers: { 36552 "Content-Type": "application/json", 36553 "X-CSRF-Token": csrfToken 36554 }, 36555 body: JSON.stringify({ status }) 36556 }); 36557 if (!response.ok) throw new Error("Failed to update status"); 36558 this.updateShotStatusBar(shotUuid, status); 36559 } catch (error) { 36560 console.error("[ProjectCanvas] Status update error:", error); 36561 if (window.Toast) window.Toast.show("Failed to update status", "alert"); 36562 } 36563 } 36564 updateShotStatusBar(shotUuid, status) { 36565 if (this.projectType === "element") return;
36566 const shotEl = document.querySelector(`[data-project-shot="${shotUuid}"]`); 36567 if (!shotEl) return; 36568 shotEl.dataset.shotStatus = status; 36569 const bar = shotEl.querySelector("[data-shot-status-bar]"); 36570 if (bar) { 36571 const statusConfig = _ProjectCanvas.SHOT_STATUSES.find((s) => s.key === status); 36572 bar.className = bar.className.replace(/border-l-\[3px\]\s?/g, ""); 36573 bar.className = bar.className.replace(/border-l-(transparent|white\/50|blue-500|yellow-500|purple-500|orange-500|green-500|gray-500)/g, ""); 36574 if (status !== "todo") { 36575 bar.classList.add("border-l-[3px]"); 36576 } 36577 const borderClass = statusConfig ? statusConfig.bar : "border-l-transparent"; 36578 bar.classList.add(borderClass); 36579 } 36580 } 36581 closeShotMenu() { 36582 const existing = document.querySelector("[data-shot-menu-dropdown]"); 36583 if (existing) existing.remove(); 36584 if (this._shotIndicator) this._shotIndicator.style.opacity = "1"; 36585 } 36586 async unselectShot(shotUuid) { 36587 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36588 try { 36589 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}/select`, { 36590 method: "PATCH", 36591 headers: { 36592 "Content-Type": "application/json", 36593 "X-CSRF-Token": csrfToken 36594 }, 36595 body: JSON.stringify({ media_id: null }) 36596 }); 36597 if (!response.ok) throw new Error("Failed to unselect"); 36598 if (this.currentShot && this.currentShotUuid === shotUuid) { 36599 this.currentShot.selected_media_id = null; 36600 this.nodesContainer.querySelectorAll(".project-node[data-
36600project-node-id]").forEach((el) => { 36601 el.style.outline = ""; 36602 el.style.outlineOffset = ""; 36603 const label = el.querySelector("[data-select-label]"); 36604 const btn = el.querySelector('[data-project-action="select-shot"]'); 36605 if (label) label.textContent = "SELECT FOR THIS SHOT"; 36606 if (btn) { 36607 btn.classList.remove("text-active"); 36608 btn.classList.add("text-gray/50"); 36609 } 36610 }); 36611 } 36612 const shotEl = document.querySelector(`[data-project-shot="${shotUuid}"]`); 36613 const thumbDiv = shotEl?.querySelector(":scope > div:first-child"); 36614 if (thumbDiv) { 36615 const unselectLabel = shotEl.dataset.projectShotOrder || ""; 36616 const menuBtn = thumbDiv.querySelector("[data-shot-menu]"); 36617 thumbDiv.innerHTML = `<span class="text-[9px] text-gray/50 group-hover/shot:text-white transition duration-300 ease text-center">${unselectLabel}</span>`; 36618 if (menuBtn) thumbDiv.appendChild(menuBtn); 36619 } 36620 if (window.Toast) window.Toast.show("Unselected", "success"); 36621 } catch (error) { 36622 console.error("[ProjectCanvas] Unselect error:", error); 36623 if (window.Toast) window.Toast.show("Failed to unselect", "alert"); 36624 } 36625 } 36626 async confirmDeleteShot(shotUuid) { 36627 let hasNodes = true; 36628 if (this.currentShotUuid === shotUuid && this.currentShot) { 36629 hasNodes = this.currentShot.nodes && this.currentShot.nodes.length > 0; 36630 } else { 36631 try { 36632 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}`); 36633 if (response.ok) { 36634 const data = await response.json(); 36635 hasNodes = data.shot.nodes && data.shot.nodes.length > 0; 36636 } 36637 } catch { 36638 } 36639 } 36640 if (!hasNodes) { 36641 await this.deleteShot(shotUuid); 36642 return; 36643 } 36644 const modal = document.createElement("div"); 36645 modal.className = "fixed inset-0 bg-black/90 flex justify-center modal-base modal-showing"; 36646 modal.style.cssText = "display: flex; align-items: flex-start; padding-top: 30vh; z-index: 10000;"; 36647 modal.innerHTML = ` 36648 <div class="bg-box rounded-lg p-6 max-w-md w-full mx-4 modal-content shadow-xl"> 36649 <div class="flex items-start justify-between mb-3"> 36650 <h2 class="text-14 font-bold text-white">Delete Shot</h2> 36651 <button data-modal-close class="cursor-pointer text-gray-400 hover:text-white transition-colors duration-300"> 36652 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 36653 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 36654 </svg> 36655 </button> 36656 </div> 36657 <p class="text-neutral-300 mb-6 text-sm"> 36658 Are you sure you want to delete this shot?<br>This action cannot be undone. 36659 </p> 36660 <div class="flex gap-3 justify-end"> 36661 <button data-modal-close class="btn-secondary">Cancel</button> 36662 <button data-delete-confirm class="btn-danger">Delete</button> 36663 </div> 36664 </div> 36665 `; 36666 document.body.appendChild(modal); 36667 modal.querySelectorAll("[data-modal-close]").forEach((btn) => { 36668 btn.addEventListener("click", () => modal.remove()); 36669 }); 36670 modal.addEventListener("click", (e) => { 36671 if (e.target === modal) modal.remove(); 36672 }); 36673 modal.querySelector("[data-delete-confirm]").addEventListener("click", async () => { 36674 modal.remove(); 36675 await this.deleteShot(shotUuid); 36676 }); 36677 } 36678 async deleteShot(shotUuid) { 36679 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36680 try { 36681 const response = await fetch(`${this.baseUrl()}/shots/${shotUuid}`, { 36682 method: "DELETE", 36683 headers: { "X-CSRF-Token": csrfToken } 36684 }); 36685 if (!response.ok) throw new Error("Failed to delete shot");
36686 const shotEl = document.querySelector(`[data-project-shot="${shotUuid}"]`); 36687 const sceneContainer = shotEl?.closest("[data-scene-id]"); 36688 if (shotEl) shotEl.remove(); 36689 if (sceneContainer) { 36690 this.updateShotOrderAttributes(sceneContainer); 36691 this.updateSceneEmptyState(sceneContainer); 36692 } 36693 if (this.currentShotUuid === shotUuid) { 36694 this.currentShotUuid = null; 36695 this.nodesContainer.innerHTML = ""; 36696 const allShots = Array.from(document.querySelectorAll("[data-project-shot]")); 36697 if (allShots.length > 0) { 36698 const deletedOrder = parseInt(shotEl?.dataset.projectShotOrder || "1"); 36699 const sameScene = sceneContainer ? Array.from(sceneContainer.querySelectorAll("[data-project-shot]")) : []; 36700 let nearest = sameScene[deletedOrder - 1] || sameScene[deletedOrder - 2] || sameScene[0] || allShots[0]; 36701 this.selectShot(nearest.dataset.projectShot); 36702 } 36703 } 36704 } catch (error) { 36705 console.error("[ProjectCanvas] Delete shot error:", error); 36706 if (window.Toast) window.Toast.show("Failed to delete shot", "alert"); 36707 } 36708 } 36709 // --- Scene reorder (drag scene labels to reorder) --- 36710 setupSceneReorder() { 36711 this._sceneDrag = { 36712 el: null, 36713 ghost: null, 36714 placeholder: null, 36715 isDragging: false, 36716 startX: 0, 36717 startY: 0 36718 }; 36719 if (this.projectType === "element") return; 36720 document.querySelectorAll("[data-scene-label]").forEach((label) => { 36721 this.addListener(label, "mousedown", (e) => this.onSceneMouseDown(e, label)); 36722 }); 36723 this.addListener(document, "mousemove", (e) => this.onSceneMouseMove(e)); 36724 this.addListener(document, "mouseup", (e) => this.onSceneMouseUp(e)); 36725 } 36726 onSceneMouseDown(e, labelEl) { 36727 if (e.button !== 0) return; 36728 const drag = this._sceneDrag; 36729 const sceneBlock = labelEl.closest("[data-scene-id]"); 36730 if (!sceneBlock) return; 36731 drag.el = sceneBlock; 36732 drag.labelEl = labelEl; 36733 drag.startX = e.clientX; 36734 drag.startY = e.clientY; 36735 drag.isDragging = false; 36736 e.preventDefault(); 36737 e.stopPropagation(); 36738 } 36739 onSceneMouseMove(e) { 36740 const drag = this._sceneDrag; 36741 if (!drag.el) return; 36742 const deltaX = Math.abs(e.clientX - drag.startX); 36743 const deltaY = Math.abs(e.clientY - drag.startY); 36744 if (!drag.isDragging && (deltaX > 5 || deltaY > 5)) { 36745 this.closeShotMenu(); 36746 this.startSceneDrag(e); 36747 } 36748 if (drag.isDragging) { 36749 drag.ghost.style.left = e.clientX - drag.ghostOffsetX + "px"; 36750 drag.ghost.style.top = e.clientY - drag.ghostOffsetY + "px"; 36751 this.updateScenePlaceholder(e); 36752 } 36753 } 36754 startSceneDrag(e) { 36755 const drag = this._sceneDrag; 36756 drag.isDragging = true; 36757 const sceneRect = drag.el.getBoundingClientRect(); 36758 const labelRect = drag.labelEl.getBoundingClientRect(); 36759 drag.ghost = document.createElement("div"); 36760 drag.ghost.className = "bg-box rounded px-3 py-1.5 shadow-xl"; 36761 drag.ghost.style.cssText = ` 36762 position: fixed; 36763 z-index: 9999; 36764 pointer-events: none; 36765 `; 36766 drag.ghost.innerHTML = `<span class="text-[10px] text-white uppercase whitespace-nowrap">${drag.labelEl.textContent}</span>`; 36767 document.body.appendChild(drag.ghost); 36768 drag.ghostOffsetX = e.clientX - labelRect.left; 36769 drag.ghostOffsetY = e.clientY - labelRect.top; 36770 drag.ghost.style.left = e.clientX - drag.ghostOffsetX + "px"; 36771 drag.ghost.style.top = e.clientY - drag.ghostOffsetY + "px"; 36772 drag.placeholder = document.createElement("div"); 36773 drag.placeholder.style.cssText = ` 36774 width: ${sceneRect.width}px; 36775 height: ${sceneRect.height}px; 36776 flex-shrink: 0; 36777 `; 36778 drag.placeholder.className = "rounded border border-dashed border-active/50 bg-active/10"; 36779 drag.el.parentNode.insertBefore(drag.placeholder, drag.el); 36780 drag.el.style.display = "none"; 36781 document.querySelectorAll("[data-project-shot]").forEach((el) => { 36782 el.style.pointerEvents = "none"; 36783 }); 36784 } 36785 updateScenePlaceholder(e) { 36786 const drag = this._sceneDrag; 36787 if (!drag.placeholder) return; 36788 const scenes = Array.from( 36789 document.querySelectorAll("[data-scene-id]") 36790 ).filter((el) => el.style.display !== "none");
36791 let closestScene = null; 36792 let closestDistance = Infinity; 36793 let insertBefore = true; 36794 for (const scene of scenes) { 36795 const rect = scene.getBoundingClientRect(); 36796 const centerX = rect.left + rect.width / 2; 36797 const distance = Math.abs(e.clientX - centerX); 36798 if (distance < closestDistance) { 36799 closestDistance = distance; 36800 closestScene = scene; 36801 insertBefore = e.clientX < centerX; 36802 } 36803 } 36804 if (closestScene) { 36805 if (insertBefore) { 36806 closestScene.before(drag.placeholder); 36807 } else { 36808 closestScene.after(drag.placeholder); 36809 } 36810 } 36811 } 36812 onSceneMouseUp(e) { 36813 const drag = this._sceneDrag; 36814 if (!drag.el) return; 36815 if (drag.isDragging) { 36816 if (drag.ghost) { 36817 drag.ghost.remove(); 36818 drag.ghost = null; 36819 } 36820 drag.el.style.display = ""; 36821 if (drag.placeholder?.parentNode) { 36822 drag.placeholder.parentNode.insertBefore(drag.el, drag.placeholder); 36823 drag.placeholder.remove(); 36824 } 36825 drag.placeholder = null; 36826 document.querySelectorAll("[data-project-shot]").forEach((el) => { 36827 el.style.pointerEvents = ""; 36828 }); 36829 this.saveSceneOrder(); 36830 } 36831 drag.el = null; 36832 drag.labelEl = null; 36833 drag.isDragging = false; 36834 requestAnimationFrame(() => this.repositionShotIndicator()); 36835 } 36836 saveSceneOrder() { 36837 const scenes = document.querySelectorAll("[data-scene-id]"); 36838 const order = Array.from(scenes).map((el) => el.dataset.sceneId); 36839 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 36840 scenes.forEach((el, i) => { 36841 const label = el.querySelector("[data-scene-label]"); 36842 if (label) label.textContent = `Scene ${i + 1}`; 36843 }); 36844 fetch(`${this.baseUrl()}/reorder_scenes`, { 36845 method: "POST", 36846 headers: { 36847 "Content-Type": "application/json", 36848 "X-CSRF-Token": csrfToken 36849 }, 36850 body: JSON.stringify({ order }) 36851 }).catch((err) => console.error("[ProjectCanvas] Scene reorder error:", err)); 36852 } 36853 // --- Shot reorder (drag to reorder within scene) --- 36854 setupShotReorder() { 36855 this._shotDrag = { 36856 el: null, 36857 placeholder: null, 36858 isDragging: false, 36859 startX: 0, 36860 startY: 0, 36861 offsetX: 0, 36862 offsetY: 0, 36863 sceneContainer: null, 36864 sceneId: null, 36865 targetSceneContainer: null, 36866 targetSceneId: null, 36867 originalWidth: 0, 36868 originalHeight: 0 36869 }; 36870 document.querySelectorAll("[data-project-shot]").forEach((shotEl) => { 36871 this.addListener(shotEl, "mousedown", (e) => this.onShotMouseDown(e, shotEl)); 36872 }); 36873 this.addListener(document, "mousemove", (e) => this.onShotMouseMove(e)); 36874 this.addListener(document, "mouseup", (e) => this.onShotMouseUp(e)); 36875 } 36876 onShotMouseDown(e, shotEl) { 36877 if (e.button !== 0) return; 36878 if (e.target.closest("[data-shot-menu]")) return; 36879 const rect = shotEl.getBoundingClientRect(); 36880 const drag = this._shotDrag; 36881 drag.el = shotEl; 36882 drag.startX = e.clientX; 36883 drag.startY = e.clientY; 36884 drag.offsetX = e.clientX - rect.left; 36885 drag.offsetY = e.clientY - rect.top; 36886 drag.originalWidth = rect.width; 36887 drag.originalHeight = rect.height; 36888 drag.isDragging = false; 36889 const sceneBlock = shotEl.closest("[data-scene-id]"); 36890 drag.sceneContainer = sceneBlock; 36891 drag.sceneId = sceneBlock?.dataset.sceneId || null; 36892 e.preventDefault(); 36893 } 36894 onShotMouseMove(e) { 36895 const drag = this._shotDrag; 36896 if (!drag.el) return; 36897 const deltaX = Math.abs(e.clientX - drag.startX); 36898 const deltaY = Math.abs(e.clientY - drag.startY); 36899 if (!drag.isDragging && (deltaX > 5 || deltaY > 5)) { 36900 this.closeShotMenu(); 36901 this.startShotDrag(e); 36902 } 36903 if (drag.isDragging) { 36904 this.updateShotDragPosition(e); 36905 this.updateShotPlaceholder(e); 36906 } 36907 } 36908 startShotDrag(e) { 36909 const drag = this._shotDrag; 36910 drag.isDragging = true; 36911 const rect = drag.el.getBoundingClientRect(); 36912 drag.placeholder = document.createElement("div"); 36913 drag.placeholder.style.cssText = ` 36914 width: ${drag.originalWidth}px; 36915 height: ${drag.originalHeight}px; 36916 flex-shrink: 0; 36917 `; 36918 drag.placeholder.className = "rounded border border-dashed border-active/50 bg-active/10"; 36919 drag.el.parentNode.insertBefore(drag.placeholder, drag.el); 36920 drag.el.style.position = "fixed"; 36921 drag.el.style.width = rect.width + "px"; 36922 drag.el.style.height = rect.height + "px"; 36923 drag.el.style.left = rect.left + "px"; 36924 drag.el.style.top = rect.top + "px"; 36925 drag.el.style.zIndex = "9999"; 36926 drag.el.style.pointerEvents = "none"; 36927 drag.el.style.transition = "none"; 36928 drag.el.classList.add("shadow-xl"); 36929 } 36930 updateShotDragPosition(e) { 36931 const drag = this._shotDrag; 36932 if (!drag.el) return; 36933 drag.el.style.left = e.clientX - drag.offsetX + "px"; 36934 drag.el.style.top = e.clientY - drag.offsetY + "px"; 36935 } 36936 updateShotPlaceholder(e) { 36937 const drag = this._shotDrag; 36938 if (!drag.placeholder) return; 36939 const allScenes = document.querySelectorAll("[data-scene-id]"); 36940 let targetScene = null; 36941 let targetSceneDistance = Infinity;
36942 allScenes.forEach((scene) => { 36943 const rect = scene.getBoundingClientRect(); 36944 const centerX = rect.left + rect.width / 2; 36945 const dist = Math.abs(e.clientX - centerX); 36946 if (e.clientY >= rect.top - 20 && e.clientY <= rect.bottom + 20 && dist < targetSceneDistance) { 36947 targetSceneDistance = dist; 36948 targetScene = scene; 36949 } 36950 }); 36951 if (!targetScene) return; 36952 drag.targetSceneContainer = targetScene; 36953 drag.targetSceneId = targetScene.dataset.sceneId; 36954 const shots = Array.from( 36955 targetScene.querySelectorAll('[data-project-shot]:not([style*="position: fixed"])') 36956 ); 36957 let closestShot = null; 36958 let closestDistance = Infinity; 36959 let insertBefore = true; 36960 for (const shot of shots) { 36961 const rect = shot.getBoundingClientRect(); 36962 const centerX = rect.left + rect.width / 2; 36963 const distance = Math.abs(e.clientX - centerX); 36964 if (distance < closestDistance) { 36965 closestDistance = distance; 36966 closestShot = shot; 36967 insertBefore = e.clientX < centerX; 36968 } 36969 } 36970 if (closestShot) { 36971 if (insertBefore) { 36972 closestShot.before(drag.placeholder); 36973 } else { 36974 closestShot.after(drag.placeholder); 36975 } 36976 } else { 36977 const addBtn = targetScene.querySelector("form") || targetScene.querySelector("[data-add-shot]") || targetScene.lastElementChild; 36978 if (addBtn) { 36979 addBtn.before(drag.placeholder); 36980 } else { 36981 targetScene.appendChild(drag.placeholder); 36982 } 36983 } 36984 } 36985 onShotMouseUp(e) { 36986 const drag = this._shotDrag; 36987 if (!drag.el) return; 36988 if (drag.isDragging) { 36989 this._didDragShot = true; 36990 setTimeout(() => { 36991 this._didDragShot = false; 36992 }, 0); 36993 drag.el.style.position = ""; 36994 drag.el.style.width = ""; 36995 drag.el.style.height = ""; 36996 drag.el.style.left = ""; 36997 drag.el.style.top = ""; 36998 drag.el.style.zIndex = ""; 36999 drag.el.style.pointerEvents = ""; 37000 drag.el.style.transition = ""; 37001 drag.el.classList.remove("shadow-xl"); 37002 if (drag.placeholder?.parentNode) { 37003 drag.placeholder.parentNode.insertBefore(drag.el, drag.placeholder); 37004 drag.placeholder.remove(); 37005 } 37006 drag.placeholder = null; 37007 const sourceSceneId = drag.sceneId; 37008 const targetSceneId = drag.targetSceneId; 37009 const targetSceneContainer = drag.targetSceneContainer; 37010 const sourceSceneContainer = drag.sceneContainer; 37011 if (targetSceneId && targetSceneId !== sourceSceneId) { 37012 this.updateShotOrderAttributes(targetSceneContainer);
37013 this.updateShotOrderAttributes(sourceSceneContainer); 37014 this.moveShotToScene(drag.el.dataset.projectShot, targetSceneId, targetSceneContainer); 37015 this.updateSceneEmptyState(sourceSceneContainer); 37016 } else if (sourceSceneId) { 37017 this.updateShotOrderAttributes(sourceSceneContainer); 37018 this.saveShotOrder(sourceSceneId); 37019 } 37020 } 37021 drag.el = null; 37022 drag.isDragging = false; 37023 drag.sceneContainer = null; 37024 drag.sceneId = null; 37025 drag.targetSceneContainer = null; 37026 drag.targetSceneId = null; 37027 requestAnimationFrame(() => this.repositionShotIndicator()); 37028 } 37029 updateSceneEmptyState(sceneContainer) { 37030 if (!sceneContainer) return; 37031 const shots = sceneContainer.querySelectorAll("[data-project-shot]"); 37032 const sceneUuid = sceneContainer.dataset.sceneId; 37033 const addBtn = sceneContainer.querySelector(`[data-add-menu="${sceneUuid}"]`) || sceneContainer.querySelector(`[data-add-shot="${sceneUuid}"]`); 37034 if (shots.length === 0 && addBtn) { 37035 const combo = document.createElement("div"); 37036 combo.className = "flex flex-row gap-1 shrink-0"; 37037 combo.innerHTML = ` 37038 <button data-add-shot="${sceneUuid}" class="w-7 bg-inside rounded flex items-center justify-center cursor-pointer text-gray/50 hover:text-white transition duration-300 ease" style="height: 34px"> 37039 ${this.icon("plus", "w-3 h-3")} 37040 </button> 37041 <button data-delete-scene="${sceneUuid}" class="w-7 bg-inside hover:bg-red-900 rounded flex items-center justify-center cursor-pointer text-gray hover:text-red-400 transition duration-300 ease" style="height: 34px"> 37042 ${this.icon("trash-2", "w-3 h-3")} 37043 </button> 37044 `; 37045 addBtn.replaceWith(combo); 37046 const newAddBtn = combo.querySelector(`[data-add-shot="${sceneUuid}"]`); 37047 if (newAddBtn) { 37048 newAddBtn.addEventListener("click", (e) => { 37049 e.stopPropagation(); 37050 this.createShot(sceneUuid, newAddBtn); 37051 }); 37052 } 37053 const deleteSceneBtn = combo.querySelector(`[data-delete-scene="${sceneUuid}"]`); 37054 if (deleteSceneBtn) { 37055 deleteSceneBtn.addEventListener("click", (e) => { 37056 e.stopPropagation(); 37057 this._deleteSceneUuid = sceneUuid; 37058 const modal = document.querySelector("[data-delete-scene-modal]"); 37059 if (modal) modal.classList.remove("hidden"); 37060 }); 37061 } 37062 } 37063 } 37064 updateShotOrderAttributes(sceneContainer) { 37065 if (!sceneContainer) return; 37066 const shots = sceneContainer.querySelectorAll("[data-project-shot]"); 37067 shots.forEach((el, index) => { 37068 el.dataset.projectShotOrder = index + 1; 37069 const spans = el.querySelectorAll("span"); 37070 spans.forEach((span) => { 37071 if (span.textContent.match(/^(Shot )?\d+$/)) { 37072 span.textContent = `${index + 1}`; 37073 } 37074 }); 37075 }); 37076 } 37077 saveShotOrder(sceneUuid) { 37078 const sceneContainer = document.querySelector(`[data-scene-id="${sceneUuid}"]`); 37079 if (!sceneContainer) return; 37080 const shots = sceneContainer.querySelectorAll("[data-project-shot]"); 37081 const order = Array.from(shots).map((el) => el.dataset.projectShot); 37082 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 37083 fetch(`${this.baseUrl()}/scenes/${sceneUuid}/reorder_shots`, { 37084 method: "POST", 37085 headers: { 37086 "Content-Type": "application/json", 37087 "X-CSRF-Token": csrfToken 37088 }, 37089 body: JSON.stringify({ order }) 37090 }).catch((err) => console.error("[ProjectCanvas] Reorder error:", err)); 37091 } 37092 moveShotToScene(shotUuid, targetSceneId, targetSceneContainer) { 37093 const shots = targetSceneContainer.querySelectorAll("[data-project-shot]"); 37094 const order = Array.from(shots).map((el) => el.dataset.projectShot); 37095 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 37096 fetch(`${this.baseUrl()}/shots/${shotUuid}/move`, { 37097 method: "PATCH", 37098 headers: { 37099 "Content-Type": "application/json", 37100 "X-CSRF-Token": csrfToken 37101 }, 37102 body: JSON.stringify({ target_scene_id: targetSceneId, order }) 37103 }).catch((err) => console.error("[ProjectCanvas] Move shot error:", err)); 37104 } 37105 // --- Preview --- 37106 showPreviewMenu(btnEl) { 37107 const existing = document.querySelector("[data-preview-menu-dropdown]"); 37108 if (existing) { 37109 existing.remove(); 37110 return; 37111 } 37112 const menu = document.createElement("div"); 37113 menu.setAttribute("data-preview-menu-dropdown", ""); 37114 menu.className = "bg-box rounded-sm shadow-lg overflow-hidden"; 37115 menu.style.cssText = "position: fixed; z-index: 9999; min-width: 160px;"; 37116 const hasSequences = this.projectType === "movie" || this.projectType === "short"; 37117 const previewLabel = hasSequences ? "This Sequence" : "This Project"; 37118 const previewAllLabel = hasSequences ? "All Sequences" : "All Projects"; 37119 const previewDesc = "Selected shots will be previewed"; 37120 const previewAllDesc = "All selected shots will be previewed"; 37121 menu.innerHTML = ` 37122 <div class="py-1">
37123 <button data-preview-mode="sequence" class="flex items-start w-full px-3 py-2.5 hover:bg-inside transition duration-300 cursor-pointer gap-2.5 text-left"> 37124 <span class="mt-0.5">${this.icon("clapperboard", "w-3.5 h-3.5 text-gray")}</span> 37125 <div class="flex flex-col gap-0.5"> 37126 <span class="text-xs text-white">${previewLabel}</span> 37127 <span class="text-[10px] text-gray leading-tight">${previewDesc}</span> 37128 </div> 37129 </button> 37130 <button data-preview-mode="all" class="flex items-start w-full px-3 py-2.5 hover:bg-inside transition duration-300 cursor-pointer gap-2.5 text-left"> 37131 <span class="mt-0.5">${this.icon("film", "w-3.5 h-3.5 text-gray")}</span> 37132 <div class="flex flex-col gap-0.5"> 37133 <span class="text-xs text-white">${previewAllLabel}</span> 37134 <span class="text-[10px] text-gray leading-tight">${previewAllDesc}</span> 37135 </div> 37136 </button> 37137 </div> 37138 `; 37139 document.body.appendChild(menu); 37140 const scenesBar = document.getElementById("project-scenes-strip"); 37141 if (scenesBar) { 37142 const barRect = scenesBar.getBoundingClientRect(); 37143 menu.style.top = "67px"; 37144 menu.style.right = `${window.innerWidth - barRect.right}px`; 37145 } 37146 menu.querySelector('[data-preview-mode="sequence"]').addEventListener("click", (e) => { 37147 e.stopPropagation(); 37148 menu.remove(); 37149 this.openPreview("sequence"); 37150 }); 37151 menu.querySelector('[data-preview-mode="all"]').addEventListener("click", (e) => { 37152 e.stopPropagation(); 37153 menu.remove(); 37154 this.openPreview("all"); 37155 }); 37156 const dismiss = (e) => { 37157 if (!menu.contains(e.target) && e.target !== btnEl && !btnEl.contains(e.target)) { 37158 menu.remove(); 37159 document.removeEventListener("mousedown", dismiss); 37160 } 37161 }; 37162 setTimeout(() => document.addEventListener("mousedown", dismiss), 0); 37163 } 37164 async openPreview(mode = "sequence") { 37165 try { 37166 const url = mode === "all" ? `/${this.projectType}/${this.projectUuid}/preview` : `${this.baseUrl()}/preview`; 37167 const response = await fetch(url); 37168 if (!response.ok) throw new Error("Failed to load preview"); 37169 const data = await response.json(); 37170 const shots = data.shots || []; 37171 const ratio = data.ratio || this.ratio || "16:9"; 37172 if (shots.length === 0) { 37173 if (window.Toast) window.Toast.show("No shots with selected media", "notice"); 37174 return; 37175 } 37176 const modal = document.createElement("div"); 37177 modal.id = "project-preview-modal"; 37178 modal.style.cssText = "position: fixed; inset: 0; z-index: 10001; background: black; display: flex; flex-direction: column;"; 37179 const previewTitle = mode === "all" ? "Preview All" : "Preview"; 37180 const topBar = document.createElement("div"); 37181 topBar.style.cssText = "display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; flex-shrink: 0;"; 37182 topBar.innerHTML = ` 37183 <div style="display: flex; align-items: center; gap: 12px;"> 37184 <span style="font-size: 13px; color: white; font-weight: 600;">${previewTitle}</span> 37185 <span style="font-size: 11px; color: rgba(255,255,255,0.4);" data-preview-counter>1 / ${shots.length}</span> 37186 </div> 37187 <button data-preview-close style="background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 4px; display: flex; align-items: center; transition: color 0.2s;"> 37188 <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg> 37189 </button> 37190 `; 37191 const mediaContainer = document.createElement("div"); 37192 mediaContainer.style.cssText = "flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 0 24px;"; 37193 const mediaWrapper = document.createElement("div"); 37194 mediaWrapper.style.cssText = `aspect-ratio: ${ratio.replace(":", "/")}; max-width: 90vw; max-height: 75vh; width: 100%; position: relative; border-radius: 8px; overflow: hidden; background: #111;`; 37195 mediaWrapper.setAttribute("data-preview-media", ""); 37196 mediaContainer.appendChild(mediaWrapper); 37197 const bottomBar = document.createElement("div"); 37198 bottomBar.style.cssText = "flex-shrink: 0; padding: 16px 24px 24px; display: flex; flex-direction: column; gap: 12px; align-items: center;"; 37199 const controls = document.createElement("div"); 37200 controls.style.cssText = "display: flex; align-items: center; gap: 12px; width: 100%; max-width: 90vw;"; 37201 const playBtn = document.createElement("button"); 37202 playBtn.setAttribute("data-preview-play", ""); 37203 playBtn.style.cssText = "background: none; border: none; color: white; cursor: pointer; padding: 4px; display: flex; align-items: center; flex-shrink: 0;"; 37204 playBtn.innerHTML = `<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
37204<path d="M8 5v14l11-7z"/></svg>`; 37205 controls.appendChild(playBtn); 37206 const timelineBar = document.createElement("div"); 37207 timelineBar.style.cssText = "flex: 1; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; position: relative; pointer-events: none;"; 37208 const timelineProgress = document.createElement("div"); 37209 timelineProgress.style.cssText = "height: 100%; background: rgba(99,102,241,0.8); border-radius: 2px; width: 0%; position: relative;"; 37210 timelineBar.appendChild(timelineProgress); 37211 controls.appendChild(timelineBar); 37212 const timeLabel = document.createElement("span"); 37213 timeLabel.setAttribute("data-preview-time", ""); 37214 timeLabel.style.cssText = "font-size: 11px; color: rgba(255,255,255,0.4); min-width: 80px; text-align: right; flex-shrink: 0;"; 37215 timeLabel.textContent = "0:00"; 37216 controls.appendChild(timeLabel); 37217 bottomBar.appendChild(controls); 37218 const strip = document.createElement("div"); 37219 strip.style.cssText = "display: flex; gap: 6px; align-items: center; max-width: 90vw; overflow-x: auto;"; 37220 shots.forEach((shot, i) => { 37221 const thumb = document.createElement("div"); 37222 thumb.setAttribute("data-preview-thumb", i); 37223 const thumbSrc = shot.thumbnail_url || shot.media_url; 37224 thumb.style.cssText = ` 37225 width: 56px; height: 36px; border-radius: 4px; overflow: hidden; cursor: pointer; 37226 border: 2px solid ${i === 0 ? "rgba(99,102,241,0.8)" : "transparent"}; 37227 flex-shrink: 0; position: relative; transition: border-color 0.2s; 37228 `; 37229 const playOverlay = shot.is_video ? `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"><svg width="12" height="12" fill="white" viewBox="0 0 24 24" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));"><path d="M8 5v14l11-7z"/></svg></div>` : ""; 37230 const mediaTag = thumbSrc.includes(".mp4") ? `<video src="${thumbSrc}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover;pointer-events:none;"></video>` : `<img src="${thumbSrc}" style="width:100%;height:100%;object-fit:cover;" />`; 37231 thumb.innerHTML = `${mediaTag}${playOverlay}`; 37232 strip.appendChild(thumb); 37233 }); 37234 bottomBar.appendChild(strip); 37235 modal.appendChild(topBar); 37236 modal.appendChild(mediaContainer); 37237 modal.appendChild(bottomBar); 37238 document.body.appendChild(modal); 37239 let currentIndex = 0; 37240 let isPlaying = false; 37241 let autoAdvanceTimeout = null; 37242 let globalTimerInterval = null; 37243 const IMAGE_DURATION = 3; 37244 const shotDurations = shots.map((s) => s.media_url?.includes(".mp4") ? 0 : IMAGE_DURATION); 37245 const computeOffsets = () => { 37246 const offsets = [0]; 37247 for (let i = 1; i < shots.length; i++) { 37248 offsets[i] = offsets[i - 1] + shotDurations[i - 1]; 37249 } 37250 return offsets; 37251 }; 37252 let shotOffsets = computeOffsets(); 37253 let shotStartedAt = Date.now(); 37254 const counterEl = modal.querySelector("[data-preview-counter]"); 37255 const mediaEl = modal.querySelector("[data-preview-media]"); 37256 const timeEl = modal.querySelector("[data-preview-time]"); 37257 const preloadedVideos = {}; 37258 mediaEl.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;"><span style="color:rgba(255,255,255,0.4);font-size:12px;">Loading...</span></div>'; 37259 const preloadPromises = shots.map((shot, i) => { 37260 if (!shot.media_url?.includes(".mp4")) return Promise.resolve(); 37261 return new Promise((resolve) => { 37262 const video = document.createElement("video"); 37263 video.preload = "auto"; 37264 video.playsInline = true; 37265 video.src = shot.media_url; 37266 if (shot.thumbnail_url) video.poster = shot.thumbnail_url; 37267 video.style.cssText = "width:100%;height:100%;object-fit:contain;"; 37268 video.addEventListener("loadedmetadata", () => { 37269 if (video.duration && isFinite(video.duration)) { 37270 shotDurations[i] = video.duration; 37271 shotOffsets = computeOffsets(); 37272 } 37273 }); 37274 preloadedVideos[i] = video; 37275 const done = () => resolve(); 37276 video.addEventListener("canplaythrough", done, { once: true }); 37277 setTimeout(done, 1e4); 37278 }); 37279 }); 37280 const formatTime = (seconds) => { 37281 const mins = Math.floor(seconds / 60); 37282 const secs = Math.floor(seconds % 60); 37283 return `${mins}:${secs.toString().padStart(2, "0")}`; 37284 }; 37285 const getTotalDuration = () => { 37286 let total = 0; 37287 for (let i = 0; i < shotDurations.length; i++) total += shotDurations[i]; 37288 return total; 37289 }; 37290 const getGlobalTime = () => { 37291 const offset = shotOffsets[currentIndex] || 0; 37292 const video = mediaEl.querySelector("video"); 37293 if (video && !isNaN(video.currentTime) && video.currentTime > 0) { 37294 return offset + video.currentTime; 37295 } 37296 return offset + (Date.now() - shotStartedAt) / 1e3; 37297 }; 37298 let lastTimeText = ""; 37299 const updateTimeline = () => { 37300 const current = getGlobalTime(); 37301 const total = getTotalDuration(); 37302 const pct = total > 0 ? Math.min(current / total * 100, 100) : 0; 37303 timelineProgress.style.width = `${pct}%`; 37304 const text = `${formatTime(current)} / ${formatTime(total)}`; 37305 if (text !== lastTimeText) { 37306 timeEl.textContent = text; 37307 lastTimeText = text; 37308 } 37309 }; 37310 const rafLoop = () => { 37311 if (!globalTimerInterval) return; 37312 updateTimeline(); 37313 requestAnimationFrame(rafLoop); 37314 }; 37315 const startGlobalTimer = () => { 37316 if (globalTimerInterval) return; 37317 globalTimerInterval = true; 37318 requestAnimationFrame(rafLoop); 37319 }; 37320 const clearTimers = () => { 37321 if (autoAdvanceTimeout) { 37322 clearTimeout(autoAdvanceTimeout); 37323 autoAdvanceTimeout = null; 37324 } 37325 }; 37326 const stopGlobalTimer = () => { 37327 globalTimerInterval = null; 37328 }; 37329 const showShot = (index) => { 37330 clearTimers(); 37331 currentIndex = index; 37332 shotStartedAt = Date.now(); 37333 const shot = shots[index]; 37334 counterEl.textContent = `${index + 1} / ${shots.length}`; 37335 timeEl.textContent = formatTime(shotOffsets[index] || 0); 37336 startGlobalTimer(); 37337 modal.querySelectorAll("[data-preview-thumb]").forEach((t) => { 37338 t.style.borderColor = parseInt(t.getAttribute("data-preview-thumb")) === index ? "rgba(99,102,241,0.8)" : "transparent";
37339 }); 37340 if (shot.media_url?.includes(".mp4")) { 37341 const video = preloadedVideos[index]; 37342 mediaEl.innerHTML = ""; 37343 mediaEl.appendChild(video); 37344 video.currentTime = 0; 37345 video.muted = false; 37346 video.play().catch(() => { 37347 }); 37348 video.onended = () => { 37349 if (isPlaying && currentIndex < shots.length - 1) { 37350 showShot(currentIndex + 1); 37351 } else if (isPlaying) { 37352 isPlaying = false; 37353 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37354 } 37355 }; 37356 } else { 37357 mediaEl.innerHTML = `<img src="${shot.media_url}" style="width:100%;height:100%;object-fit:contain;" />`; 37358 if (isPlaying) { 37359 autoAdvanceTimeout = setTimeout(() => { 37360 if (isPlaying && currentIndex < shots.length - 1) { 37361 showShot(currentIndex + 1); 37362 } else if (isPlaying) { 37363 isPlaying = false; 37364 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37365 } 37366 }, IMAGE_DURATION * 1e3); 37367 } 37368 } 37369 }; 37370 playBtn.addEventListener("click", () => { 37371 if (isPlaying) { 37372 isPlaying = false; 37373 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37374 clearTimers(); 37375 stopGlobalTimer(); 37376 const video = mediaEl.querySelector("video"); 37377 if (video) video.pause(); 37378 } else { 37379 isPlaying = true; 37380 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`; 37381 startGlobalTimer(); 37382 const video = mediaEl.querySelector("video"); 37383 if (video) { 37384 video.play(); 37385 } else { 37386 showShot(currentIndex); 37387 } 37388 } 37389 }); 37390 modal.querySelectorAll("[data-preview-thumb]").forEach((t) => { 37391 t.addEventListener("click", () => { 37392 const idx = parseInt(t.getAttribute("data-preview-thumb")); 37393 showShot(idx); 37394 }); 37395 }); 37396 const closePreview = () => { 37397 isPlaying = false; 37398 clearTimers(); 37399 stopGlobalTimer(); 37400 Object.values(preloadedVideos).forEach((v) => { 37401 v.pause(); 37402 v.removeAttribute("src"); 37403 v.load(); 37404 }); 37405 modal.remove(); 37406 }; 37407 modal.querySelector("[data-preview-close]").addEventListener("click", closePreview); 37408 const escHandler = (e) => { 37409 if (e.key === "Escape") { 37410 closePreview(); 37411 document.removeEventListener("keydown", escHandler); 37412 } 37413 }; 37414 document.addEventListener("keydown", escHandler); 37415 const arrowHandler = (e) => { 37416 if (!document.getElementById("project-preview-modal")) { 37417 document.removeEventListener("keydown", arrowHandler); 37418 return; 37419 } 37420 if (e.key === "ArrowRight" && currentIndex < shots.length - 1) { 37421 showShot(currentIndex + 1); 37422 } else if (e.key === "ArrowLeft" && currentIndex > 0) { 37423 showShot(currentIndex - 1); 37424 } else if (e.key === " ") { 37425 e.preventDefault(); 37426 playBtn.click(); 37427 } 37428 }; 37429 document.addEventListener("keydown", arrowHandler); 37430 await Promise.all(preloadPromises); 37431 isPlaying = true; 37432 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`; 37433 showShot(0); 37434 } catch (error) { 37435 console.error("[ProjectCanvas] Preview error:", error); 37436 if (window.Toast) window.Toast.show("Failed to load preview", "alert"); 37437 } 37438 } 37439 // --- Cleanup --- 37440 destroy() { 37441 if (this._pollIntervals) { 37442 this._pollIntervals.forEach((id) => clearInterval(id)); 37443 this._pollIntervals = []; 37444 } 37445 if (this._pollTimeouts) {
37446 this._pollTimeouts.forEach((id) => clearTimeout(id)); 37447 this._pollTimeouts = []; 37448 } 37449 if (this._batchPollTimer) { 37450 clearTimeout(this._batchPollTimer); 37451 this._batchPollTimer = null; 37452 } 37453 if (this._processingNodes) { 37454 this._processingNodes.clear(); 37455 } 37456 const groupMenu = document.querySelector("[data-group-context-menu]"); 37457 if (groupMenu) groupMenu.remove(); 37458 const groupSettingsMenu = document.querySelector("[data-group-settings-menu]"); 37459 if (groupSettingsMenu) groupSettingsMenu.remove(); 37460 const groupActionBar = document.getElementById("project-group-action-bar"); 37461 if (groupActionBar) groupActionBar.remove(); 37462 this.closeEditBar(); 37463 this.closeShotMenu(); 37464 this.listeners.forEach(({ target, event, handler }) => { 37465 target.removeEventListener(event, handler); 37466 }); 37467 this.listeners = []; 37468 } 37469 // ââ Weather Picker âââââââââââââââââââââââââââââââââââââââââ 37470 openWeatherPicker() { 37471 const modal = document.getElementById("weather-picker-modal"); 37472 if (!modal) return; 37473 this.weatherSelections = []; 37474 modal.querySelectorAll("[data-weather-option]").forEach((btn) => { 37475 btn.classList.remove("border-active", "bg-active/10", "text-white"); 37476 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 37477 btn.querySelector("[data-weather-check]")?.classList.add("hidden"); 37478 }); 37479 const counter = modal.querySelector("[data-weather-picker-counter]"); 37480 if (counter) counter.textContent = "0/4 selected"; 37481 const genBtn = modal.querySelector("[data-weather-picker-generate]"); 37482 if (genBtn) genBtn.disabled = true; 37483 modal.classList.remove("hidden"); 37484 if (!this._weatherPickerBound) { 37485 this._bindWeatherPicker(modal); 37486 this._weatherPickerBound = true; 37487 } 37488 } 37489 _bindWeatherPicker(modal) { 37490 modal.querySelector("[data-weather-picker-close]")?.addEventListener("click", () => { 37491 modal.classList.add("hidden"); 37492 }); 37493 modal.addEventListener("click", (e) => { 37494 if (e.target === modal) modal.classList.add("hidden"); 37495 }); 37496 modal.querySelectorAll("[data-weather-option]").forEach((btn) => { 37497 btn.addEventListener("click", () => { 37498 btn.blur(); 37499 const value = btn.dataset.weatherOption; 37500 const idx = this.weatherSelections.indexOf(value); 37501 if (idx > -1) { 37502 this.weatherSelections.splice(idx, 1); 37503 btn.classList.remove("border-active", "bg-active/10", "text-white"); 37504 btn.classList.add("border-light/10", "bg-inside", "text-gray"); 37505 btn.querySelector("[data-weather-check]")?.classList.add("hidden"); 37506 } else { 37507 if (this.weatherSelections.length >= 4) { 37508 const oldest = this.weatherSelections.shift(); 37509 const oldBtn = modal.querySelector(`[data-weather-option="${oldest}"]`); 37510 if (oldBtn) { 37511 oldBtn.classList.remove("border-active", "bg-active/10", "text-white"); 37512 oldBtn.classList.add("border-light/10", "bg-inside", "text-gray"); 37513 oldBtn.querySelector("[data-weather-check]")?.classList.add("hidden"); 37514 } 37515 } 37516 this.weatherSelections.push(value); 37517 btn.classList.add("border-active", "bg-active/10", "text-white"); 37518 btn.classList.remove("border-light/10", "bg-inside", "text-gray"); 37519 btn.querySelector("[data-weather-check]")?.classList.remove("hidden"); 37520 } 37521 const counter = modal.querySelector("[data-weather-picker-counter]"); 37522 if (counter) counter.textContent = `${this.weatherSelections.length}/4 selected`; 37523 const genBtn = modal.querySelector("[data-weather-picker-generate]"); 37524 if (genBtn) genBtn.disabled = this.weatherSelections.length !== 4; 37525 }); 37526 }); 37527 modal.querySelector("[data-weather-picker-generate]")?.addEventListener("click", () => { 37528 if (this.weatherSelections.length !== 4) return; 37529 modal.classList.add("hidden"); 37530 this._submitWeatherGeneration(); 37531 }); 37532 } 37533 async _submitWeatherGeneration() { 37534 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 37535 try { 37536 const response = await fetch(`${this.baseUrl()}/generate-weather`, { 37537 method: "POST", 37538 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, 37539 body: JSON.stringify({ selections: this.weatherSelections }) 37540 }); 37541 if (!response.ok) { 37542 const data2 = await response.json().catch(() => ({})); 37543 window.Toast?.show(data2.error || "Failed to generate weather", "alert"); 37544 return; 37545 } 37546 const data = await response.json(); 37547 if (data.success) { 37548 const currentUrl = window.location.pathname; 37549 window.location.href = `${currentUrl}#${data.shot_uuid}`; 37550 window.location.reload(); 37551 } 37552 } catch (err) { 37553 console.error("[ProjectCanvas] Weather generation error:", err); 37554 window.Toast?.show("Failed to generate weather", "alert"); 37555 } 37556 } 37557}; 37558Object.assign(ProjectCanvas.prototype, IconsMixin, NodesMixin2, BarMixin2, GroupsMixin, MentionsMixin2); 37559
37560// app/javascript/project/index.js 37561var ProjectApp = class { 37562 constructor(container) { 37563 this.container = container; 37564 this.canvas = null; 37565 this.init(); 37566 } 37567 init() { 37568 const canvasContainer = this.container.querySelector("#project-canvas-container"); 37569 if (canvasContainer) { 37570 this.canvas = new ProjectCanvas(canvasContainer, { 37571 projectUuid: this.container.dataset.projectUuid, 37572 sequenceId: this.container.dataset.sequenceId, 37573 ratio: this.container.dataset.projectRatio, 37574 projectType: this.container.dataset.projectType 37575 }); 37576 } 37577 } 37578 destroy() { 37579 if (this.canvas) { 37580 this.canvas.destroy(); 37581 this.canvas = null; 37582 } 37583 } 37584}; 37585async function openPreviewAll() { 37586 try { 37587 const el = document.querySelector("[data-project-uuid]"); 37588 const projectUuid = el?.dataset.projectUuid; 37589 const projectType = el?.dataset.projectType || "movie"; 37590 const response = await fetch(`/${projectType}/${projectUuid}/preview`); 37591 if (!response.ok) throw new Error("Failed to load preview"); 37592 const data = await response.json(); 37593 const shots = data.shots || []; 37594 const ratio = data.ratio || "16:9"; 37595 if (shots.length === 0) { 37596 if (window.Toast) window.Toast.show("No shots with selected media", "notice"); 37597 return; 37598 } 37599 const modal = document.createElement("div"); 37600 modal.id = "project-preview-modal"; 37601 modal.style.cssText = "position: fixed; inset: 0; z-index: 10001; background: black; display: flex; flex-direction: column;"; 37602 const topBar = document.createElement("div"); 37603 topBar.style.cssText = "display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; flex-shrink: 0;"; 37604 topBar.innerHTML = ` 37605 <div style="display: flex; align-items: center; gap: 12px;"> 37606 <span style="font-size: 13px; color: white; font-weight: 600;">Preview All</span> 37607 <span style="font-size: 11px; color: rgba(255,255,255,0.4);" data-preview-counter>1 / ${shots.length}</span> 37608 </div> 37609 <button data-preview-close style="background: none; border: none; color: rgba(255,255,255,0.5); cursor: pointer; padding: 4px; display: flex; align-items: center; transition: color 0.2s;"> 37610 <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg> 37611 </button> 37612 `; 37613 const mediaContainer = document.createElement("div"); 37614 mediaContainer.style.cssText = "flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 0 24px;"; 37615 const mediaWrapper = document.createElement("div"); 37616 mediaWrapper.style.cssText = `aspect-ratio: ${ratio.replace(":", "/")}; max-width: 90vw; max-height: 75vh; width: 100%; position: relative; border-radius: 8px; overflow: hidden; background: #111;`; 37617 mediaWrapper.setAttribute("data-preview-media", ""); 37618 mediaContainer.appendChild(mediaWrapper); 37619 const bottomBar = document.createElement("div"); 37620 bottomBar.style.cssText = "flex-shrink: 0; padding: 16px 24px 24px; display: flex; flex-direction: column; gap: 12px; align-items: center;"; 37621 const controls = document.createElement("div"); 37622 controls.style.cssText = "display: flex; align-items: center; gap: 12px; width: 100%; max-width: 90vw;"; 37623 const playBtn = document.createElement("button"); 37624 playBtn.style.cssText = "background: none; border: none; color: white; cursor: pointer; padding: 4px; display: flex; align-items: center; flex-shrink: 0;"; 37625 playBtn.innerHTML = `<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37626 controls.appendChild(playBtn); 37627 const timelineBar = document.createElement("div"); 37628 timelineBar.style.cssText = "flex: 1; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; position: relative; pointer-events: none;"; 37629 const timelineProgress = document.createElement("div"); 37630 timelineProgress.style.cssText = "height: 100%; background: rgba(99,102,241,0.8); border-radius: 2px; width: 0%; position: relative;"; 37631 timelineBar.appendChild(timelineProgress); 37632 controls.appendChild(timelineBar); 37633 const timeLabel = document.createElement("span"); 37634 timeLabel.style.cssText = "font-size: 11px; color: rgba(255,255,255,0.4); min-width: 80px; text-align: right; flex-shrink: 0;"; 37635 timeLabel.textContent = "0:00"; 37636 controls.appendChild(timeLabel); 37637 bottomBar.appendChild(controls); 37638 const strip = document.createElement("div"); 37639 strip.style.cssText = "display: flex; gap: 6px; align-items: center; max-width: 90vw; overflow-x: auto;";
37640 shots.forEach((shot, i) => { 37641 const thumb = document.createElement("div"); 37642 thumb.setAttribute("data-preview-thumb", i); 37643 const thumbSrc = shot.thumbnail_url || shot.media_url; 37644 thumb.style.cssText = `width: 56px; height: 36px; border-radius: 4px; overflow: hidden; cursor: pointer; border: 2px solid ${i === 0 ? "rgba(99,102,241,0.8)" : "transparent"}; flex-shrink: 0; position: relative; transition: border-color 0.2s;`; 37645 const playOverlay = shot.is_video ? `<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"><svg width="12" height="12" fill="white" viewBox="0 0 24 24" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.5));"><path d="M8 5v14l11-7z"/></svg></div>` : ""; 37646 const mediaTag = thumbSrc.includes(".mp4") ? `<video src="${thumbSrc}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover;pointer-events:none;"></video>` : `<img src="${thumbSrc}" style="width:100%;height:100%;object-fit:cover;" />`; 37647 thumb.innerHTML = `${mediaTag}${playOverlay}`; 37648 strip.appendChild(thumb); 37649 }); 37650 bottomBar.appendChild(strip); 37651 modal.appendChild(topBar); 37652 modal.appendChild(mediaContainer); 37653 modal.appendChild(bottomBar); 37654 document.body.appendChild(modal); 37655 let currentIndex = 0; 37656 let isPlaying = false; 37657 let autoAdvanceTimeout = null; 37658 let globalTimerInterval = null; 37659 const IMAGE_DURATION = 3; 37660 const shotDurations = shots.map((s) => s.media_url?.includes(".mp4") ? 0 : IMAGE_DURATION); 37661 const computeOffsets = () => { 37662 const offsets = [0]; 37663 for (let i = 1; i < shots.length; i++) { 37664 offsets[i] = offsets[i - 1] + shotDurations[i - 1]; 37665 } 37666 return offsets; 37667 }; 37668 let shotOffsets = computeOffsets(); 37669 let shotStartedAt = Date.now(); 37670 const counterEl = modal.querySelector("[data-preview-counter]"); 37671 const mediaEl = modal.querySelector("[data-preview-media]"); 37672 const timeEl = timeLabel; 37673 const preloadedVideos = {}; 37674 mediaEl.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;"><span style="color:rgba(255,255,255,0.4);font-size:12px;">Loading...</span></div>'; 37675 const preloadPromises = shots.map((shot, i) => { 37676 if (!shot.media_url?.includes(".mp4")) return Promise.resolve(); 37677 return new Promise((resolve) => { 37678 const video = document.createElement("video"); 37679 video.preload = "auto"; 37680 video.playsInline = true; 37681 video.src = shot.media_url; 37682 if (shot.thumbnail_url) video.poster = shot.thumbnail_url; 37683 video.style.cssText = "width:100%;height:100%;object-fit:contain;"; 37684 video.addEventListener("loadedmetadata", () => { 37685 if (video.duration && isFinite(video.duration)) { 37686 shotDurations[i] = video.duration; 37687 shotOffsets = computeOffsets(); 37688 } 37689 }); 37690 preloadedVideos[i] = video; 37691 const done = () => resolve(); 37692 video.addEventListener("canplaythrough", done, { once: true }); 37693 setTimeout(done, 1e4); 37694 }); 37695 }); 37696 const formatTime = (seconds) => { 37697 const mins = Math.floor(seconds / 60); 37698 const secs = Math.floor(seconds % 60); 37699 return `${mins}:${secs.toString().padStart(2, "0")}`; 37700 }; 37701 const getTotalDuration = () => { 37702 let total = 0; 37703 for (let i = 0; i < shotDurations.length; i++) total += shotDurations[i]; 37704 return total; 37705 }; 37706 const getGlobalTime = () => { 37707 const offset = shotOffsets[currentIndex] || 0; 37708 const video = mediaEl.querySelector("video"); 37709 if (video && !isNaN(video.currentTime) && video.currentTime > 0) { 37710 return offset + video.currentTime; 37711 } 37712 return offset + (Date.now() - shotStartedAt) / 1e3; 37713 }; 37714 let lastTimeText = ""; 37715 const updateTimeline = () => { 37716 const current = getGlobalTime(); 37717 const total = getTotalDuration(); 37718 const pct = total > 0 ? Math.min(current / total * 100, 100) : 0; 37719 timelineProgress.style.width = `${pct}%`; 37720 const text = `${formatTime(current)} / ${formatTime(total)}`; 37721 if (text !== lastTimeText) { 37722 timeEl.textContent = text; 37723 lastTimeText = text; 37724 } 37725 }; 37726 const rafLoop = () => { 37727 if (!globalTimerInterval) return; 37728 updateTimeline(); 37729 requestAnimationFrame(rafLoop); 37730 }; 37731 const startGlobalTimer = () => { 37732 if (globalTimerInterval) return; 37733 globalTimerInterval = true; 37734 requestAnimationFrame(rafLoop); 37735 }; 37736 const clearTimers = () => { 37737 if (autoAdvanceTimeout) { 37738 clearTimeout(autoAdvanceTimeout); 37739 autoAdvanceTimeout = null; 37740 } 37741 }; 37742 const stopGlobalTimer = () => { 37743 globalTimerInterval = null; 37744 }; 37745 const showShot = (index) => { 37746 clearTimers(); 37747 currentIndex = index; 37748 shotStartedAt = Date.now(); 37749 const shot = shots[index]; 37750 counterEl.textContent = `${index + 1} / ${shots.length}`; 37751 timeEl.textContent = formatTime(shotOffsets[index] || 0); 37752 startGlobalTimer(); 37753 modal.querySelectorAll("[data-preview-thumb]").forEach((t) => { 37754 t.style.borderColor = parseInt(t.getAttribute("data-preview-thumb")) === index ? "rgba(99,102,241,0.8)" : "transparent";
37755 }); 37756 if (shot.media_url?.includes(".mp4")) { 37757 const video = preloadedVideos[index]; 37758 mediaEl.innerHTML = ""; 37759 mediaEl.appendChild(video); 37760 video.currentTime = 0; 37761 video.muted = false; 37762 video.play().catch(() => { 37763 }); 37764 video.onended = () => { 37765 if (isPlaying && currentIndex < shots.length - 1) { 37766 showShot(currentIndex + 1); 37767 } else if (isPlaying) { 37768 isPlaying = false; 37769 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37770 } 37771 }; 37772 } else { 37773 mediaEl.innerHTML = `<img src="${shot.media_url}" style="width:100%;height:100%;object-fit:contain;" />`; 37774 if (isPlaying) { 37775 autoAdvanceTimeout = setTimeout(() => { 37776 if (isPlaying && currentIndex < shots.length - 1) { 37777 showShot(currentIndex + 1); 37778 } else if (isPlaying) { 37779 isPlaying = false; 37780 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37781 } 37782 }, IMAGE_DURATION * 1e3); 37783 } 37784 } 37785 }; 37786 playBtn.addEventListener("click", () => { 37787 if (isPlaying) { 37788 isPlaying = false; 37789 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`; 37790 clearTimers(); 37791 stopGlobalTimer(); 37792 const video = mediaEl.querySelector("video"); 37793 if (video) video.pause(); 37794 } else { 37795 isPlaying = true; 37796 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`; 37797 startGlobalTimer(); 37798 const video = mediaEl.querySelector("video"); 37799 if (video) { 37800 video.play(); 37801 } else { 37802 showShot(currentIndex); 37803 } 37804 } 37805 }); 37806 modal.querySelectorAll("[data-preview-thumb]").forEach((t) => { 37807 t.addEventListener("click", () => showShot(parseInt(t.getAttribute("data-preview-thumb")))); 37808 }); 37809 const closePreview = () => { 37810 isPlaying = false; 37811 clearTimers(); 37812 stopGlobalTimer(); 37813 Object.values(preloadedVideos).forEach((v) => { 37814 v.pause(); 37815 v.removeAttribute("src"); 37816 v.load(); 37817 }); 37818 modal.remove(); 37819 }; 37820 modal.querySelector("[data-preview-close]").addEventListener("click", closePreview); 37821 const keyHandler = (e) => { 37822 if (!document.getElementById("project-preview-modal")) { 37823 document.removeEventListener("keydown", keyHandler); 37824 return; 37825 } 37826 if (e.key === "Escape") { 37827 closePreview(); 37828 return; 37829 } 37830 if (e.key === "ArrowRight" && currentIndex < shots.length - 1) showShot(currentIndex + 1); 37831 else if (e.key === "ArrowLeft" && currentIndex > 0) showShot(currentIndex - 1); 37832 else if (e.key === " ") { 37833 e.preventDefault(); 37834 playBtn.click(); 37835 } 37836 }; 37837 document.addEventListener("keydown", keyHandler); 37838 await Promise.all(preloadPromises); 37839 isPlaying = true; 37840 playBtn.innerHTML = `<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>`; 37841 showShot(0); 37842 } catch (error) { 37843 console.error("[Project] Preview error:", error); 37844 if (window.Toast) window.Toast.show("Failed to load preview", "alert"); 37845 } 37846} 37847document.addEventListener("click", (e) => { 37848 if (e.target.closest("[data-project-preview-all]")) { 37849 e.preventDefault(); 37850 openPreviewAll(); 37851 } 37852 if (e.target.closest("[data-project-download-all]")) { 37853 e.preventDefault(); 37854 openDownloadAll(); 37855 } 37856}); 37857function slugify(str) { 37858 return str.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); 37859} 37860function shotFilename(item) { 37861 const seq = slugify(item.sequence_name || "sequence"); 37862 const scene = slugify(item.scene_name || "scene"); 37863 return `${seq}-${scene}-shot-${item.shot_order}.mp4`; 37864} 37865function triggerDownload(mediaUrl, filename) { 37866 let downloadUrl = `/media/download?url=${encodeURIComponent(mediaUrl)}&is_video=true`; 37867 if (filename) downloadUrl += `&filename=${encodeURIComponent(filename)}`; 37868 const a = document.createElement("a"); 37869 a.href = downloadUrl; 37870 a.setAttribute("data-turbo", "false"); 37871 a.download = ""; 37872 document.body.appendChild(a); 37873 a.click(); 37874 a.remove(); 37875} 37876async function openDownloadAll() { 37877 try { 37878 const dlEl = document.querySelector("[data-project-uuid]"); 37879 const projectUuid = dlEl?.dataset.projectUuid; 37880 const projectType = dlEl?.dataset.projectType || "movie"; 37881 const response = await fetch(`/${projectType}/${projectUuid}/downloads`); 37882 if (!response.ok) throw new Error("Failed to load downloads"); 37883 const data = await response.json(); 37884 const items = data.items || []; 37885 if (items.length === 0) { 37886 if (window.Toast) window.Toast.show("No video shots to download", "notice"); 37887 return; 37888 } 37889 const modal = document.createElement("div"); 37890 modal.id = "project-download-modal"; 37891 modal.style.cssText = "position: fixed; inset: 0; z-index: 10001; background: rgba(0,0,0,0.85); display: flex; align-items: flex-start; justify-content: center; padding-top: 15vh;"; 37892 const bySequence = {};
37893 items.forEach((item) => { 37894 const seqKey = item.sequence_name || "Sequence"; 37895 if (!bySequence[seqKey]) bySequence[seqKey] = {}; 37896 const sceneKey = item.scene_name || "Scene"; 37897 if (!bySequence[seqKey][sceneKey]) bySequence[seqKey][sceneKey] = []; 37898 bySequence[seqKey][sceneKey].push(item); 37899 }); 37900 let listHtml = ""; 37901 for (const [seqName, scenes] of Object.entries(bySequence)) { 37902 listHtml += `<div class="mb-5 last:mb-0">`; 37903 listHtml += `<div class="text-[11px] text-white font-medium mb-2 px-1">${seqName}</div>`; 37904 for (const [sceneName, shots] of Object.entries(scenes)) { 37905 listHtml += `<div class="text-[10px] uppercase text-gray font-medium mb-1.5 px-1">${sceneName}</div>`; 37906 shots.forEach((shot) => { 37907 const thumbSrc = shot.thumbnail_url || shot.media_url; 37908 listHtml += ` 37909 <div class="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-light/10 transition"> 37910 <div class="w-14 h-9 rounded overflow-hidden bg-inside flex-shrink-0 relative"> 37911 ${thumbSrc.includes(".mp4") ? `<video src="${thumbSrc}" muted preload="metadata" class="w-full h-full object-cover pointer-events-none"></video>` : `<img src="${thumbSrc}" class="w-full h-full object-cover" />`} 37912 <div class="absolute inset-0 flex items-center justify-center"> 37913 <svg class="w-3 h-3 text-white/70" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> 37914 </div> 37915 </div> 37916 <div class="flex-1 min-w-0"> 37917 <span class="text-xs text-white">Shot ${shot.shot_order}</span> 37918 </div> 37919 <a href="/media/download?url=${encodeURIComponent(shot.media_url)}&is_video=true&filename=${encodeURIComponent(shotFilename(shot))}" data-turbo="false" download class="flex items-center gap-1.5 px-3 py-1.5 rounded-sm bg-inside hover:bg-active/40 text-[10px] text-gray hover:text-white font-medium transition cursor-pointer flex-shrink-0"> 37920 <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 37921 Download 37922 </a> 37923 </div> 37924 `; 37925 }); 37926 } 37927 listHtml += `</div>`; 37928 } 37929 modal.innerHTML = ` 37930 <div class="bg-box rounded-xl shadow-xl w-full max-w-md mx-4" style="max-height: 70vh; display: flex; flex-direction: column;"> 37931 <div class="flex items-center justify-between p-4 pb-2 flex-shrink-0"> 37932 <div class="flex items-center gap-2"> 37933 <span class="text-sm font-medium text-white">Download Videos</span> 37934 <span class="text-[10px] text-gray">${items.length} video${items.length > 1 ? "s" : ""}</span> 37935 </div> 37936 <button data-download-close class="text-gray hover:text-white transition cursor-pointer p-1"> 37937 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg> 37938 </button> 37939 </div> 37940 <div class="px-4 pb-4 overflow-y-auto custom-scrollbar flex-1"> 37941 ${listHtml} 37942 </div> 37943 <div class="px-4 pb-4 pt-2 border-t border-light/10 flex-shrink-0"> 37944 <button data-download-all-btn class="w-full py-2.5 rounded-md bg-active hover:bg-active/80 text-white text-xs font-bold transition cursor-pointer flex items-center justify-center gap-2"> 37945 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 37946 DOWNLOAD ALL 37947 </button> 37948 </div> 37949 </div> 37950 `; 37951 document.body.appendChild(modal); 37952 const closeModal = () => modal.remove(); 37953 modal.querySelector("[data-download-close]").addEventListener("click", closeModal); 37954 modal.addEventListener("click", (e) => { 37955 if (e.target === modal) closeModal(); 37956 });
37957 const keyHandler = (e) => { 37958 if (e.key === "Escape") { 37959 closeModal(); 37960 document.removeEventListener("keydown", keyHandler); 37961 } 37962 }; 37963 document.addEventListener("keydown", keyHandler); 37964 modal.querySelector("[data-download-all-btn]").addEventListener("click", () => { 37965 items.forEach((item, i) => { 37966 setTimeout(() => triggerDownload(item.media_url, shotFilename(item)), i * 500); 37967 }); 37968 }); 37969 } catch (error) { 37970 console.error("[Project] Download error:", error); 37971 if (window.Toast) window.Toast.show("Failed to load downloads", "alert"); 37972 } 37973} 37974var SequenceReorder = class { 37975 constructor() { 37976 this.grid = document.getElementById("sequences-grid"); 37977 if (!this.grid) return; 37978 this.draggedElement = null; 37979 this.placeholder = null; 37980 this.isDragging = false; 37981 this.startX = 0; 37982 this.startY = 0; 37983 this.offsetX = 0; 37984 this.offsetY = 0; 37985 this.dragThreshold = 5; 37986 this.init(); 37987 } 37988 init() { 37989 this._handleMouseDowns = []; 37990 this.grid.querySelectorAll(".sequence-card").forEach((card) => { 37991 card.addEventListener("dragstart", (e) => e.preventDefault()); 37992 const handle = card.querySelector(".drag-handle"); 37993 if (handle) { 37994 const handler = (e) => this.onMouseDown(e, card); 37995 handle.addEventListener("mousedown", handler); 37996 this._handleMouseDowns.push({ el: handle, handler }); 37997 } 37998 }); 37999 this._onMouseMove = (e) => this.onMouseMove(e); 38000 this._onMouseUp = (e) => this.onMouseUp(e); 38001 document.addEventListener("mousemove", this._onMouseMove); 38002 document.addEventListener("mouseup", this._onMouseUp); 38003 } 38004 destroy() { 38005 document.removeEventListener("mousemove", this._onMouseMove); 38006 document.removeEventListener("mouseup", this._onMouseUp); 38007 if (this._handleMouseDowns) { 38008 this._handleMouseDowns.forEach(({ el, handler }) => el.removeEventListener("mousedown", handler)); 38009 this._handleMouseDowns = []; 38010 } 38011 } 38012 onMouseDown(e, card) { 38013 if (e.button !== 0) return; 38014 e.preventDefault(); 38015 const rect = card.getBoundingClientRect(); 38016 this.draggedElement = card; 38017 this.startX = e.clientX; 38018 this.startY = e.clientY; 38019 this.offsetX = e.clientX - rect.left; 38020 this.offsetY = e.clientY - rect.top; 38021 this.isDragging = false; 38022 } 38023 onMouseMove(e) { 38024 if (!this.draggedElement) return; 38025 const deltaX = Math.abs(e.clientX - this.startX); 38026 const deltaY = Math.abs(e.clientY - this.startY); 38027 if (!this.isDragging && (deltaX > this.dragThreshold || deltaY > this.dragThreshold)) { 38028 this.startDrag(e); 38029 } 38030 if (this.isDragging) { 38031 this.updateDragPosition(e); 38032 this.updatePlaceholder(e); 38033 } 38034 } 38035 startDrag(e) { 38036 this.isDragging = true; 38037 const rect = this.draggedElement.getBoundingClientRect(); 38038 this.placeholder = document.createElement("div"); 38039 this.placeholder.style.cssText = `height: ${rect.height}px; border-radius: 6px; border: 1px dashed rgba(255,255,255,1);`; 38040 this.draggedElement.parentNode.insertBefore(this.placeholder, this.draggedElement); 38041 document.body.appendChild(this.draggedElement); 38042 this.draggedElement.style.position = "fixed"; 38043 this.draggedElement.style.width = rect.width + "px"; 38044 this.draggedElement.style.height = rect.height + "px"; 38045 this.draggedElement.style.zIndex = "9999"; 38046 this.draggedElement.style.pointerEvents = "none"; 38047 this.draggedElement.style.transition = "none"; 38048 this.draggedElement.style.opacity = "0.9"; 38049 this.draggedElement.classList.add("shadow-xl"); 38050 this.updateDragPosition(e); 38051 } 38052 updateDragPosition(e) { 38053 if (!this.draggedElement) return; 38054 this.draggedElement.style.left = e.clientX - this.offsetX + "px"; 38055 this.draggedElement.style.top = e.clientY - this.offsetY + "px"; 38056 } 38057 updatePlaceholder(e) { 38058 const cards = Array.from(this.grid.querySelectorAll('.sequence-card:not([style*="position: fixed"])')); 38059 let closestCard = null; 38060 let closestDistance = Infinity; 38061 let insertBefore = true; 38062 for (const card of cards) { 38063 const rect = card.getBoundingClientRect(); 38064 const centerY = rect.top + rect.height / 2; 38065 const distance = Math.abs(e.clientY - centerY); 38066 if (distance < closestDistance) { 38067 closestDistance = distance; 38068 closestCard = card; 38069 insertBefore = e.clientY < centerY; 38070 } 38071 } 38072 if (closestCard && this.placeholder) { 38073 if (insertBefore) { 38074 closestCard.parentNode.insertBefore(this.placeholder, closestCard); 38075 } else { 38076 closestCard.parentNode.insertBefore(this.placeholder, closestCard.nextSibling); 38077 } 38078 } 38079 } 38080 onMouseUp() { 38081 if (!this.draggedElement) return; 38082 if (this.isDragging) { 38083 this.draggedElement.style.position = ""; 38084 this.draggedElement.style.width = ""; 38085 this.draggedElement.style.height = ""; 38086 this.draggedElement.style.left = ""; 38087 this.draggedElement.style.top = ""; 38088 this.draggedElement.style.zIndex = ""; 38089 this.draggedElement.style.pointerEvents = ""; 38090 this.draggedElement.style.transition = ""; 38091 this.draggedElement.style.opacity = ""; 38092 this.draggedElement.classList.remove("shadow-xl"); 38093 if (this.placeholder && this.placeholder.parentNode) { 38094 this.placeholder.parentNode.insertBefore(this.draggedElement, this.placeholder); 38095 this.placeholder.remove(); 38096 } 38097 this.placeholder = null; 38098 this.saveOrder(); 38099 } 38100 this.draggedElement = null; 38101 this.isDragging = false;
38102 } 38103 saveOrder() { 38104 const cards = this.grid.querySelectorAll(".sequence-card"); 38105 if (cards.length <= 1) return; 38106 cards.forEach((card, i) => { 38107 const nameEl = card.querySelector("[data-sequence-name]"); 38108 if (nameEl) nameEl.textContent = `Sequence ${i + 1}`; 38109 }); 38110 const order = Array.from(cards).map((card) => card.dataset.sequenceId); 38111 const reorderEl = document.querySelector("[data-project-uuid]"); 38112 const projectUuid = reorderEl?.dataset.projectUuid; 38113 const projectType = reorderEl?.dataset.projectType || "movie"; 38114 fetch(`/${projectType}/${projectUuid}/sequences/reorder`, { 38115 method: "POST", 38116 headers: { 38117 "Content-Type": "application/json", 38118 "X-CSRF-Token": document.querySelector('[name="csrf-token"]')?.content || "" 38119 }, 38120 body: JSON.stringify({ order }) 38121 }).catch((err) => console.error("Failed to save sequence order:", err)); 38122 } 38123}; 38124var sequenceReorder = null; 38125document.addEventListener("turbo:load", () => { 38126 if (sequenceReorder) { 38127 sequenceReorder.destroy(); 38128 sequenceReorder = null; 38129 } 38130 sequenceReorder = new SequenceReorder(); 38131}); 38132document.addEventListener("turbo:before-cache", () => { 38133 if (sequenceReorder) { 38134 sequenceReorder.destroy(); 38135 sequenceReorder = null; 38136 } 38137}); 38138var pendingDeleteSequenceId = null; 38139function deleteSequence(uuid2) { 38140 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 38141 const delEl = document.querySelector("[data-project-uuid]"); 38142 const projectUuid = delEl?.dataset.projectUuid; 38143 const projectType = delEl?.dataset.projectType || "movie"; 38144 fetch(`/${projectType}/${projectUuid}/${uuid2}`, { 38145 method: "DELETE", 38146 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken } 38147 }).then((response) => { 38148 if (response.ok) { 38149 const card = document.querySelector(`.sequence-card[data-sequence-id="${uuid2}"]`); 38150 if (card) card.remove(); 38151 const grid = document.getElementById("sequences-grid"); 38152 if (grid) { 38153 grid.querySelectorAll(".sequence-card").forEach((c, i) => { 38154 const nameEl = c.querySelector("[data-sequence-name]"); 38155 if (nameEl) nameEl.textContent = `Sequence ${i + 1}`; 38156 }); 38157 } 38158 if (window.Toast) window.Toast.show("Sequence deleted", "success"); 38159 } else { 38160 if (window.Toast) window.Toast.show("Failed to delete sequence", "alert"); 38161 } 38162 }).catch(() => { 38163 if (window.Toast) window.Toast.show("Failed to delete sequence", "alert"); 38164 }); 38165} 38166document.addEventListener("click", (e) => { 38167 const deleteBtn = e.target.closest("[data-delete-sequence-btn]"); 38168 if (deleteBtn) { 38169 e.preventDefault(); 38170 e.stopPropagation(); 38171 pendingDeleteSequenceId = deleteBtn.dataset.deleteSequenceBtn; 38172 const card = document.querySelector(`.sequence-card[data-sequence-id="${pendingDeleteSequenceId}"]`); 38173 const shotsCount = parseInt(card?.dataset.shotsCount || "0", 10); 38174 if (shotsCount === 0) { 38175 deleteSequence(pendingDeleteSequenceId); 38176 pendingDeleteSequenceId = null; 38177 return; 38178 } 38179 const modal = document.getElementById("delete-sequence-modal"); 38180 if (modal) { 38181 modal.classList.remove("hidden"); 38182 requestAnimationFrame(() => modal.classList.add("modal-showing")); 38183 } 38184 return; 38185 } 38186 if (e.target.closest("[data-delete-seq-modal-close]") || e.target.id === "delete-sequence-modal") { 38187 const modal = document.getElementById("delete-sequence-modal"); 38188 if (modal) { 38189 modal.classList.remove("modal-showing"); 38190 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38191 } 38192 pendingDeleteSequenceId = null; 38193 return; 38194 } 38195}); 38196document.addEventListener("click", (e) => { 38197 if (e.target.closest("[data-project-settings-close]")) { 38198 const modal = document.getElementById("project-settings-modal"); 38199 if (modal) { 38200 modal.classList.remove("modal-showing"); 38201 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38202 } 38203 return; 38204 } 38205 if (e.target.id === "project-settings-modal") { 38206 e.target.classList.remove("modal-showing"); 38207 e.target.addEventListener("transitionend", () => e.target.classList.add("hidden"), { once: true }); 38208 } 38209}); 38210document.addEventListener("submit", async (e) => { 38211 if (e.target.id !== "project-settings-form") return; 38212 e.preventDefault(); 38213 const form = e.target; 38214 const name = form.querySelector('input[name="name"]').value.trim(); 38215 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 38216 try { 38217 const renameEl = document.querySelector("[data-project-uuid]"); 38218 const projectUuid = renameEl?.dataset.projectUuid; 38219 const projectType = renameEl?.dataset.projectType || "movie"; 38220 const response = await fetch(`/${projectType}/${projectUuid}`, { 38221 method: "PATCH", 38222 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken }, 38223 body: JSON.stringify({ name }) 38224 }); 38225 const data = await response.json(); 38226 if (data.success) { 38227 const modal = document.getElementById("project-settings-modal"); 38228 if (modal) { 38229 modal.classList.remove("modal-showing"); 38230 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38231 } 38232 const nameDisplay = document.getElementById("project-name-display"); 38233 if (nameDisplay) nameDisplay.textContent = data.name || "Untitled"; 38234 } 38235 } catch (err) { 38236 if (window.Toast) window.Toast.show("Failed to save settings", "alert"); 38237 } 38238}); 38239document.addEventListener("click", async (e) => { 38240 if (e.target.closest("[data-delete-project-close]") || e.target.id === "delete-project-modal") { 38241 const modal = document.getElementById("delete-project-modal"); 38242 if (modal) { 38243 modal.classList.remove("modal-showing"); 38244 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38245 } 38246 return; 38247 } 38248 if (e.target.closest("[data-delete-project-blocked-close]") || e.target.id === "delete-project-blocked-modal") { 38249 const modal = document.getElementById("delete-project-blocked-modal"); 38250 if (modal) { 38251 modal.classList.remove("modal-showing"); 38252 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38253 } 38254 return; 38255 } 38256 if (e.target.closest("[data-delete-project-confirm]")) { 38257 const deleteEl = document.querySelector("[data-project-uuid]"); 38258 const projectUuid = deleteEl?.dataset.projectUuid; 38259 const projectType = deleteEl?.dataset.projectType || "movie"; 38260 if (!projectUuid) return; 38261 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 38262 try { 38263 const response = await fetch(`/${projectType}/${projectUuid}`, { 38264 method: "DELETE", 38265 headers: { "Accept": "application/json", "X-CSRF-Token": csrfToken } 38266 }); 38267 const data = await response.json(); 38268 if (data.success) { 38269 window.location.href = data.redirect_to || `/${projectType}s`; 38270 } 38271 } catch (err) { 38272 console.error("[Project] Delete error:", err); 38273 } 38274 } 38275}); 38276document.addEventListener("click", (e) => { 38277 if (e.target.closest("[data-create-project]")) { 38278 const modal = document.getElementById("create-project-modal"); 38279 const form = document.getElementById("create-project-form"); 38280 if (modal && form) { 38281 form.reset(); 38282 const projectType = form.dataset.projectType || "movie"; 38283 const etInput = form.querySelector('input[name="element_type"]'); 38284 if (etInput) { 38285 etInput.value = "character"; 38286 modal.querySelectorAll("[data-create-element-type]").forEach((b) => { 38287 const isDefault = b.dataset.createElementType === "character"; 38288 b.classList.toggle("bg-active/40", isDefault); 38289 b.classList.toggle("border-active", isDefault); 38290 b.classList.toggle("text-white", isDefault); 38291 b.classList.toggle("bg-inside", !isDefault); 38292 b.classList.toggle("border-light/10", !isDefault); 38293 b.classList.toggle("text-gray", !isDefault); 38294 }); 38295 } 38296 const styleInput = form.querySelector('input[name="style"]'); 38297 if (styleInput) styleInput.value = "realistic"; 38298 modal.querySelectorAll("[data-create-project-style]").forEach((b) => { 38299 b.style.outline = b.dataset.createProjectStyle === "realistic" ? "2px solid var(--color-active)" : "none"; 38300 b.style.outlineOffset = "2px"; 38301 }); 38302 modal.classList.remove("hidden"); 38303 requestAnimationFrame(() => modal.classList.add("modal-showing")); 38304 form.querySelector('input[name="name"]').focus(); 38305 const closeCreateModal = () => { 38306 document.removeEventListener("keydown", createModalEsc); 38307 modal.classList.remove("modal-showing"); 38308 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38309 }; 38310 const createModalEsc = (e2) => {
38311 if (e2.key === "Escape") closeCreateModal(); 38312 }; 38313 document.addEventListener("keydown", createModalEsc); 38314 modal._closeCreateModal = closeCreateModal; 38315 } 38316 return; 38317 } 38318 if (e.target.closest("[data-create-project-close]")) { 38319 const modal = document.getElementById("create-project-modal"); 38320 if (modal) { 38321 if (modal._closeCreateModal) modal._closeCreateModal(); 38322 else { 38323 modal.classList.remove("modal-showing"); 38324 modal.addEventListener("transitionend", () => modal.classList.add("hidden"), { once: true }); 38325 } 38326 } 38327 return; 38328 } 38329 if (e.target.id === "create-project-modal") { 38330 if (e.target._closeCreateModal) e.target._closeCreateModal(); 38331 else { 38332 e.target.classList.remove("modal-showing"); 38333 e.target.addEventListener("transitionend", () => e.target.classList.add("hidden"), { once: true }); 38334 } 38335 return; 38336 } 38337 const etBtn = e.target.closest("[data-create-element-type]"); 38338 if (etBtn) { 38339 const form = document.getElementById("create-project-form"); 38340 if (form) { 38341 form.querySelector('input[name="element_type"]').value = etBtn.dataset.createElementType; 38342 document.querySelectorAll("[data-create-element-type]").forEach((b) => { 38343 if (b === etBtn) { 38344 b.classList.remove("bg-inside", "border-light/10", "text-gray"); 38345 b.classList.add("bg-active/40", "border-active", "text-white"); 38346 } else { 38347 b.classList.remove("bg-active/40", "border-active", "text-white"); 38348 b.classList.add("bg-inside", "border-light/10", "text-gray"); 38349 } 38350 }); 38351 } 38352 etBtn.blur(); 38353 return; 38354 } 38355 const styleBtn = e.target.closest("[data-create-project-style]"); 38356 if (styleBtn) { 38357 const form = document.getElementById("create-project-form"); 38358 if (form) { 38359 form.querySelector('input[name="style"]').value = styleBtn.dataset.createProjectStyle; 38360 document.querySelectorAll("[data-create-project-style]").forEach((b) => { 38361 b.style.outline = b === styleBtn ? "2px solid var(--color-active)" : "none"; 38362 b.style.outlineOffset = "2px"; 38363 }); 38364 } 38365 styleBtn.blur(); 38366 return; 38367 } 38368}); 38369document.addEventListener("submit", async (e) => { 38370 if (e.target.id !== "create-project-form") return; 38371 e.preventDefault(); 38372 const form = e.target; 38373 const name = form.querySelector('input[name="name"]').value.trim(); 38374 const projectType = form.dataset.projectType || "movie"; 38375 const csrfToken = document.querySelector('[name="csrf-token"]')?.content || ""; 38376 const body = { name, project_type: projectType }; 38377 const etInput = form.querySelector('input[name="element_type"]'); 38378 if (etInput) body.element_type = etInput.value; 38379 const styleInput = form.querySelector('input[name="style"]'); 38380 if (styleInput) body.style = styleInput.value; 38381 try { 38382 const typePlural = projectType === "element" ? "elements" : projectType === "short" ? "shorts" : "movies"; 38383 const response = await fetch(`/${typePlural}`, { 38384 method: "POST", 38385 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrfToken }, 38386 body: JSON.stringify(body) 38387 }); 38388 const data = await response.json(); 38389 if (data.success && data.redirect_to) { 38390 window.Turbo ? Turbo.visit(data.redirect_to) : window.location.href = data.redirect_to; 38391 } 38392 } catch (err) { 38393 if (window.Toast) window.Toast.show("Failed to create project", "alert"); 38394 } 38395}); 38396document.addEventListener("click", (e) => { 38397 const menuBtn = e.target.closest("[data-project-menu-btn]"); 38398 const dropdown = document.querySelector("[data-project-menu-dropdown]"); 38399 if (!dropdown) return; 38400 if (menuBtn) { 38401 dropdown.classList.toggle("hidden"); 38402 return; 38403 } 38404 if (e.target.closest("[data-project-menu-rename]")) { 38405 dropdown.classList.add("hidden"); 38406 const modal = document.getElementById("project-settings-modal"); 38407 if (modal) { 38408 modal.classList.remove("hidden"); 38409 requestAnimationFrame(() => modal.classList.add("modal-showing")); 38410 modal.querySelector('input[name="name"]')?.focus(); 38411 } 38412 return; 38413 } 38414 if (e.target.closest("[data-project-menu-delete]")) { 38415 dropdown.classList.add("hidden"); 38416 const hasSequences = document.querySelectorAll(".sequence-card").length > 0; 38417 const modalId = hasSequences ? "delete-project-blocked-modal" : "delete-project-modal"; 38418 const modal = document.getElementById(modalId); 38419 if (modal) { 38420 modal.classList.remove("hidden"); 38421 requestAnimationFrame(() => modal.classList.add("modal-showing")); 38422 } 38423 return; 38424 } 38425 if (!e.target.closest("[data-project-menu-dropdown]")) { 38426 dropdown.classList.add("hidden"); 38427 } 38428}); 38429function initProject() { 38430 if (document.documentElement.hasAttribute("data-turbo-preview")) return; 38431 const container = document.querySelector("[data-project-init]"); 38432 if (container && !container._projectApp) { 38433 container._projectApp = new ProjectApp(container); 38434 } 38435} 38436function cleanupProject() { 38437 const container = document.querySelector("[data-project-init]"); 38438 if (container && container._projectApp) { 38439 container._projectApp.destroy(); 38440 delete container._projectApp; 38441 } 38442} 38443document.addEventListener("turbo:load", initProject);
38444document.addEventListener("turbo:before-cache", cleanupProject); 38445
38446// app/javascript/brand_menu.js 38447var BrandMenu = class { 38448 constructor() { 38449 this.menu = null; 38450 this.isOpen = false; 38451 this.handlers = { 38452 contextmenu: this.handleContextMenu.bind(this), 38453 keydown: this.handleKeyDown.bind(this), 38454 mousedown: this.handleMouseDown.bind(this), 38455 menuClick: this.handleMenuClick.bind(this) 38456 }; 38457 this.init(); 38458 } 38459 init() { 38460 this.menu = document.getElementById("brand-menu"); 38461 if (!this.menu) return; 38462 document.addEventListener("contextmenu", this.handlers.contextmenu); 38463 } 38464 handleContextMenu(e) { 38465 const trigger = e.target.closest("[data-brand-menu-trigger]"); 38466 if (!trigger) return; 38467 e.preventDefault(); 38468 this.open(trigger); 38469 } 38470 open(trigger) { 38471 if (!this.menu) return; 38472 this.menu.style.left = "-9999px"; 38473 this.menu.style.top = "-9999px"; 38474 this.menu.classList.remove("hidden"); 38475 const triggerRect = trigger.getBoundingClientRect(); 38476 const menuRect = this.menu.getBoundingClientRect(); 38477 const margin = 8; 38478 const gap = 8; 38479 const maxX = window.innerWidth - menuRect.width - margin; 38480 const maxY = window.innerHeight - menuRect.height - margin; 38481 const finalX = Math.max(margin, Math.min(triggerRect.left, maxX)); 38482 const finalY = Math.max(margin, Math.min(triggerRect.bottom + gap, maxY)); 38483 this.menu.style.left = `${finalX}px`; 38484 this.menu.style.top = `${finalY}px`; 38485 if (!this.isOpen) { 38486 document.addEventListener("keydown", this.handlers.keydown); 38487 document.addEventListener("mousedown", this.handlers.mousedown); 38488 this.menu.addEventListener("click", this.handlers.menuClick); 38489 this.isOpen = true; 38490 } 38491 } 38492 close() { 38493 if (!this.menu || !this.isOpen) return; 38494 this.menu.classList.add("hidden"); 38495 document.removeEventListener("keydown", this.handlers.keydown); 38496 document.removeEventListener("mousedown", this.handlers.mousedown); 38497 this.menu.removeEventListener("click", this.handlers.menuClick); 38498 this.isOpen = false; 38499 } 38500 handleKeyDown(e) { 38501 if (e.key === "Escape") this.close(); 38502 } 38503 handleMouseDown(e) { 38504 if (!this.menu.contains(e.target)) this.close(); 38505 } 38506 handleMenuClick(e) { 38507 if (e.target.closest("[data-brand-download]")) { 38508 this.close(); 38509 } 38510 } 38511 destroy() { 38512 document.removeEventListener("contextmenu", this.handlers.contextmenu); 38513 this.close(); 38514 this.menu = null; 38515 } 38516}; 38517document.addEventListener("turbo:load", () => { 38518 if (window.brandMenu && typeof window.brandMenu.destroy === "function") { 38519 window.brandMenu.destroy(); 38520 } 38521 window.brandMenu = new BrandMenu(); 38522}); 38523document.addEventListener("turbo:before-cache", () => { 38524 if (window.brandMenu && typeof window.brandMenu.destroy === "function") { 38525 window.brandMenu.destroy(); 38526 } 38527 window.brandMenu = null; 38528}); 38529
38530// app/javascript/application.js 38531document.addEventListener("turbo:before-prefetch", function(event) { 38532 event.preventDefault(); 38533}); 38534document.addEventListener("turbo:before-render", function(event) { 38535 const newDocument = event.detail.newBody || event.detail.newStream?.document?.body; 38536 if (newDocument) { 38537 const fadeInElements = newDocument.querySelectorAll(".fade-in"); 38538 fadeInElements.forEach((element) => { 38539 element.style.opacity = "0"; 38540 }); 38541 } 38542}); 38543document.addEventListener("turbo:load", function() { 38544 const fadeInElements = document.querySelectorAll(".fade-in"); 38545 if (fadeInElements.length > 0) { 38546 fadeInElements.forEach((element) => { 38547 requestAnimationFrame(() => { 38548 element.style.opacity = "1"; 38549 }); 38550 }); 38551 } 38552}); 38553document.addEventListener("click", function(e) { 38554 const styleClear = e.target.closest(".style-clear"); 38555 if (styleClear) { 38556 e.stopPropagation(); 38557 const styleImageContainer = document.querySelector( 38558 ".style-image-container" 38559 ); 38560 if (styleImageContainer) { 38561 styleImageContainer.classList.add("hidden"); 38562 const styleButton = document.getElementById("style-button"); 38563 const styleSpan = styleButton?.querySelector("span.flex-1"); 38564 if (styleSpan) { 38565 styleSpan.textContent = "Style"; 38566 } 38567 if (styleButton) { 38568 styleButton.classList.remove("text-white"); 38569 styleButton.classList.add("text-gray"); 38570 } 38571 const form = document.getElementById("generate-form"); 38572 if (form) { 38573 let styleInput = form.querySelector('input[name="style"]'); 38574 if (styleInput) { 38575 styleInput.value = "none"; 38576 } 38577 } 38578 if (window.imageGenerator) { 38579 window.imageGenerator.saveFormValues(); 38580 } 38581 } 38582 } 38583}); 38584window.loadSigninModal = function() { 38585 const currentUrl = window.location.href; 38586 fetch("/modal/join", { 38587 headers: { 38588 Accept: "text/html", 38589 "X-Requested-With": "XMLHttpRequest" 38590 } 38591 }).then((response) => response.text()).then((html) => { 38592 const existingModal = document.getElementById("join-modal"); 38593 if (existingModal) { 38594 existingModal.remove(); 38595 } 38596 document.body.insertAdjacentHTML("beforeend", html); 38597 const modal = document.getElementById("join-modal"); 38598 if (modal) { 38599 const forms2 = modal.querySelectorAll("form"); 38600 forms2.forEach((form) => { 38601 const input = document.createElement("input"); 38602 input.type = "hidden"; 38603 input.name = "return_to"; 38604 input.value = currentUrl; 38605 form.appendChild(input); 38606 }); 38607 if (window.Modal) { 38608 window.Modal.open("join-modal"); 38609 } 38610 } 38611 }).catch((error) => { 38612 console.error("Error loading join modal:", error); 38613 }); 38614}; 38615window.loadCloneModal = function(flowId) { 38616 fetch(`/modal/clone/${flowId}`, { 38617 headers: { 38618 Accept: "text/html", 38619 "X-Requested-With": "XMLHttpRequest" 38620 } 38621 }).then((response) => response.text()).then((html) => { 38622 const existingModal = document.getElementById("clone-modal"); 38623 if (existingModal) { 38624 existingModal.remove(); 38625 } 38626 document.body.insertAdjacentHTML("beforeend", html); 38627 const modal = document.getElementById("clone-modal"); 38628 if (modal) { 38629 if (window.Modal) { 38630 window.Modal.open("clone-modal"); 38631 } 38632 } 38633 }).catch((error) => { 38634 console.error("Error loading clone modal:", error); 38635 }); 38636}; 38637/*! Bundled license information: 38638 38639@hotwired/turbo/dist/turbo.es2017-esm.js: 38640 (*! 38641 Turbo 8.0.13 38642 Copyright © 2025 37signals LLC 38643 *) 38644 38645@melloware/coloris/dist/esm/coloris.js: 38646 (*! 38647 * Copyright (c) 2021-2024 Momo Bassit. 38648 * Licensed under the MIT License (MIT) 38649 * https://github.com/mdbassit/Coloris 38650 * Version: 0.25.0 38651 * NPM: https://github.com/melloware/coloris-npm 38652 *) 38653*/ 38654//# sourceMappingURL=/assets/application-34b28341.js.map
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.