PageSourceSearch

https://reflet.ai/assets/application-126468c9.js

js reflet.ai collected 2026-10-05 14:41:56 UTC 1,573,187 bytes, 38,654 lines download raw bytes

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 = "&#10003;";
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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} &middot; ${typeLabel}${mediaCount > 0 ? ` &middot; ${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 ? "&#10003;" : ""}
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">&#9733;</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 ? "&#10003;" : ""}
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">&#9733;</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 ? "&#10003;" : ""}
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">&#9733;</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">&#10003;</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.