diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c55a1127..be8bb339 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -38,6 +38,9 @@ jobs: - name: Lint Frontend run: npm run lint + - name: Frontend Unit Tests + run: npm run test:js + # - name: Commit Changes # uses: stefanzweifel/git-auto-commit-action@v5 # with: diff --git a/lang/ar/posts.php b/lang/ar/posts.php index f3e316bd..fc12ea89 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'عدّل إعدادات علامتك التجارية للتأثير في المنشورات المستقبلية.', 'loading_tip_carousel' => 'تقدّم العروض الدائرية شريحة واحدة لكل صورة مرفوعة.', 'loading_tip_quality' => 'تم ضبط جودة الصورة لتحقيق توازن بين السرعة والتكلفة.', + 'bar_generating_one' => 'الذكاء الاصطناعي ينشئ منشورك…', + 'bar_generating_other' => 'الذكاء الاصطناعي ينشئ :count منشورات…', + 'bar_done' => 'منشورك جاهز!', + 'bar_done_other' => ':count منشورات جاهزة!', + 'bar_done_cta' => 'عرض المنشور', + 'bar_error' => 'فشل إنشاء المنشور.', + 'bar_error_other' => 'فشل إنشاء :count منشورات.', 'create' => 'إنشاء منشور', 'back' => 'رجوع', 'next' => 'متابعة', diff --git a/lang/de/posts.php b/lang/de/posts.php index 21bfbfae..84efa85e 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -628,6 +628,13 @@ 'loading_tip_brand' => 'Passe deine Markeneinstellungen an, um zukünftige Beiträge zu beeinflussen.', 'loading_tip_carousel' => 'Karussells liefern pro hochgeladenem Bild ein Slide.', 'loading_tip_quality' => 'Die Bildqualität ist auf ein Gleichgewicht aus Geschwindigkeit und Kosten eingestellt.', + 'bar_generating_one' => 'Die KI erstellt deinen Beitrag…', + 'bar_generating_other' => 'Die KI erstellt :count Beiträge…', + 'bar_done' => 'Dein Beitrag ist fertig!', + 'bar_done_other' => ':count Beiträge sind fertig!', + 'bar_done_cta' => 'Beitrag ansehen', + 'bar_error' => 'Die Erstellung des Beitrags ist fehlgeschlagen.', + 'bar_error_other' => 'Die Erstellung von :count Beiträgen ist fehlgeschlagen.', 'create' => 'Beitrag erstellen', 'back' => 'Zurück', 'next' => 'Weiter', diff --git a/lang/el/posts.php b/lang/el/posts.php index a4d6dce2..7e0b4f4d 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Προσαρμόστε τις ρυθμίσεις της μάρκας σας για να επηρεάσετε μελλοντικές δημοσιεύσεις.', 'loading_tip_carousel' => 'Τα carousels παρέχουν ένα slide ανά μεταφορτωμένη εικόνα.', 'loading_tip_quality' => 'Η ποιότητα εικόνας ρυθμίζεται για ισορροπία ταχύτητας και κόστους.', + 'bar_generating_one' => 'Το AI δημιουργεί τη δημοσίευσή σας…', + 'bar_generating_other' => 'Το AI δημιουργεί :count δημοσιεύσεις…', + 'bar_done' => 'Η δημοσίευσή σας είναι έτοιμη!', + 'bar_done_other' => ':count δημοσιεύσεις είναι έτοιμες!', + 'bar_done_cta' => 'Προβολή δημοσίευσης', + 'bar_error' => 'Η δημιουργία της δημοσίευσης απέτυχε.', + 'bar_error_other' => 'Η δημιουργία :count δημοσιεύσεων απέτυχε.', 'create' => 'Δημιουργία δημοσίευσης', 'back' => 'Πίσω', 'next' => 'Συνέχεια', diff --git a/lang/en/posts.php b/lang/en/posts.php index b0a647e5..8bfe1cb4 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Tweak your brand settings to influence future posts.', 'loading_tip_carousel' => 'Carousels deliver one slide per uploaded image.', 'loading_tip_quality' => 'Image quality is set to balance speed and cost.', + 'bar_generating_one' => 'AI is generating your post…', + 'bar_generating_other' => 'AI is generating :count posts…', + 'bar_done' => 'Your post is ready!', + 'bar_done_other' => ':count posts are ready!', + 'bar_done_cta' => 'View post', + 'bar_error' => 'Post generation failed.', + 'bar_error_other' => ':count post generations failed.', 'create' => 'Create post', 'back' => 'Back', 'next' => 'Continue', diff --git a/lang/es/posts.php b/lang/es/posts.php index 0570a629..0fb53ba1 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -627,6 +627,13 @@ 'loading_tip_brand' => 'Ajusta tu marca para influir en las próximas publicaciones.', 'loading_tip_carousel' => 'Los carruseles generan una diapositiva por imagen solicitada.', 'loading_tip_quality' => 'La calidad balancea velocidad y costo.', + 'bar_generating_one' => 'La IA está generando tu post…', + 'bar_generating_other' => 'La IA está generando :count posts…', + 'bar_done' => '¡Tu post está listo!', + 'bar_done_other' => '¡:count posts están listos!', + 'bar_done_cta' => 'Ver post', + 'bar_error' => 'Falló la generación del post.', + 'bar_error_other' => 'Falló la generación de :count posts.', 'create' => 'Crear post', 'back' => 'Atrás', 'next' => 'Continuar', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index ae94b65a..cace6e09 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Ajustez les paramètres de votre marque pour influencer les futures publications.', 'loading_tip_carousel' => 'Les carrousels affichent une diapositive par image importée.', 'loading_tip_quality' => 'La qualité des images est réglée pour équilibrer vitesse et coût.', + 'bar_generating_one' => 'L’IA génère votre publication…', + 'bar_generating_other' => 'L’IA génère :count publications…', + 'bar_done' => 'Votre publication est prête !', + 'bar_done_other' => ':count publications sont prêtes !', + 'bar_done_cta' => 'Voir la publication', + 'bar_error' => 'La génération de la publication a échoué.', + 'bar_error_other' => 'La génération de :count publications a échoué.', 'create' => 'Créer la publication', 'back' => 'Retour', 'next' => 'Continuer', diff --git a/lang/it/posts.php b/lang/it/posts.php index 8e9d4670..a20f30a4 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Modifica le impostazioni del tuo brand per influenzare i post futuri.', 'loading_tip_carousel' => 'I caroselli producono una slide per ogni immagine caricata.', 'loading_tip_quality' => 'La qualità dell\'immagine è impostata per bilanciare velocità e costo.', + 'bar_generating_one' => 'L’IA sta generando il tuo post…', + 'bar_generating_other' => 'L’IA sta generando :count post…', + 'bar_done' => 'Il tuo post è pronto!', + 'bar_done_other' => ':count post sono pronti!', + 'bar_done_cta' => 'Vedi post', + 'bar_error' => 'Generazione del post non riuscita.', + 'bar_error_other' => 'Generazione di :count post non riuscita.', 'create' => 'Crea post', 'back' => 'Indietro', 'next' => 'Continua', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 01423419..f96be9c6 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'ブランド設定を調整すると、今後の投稿に反映されます。', 'loading_tip_carousel' => 'カルーセルはアップロードした画像 1 枚につき 1 スライドを作成します。', 'loading_tip_quality' => '画像の品質は、速度とコストのバランスを取るよう設定されています。', + 'bar_generating_one' => 'AIが投稿を生成しています…', + 'bar_generating_other' => 'AIが:count件の投稿を生成しています…', + 'bar_done' => '投稿の準備ができました!', + 'bar_done_other' => ':count件の投稿の準備ができました!', + 'bar_done_cta' => '投稿を見る', + 'bar_error' => '投稿の生成に失敗しました。', + 'bar_error_other' => ':count件の投稿の生成に失敗しました。', 'create' => '投稿を作成', 'back' => '戻る', 'next' => '続ける', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index cccdc7fb..4227f89b 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => '브랜드 설정을 조정하여 향후 게시물에 영향을 주세요.', 'loading_tip_carousel' => '캐러셀은 업로드한 이미지당 슬라이드 하나를 제공합니다.', 'loading_tip_quality' => '이미지 품질은 속도와 비용의 균형에 맞게 설정됩니다.', + 'bar_generating_one' => 'AI가 게시물을 생성하고 있습니다…', + 'bar_generating_other' => 'AI가 게시물 :count개를 생성하고 있습니다…', + 'bar_done' => '게시물이 준비되었습니다!', + 'bar_done_other' => '게시물 :count개가 준비되었습니다!', + 'bar_done_cta' => '게시물 보기', + 'bar_error' => '게시물 생성에 실패했습니다.', + 'bar_error_other' => '게시물 :count개 생성에 실패했습니다.', 'create' => '게시물 만들기', 'back' => '뒤로', 'next' => '계속', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index ef6f2228..5246af73 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Pas je merkinstellingen aan om toekomstige posts te beïnvloeden.', 'loading_tip_carousel' => 'Carrousels leveren één slide per geüploade afbeelding.', 'loading_tip_quality' => 'De beeldkwaliteit is ingesteld op een balans tussen snelheid en kosten.', + 'bar_generating_one' => 'AI genereert je post…', + 'bar_generating_other' => 'AI genereert :count posts…', + 'bar_done' => 'Je post is klaar!', + 'bar_done_other' => ':count posts zijn klaar!', + 'bar_done_cta' => 'Post bekijken', + 'bar_error' => 'Genereren van de post is mislukt.', + 'bar_error_other' => 'Genereren van :count posts is mislukt.', 'create' => 'Post aanmaken', 'back' => 'Terug', 'next' => 'Doorgaan', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 694d521a..4fc57dfe 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Dostosuj ustawienia swojej marki, aby wpłynąć na przyszłe posty.', 'loading_tip_carousel' => 'Karuzele tworzą jeden slajd na każdy przesłany obraz.', 'loading_tip_quality' => 'Jakość obrazu jest ustawiona tak, aby równoważyć szybkość i koszt.', + 'bar_generating_one' => 'AI generuje Twój post…', + 'bar_generating_other' => 'AI generuje posty: :count…', + 'bar_done' => 'Twój post jest gotowy!', + 'bar_done_other' => 'Gotowe posty: :count', + 'bar_done_cta' => 'Zobacz post', + 'bar_error' => 'Generowanie posta nie powiodło się.', + 'bar_error_other' => 'Nieudane generowanie postów: :count', 'create' => 'Utwórz post', 'back' => 'Wstecz', 'next' => 'Kontynuuj', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 588a674e..70ef5a6a 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Ajuste sua marca pra influenciar os próximos posts.', 'loading_tip_carousel' => 'Carrosséis geram um slide por imagem solicitada.', 'loading_tip_quality' => 'A qualidade equilibra velocidade e custo.', + 'bar_generating_one' => 'IA gerando seu post…', + 'bar_generating_other' => 'IA gerando :count posts…', + 'bar_done' => 'Seu post ficou pronto!', + 'bar_done_other' => ':count posts ficaram prontos!', + 'bar_done_cta' => 'Ver post', + 'bar_error' => 'A geração do post falhou.', + 'bar_error_other' => 'A geração de :count posts falhou.', 'create' => 'Criar post', 'back' => 'Voltar', 'next' => 'Continuar', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 7c346dd7..0a74b007 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => 'Настройте параметры бренда, чтобы влиять на будущие посты.', 'loading_tip_carousel' => 'В карусели создаётся один слайд на каждое загруженное изображение.', 'loading_tip_quality' => 'Качество изображений настроено на баланс скорости и стоимости.', + 'bar_generating_one' => 'ИИ создаёт ваш пост…', + 'bar_generating_other' => 'ИИ создаёт посты: :count…', + 'bar_done' => 'Ваш пост готов!', + 'bar_done_other' => 'Готовые посты: :count', + 'bar_done_cta' => 'Открыть пост', + 'bar_error' => 'Не удалось создать пост.', + 'bar_error_other' => 'Не удалось создать посты: :count', 'create' => 'Создать пост', 'back' => 'Назад', 'next' => 'Продолжить', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index 89a217d2..449bf399 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -628,6 +628,13 @@ 'loading_tip_brand' => 'Gelecekteki gönderileri etkilemek için marka ayarlarınızı düzenleyin.', 'loading_tip_carousel' => 'Karuseller yüklenen her görsel için bir slayt sunar.', 'loading_tip_quality' => 'Görsel kalitesi, hız ve maliyeti dengelemek için ayarlanır.', + 'bar_generating_one' => 'Yapay zeka gönderinizi oluşturuyor…', + 'bar_generating_other' => 'Yapay zeka :count gönderi oluşturuyor…', + 'bar_done' => 'Gönderiniz hazır!', + 'bar_done_other' => ':count gönderi hazır!', + 'bar_done_cta' => 'Gönderiyi görüntüle', + 'bar_error' => 'Gönderi oluşturulamadı.', + 'bar_error_other' => ':count gönderi oluşturulamadı.', 'create' => 'Gönderi oluştur', 'back' => 'Geri', 'next' => 'Devam et', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 066eba91..febbbd6d 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -626,6 +626,13 @@ 'loading_tip_brand' => '调整你的品牌设置,以影响今后的帖子。', 'loading_tip_carousel' => '轮播会为每张上传的图片生成一张幻灯片。', 'loading_tip_quality' => '图片质量已设为在速度与成本之间取得平衡。', + 'bar_generating_one' => 'AI 正在生成你的帖子…', + 'bar_generating_other' => 'AI 正在生成 :count 篇帖子…', + 'bar_done' => '你的帖子已就绪!', + 'bar_done_other' => ':count 篇帖子已就绪!', + 'bar_done_cta' => '查看帖子', + 'bar_error' => '帖子生成失败。', + 'bar_error_other' => ':count 篇帖子生成失败。', 'create' => '创建帖子', 'back' => '返回', 'next' => '继续', diff --git a/package-lock.json b/package-lock.json index 51f067e3..ee828f54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "trypost", + "name": "wt-aibar", "lockfileVersion": 3, "requires": true, "packages": { @@ -54,6 +54,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.32.0", + "jsdom": "^29.1.1", "laravel-echo": "^2.3.0", "playwright": "^1.61.1", "prettier": "^3.4.2", @@ -63,6 +64,7 @@ "typescript": "^5.2.2", "typescript-eslint": "^8.23.0", "vite": "^8.0.16", + "vitest": "^4.1.10", "vue-tsc": "^2.2.4" }, "optionalDependencies": { @@ -74,6 +76,57 @@ "lightningcss-win32-x64-msvc": "^1.29.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -220,6 +273,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.3", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", @@ -311,6 +377,146 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -625,6 +831,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -1670,6 +1894,13 @@ "license": "MIT", "peer": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -1927,6 +2158,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", @@ -2036,6 +2333,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2361,6 +2669,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3187,6 +3502,136 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -3787,6 +4232,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3854,6 +4309,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3943,6 +4408,16 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4192,6 +4667,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csscolorparser": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", @@ -4721,6 +5210,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4805,6 +5308,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5116,6 +5626,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5720,6 +6237,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6302,6 +6829,19 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -6682,6 +7222,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6870,6 +7417,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7338,6 +7936,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7396,6 +8004,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7638,6 +8253,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -7744,6 +8373,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -7787,6 +8442,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -8307,6 +8969,16 @@ "vue": "^3.5.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8518,6 +9190,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -8690,6 +9375,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -8751,6 +9443,20 @@ "node": ">=12.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8937,6 +9643,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -8984,6 +9697,23 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9036,6 +9766,36 @@ "dev": true, "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9071,6 +9831,32 @@ "dev": true, "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -9290,6 +10076,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9554,6 +10350,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -9689,12 +10588,70 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/web-vitals": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", "integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9799,6 +10756,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9873,6 +10847,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/package.json b/package.json index 5920e3a0..dbf65c7a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "vite", "format": "prettier --write resources/", "format:check": "prettier --check resources/", - "lint": "eslint . --fix" + "lint": "eslint . --fix", + "test:js": "vitest run" }, "devDependencies": { "@eslint/js": "^9.19.0", @@ -27,6 +28,7 @@ "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-vue": "^9.32.0", + "jsdom": "^29.1.1", "laravel-echo": "^2.3.0", "playwright": "^1.61.1", "prettier": "^3.4.2", @@ -36,6 +38,7 @@ "typescript": "^5.2.2", "typescript-eslint": "^8.23.0", "vite": "^8.0.16", + "vitest": "^4.1.10", "vue-tsc": "^2.2.4" }, "dependencies": { diff --git a/resources/js/components/AiGenerationBar.vue b/resources/js/components/AiGenerationBar.vue new file mode 100644 index 00000000..2a29af80 --- /dev/null +++ b/resources/js/components/AiGenerationBar.vue @@ -0,0 +1,83 @@ + + + + + + + + {{ errorLabel }} + + + + + + + + {{ doneLabel }} + + {{ $t('posts.create.steps.bar_done_cta') }} + + + + + + + {{ generatingLabel }} + + + diff --git a/resources/js/composables/useAiGeneration.ts b/resources/js/composables/useAiGeneration.ts new file mode 100644 index 00000000..dd21dd51 --- /dev/null +++ b/resources/js/composables/useAiGeneration.ts @@ -0,0 +1,216 @@ +import { router } from '@inertiajs/vue3'; +import { echo } from '@laravel/echo-vue'; +import { computed, ref } from 'vue'; + +import { edit as editPostRoute } from '@/routes/app/posts'; + +/** + * Global state for in-flight AI post generations. + * + * Generation runs on the backend (the `StreamPostCreation` job) and broadcasts + * `.ai.creation.completed` on the private `user.{id}.ai-creation.{creationId}` + * channel. This composable is the SOLE OWNER of that channel subscription, so + * the "AI is generating…" notice persists across navigation (module-level state, + * SPA) and survives a hard reload via `sessionStorage`. + */ + +export interface AiGeneration { + /** creationId — identifies the generation. */ + id: string; + /** Full private channel name (without the `private-` prefix). */ + channel: string; + imageCount: number; + /** epoch ms — used by the safety timeout and by hydration. */ + startedAt: number; + status: 'loading' | 'done' | 'error'; + postId?: string; + error?: string; + /** epoch ms at which the bar surfaced the terminal state — starts the TTL. */ + seenAt?: number; +} + +const STORAGE_KEY = 'ai-generations'; +/** After this, if nothing arrived, the bar disappears on its own (avoids getting stuck). */ +const MAX_LIFETIME_MS = 6 * 60 * 1000; +/** How long the "done"/"error" state stays visible before disappearing. */ +const DONE_TTL_MS = 12 * 1000; + +const generations = ref([]); +const subscribed = new Set(); +const timers = new Map>(); +let hydrated = false; + +const persist = (): void => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(generations.value)); + } catch { + /* sessionStorage unavailable — carry on without persisting */ + } +}; + +const clearTimer = (id: string): void => { + const timer = timers.get(id); + if (timer) { + clearTimeout(timer); + timers.delete(id); + } +}; + +const leaveChannel = (gen: AiGeneration): void => { + if (subscribed.has(gen.id)) { + echo().leave(`private-${gen.channel}`); + subscribed.delete(gen.id); + } +}; + +const remove = (id: string): void => { + const gen = generations.value.find((g) => g.id === id); + if (gen) { + leaveChannel(gen); + } + clearTimer(id); + generations.value = generations.value.filter((g) => g.id !== id); + persist(); +}; + +const complete = (id: string, postId?: string, error?: string): void => { + const gen = generations.value.find((g) => g.id === id); + if (!gen) { + return; + } + leaveChannel(gen); + clearTimer(id); + if (error || !postId) { + gen.status = 'error'; + gen.error = error ?? ''; + } else { + gen.status = 'done'; + gen.postId = postId; + } + persist(); + // The terminal state is transient, but its TTL only starts once the bar has + // actually SURFACED this generation (markSeen). Otherwise a generation that + // finishes while another is still running could be dropped without ever + // having been shown. Until surfaced, the generation's own lifetime cap + // applies, so a terminal state can never get stuck if the bar never mounts. + const unseenCeiling = Math.max(DONE_TTL_MS, MAX_LIFETIME_MS - (Date.now() - gen.startedAt)); + timers.set(id, setTimeout(() => remove(id), gen.seenAt ? DONE_TTL_MS : unseenCeiling)); +}; + +/** + * The bar reports that it is surfacing this generation; only then does the + * terminal state's TTL start running. This is what guarantees every completion + * is seen, even when several generations finish at different times. + */ +const markSeen = (id: string): void => { + const gen = generations.value.find((g) => g.id === id); + if (!gen || gen.seenAt || gen.status === 'loading') { + return; + } + gen.seenAt = Date.now(); + persist(); + clearTimer(id); + timers.set(id, setTimeout(() => remove(id), DONE_TTL_MS)); +}; + +const subscribe = (gen: AiGeneration): void => { + if (subscribed.has(gen.id)) { + return; + } + subscribed.add(gen.id); + echo() + .private(gen.channel) + .listen('.ai.creation.completed', (e: { post_id?: string; error?: string }) => { + complete(gen.id, e.post_id, e.error); + }); + // Safety: if the event never arrives, don't leave the bar stuck forever. + // Rebased on the real startedAt (hydrated generations already spent part of the time). + const remaining = Math.max(0, MAX_LIFETIME_MS - (Date.now() - gen.startedAt)); + timers.set(gen.id, setTimeout(() => remove(gen.id), remaining)); +}; + +const track = (input: { id: string; channel: string; imageCount?: number; startedAt?: number }): void => { + if (generations.value.some((g) => g.id === input.id)) { + return; + } + const gen: AiGeneration = { + id: input.id, + channel: input.channel, + imageCount: input.imageCount ?? 0, + startedAt: input.startedAt ?? Date.now(), + status: 'loading', + }; + generations.value = [...generations.value, gen]; + subscribe(gen); + persist(); +}; + +/** Re-subscribe to still-running generations after a hard reload. */ +const hydrate = (): void => { + if (hydrated) { + return; + } + hydrated = true; + let stored: AiGeneration[] = []; + try { + stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]') as AiGeneration[]; + } catch { + stored = []; + } + const now = Date.now(); + stored.forEach((g) => { + if (g.status === 'loading' && now - g.startedAt < MAX_LIFETIME_MS) { + track({ id: g.id, channel: g.channel, imageCount: g.imageCount, startedAt: g.startedAt }); + } + }); + persist(); +}; + +const openPost = (gen: AiGeneration): void => { + if (gen.postId) { + router.visit(editPostRoute(gen.postId).url); + } + remove(gen.id); +}; + +const loadingCount = computed(() => generations.value.filter((g) => g.status === 'loading').length); +const isGenerating = computed(() => loadingCount.value > 0); + +// Terminal states are COLLECTIONS, not a single slot: several generations can +// finish at different times while others are still running, and each completion +// needs its own turn. Oldest first — the order the CTA opens them in. +const byOldest = (a: AiGeneration, b: AiGeneration): number => a.startedAt - b.startedAt; +const doneGenerations = computed(() => generations.value.filter((g) => g.status === 'done').sort(byOldest)); +const errorGenerations = computed(() => generations.value.filter((g) => g.status === 'error').sort(byOldest)); + +/** Opens the oldest completion; the rest stay on the bar, one per click. */ +const openNextDone = (): void => { + const next = doneGenerations.value[0]; + if (next) { + openPost(next); + } +}; + +/** Dismisses every failure at once — an error has no per-post action. */ +const dismissErrors = (): void => { + errorGenerations.value.forEach((g) => remove(g.id)); +}; + +const find = (id: string) => computed(() => generations.value.find((g) => g.id === id) ?? null); + +export const useAiGeneration = () => ({ + generations, + isGenerating, + loadingCount, + doneGenerations, + errorGenerations, + track, + remove, + dismiss: remove, + dismissErrors, + markSeen, + openPost, + openNextDone, + hydrate, + find, +}); diff --git a/resources/js/layouts/app/AppSidebarLayout.vue b/resources/js/layouts/app/AppSidebarLayout.vue index f0ac4b82..58d203a4 100644 --- a/resources/js/layouts/app/AppSidebarLayout.vue +++ b/resources/js/layouts/app/AppSidebarLayout.vue @@ -2,6 +2,7 @@ import { useHttp, usePage } from '@inertiajs/vue3'; import { onBeforeUnmount, onMounted } from 'vue'; +import AiGenerationBar from '@/components/AiGenerationBar.vue'; import AppHeader from '@/components/AppHeader.vue'; import AppSidebar from '@/components/AppSidebar.vue'; import Toast from '@/components/Toast.vue'; @@ -42,6 +43,7 @@ onBeforeUnmount(() => { + diff --git a/resources/js/pages/posts/ai/Loading.vue b/resources/js/pages/posts/ai/Loading.vue index 40bc0245..c0532a72 100644 --- a/resources/js/pages/posts/ai/Loading.vue +++ b/resources/js/pages/posts/ai/Loading.vue @@ -1,11 +1,11 @@