diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1fec0c..cec6d1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.6.8 +## 0.7.0 - added tls support in mqtt - prepared support of TB2 (added new v3 tonies APIs) @@ -9,19 +9,20 @@ - gui: removed switch enable rtnl on rtnl page, is now autoenabled - gui: Added find all unused TAF Search in Library [https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/279](https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/279) - gui: Prepared support for TB2 +- gui: Added Custom Tonies Editor with Image Manager for managing custom models [PR #283](https://github.com/toniebox-reverse-engineering/teddycloud_web/pull/283) ### Commits -- [https://github.com/toniebox-reverse-engineering/teddycloud/compare/tc_v0.6.7...tc_v0.6.8](https://github.com/toniebox-reverse-engineering/teddycloud/compare/tc_v0.6.7...tc_v0.6.8) -- [https://github.com/toniebox-reverse-engineering/teddycloud_web/compare/tcw_v0.6.7...tcw_v0.6.8](https://github.com/toniebox-reverse-engineering/teddycloud_web/compare/tcw_v0.6.7...tcw_v0.6.8) +- [https://github.com/toniebox-reverse-engineering/teddycloud/compare/tc_v0.6.7...tc_v0.7.0](https://github.com/toniebox-reverse-engineering/teddycloud/compare/tc_v0.6.7...tc_v0.7.0) +- [https://github.com/toniebox-reverse-engineering/teddycloud_web/compare/tcw_v0.6.7...tcw_v0.7.0](https://github.com/toniebox-reverse-engineering/teddycloud_web/compare/tcw_v0.6.7...tcw_v0.7.0) ### Discussion -- [https://forum.revvox.de/t/release-notes-0-6-8/3235](https://forum.revvox.de/t/release-notes-0-6-8/3235) +- [https://forum.revvox.de/t/release-notes-0-7-0/3235](https://forum.revvox.de/t/release-notes-0-7-0/3235) ### GitHub Release -- [https://github.com/toniebox-reverse-engineering/teddycloud/releases/tag/tc_v0.6.8](https://github.com/toniebox-reverse-engineering/teddycloud/releases/tag/tc_v0.6.8) +- [https://github.com/toniebox-reverse-engineering/teddycloud/releases/tag/tc_v0.7.0](https://github.com/toniebox-reverse-engineering/teddycloud/releases/tag/tc_v0.7.0) ## 0.6.7 diff --git a/package-lock.json b/package-lock.json index 6fc23b07..468755a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teddycloud-web", - "version": "0.6.8", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teddycloud-web", - "version": "0.6.8", + "version": "0.7.0", "dependencies": { "@ant-design/icons": "^6.1.0", "@dnd-kit/core": "^6.3.1", @@ -618,7 +618,6 @@ "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "aix" @@ -634,7 +633,6 @@ "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -650,7 +648,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -666,7 +663,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "android" @@ -682,7 +678,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -698,7 +693,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -714,7 +708,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -730,7 +723,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "freebsd" @@ -746,7 +738,6 @@ "cpu": [ "arm" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -762,7 +753,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -778,7 +768,6 @@ "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -794,7 +783,6 @@ "cpu": [ "loong64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -810,7 +798,6 @@ "cpu": [ "mips64el" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -826,7 +813,6 @@ "cpu": [ "ppc64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -842,7 +828,6 @@ "cpu": [ "riscv64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -858,7 +843,6 @@ "cpu": [ "s390x" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -874,7 +858,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -890,7 +873,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -906,7 +888,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "netbsd" @@ -922,7 +903,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -938,7 +918,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "openbsd" @@ -954,7 +933,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "openharmony" @@ -970,7 +948,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "sunos" @@ -986,7 +963,6 @@ "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1002,7 +978,6 @@ "cpu": [ "ia32" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1018,7 +993,6 @@ "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1870,8 +1844,7 @@ "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", - "license": "MIT" + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==" }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", @@ -2673,7 +2646,6 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", - "license": "MIT", "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", @@ -3315,7 +3287,6 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "hasInstallScript": true, - "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -3436,7 +3407,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", "engines": { "node": ">=12.0.0" }, @@ -5057,7 +5027,6 @@ "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5567,7 +5536,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "license": "MIT", "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -5854,7 +5822,6 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5942,7 +5909,6 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.1.1.tgz", "integrity": "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==", - "license": "MIT", "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", diff --git a/package.json b/package.json index f5ad96d6..c7923782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teddycloud-web", - "version": "0.6.8", + "version": "0.7.0", "private": true, "homepage": "/web", "dependencies": { diff --git a/public/translations/de.json b/public/translations/de.json index ac487a89..183c9c4b 100644 --- a/public/translations/de.json +++ b/public/translations/de.json @@ -10,6 +10,13 @@ "errorNoOggOpusSupportByApple": "Dein Browser unterstützt im Moment keine OGG/Opus (taf) Dateien. Nutzt du ein Apple Gerät, dann hilf uns, indem Sie ein Ticket bei Apple zur Behebung des Problems einreichen!", "unknownSource": "Unbekannt" }, + "common": { + "cancel": "Abbrechen", + "clean": "unverändert", + "changed": "geändert*", + "deleted": "gelöscht*", + "new": "neu*" + }, "community": { "attribution": { "navigationTitle": "Lizenzhinweise", @@ -566,7 +573,7 @@ "items": { "01assignContent": "Inhalte und andere Eigenschaften jedem Tag zuweisen", "02searchStreams": "Webradio-Streams suchen und jedem Tag zuweisen", - "03defineMetaData": "Metadaten deiner benutzerdefinierten Tags definieren (PoC im WebFrontend)", + "03defineMetaData": "Metadaten deiner benutzerdefinierten Tags definieren (Custom Models Editor im WebFrontend)", "04filterTonies": "Tonies/Tags filtern", "05hideTonies": "Nicht verfügbare Tonies/Tags ausblenden", "06showLastPlayed": "Zuletzt gespielte Tonies/Tags anzeigen" @@ -636,9 +643,8 @@ "01firmwareUpdate": "Firmware-Update über TeddyCloud", "03reportUnknownTonies": "Unbekannte Tonies melden", "04completeTAPIntegration": "Abschluss der TAP-WebFrontend-Integration", - "05completeCustomToniesIntegration": "Abschluss der Integration benutzerdefinierter Tonies im JSON-WebFront (\"Metadaten benutzerdefinierter Tags\")", - "06moreStats": "Mehr Statistiken über deine gespielten Tonies/Tags", - "07securityImprovements": "Sicherheitsverbesserungen wie Zugriffskontrolle (Tonieboxen und Benutzer)" + "05moreStats": "Mehr Statistiken über deine gespielten Tonies/Tags", + "06securityImprovements": "Sicherheitsverbesserungen wie Zugriffskontrolle (Tonieboxen und Benutzer)" }, "navigationTitle": "Features", "title": "Features TeddyCloud", @@ -1802,6 +1808,85 @@ "track": "Track" }, "addToniesCustomJsonEntry": "WIP: Modell anlegen", + "customToniesEditorJsonEntry": "Custom-Modelle", + "imageManager": { + "title": "Bilder auswählen & verwalten", + "okText": "Übernehmen", + "sourceCustom": "Custom", + "sourceOriginal": "Original", + "noOriginalImages": "Keine Original-Bilder gefunden" + }, + "customEditor": { + "actions": { + "delete": "Löschen", + "deleteCount": "{{count}} Modell(e) löschen", + "discard": "Verwerfen", + "duplicate": "Duplizieren", + "edit": "Bearbeiten", + "newModel": "Neues Modell", + "preview": "Vorschau", + "restore": "Wiederherstellen", + "toggleChangesOnly": "Nur Änderungen" + }, + "audio": { + "libraryLabel": "Custom-Audio (Bibliothek)", + "notInIndex": "Nicht im Custom-Audio-Index: {{audioId}}/{{hash}}", + "placeholder": "Custom-Audio aus der Bibliothek wählen" + }, + "batch": { + "description": "Änderungen gelten für alle ausgewählten Modelle.", + "title": "Batch-Bearbeitung aktiv" + }, + "coinHint": { + "description": "Optional – nur für Metadaten (Titel, Tracks). Custom Coins funktionieren auch ohne Modell. Nur Custom-Audio – offizielle IDs überschreiben echte Tonies. Verknüpft keine NFC-Tags mit Audio.", + "title": "Hinweis: Audio-Zuordnung" + }, + "columns": { + "actions": "Aktionen", + "image": "Bild", + "status": "Status" + }, + "errors": { + "emptyResult": "Mindestens ein Modell muss erhalten bleiben.", + "invalidSelectedIndex": "Ungültiger ausgewählter Index", + "releaseNumeric": "Release muss numerisch sein" + }, + "filterPlaceholder": "Modelle filtern...", + "modelsTitle": "Modelle", + "noChangesBody": "Es gibt aktuell nichts zu speichern.", + "noChangesTitle": "Keine Änderungen erkannt", + "optionalColumns": "Zusatzspalten", + "picHint": "Bild aus custom_img oder von Original-Tonies wählen", + "preflight": { + "confirm": "Jetzt speichern", + "deletes": "Löschungen: {{count}}", + "description": "Bitte bestätige den Umfang dieses Speichervorgangs.", + "renames": "Umbenennungen: {{count}}", + "title": "Änderungen vor dem Speichern prüfen", + "upserts": "Aktualisierungen/Neuanlagen: {{count}}" + }, + "previewTitle": "Bildvorschau", + "saveSuccessWithCount": "tonies.custom.json gespeichert ({{count}} Einträge). Sicherung und Neuladen wurden ausgelöst.", + "savedState": "Alle Änderungen gespeichert", + "sections": { + "audio": "Audio-Zuordnung", + "media": "Medien und Bilder", + "metadata": "Optionale Metadaten", + "tracks": "Tracks" + }, + "selection": { + "description": "Das Bearbeitungsformular unten gilt immer für die aktuelle Auswahl.", + "multiple": "{{count}} Modelle ausgewählt", + "none": "Kein Modell ausgewählt", + "single": "Ausgewähltes Modell: {{model}}" + }, + "tableMenu": "Tabellen-Menü", + "title": "Modell-Editor", + "unsaved": "{{count}} ungespeicherte Änderung(en)", + "validation": { + "title": "Bitte zuerst diese Probleme beheben" + } + }, "alternativeSource": "Der Toniefigur {{originalTonie}} ist der Inhalt {{assignedContent}} zugewiesen!", "alternativeSourceUnknown": "Der Toniefigur {{originalTonie}} ist alternativer Inhalt zugewiesen!", "cancel": "Abbrechen", @@ -1822,6 +1907,7 @@ }, "currentPath": "Aktueller Pfad: ", "editModal": { + "editSelectedCustomModel": "Modell weiter bearbeiten", "model": "Modell", "placeholderSearchForAModel": "Nach einem Modell suchen", "placeholderSearchForARadioStream": "Suche nach einem Radiostream", diff --git a/public/translations/en.json b/public/translations/en.json index 4d6c2cb1..2bfbbe77 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -10,6 +10,13 @@ "errorNoOggOpusSupportByApple": "Your browser does not support OGG/Opus (taf) files at the moment. If you are using an Apple device, help us out by submitting a ticket to Apple for a fix!", "unknownSource": "Unkown" }, + "common": { + "cancel": "Cancel", + "clean": "unchanged", + "changed": "changed*", + "deleted": "deleted*", + "new": "new*" + }, "community": { "attribution": { "navigationTitle": "Attribution", @@ -566,7 +573,7 @@ "items": { "01assignContent": "assign content and other properties to each tag", "02searchStreams": "search and assign webradio streams to each tag", - "03defineMetaData": "define meta data of your custom tags (PoC in WebFrontend)", + "03defineMetaData": "define meta data of your custom tags (Custom Models Editor in WebFrontend)", "04filterTonies": "filter Tonies/Tags", "05hideTonies": "hide unavailable Tonies/Tags", "06showLastPlayed": "show last played Tonies/Tags" @@ -636,9 +643,8 @@ "01firmwareUpdate": "Firmware update through TeddyCloud", "03reportUnknownTonies": "Report unknown Tonies", "04completeTAPIntegration": "Completion of TAP WebFrontend Integration", - "05completeCustomToniesIntegration": "Completion of Custom Tonies Json WebFront Integration (\"Meta data of custom tags\")", - "06moreStats": "More stats about your played Tonies/Tags", - "07securityImprovements": "Security improvements like Access control (tonieboxes and user)" + "05moreStats": "More stats about your played Tonies/Tags", + "06securityImprovements": "Security improvements like Access control (tonieboxes and user)" }, "navigationTitle": "Features", "title": "Features TeddyCloud", @@ -1802,6 +1808,85 @@ "track": "Track" }, "addToniesCustomJsonEntry": "WIP: Add Model", + "customToniesEditorJsonEntry": "Custom models", + "imageManager": { + "title": "Select & manage images", + "okText": "Apply", + "sourceCustom": "Custom", + "sourceOriginal": "Original", + "noOriginalImages": "No original images found" + }, + "customEditor": { + "actions": { + "delete": "Delete", + "deleteCount": "Delete {{count}} model(s)", + "discard": "Discard", + "duplicate": "Duplicate", + "edit": "Edit", + "newModel": "New model", + "preview": "Preview", + "restore": "Restore", + "toggleChangesOnly": "Changes only" + }, + "audio": { + "libraryLabel": "Custom audio (library)", + "notInIndex": "Not in custom audio index: {{audioId}}/{{hash}}", + "placeholder": "Select custom audio from library" + }, + "batch": { + "description": "Changes apply to all selected models.", + "title": "Batch editing active" + }, + "coinHint": { + "description": "Optional – only for metadata (title, tracks). Custom coins work without a model. Use only custom audio – official IDs overwrite real Tonies. Does not link NFC tags to audio.", + "title": "Hint: Audio assignment" + }, + "columns": { + "actions": "Actions", + "image": "Image", + "status": "Status" + }, + "errors": { + "emptyResult": "At least one model must remain.", + "invalidSelectedIndex": "Invalid selected index", + "releaseNumeric": "Release must be numeric" + }, + "filterPlaceholder": "Filter models...", + "modelsTitle": "Models", + "noChangesBody": "There is nothing to save at the moment.", + "noChangesTitle": "No changes detected", + "optionalColumns": "Optional columns", + "picHint": "Select image from custom_img or from original tonies", + "preflight": { + "confirm": "Save now", + "deletes": "Deletions: {{count}}", + "description": "Please confirm the scope of this save operation.", + "renames": "Renames: {{count}}", + "title": "Review changes before saving", + "upserts": "Updates/new entries: {{count}}" + }, + "previewTitle": "Image preview", + "saveSuccessWithCount": "tonies.custom.json saved ({{count}} entries). Backup and reload triggered.", + "savedState": "All changes saved", + "sections": { + "audio": "Audio assignment", + "media": "Media and images", + "metadata": "Optional metadata", + "tracks": "Tracks" + }, + "selection": { + "description": "The edit form below always applies to the current selection.", + "multiple": "{{count}} models selected", + "none": "No model selected", + "single": "Selected model: {{model}}" + }, + "tableMenu": "Table menu", + "title": "Model editor", + "unsaved": "{{count}} unsaved change(s)", + "validation": { + "title": "Please fix these issues first" + } + }, "alternativeSource": "This Tonie {{originalTonie}} is assigned the content {{assignedContent}}!", "alternativeSourceUnknown": "This Tonie {{originalTonie}} is assigned alternative content!", "cancel": "Cancel", @@ -1822,6 +1907,7 @@ }, "currentPath": "Current Path: ", "editModal": { + "editSelectedCustomModel": "Continue editing model", "model": "Model", "placeholderSearchForAModel": "Search for a model", "placeholderSearchForARadioStream": "Search for a radio stream", diff --git a/public/translations/es.json b/public/translations/es.json index fdddf47e..813a9e0f 100644 --- a/public/translations/es.json +++ b/public/translations/es.json @@ -10,6 +10,13 @@ "errorNoOggOpusSupportByApple": "Tu navegador no soporta archivos OGG/Opus (taf) en este momento. Si estás utilizando un dispositivo Apple, ¡ayúdanos enviando un ticket a Apple para solicitar una solución!", "unknownSource": "Desconocido" }, + "common": { + "cancel": "Cancelar", + "clean": "sin cambios", + "changed": "modificado*", + "deleted": "eliminado*", + "new": "nuevo*" + }, "community": { "attribution": { "navigationTitle": "Atribuciones", @@ -566,7 +573,7 @@ "items": { "01assignContent": "asignar contenido y otras propiedades a cada etiqueta", "02searchStreams": "buscar y asignar transmisiones de radio web a cada etiqueta", - "03defineMetaData": "definir metadatos de tus etiquetas personalizadas (PoC en el frontend web)", + "03defineMetaData": "definir metadatos de tus etiquetas personalizadas (editor de modelos personalizados en el frontend web)", "04filterTonies": "filtrar Tonies/Etiquetas", "05hideTonies": "ocultar Tonies/Etiquetas no disponibles", "06showLastPlayed": "mostrar últimos Tonies/Etiquetas reproducidos" @@ -636,9 +643,8 @@ "01firmwareUpdate": "Actualización de firmware a través de TeddyCloud", "03reportUnknownTonies": "Informar sobre Tonies desconocidos", "04completeTAPIntegration": "Finalización de la integración TAP en el frontend web", - "05completeCustomToniesIntegration": "Finalización de la integración del archivo Tonies Json personalizado en el frontend web (\"Metadatos de etiquetas personalizadas\")", - "06moreStats": "Más estadísticas sobre tus Tonies/Etiquetas reproducidos", - "07securityImprovements": "Mejoras en la seguridad, como control de acceso (tonieboxes y usuarios)" + "05moreStats": "Más estadísticas sobre tus Tonies/Etiquetas reproducidos", + "06securityImprovements": "Mejoras en la seguridad, como control de acceso (tonieboxes y usuarios)" }, "navigationTitle": "Características", "title": "Características de TeddyCloud", @@ -1802,6 +1808,85 @@ "track": "Pista" }, "addToniesCustomJsonEntry": "WIP: Agregar modelo", + "customToniesEditorJsonEntry": "Modelos personalizados", + "imageManager": { + "title": "Seleccionar y gestionar imágenes", + "okText": "Aplicar", + "sourceCustom": "Personalizado", + "sourceOriginal": "Original", + "noOriginalImages": "No se encontraron imágenes originales" + }, + "customEditor": { + "actions": { + "delete": "Eliminar", + "deleteCount": "Eliminar {{count}} modelo(s)", + "discard": "Descartar", + "duplicate": "Duplicar", + "edit": "Editar", + "newModel": "Nuevo modelo", + "preview": "Vista previa", + "restore": "Restaurar", + "toggleChangesOnly": "Solo cambios" + }, + "audio": { + "libraryLabel": "Audio personalizado (biblioteca)", + "notInIndex": "No en el índice de audio personalizado: {{audioId}}/{{hash}}", + "placeholder": "Seleccionar audio personalizado de la biblioteca" + }, + "batch": { + "description": "Los cambios se aplican a todos los modelos seleccionados.", + "title": "Edición por lotes activa" + }, + "coinHint": { + "description": "Opcional – solo para metadatos (título, pistas). Las monedas personalizadas funcionan sin modelo. Solo audio personalizado – los IDs oficiales sobrescriben Tonies reales. No vincula tags NFC con audio.", + "title": "Nota: Asignación de audio" + }, + "columns": { + "actions": "Acciones", + "image": "Imagen", + "status": "Estado" + }, + "errors": { + "emptyResult": "Debe permanecer al menos un modelo.", + "invalidSelectedIndex": "Índice seleccionado no válido", + "releaseNumeric": "Release debe ser numérico" + }, + "filterPlaceholder": "Filtrar modelos...", + "modelsTitle": "Modelos", + "noChangesBody": "No hay nada que guardar en este momento.", + "noChangesTitle": "No se detectaron cambios", + "optionalColumns": "Columnas opcionales", + "picHint": "Seleccionar imagen de custom_img o de tonies originales", + "preflight": { + "confirm": "Guardar ahora", + "deletes": "Eliminaciones: {{count}}", + "description": "Por favor confirma el alcance de esta operación de guardado.", + "renames": "Renombrados: {{count}}", + "title": "Revisar cambios antes de guardar", + "upserts": "Actualizaciones/nuevas entradas: {{count}}" + }, + "previewTitle": "Vista previa de imagen", + "saveSuccessWithCount": "tonies.custom.json guardado ({{count}} entradas). Copia de seguridad y recarga activadas.", + "savedState": "Todos los cambios guardados", + "sections": { + "audio": "Asignación de audio", + "media": "Medios e imágenes", + "metadata": "Metadatos opcionales", + "tracks": "Pistas" + }, + "selection": { + "description": "El formulario de edición siguiente siempre se aplica a la selección actual.", + "multiple": "{{count}} modelos seleccionados", + "none": "Ningún modelo seleccionado", + "single": "Modelo seleccionado: {{model}}" + }, + "tableMenu": "Menú de tabla", + "title": "Editor de modelos", + "unsaved": "{{count}} cambio(s) sin guardar", + "validation": { + "title": "Por favor corrige estos problemas primero" + } + }, "alternativeSource": "Este Tonie {{originalTonie}} tiene asignado el contenido {{assignedContent}}!", "alternativeSourceUnknown": "¡Este Tonie {{originalTonie}} tiene asignado contenido alternativo!", "cancel": "Cancelar", @@ -1822,6 +1907,7 @@ }, "currentPath": "Ruta actual: ", "editModal": { + "editSelectedCustomModel": "Continuar editando modelo", "model": "Modelo", "placeholderSearchForAModel": "Buscar un modelo", "placeholderSearchForARadioStream": "Buscar una transmisión de radio", diff --git a/public/translations/fr.json b/public/translations/fr.json index ebbf446a..4eb98b3f 100644 --- a/public/translations/fr.json +++ b/public/translations/fr.json @@ -10,6 +10,13 @@ "errorNoOggOpusSupportByApple": "Votre navigateur ne prend pas en charge les fichiers OGG/Opus (taf) pour le moment. Si vous utilisez un appareil Apple, aidez-nous en soumettant un ticket à Apple pour demander une solution !", "unknownSource": "Inconnu" }, + "common": { + "cancel": "Annuler", + "clean": "inchangé", + "changed": "modifié*", + "deleted": "supprimé*", + "new": "nouveau*" + }, "community": { "attribution": { "navigationTitle": "Attributions", @@ -566,7 +573,7 @@ "items": { "01assignContent": "attribuer du contenu et d'autres propriétés à chaque tag", "02searchStreams": "rechercher et attribuer des flux de webradio à chaque tag", - "03defineMetaData": "définir les métadonnées de vos tags personnalisés (PoC dans le WebFrontend)", + "03defineMetaData": "définir les métadonnées de vos tags personnalisés (éditeur de modèles personnalisés dans le WebFrontend)", "04filterTonies": "filtrer les Tonies/Tags", "05hideTonies": "masquer les Tonies/Tags non disponibles", "06showLastPlayed": "afficher les derniers Tonies/Tags joués" @@ -636,9 +643,8 @@ "01firmwareUpdate": "Mise à jour du firmware via TeddyCloud", "03reportUnknownTonies": "Signaler les Tonies inconnus", "04completeTAPIntegration": "Achèvement de l'intégration de TAP WebFrontend", - "05completeCustomToniesIntegration": "Achèvement de l'intégration des Tonies personnalisés dans le WebFront JSON (\"Métadonnées des tags personnalisés\")", - "06moreStats": "Plus de statistiques sur vos Tonies/Tags joués", - "07securityImprovements": "Améliorations de la sécurité comme le contrôle d'accès (tonieboxes et utilisateur)" + "05moreStats": "Plus de statistiques sur vos Tonies/Tags joués", + "06securityImprovements": "Améliorations de la sécurité comme le contrôle d'accès (tonieboxes et utilisateur)" }, "navigationTitle": "Fonctionnalités", "title": "Fonctionnalités TeddyCloud", @@ -1802,6 +1808,85 @@ "track": "Piste" }, "addToniesCustomJsonEntry": "WIP: Ajouter un modèle", + "customToniesEditorJsonEntry": "Modèles personnalisés", + "imageManager": { + "title": "Sélectionner et gérer les images", + "okText": "Appliquer", + "sourceCustom": "Personnalisé", + "sourceOriginal": "Original", + "noOriginalImages": "Aucune image originale trouvée" + }, + "customEditor": { + "actions": { + "delete": "Supprimer", + "deleteCount": "Supprimer {{count}} modèle(s)", + "discard": "Annuler", + "duplicate": "Dupliquer", + "edit": "Modifier", + "newModel": "Nouveau modèle", + "preview": "Aperçu", + "restore": "Restaurer", + "toggleChangesOnly": "Modifications uniquement" + }, + "audio": { + "libraryLabel": "Audio personnalisé (bibliothèque)", + "notInIndex": "Pas dans l'index audio personnalisé : {{audioId}}/{{hash}}", + "placeholder": "Sélectionner l'audio personnalisé dans la bibliothèque" + }, + "batch": { + "description": "Les modifications s'appliquent à tous les modèles sélectionnés.", + "title": "Édition par lot active" + }, + "coinHint": { + "description": "Optionnel – uniquement pour les métadonnées (titre, pistes). Les pièces personnalisées fonctionnent sans modèle. Uniquement l'audio personnalisé – les IDs officiels écrasent les vrais Tonies. Ne lie pas les tags NFC à l'audio.", + "title": "Conseil : Assignation audio" + }, + "columns": { + "actions": "Actions", + "image": "Image", + "status": "Statut" + }, + "errors": { + "emptyResult": "Au moins un modèle doit rester.", + "invalidSelectedIndex": "Index sélectionné invalide", + "releaseNumeric": "Release doit être numérique" + }, + "filterPlaceholder": "Filtrer les modèles...", + "modelsTitle": "Modèles", + "noChangesBody": "Il n'y a rien à enregistrer pour le moment.", + "noChangesTitle": "Aucune modification détectée", + "optionalColumns": "Colonnes optionnelles", + "picHint": "Sélectionner une image depuis custom_img ou les tonies originaux", + "preflight": { + "confirm": "Enregistrer maintenant", + "deletes": "Suppressions : {{count}}", + "description": "Veuillez confirmer la portée de cette opération d'enregistrement.", + "renames": "Renommages : {{count}}", + "title": "Vérifier les modifications avant l'enregistrement", + "upserts": "Mises à jour/nouvelles entrées : {{count}}" + }, + "previewTitle": "Aperçu de l'image", + "saveSuccessWithCount": "tonies.custom.json enregistré ({{count}} entrées). Sauvegarde et rechargement déclenchés.", + "savedState": "Toutes les modifications enregistrées", + "sections": { + "audio": "Assignation audio", + "media": "Médias et images", + "metadata": "Métadonnées optionnelles", + "tracks": "Pistes" + }, + "selection": { + "description": "Le formulaire d'édition ci-dessous s'applique toujours à la sélection actuelle.", + "multiple": "{{count}} modèles sélectionnés", + "none": "Aucun modèle sélectionné", + "single": "Modèle sélectionné : {{model}}" + }, + "tableMenu": "Menu du tableau", + "title": "Éditeur de modèles", + "unsaved": "{{count}} modification(s) non enregistrée(s)", + "validation": { + "title": "Veuillez d'abord corriger ces problèmes" + } + }, "alternativeSource": "Cette Tonie {{originalTonie}} est assignée à un contenu {{assignedContent}} !", "alternativeSourceUnknown": "Cette Tonie {{originalTonie}} est assignée à un contenu alternatif !", "cancel": "Annuler", @@ -1822,6 +1907,7 @@ }, "currentPath": "Chemin actuel : ", "editModal": { + "editSelectedCustomModel": "Continuer l'édition du modèle", "model": "Modèle", "placeholderSearchForAModel": "Rechercher un modèle", "placeholderSearchForARadioStream": "Rechercher un flux radio", diff --git a/src/App.tsx b/src/App.tsx index f6d6195e..b0364a1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,7 @@ import { TeddyAudioPlayerPage } from "./pages/tonies/TeddyAudioPlayerPage"; import { TeddyAudioPlaylistsPage } from "./pages/tonies/TeddyAudioPlaylistsPage"; import { TeddyStudioPage } from "./pages/tonies/TeddyStudioPage"; import { ToniesPage } from "./pages/tonies/ToniesPage"; +import { CustomTonieCreatorPage } from "./pages/tonies/CustomTonieCreatorPage"; import "./styles/matrix/matrix.css"; import { matrixAlgorithm } from "./styles/matrix/matrixAlgorithm"; @@ -176,6 +177,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/settings/settings/Settings.tsx b/src/components/settings/settings/Settings.tsx index 97580912..0c62239e 100644 --- a/src/components/settings/settings/Settings.tsx +++ b/src/components/settings/settings/Settings.tsx @@ -88,15 +88,18 @@ export const Settings: React.FC = () => { >
{settingsOptions.map((option, index, array) => { - if (option.iD.includes("core.settings_level")) { + const optionId = option.iD; + + if (optionId.includes("core.settings_level")) { return null; } - const parts = option.iD.split("."); - const lastParts = array[index - 1] ? array[index - 1].iD.split(".") : []; + const parts = optionId.split("."); + const previousOptionId = array[index - 1] ? array[index - 1].iD : ""; + const lastParts = previousOptionId ? previousOptionId.split(".") : []; return ( - + {parts.slice(0, -1).map((part, partIndex) => { if (lastParts[partIndex] !== part) { if (partIndex === 0) { @@ -106,7 +109,7 @@ export const Settings: React.FC = () => { marginLeft: `${partIndex * 20}px`, marginBottom: "10px", }} - key={`category-${option.iD}-${partIndex}`} + key={`category-${optionId}-${partIndex}`} > Category {part} @@ -119,7 +122,7 @@ export const Settings: React.FC = () => { marginTop: "10px", marginBottom: "10px", }} - key={`category-${option.iD}-${partIndex}`} + key={`category-${optionId}-${partIndex}`} > .{part} @@ -128,7 +131,7 @@ export const Settings: React.FC = () => { } return null; })} - + ); })} diff --git a/src/components/tonies/ToniesCustomJsonEditorEnhanced.tsx b/src/components/tonies/ToniesCustomJsonEditorEnhanced.tsx new file mode 100644 index 00000000..80591fa9 --- /dev/null +++ b/src/components/tonies/ToniesCustomJsonEditorEnhanced.tsx @@ -0,0 +1,2133 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Badge, + Button, + Checkbox, + Col, + Collapse, + Form, + Input, + Modal, + Popover, + Row, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, +} from "antd"; +import { + CopyOutlined, + DeleteOutlined, + EditOutlined, + EyeOutlined, + InfoCircleOutlined, + PlusOutlined, + RollbackOutlined, +} from "@ant-design/icons"; + +import { TonieCardProps } from "../../types/tonieTypes"; +import { TeddyCloudApi } from "../../api"; +import { defaultAPIConfig } from "../../config/defaultApiConfig"; +import { IMAGE_EXTENSIONS } from "../../constants/fileTypes"; +import { useTeddyCloud } from "../../contexts/TeddyCloudContext"; +import { NotificationTypeEnum } from "../../types/teddyCloudNotificationTypes"; +import ImageManagerModal from "./filebrowser/modals/ImageManagerModal"; + +const api = new TeddyCloudApi(defaultAPIConfig()); + +interface ToniesCustomJsonEditorProps { + open: boolean; + onClose: () => void; + setValue?: (value: any) => void; + props?: any; + tonieCardProps?: TonieCardProps; + audioId?: number; + hash?: string; + embedded?: boolean; + startInCreateMode?: boolean; + initialSelectedModel?: string; + onModelCreated?: (model: string) => void; +} + +type AudioPair = { audio_id: string; hash: string }; +type TrackRow = { track: string }; +type AudioSelectOption = { + key: string; + value: string; + label: string; + audio_id: string; + hash: string; +}; + +type LibraryRecord = { + name?: string; + isDir?: boolean; + tafHeader?: { + audioId?: string | number; + sha1Hash?: string; + }; + tonieInfo?: { + model?: string; + series?: string; + }; +}; + +type CustomEntry = { + no?: string; + model: string; + audio_id?: string[]; + hash?: string[]; + title?: string; + series: string; + episodes?: string; + tracks?: string[]; + release?: string; + language?: string; + category?: string; + pic?: string; +}; + +type FormValues = { + no?: string; + model: string; + title?: string; + series: string; + episodes?: string; + release?: string | number; + language?: string; + category?: string; + pic?: string; + audioPairs: AudioPair[]; + tracks: TrackRow[]; +}; + +const TABLE_SETTINGS_STORAGE_KEY = "tonies.customEditor.tableSettings.v1"; + +type OptionalColumnKey = "title" | "episodes" | "release" | "language" | "category" | "no" | "status"; +type SortColumnKey = "series" | "model" | "title" | "episodes" | "release" | "language" | "category" | "no" | "status"; +type SortOrder = "ascend" | "descend" | null; +type FilterFieldKey = "series" | "model" | "title" | "episodes" | "release" | "language" | "category" | "no" | "status"; +type DraftStatus = "clean" | "changed" | "new" | "deleted"; + +type TableRow = { + idx: number; + entry: CustomEntry; + status: DraftStatus; +}; + +type SavePlan = { + nextWithoutDeleted: CustomEntry[]; + activeEntry: CustomEntry; + renameOps: Array<{ fromModel: string; toModel: string }>; + upsertEntries: CustomEntry[]; + deleteModels: string[]; +}; + +const normalizeDirPath = (value: string) => value.replace(/^\/+/, "").replace(/\/+$/, ""); + +const toCustomImgWebPath = (path: string, fileName: string) => { + const normalizedPath = normalizeDirPath(path); + return normalizedPath ? `/custom_img/${normalizedPath}/${fileName}` : `/custom_img/${fileName}`; +}; + +const toPreviewableImageUrl = (value?: string) => { + const raw = (value || "").trim(); + if (!raw) return ""; + if (/^(https?:\/\/|data:|blob:)/i.test(raw)) return raw; + if (raw.startsWith("/")) return raw; + if (raw.startsWith("custom_img/")) return `/${raw}`; + const normalized = normalizeDirPath(raw); + if (!normalized) return ""; + const encoded = normalized + .split("/") + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `/custom_img/${encoded}`; +}; + +const buildAudioPairKey = (audioId: string, hash: string) => `${audioId.trim()}::${hash.trim().toLowerCase()}`; + +const cloneEntry = (entry: CustomEntry): CustomEntry => JSON.parse(JSON.stringify(entry)); + +const normalizeText = (value?: string) => (value || "").trim(); + +const normalizeAudioPairs = (entry: CustomEntry) => { + const audioIds = entry.audio_id || []; + const hashes = entry.hash || []; + return audioIds + .map((audioId, index) => `${normalizeText(audioId)}::${normalizeText(hashes[index]).toLowerCase()}`) + .filter((pair) => pair !== "::"); +}; + +const normalizeTracks = (entry: CustomEntry) => (entry.tracks || []).map((track) => normalizeText(track)).filter(Boolean); + +const areStringArraysEqual = (left: string[], right: string[]) => + left.length === right.length && left.every((value, index) => value === right[index]); + +const toModelKey = (model?: string) => normalizeText(model).toLowerCase(); + +const toFormValues = (entry: CustomEntry): FormValues => ({ + no: entry.no ?? "", + model: entry.model ?? "", + title: entry.title ?? "", + series: entry.series ?? "", + episodes: entry.episodes ?? "", + release: entry.release ?? "", + language: entry.language ?? "", + category: entry.category ?? "", + pic: entry.pic ?? "", + audioPairs: + entry.audio_id && entry.hash + ? entry.audio_id.map((audio_id, idx) => ({ audio_id: audio_id ?? "", hash: entry.hash?.[idx] ?? "" })) + : [{ audio_id: "", hash: "" }], + tracks: entry.tracks && entry.tracks.length > 0 ? entry.tracks.map((track) => ({ track })) : [{ track: "" }], +}); + +const parseModelId = (model: string): number | null => { + const match = /^custom-(\d+)$/i.exec(model.trim()); + return match ? Number(match[1]) : null; +}; + +const buildSuggestedModel = (entries: CustomEntry[]): string => { + let maxId = 0; + entries.forEach((entry) => { + const parsed = parseModelId(entry.model || ""); + if (parsed !== null && parsed > maxId) maxId = parsed; + }); + return `custom-${maxId + 1}`; +}; + +const toEntry = (values: FormValues): CustomEntry => { + const pairs = (values.audioPairs || []) + .map((pair) => ({ + audio_id: (pair.audio_id || "").trim(), + hash: (pair.hash || "").trim(), + })) + .filter((pair) => pair.audio_id && pair.hash); + + const tracks = (values.tracks || []) + .map((track) => (track.track || "").trim()) + .filter((track) => track.length > 0); + + const releaseRaw = values.release === undefined || values.release === null ? "" : String(values.release).trim(); + const releaseNormalized = releaseRaw.length > 0 ? releaseRaw : undefined; + + const entry: CustomEntry = { + no: (values.no || "").trim() || undefined, + model: (values.model || "").trim(), + audio_id: pairs.length > 0 ? pairs.map((pair) => pair.audio_id) : undefined, + hash: pairs.length > 0 ? pairs.map((pair) => pair.hash) : undefined, + title: (values.title || "").trim() || undefined, + series: (values.series || "").trim(), + episodes: (values.episodes || "").trim() || undefined, + tracks: tracks.length > 0 ? tracks : undefined, + release: releaseNormalized, + language: (values.language || "").trim() || undefined, + category: (values.category || "").trim() || undefined, + pic: (values.pic || "").trim() || undefined, + }; + + return entry; +}; + +const isImageFile = (name: string) => IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext)); + +export const ToniesCustomJsonEditor: React.FC = ({ + open, + onClose, + setValue, + props, + tonieCardProps, + audioId, + hash, + embedded = false, + startInCreateMode = false, + initialSelectedModel = "", + onModelCreated, +}) => { + const { t } = useTranslation(); + const { addNotification } = useTeddyCloud(); + const [form] = Form.useForm(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + const [customEntries, setCustomEntries] = useState([]); + const [persistedEntries, setPersistedEntries] = useState([]); + const [deletedModelKeys, setDeletedModelKeys] = useState>(new Set()); + const [baseEntries, setBaseEntries] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); + + const [imageManagerOpen, setImageManagerOpen] = useState(false); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(""); + + const [imagePathOptions, setImagePathOptions] = useState([]); + const imagePathsCollectedRef = useRef(false); + const [customAudioOptions, setCustomAudioOptions] = useState([]); + const [customAudioOptionsLoading, setCustomAudioOptionsLoading] = useState(false); + const [visibleOptionalColumns, setVisibleOptionalColumns] = useState(["status"]); + const [tableSortColumn, setTableSortColumn] = useState("model"); + const [tableSortOrder, setTableSortOrder] = useState("ascend"); + const [filterText, setFilterText] = useState(""); + const [filterFields, setFilterFields] = useState(["series", "model"]); + const [selectedRowIndexes, setSelectedRowIndexes] = useState([]); + const [showChangesOnly, setShowChangesOnly] = useState(false); + const [preflightOpen, setPreflightOpen] = useState(false); + const [pendingSavePlan, setPendingSavePlan] = useState(null); + const [validationMessages, setValidationMessages] = useState([]); + const [brokenImageUrls, setBrokenImageUrls] = useState>(new Set()); + + const listWithCurrentDraft = async (skipValidationForCurrentSelection = false) => { + const values = skipValidationForCurrentSelection + ? (form.getFieldsValue(true) as FormValues) + : await form.validateFields(); + const draft = toEntry(values); + const next = customEntries.map((entry) => cloneEntry(entry)); + if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= next.length) { + throw new Error(t("tonies.customEditor.errors.invalidSelectedIndex")); + } + next[selectedIndex] = draft; + return { next, activeEntry: draft }; + }; + + const mergeCurrentFormIntoEntries = (entries: CustomEntry[]) => { + if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= entries.length) { + return entries.map((entry) => cloneEntry(entry)); + } + const values = form.getFieldsValue(true) as FormValues; + const draft = toEntry(values); + const next = entries.map((entry) => cloneEntry(entry)); + next[selectedIndex] = draft; + return next; + }; + + const validateEntryList = (entries: CustomEntry[]) => { + const modelMap = new Map(); + const pairMap = new Map(); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const modelKey = entry.model.trim().toLowerCase(); + if (modelMap.has(modelKey)) { + return { + error: t("tonies.addNewCustomTonieModal.modelRequired") + ` (Duplikat: ${entry.model})`, + baseWarning: "", + }; + } + modelMap.set(modelKey, i); + + const audioIds = entry.audio_id || []; + const hashes = entry.hash || []; + for (let j = 0; j < Math.min(audioIds.length, hashes.length); j++) { + const pair = `${audioIds[j]}::${hashes[j].toLowerCase()}`; + if (pairMap.has(pair)) { + return { + error: `Doppelte audio_id+hash-Kombination erkannt: ${audioIds[j]}`, + baseWarning: "", + }; + } + pairMap.set(pair, entry.model); + } + } + + const baseModelSet = new Set(baseEntries.map((entry) => (entry.model || "").trim().toLowerCase())); + const baseWarningModels = entries + .filter((entry) => baseModelSet.has(entry.model.trim().toLowerCase())) + .map((entry) => entry.model); + if (baseWarningModels.length > 0) { + return { + error: "", + baseWarning: `Modell existiert in base tonies.json: ${Array.from(new Set(baseWarningModels)).join(", ")}`, + }; + } + + return { error: "", baseWarning: "" }; + }; + + const buildNewEntryDraft = (seedEntries: CustomEntry[]): CustomEntry => { + const suggestedModel = buildSuggestedModel(seedEntries); + const seedAudioId = audioId && hash ? [String(audioId)] : undefined; + const seedHash = audioId && hash ? [hash] : undefined; + return { + no: "", + model: suggestedModel, + title: "", + series: tonieCardProps?.tonieInfo?.series || "", + episodes: tonieCardProps?.tonieInfo?.episode || "", + release: "", + language: tonieCardProps?.tonieInfo?.language || "", + category: "", + pic: tonieCardProps?.tonieInfo?.picture || "", + audio_id: seedAudioId, + hash: seedHash, + tracks: undefined, + }; + }; + + const createAndSelectNewEntry = (seedEntries: CustomEntry[]) => { + const newEntry = buildNewEntryDraft(seedEntries); + const nextEntries = [...seedEntries.map((entry) => cloneEntry(entry)), newEntry]; + setCustomEntries(nextEntries); + setSelectedIndex(nextEntries.length - 1); + form.setFieldsValue(toFormValues(newEntry)); + return nextEntries; + }; + + const loadJsonData = async () => { + setLoading(true); + try { + const [customResponse, baseResponse] = await Promise.all([ + api.apiGetTeddyCloudApiRaw("/api/toniesCustomJson"), + api.apiGetTeddyCloudApiRaw("/api/toniesJson"), + ]); + + const [customData, baseData] = await Promise.all([customResponse.json(), baseResponse.json()]); + const normalizedCustom = Array.isArray(customData) ? customData : []; + const normalizedBase = Array.isArray(baseData) ? baseData : []; + setCustomEntries(normalizedCustom); + setPersistedEntries(normalizedCustom); + setDeletedModelKeys(new Set()); + setBaseEntries(normalizedBase); + + if (startInCreateMode) { + void createAndSelectNewEntry(normalizedCustom); + return; + } + + const initialModelKey = toModelKey(initialSelectedModel); + if (normalizedCustom.length > 0 && initialModelKey) { + const selectedIdx = normalizedCustom.findIndex((entry) => toModelKey(entry.model) === initialModelKey); + if (selectedIdx >= 0) { + setSelectedIndex(selectedIdx); + form.setFieldsValue(toFormValues(normalizedCustom[selectedIdx])); + return; + } + } + + if (normalizedCustom.length > 0) { + setSelectedIndex(0); + form.setFieldsValue(toFormValues(normalizedCustom[0])); + } else { + void createAndSelectNewEntry(normalizedCustom); + } + } catch (error) { + const maybeErrorFields = (error as any)?.errorFields as Array<{ errors?: string[] }> | undefined; + if (Array.isArray(maybeErrorFields) && maybeErrorFields.length > 0) { + const issues = maybeErrorFields.flatMap((item) => item.errors || []).filter(Boolean); + if (issues.length > 0) { + setValidationMessages(Array.from(new Set(issues))); + } + } else if (error instanceof Error) { + setValidationMessages([error.message]); + } + addNotification( + NotificationTypeEnum.Error, + t("tonies.addNewCustomTonieModal.failedToCreate"), + String(error), + t("tonies.customToniesEditorJsonEntry") + ); + } finally { + setLoading(false); + } + }; + + const collectImagePaths = async () => { + const discovered: string[] = []; + const fetchDir = async (current: string): Promise => { + const subdirs: string[] = []; + try { + const response = await api.apiGetTeddyCloudApiRaw( + `/api/fileIndexV2?path=${encodeURIComponent(current)}&special=custom_img` + ); + if (!response.ok) return []; + const data = await response.json(); + const files = Array.isArray(data?.files) ? data.files : []; + + files.forEach((entry: any) => { + if (!entry || entry.name === "..") return; + if (entry.isDir) { + subdirs.push(current ? `${current}/${entry.name}` : entry.name); + } else if (isImageFile(entry.name)) { + discovered.push(toCustomImgWebPath(current, entry.name)); + } + }); + } catch { + // ignore + } + return subdirs; + }; + + let currentLevels: string[] = [""]; + const seen = new Set([""]); + + while (currentLevels.length > 0) { + const results = await Promise.all(currentLevels.map((p) => fetchDir(p))); + const nextLevels: string[] = []; + results.forEach((subdirs) => { + subdirs.forEach((d) => { + if (!seen.has(d)) { + seen.add(d); + nextLevels.push(d); + } + }); + }); + currentLevels = nextLevels; + } + + setImagePathOptions(Array.from(new Set(discovered)).sort((a, b) => a.localeCompare(b))); + }; + + const collectCustomAudioOptions = async () => { + setCustomAudioOptionsLoading(true); + const queue: string[] = [""]; + const seen = new Set(); + const optionsByKey = new Map(); + + try { + while (queue.length > 0) { + const current = queue.shift() || ""; + if (seen.has(current)) continue; + seen.add(current); + + const response = await api.apiGetTeddyCloudApiRaw( + `/api/fileIndexV2?path=${encodeURIComponent(current)}&special=library` + ); + const data = await response.json(); + const files = Array.isArray(data?.files) ? (data.files as LibraryRecord[]) : []; + + files.forEach((entry) => { + if (!entry || entry.name === "..") return; + if (entry.isDir) { + const nextPath = current ? `${current}/${entry.name}` : `${entry.name}`; + queue.push(nextPath); + return; + } + + const audioIdRaw = entry.tafHeader?.audioId; + const hashRaw = entry.tafHeader?.sha1Hash; + if (audioIdRaw === undefined || audioIdRaw === null || !hashRaw) return; + + const model = (entry.tonieInfo?.model || "").trim(); + if (!/^custom-/i.test(model)) return; + + const audioId = String(audioIdRaw).trim(); + const hash = String(hashRaw).trim(); + if (!audioId || !hash) return; + + const filePath = current ? `${current}/${entry.name}` : `${entry.name}`; + const key = buildAudioPairKey(audioId, hash); + const series = (entry.tonieInfo?.series || "").trim(); + const seriesPart = series ? `${series} - ` : ""; + const label = `${seriesPart}${entry.name} (${audioId}/${hash.slice(0, 8)}...) [${model}]`; + + if (!optionsByKey.has(key)) { + optionsByKey.set(key, { + key, + value: key, + label: `${label} @ ${filePath}`, + audio_id: audioId, + hash, + }); + } + }); + } + + const sorted = Array.from(optionsByKey.values()).sort((a, b) => a.label.localeCompare(b.label)); + setCustomAudioOptions(sorted); + } catch { + setCustomAudioOptions([]); + } finally { + setCustomAudioOptionsLoading(false); + } + }; + + const isVisible = embedded || open; + + useEffect(() => { + if (!isVisible) return; + void loadJsonData(); + void collectCustomAudioOptions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialSelectedModel, isVisible, startInCreateMode]); + + const runCollectImagePathsWhenNeeded = () => { + if (!imagePathsCollectedRef.current) { + imagePathsCollectedRef.current = true; + void collectImagePaths(); + } + }; + + useEffect(() => { + try { + const raw = localStorage.getItem(TABLE_SETTINGS_STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as { + visibleOptionalColumns?: OptionalColumnKey[]; + tableSortColumn?: SortColumnKey; + tableSortOrder?: SortOrder; + filterFields?: FilterFieldKey[]; + }; + const allowedOptional: OptionalColumnKey[] = ["title", "episodes", "release", "language", "category", "no", "status"]; + const allowedSort: SortColumnKey[] = ["series", "model", "title", "episodes", "release", "language", "category", "no", "status"]; + const allowedFilterFields: FilterFieldKey[] = ["series", "model", "title", "episodes", "release", "language", "category", "no", "status"]; + if (Array.isArray(parsed.visibleOptionalColumns)) { + const nextVisible = parsed.visibleOptionalColumns.filter( + (value): value is OptionalColumnKey => allowedOptional.includes(value as OptionalColumnKey) + ); + setVisibleOptionalColumns(nextVisible); + } + if (parsed.tableSortColumn && allowedSort.includes(parsed.tableSortColumn)) { + setTableSortColumn(parsed.tableSortColumn); + } + if (parsed.tableSortOrder === "ascend" || parsed.tableSortOrder === "descend" || parsed.tableSortOrder === null) { + setTableSortOrder(parsed.tableSortOrder); + } + if (Array.isArray(parsed.filterFields)) { + const nextFilterFields = parsed.filterFields.filter( + (value): value is FilterFieldKey => allowedFilterFields.includes(value as FilterFieldKey) + ); + if (nextFilterFields.length > 0) { + setFilterFields(nextFilterFields); + } + } + } catch { + // ignore invalid persisted table config + } + }, []); + + useEffect(() => { + try { + localStorage.setItem( + TABLE_SETTINGS_STORAGE_KEY, + JSON.stringify({ + visibleOptionalColumns, + tableSortColumn, + tableSortOrder, + filterFields, + }) + ); + } catch { + // ignore storage write errors + } + }, [filterFields, tableSortColumn, tableSortOrder, visibleOptionalColumns]); + + useEffect(() => { + setSelectedRowIndexes((prev) => prev.filter((idx) => idx >= 0 && idx < customEntries.length)); + }, [customEntries.length]); + + const watchedAudioPairs = Form.useWatch("audioPairs", form) as AudioPair[] | undefined; + const watchedValues = Form.useWatch([], form) as FormValues | undefined; + + const audioPairSelectOptions = useMemo(() => { + const byKey = new Map(customAudioOptions.map((option) => [option.key, option] as const)); + (watchedAudioPairs || []).forEach((pair) => { + const audioId = (pair?.audio_id || "").trim(); + const hashValue = (pair?.hash || "").trim(); + if (!audioId || !hashValue) return; + const key = buildAudioPairKey(audioId, hashValue); + if (byKey.has(key)) return; + byKey.set(key, { + key, + value: key, + label: t("tonies.customEditor.audio.notInIndex", { + defaultValue: "Not in custom audio index: {{audioId}}/{{hash}}", + audioId, + hash: hashValue, + }), + audio_id: audioId, + hash: hashValue, + }); + }); + return Array.from(byKey.values()); + }, [customAudioOptions, watchedAudioPairs]); + + const selectedEntry = useMemo(() => { + if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= customEntries.length) return null; + return customEntries[selectedIndex]; + }, [customEntries, selectedIndex]); + + const selectedIsDeleted = useMemo(() => { + if (!selectedEntry) return false; + return deletedModelKeys.has(toModelKey(selectedEntry.model)); + }, [deletedModelKeys, selectedEntry]); + + const currentDraft = useMemo(() => toEntry((watchedValues || {}) as FormValues), [watchedValues]); + + const persistedByModel = useMemo( + () => + new Map( + persistedEntries.map((entry) => [toModelKey(entry.model), cloneEntry(entry)] as const) + ), + [persistedEntries] + ); + + const currentBaselineEntry = useMemo(() => { + const modelKey = normalizeText(currentDraft.model).toLowerCase(); + return persistedByModel.get(modelKey) || null; + }, [currentDraft.model, persistedByModel]); + + const isFieldChanged = (field: keyof CustomEntry) => { + const draftValue = normalizeText(currentDraft[field] as string | undefined); + if (!currentBaselineEntry) return draftValue.length > 0; + const baseValue = normalizeText(currentBaselineEntry[field] as string | undefined); + return draftValue !== baseValue; + }; + + const areAudioPairsChanged = useMemo(() => { + const currentPairs = normalizeAudioPairs(currentDraft); + if (!currentBaselineEntry) return currentPairs.length > 0; + return !areStringArraysEqual(currentPairs, normalizeAudioPairs(currentBaselineEntry)); + }, [currentBaselineEntry, currentDraft]); + + const areTracksChanged = useMemo(() => { + const currentTracks = normalizeTracks(currentDraft); + if (!currentBaselineEntry) return currentTracks.length > 0; + return !areStringArraysEqual(currentTracks, normalizeTracks(currentBaselineEntry)); + }, [currentBaselineEntry, currentDraft]); + + const changedInputStyle = (changed: boolean) => + changed + ? ({ + backgroundColor: "rgba(250, 173, 20, 0.18)", + borderColor: "#faad14", + } as const) + : undefined; + + const batchEditableFields: Array = ["series", "release", "language", "category", "pic"]; + + const effectiveEntries = useMemo( + () => + customEntries.map((entry, index) => + selectedIndex !== null && index === selectedIndex ? ({ ...entry, ...currentDraft } as CustomEntry) : entry + ), + [currentDraft, customEntries, selectedIndex] + ); + + const hasBatchPreviewChange = (idx: number) => { + if (selectedIndex === null) return false; + if (idx === selectedIndex) return false; + if (selectedRowIndexes.length <= 1) return false; + if (!selectedRowIndexes.includes(selectedIndex)) return false; + if (!selectedRowIndexes.includes(idx)) return false; + const sourceEntry = effectiveEntries[selectedIndex]; + const targetEntry = effectiveEntries[idx]; + if (!sourceEntry || !targetEntry) return false; + return batchEditableFields.some( + (field) => normalizeText(sourceEntry[field] as string | undefined) !== normalizeText(targetEntry[field] as string | undefined) + ); + }; + + const modelDraftStatusByIndex = useMemo(() => { + const status = new Map(); + customEntries.forEach((entry, index) => { + const effectiveEntry = + selectedIndex !== null && index === selectedIndex ? ({ ...entry, ...currentDraft } as CustomEntry) : entry; + const modelKey = toModelKey(effectiveEntry.model); + if (deletedModelKeys.has(modelKey)) { + status.set(index, "deleted"); + return; + } + const persisted = persistedByModel.get(modelKey); + if (!persisted) { + status.set(index, "new"); + return; + } + const entryChanged = + normalizeText(effectiveEntry.no) !== normalizeText(persisted.no) || + normalizeText(effectiveEntry.model) !== normalizeText(persisted.model) || + normalizeText(effectiveEntry.title) !== normalizeText(persisted.title) || + normalizeText(effectiveEntry.series) !== normalizeText(persisted.series) || + normalizeText(effectiveEntry.episodes) !== normalizeText(persisted.episodes) || + normalizeText(effectiveEntry.release) !== normalizeText(persisted.release) || + normalizeText(effectiveEntry.language) !== normalizeText(persisted.language) || + normalizeText(effectiveEntry.category) !== normalizeText(persisted.category) || + normalizeText(effectiveEntry.pic) !== normalizeText(persisted.pic) || + !areStringArraysEqual(normalizeAudioPairs(effectiveEntry), normalizeAudioPairs(persisted)) || + !areStringArraysEqual(normalizeTracks(effectiveEntry), normalizeTracks(persisted)); + status.set(index, entryChanged || hasBatchPreviewChange(index) ? "changed" : "clean"); + }); + return status; + }, [customEntries, currentDraft, deletedModelKeys, effectiveEntries, persistedByModel, selectedIndex, selectedRowIndexes]); + + const statusSortWeight = (status: DraftStatus) => { + if (status === "changed") return 0; + if (status === "new") return 1; + if (status === "deleted") return 2; + return 3; + }; + + const sortValueForEntry = (entry: CustomEntry, status: DraftStatus, column: SortColumnKey) => { + if (column === "status") return statusSortWeight(status); + if (column === "title") return normalizeText(entry.title).toLowerCase(); + if (column === "episodes") return normalizeText(entry.episodes).toLowerCase(); + if (column === "release") return normalizeText(entry.release).toLowerCase(); + if (column === "language") return normalizeText(entry.language).toLowerCase(); + if (column === "category") return normalizeText(entry.category).toLowerCase(); + if (column === "no") return normalizeText(entry.no).toLowerCase(); + if (column === "series") return normalizeText(entry.series).toLowerCase(); + return normalizeText(entry.model).toLowerCase(); + }; + + const filterValueForEntry = (entry: CustomEntry, status: DraftStatus, field: FilterFieldKey) => { + if (field === "status") return status; + if (field === "title") return normalizeText(entry.title); + if (field === "episodes") return normalizeText(entry.episodes); + if (field === "release") return normalizeText(entry.release); + if (field === "language") return normalizeText(entry.language); + if (field === "category") return normalizeText(entry.category); + if (field === "no") return normalizeText(entry.no); + if (field === "series") return normalizeText(entry.series); + return normalizeText(entry.model); + }; + + const tableRows = useMemo(() => { + const filterNeedle = filterText.trim().toLowerCase(); + const rows: TableRow[] = customEntries.map((_, idx) => ({ + idx, + entry: effectiveEntries[idx], + status: modelDraftStatusByIndex.get(idx) || "clean", + })); + const filteredRowsByText = + filterNeedle.length === 0 + ? rows + : rows.filter((row) => + filterFields.some((field) => + filterValueForEntry(row.entry, row.status, field).toLowerCase().includes(filterNeedle) + ) + ); + const filteredRows = showChangesOnly + ? filteredRowsByText.filter((row) => row.status !== "clean") + : filteredRowsByText; + + const direction = tableSortOrder === "descend" ? -1 : 1; + return filteredRows.sort((left, right) => { + const leftSort = sortValueForEntry(left.entry, left.status, tableSortColumn); + const rightSort = sortValueForEntry(right.entry, right.status, tableSortColumn); + const bySort = + tableSortColumn === "status" + ? (Number(leftSort) - Number(rightSort)) * direction + : String(leftSort).localeCompare(String(rightSort)) * direction; + if (bySort !== 0) return bySort; + return left.entry.model.localeCompare(right.entry.model) * direction; + }); + }, [ + customEntries, + effectiveEntries, + filterFields, + filterText, + modelDraftStatusByIndex, + showChangesOnly, + tableSortColumn, + tableSortOrder, + ]); + + const changedCount = useMemo(() => tableRows.filter((row) => row.status !== "clean").length, [tableRows]); + const hasUnsavedChanges = changedCount > 0 || deletedModelKeys.size > 0; + + const applySelectionForVisibleRows = (visibleRows: TableRow[], keys: React.Key[]) => { + const visibleIndexes = new Set(visibleRows.map((row) => row.idx)); + const selectedFromVisible = keys + .map((key) => Number(key)) + .filter((value) => Number.isInteger(value) && value >= 0 && value < customEntries.length); + setSelectedRowIndexes((prev) => { + const selectedOutsideVisible = prev.filter((idx) => !visibleIndexes.has(idx)); + return Array.from(new Set([...selectedOutsideVisible, ...selectedFromVisible])).filter( + (idx) => idx >= 0 && idx < customEntries.length + ); + }); + }; + + const selectedValidIndexes = useMemo( + () => selectedRowIndexes.filter((idx) => idx >= 0 && idx < customEntries.length), + [customEntries.length, selectedRowIndexes] + ); + const selectedDeletedIndexes = useMemo( + () => selectedValidIndexes.filter((idx) => deletedModelKeys.has(toModelKey(effectiveEntries[idx]?.model))), + [deletedModelKeys, effectiveEntries, selectedValidIndexes] + ); + const selectedNonDeletedIndexes = useMemo( + () => selectedValidIndexes.filter((idx) => !deletedModelKeys.has(toModelKey(effectiveEntries[idx]?.model))), + [deletedModelKeys, effectiveEntries, selectedValidIndexes] + ); + const hasSelection = selectedRowIndexes.length > 0; + const isMultiSelectMode = selectedRowIndexes.length > 1; + const onlyDeletedSelected = hasSelection && selectedNonDeletedIndexes.length === 0; + const hasDeletedSelection = selectedDeletedIndexes.length > 0; + + const getStatusLabel = (status: DraftStatus) => { + if (status === "clean") return t("common.clean", { defaultValue: "unchanged" }); + if (status === "new") return t("common.new", { defaultValue: "new*" }); + if (status === "changed") return t("common.changed", { defaultValue: "changed*" }); + return t("common.deleted", { defaultValue: "deleted*" }); + }; + + const getStatusBadge = (status: DraftStatus) => { + const label = getStatusLabel(status); + if (status === "clean") return {label}; + if (status === "changed") return {label}; + if (status === "deleted") return {label}; + if (status === "new") return {label}; + return {label}; + }; + + + const handleSelectEntry = (idx: number) => { + if (idx < 0 || idx >= customEntries.length) return; + const nextEntries = mergeCurrentFormIntoEntries(customEntries); + setCustomEntries(nextEntries); + setSelectedIndex(idx); + form.setFieldsValue(toFormValues(nextEntries[idx])); + }; + + const handleDeleteEntry = () => { + const materializedEntries = mergeCurrentFormIntoEntries(customEntries); + setCustomEntries(materializedEntries); + const targetIndexes = hasSelection + ? selectedNonDeletedIndexes.filter((idx) => idx >= 0 && idx < materializedEntries.length) + : selectedIndex !== null + ? [selectedIndex] + : []; + if (targetIndexes.length === 0) return; + setDeletedModelKeys((prev) => { + const next = new Set(prev); + targetIndexes.forEach((idx) => { + next.add(toModelKey(materializedEntries[idx].model)); + }); + return next; + }); + }; + + const handleDeleteEntryByIndex = (idx: number) => { + if (idx < 0 || idx >= customEntries.length) return; + const materializedEntries = mergeCurrentFormIntoEntries(customEntries); + setCustomEntries(materializedEntries); + setDeletedModelKeys((prev) => { + const next = new Set(prev); + next.add(toModelKey(materializedEntries[idx].model)); + return next; + }); + }; + + const handleRestoreEntry = () => { + const targetIndexes = hasSelection + ? selectedDeletedIndexes + : selectedEntry && deletedModelKeys.has(toModelKey(selectedEntry.model)) + ? [selectedIndex as number] + : []; + if (targetIndexes.length === 0) return; + setDeletedModelKeys((prev) => { + const next = new Set(prev); + targetIndexes.forEach((idx) => { + const entry = effectiveEntries[idx]; + if (!entry) return; + next.delete(toModelKey(entry.model)); + }); + return next; + }); + }; + + const handleRestoreEntryByIndex = (idx: number) => { + if (idx < 0 || idx >= effectiveEntries.length) return; + const entry = effectiveEntries[idx]; + if (!entry) return; + setDeletedModelKeys((prev) => { + const next = new Set(prev); + next.delete(toModelKey(entry.model)); + return next; + }); + }; + + const handleDuplicateEntry = () => { + if (!selectedEntry) return; + const materializedEntries = mergeCurrentFormIntoEntries(customEntries); + const base = cloneEntry(materializedEntries[selectedIndex as number]); + base.model = buildSuggestedModel(materializedEntries); + if (base.title) { + base.title = `${base.title} Kopie`; + } + const nextEntries = [...materializedEntries, base]; + setCustomEntries(nextEntries); + setSelectedIndex(nextEntries.length - 1); + setSelectedRowIndexes([nextEntries.length - 1]); + form.setFieldsValue(toFormValues(base)); + }; + + const handleDuplicateEntryByIndex = (idx: number) => { + if (idx < 0 || idx >= customEntries.length) return; + const materializedEntries = mergeCurrentFormIntoEntries(customEntries); + const base = cloneEntry(materializedEntries[idx]); + base.model = buildSuggestedModel(materializedEntries); + if (base.title) { + base.title = `${base.title} Kopie`; + } + const nextEntries = [...materializedEntries, base]; + setCustomEntries(nextEntries); + setSelectedIndex(nextEntries.length - 1); + form.setFieldsValue(toFormValues(base)); + }; + + const handleDuplicateSelection = () => { + const indexes = selectedNonDeletedIndexes.filter((idx) => idx >= 0 && idx < customEntries.length); + if (indexes.length === 0) return; + + const materializedEntries = mergeCurrentFormIntoEntries(customEntries); + const nextEntries = materializedEntries.map((entry) => cloneEntry(entry)); + const appendedIndexes: number[] = []; + + indexes.forEach((idx) => { + const source = nextEntries[idx]; + if (!source) return; + const duplicated = cloneEntry(source); + duplicated.model = buildSuggestedModel(nextEntries); + if (duplicated.title) { + duplicated.title = `${duplicated.title} Kopie`; + } + nextEntries.push(duplicated); + appendedIndexes.push(nextEntries.length - 1); + }); + + if (appendedIndexes.length === 0) return; + setCustomEntries(nextEntries); + setSelectedRowIndexes(appendedIndexes); + setSelectedIndex(appendedIndexes[0]); + form.setFieldsValue(toFormValues(nextEntries[appendedIndexes[0]])); + }; + + const buildSavePlan = async (): Promise => { + const { next, activeEntry } = await listWithCurrentDraft(selectedIsDeleted); + const multiTargets = selectedNonDeletedIndexes.filter((idx) => idx >= 0 && idx < next.length); + const nextWithBatchApplied = next.map((entry) => cloneEntry(entry)); + // Always use the latest visible draft values as batch source. + const latestBatchSource = currentDraft; + if (multiTargets.length > 1 && selectedIndex !== null) { + multiTargets.forEach((idx) => { + if (idx === selectedIndex) return; + batchEditableFields.forEach((field) => { + (nextWithBatchApplied[idx] as any)[field] = (latestBatchSource as any)[field]; + }); + }); + } + const nextWithoutDeleted = nextWithBatchApplied.filter((entry) => !deletedModelKeys.has(toModelKey(entry.model))); + const validation = validateEntryList(nextWithoutDeleted); + if (validation.error) { + throw new Error(validation.error); + } + if (validation.baseWarning) { + setValidationMessages((prev) => Array.from(new Set([...prev, validation.baseWarning]))); + } + + if (nextWithoutDeleted.length === 0) { + throw new Error(t("tonies.customEditor.errors.emptyResult", { defaultValue: "At least one model must remain." })); + } + + const modelKey = (model: string) => model.trim().toLowerCase(); + const fingerprint = (entry: CustomEntry, withModel: boolean) => + JSON.stringify({ + ...entry, + model: withModel ? entry.model : "", + }); + + const oldEntries = persistedEntries.map((entry) => cloneEntry(entry)); + const oldByModel = new Map(oldEntries.map((entry) => [modelKey(entry.model), entry] as const)); + const newByModel = new Map(nextWithoutDeleted.map((entry) => [modelKey(entry.model), entry] as const)); + + const removedModels = oldEntries + .map((entry) => entry.model) + .filter((model) => !newByModel.has(modelKey(model))); + const addedEntries = nextWithoutDeleted.filter((entry) => !oldByModel.has(modelKey(entry.model))); + + const addedByFingerprint = new Map(); + addedEntries.forEach((entry) => { + const key = fingerprint(entry, false); + const list = addedByFingerprint.get(key) || []; + list.push(entry); + addedByFingerprint.set(key, list); + }); + + const renameOps: Array<{ fromModel: string; toModel: string }> = []; + const renamedSources = new Set(); + const renamedTargets = new Set(); + + removedModels.forEach((fromModel) => { + const oldEntry = oldByModel.get(modelKey(fromModel)); + if (!oldEntry) return; + const key = fingerprint(oldEntry, false); + const candidates = addedByFingerprint.get(key); + const candidate = candidates && candidates.length > 0 ? candidates.shift() : undefined; + if (!candidate) return; + + renameOps.push({ fromModel, toModel: candidate.model }); + renamedSources.add(modelKey(fromModel)); + renamedTargets.add(modelKey(candidate.model)); + }); + + const upsertEntries: CustomEntry[] = []; + nextWithoutDeleted.forEach((entry) => { + const key = modelKey(entry.model); + if (renamedTargets.has(key)) return; + + const oldEntry = oldByModel.get(key); + if (!oldEntry) { + upsertEntries.push(entry); + return; + } + if (fingerprint(oldEntry, true) !== fingerprint(entry, true)) { + upsertEntries.push(entry); + } + }); + + const deleteModels = removedModels.filter((model) => !renamedSources.has(modelKey(model))); + + return { + nextWithoutDeleted, + activeEntry, + renameOps, + upsertEntries, + deleteModels, + }; + }; + + const executeSavePlan = async (plan: SavePlan) => { + const postJson = async (path: string, payload: unknown) => { + const response = await api.apiPostTeddyCloudRaw( + path, + JSON.stringify(payload), + undefined, + undefined, + { "Content-Type": "application/json" } + ); + const responseText = await response.text(); + if (!response.ok) { + throw new Error(responseText || `HTTP ${response.status}`); + } + }; + + const { renameOps, upsertEntries, deleteModels, nextWithoutDeleted, activeEntry } = plan; + for (const rename of renameOps) { + await postJson("/api/toniesCustomJsonRename", rename); + } + if (upsertEntries.length > 0) { + await postJson("/api/toniesCustomJsonUpsert", upsertEntries); + } + if (deleteModels.length > 0) { + await postJson("/api/toniesCustomJsonDelete", { models: deleteModels }); + } + + setCustomEntries(nextWithoutDeleted); + setPersistedEntries(nextWithoutDeleted.map((entry) => cloneEntry(entry))); + setDeletedModelKeys(new Set()); + setSelectedRowIndexes((prev) => prev.filter((idx) => idx >= 0 && idx < nextWithoutDeleted.length)); + + const activeModelKey = toModelKey(activeEntry.model); + const activeStillExists = nextWithoutDeleted.some((entry) => toModelKey(entry.model) === activeModelKey); + const modelToSelect = activeStillExists ? activeEntry.model : nextWithoutDeleted[0]?.model || ""; + if (modelToSelect) { + setValue?.(modelToSelect); + if (props?.onChange) props.onChange(modelToSelect); + } + return { model: modelToSelect, count: nextWithoutDeleted.length }; + }; + + const handleDiscard = () => { + setCustomEntries(persistedEntries.map((entry) => cloneEntry(entry))); + setDeletedModelKeys(new Set()); + if (persistedEntries.length > 0) { + setSelectedIndex(0); + form.setFieldsValue(toFormValues(persistedEntries[0])); + return; + } + void createAndSelectNewEntry([]); + }; + + const handleSave = async () => { + setValidationMessages([]); + setSaving(true); + try { + const plan = await buildSavePlan(); + const operationCount = plan.renameOps.length + plan.upsertEntries.length + plan.deleteModels.length; + if (operationCount === 0) { + addNotification( + NotificationTypeEnum.Success, + t("tonies.customEditor.noChangesTitle", { defaultValue: "No changes detected" }), + t("tonies.customEditor.noChangesBody", { defaultValue: "There is nothing to save at the moment." }), + t("tonies.customToniesEditorJsonEntry") + ); + return; + } + setPendingSavePlan(plan); + setPreflightOpen(true); + } catch (error) { + addNotification( + NotificationTypeEnum.Error, + t("tonies.addNewCustomTonieModal.failedToCreate"), + String(error), + t("tonies.customToniesEditorJsonEntry") + ); + } finally { + setSaving(false); + } + }; + + const handlePreflightConfirm = async () => { + if (!pendingSavePlan) return; + setSaving(true); + try { + const result = await executeSavePlan(pendingSavePlan); + if (result.model) { + onModelCreated?.(result.model); + } + addNotification( + NotificationTypeEnum.Success, + t("tonies.addNewCustomTonieModal.successfullyCreated"), + t("tonies.customEditor.saveSuccessWithCount", { + defaultValue: "tonies.custom.json saved ({{count}} entries). Backup and reload triggered.", + count: result.count, + }), + t("tonies.customToniesEditorJsonEntry") + ); + setPreflightOpen(false); + setPendingSavePlan(null); + await loadJsonData(); + await collectImagePaths(); + } catch (error) { + addNotification( + NotificationTypeEnum.Error, + t("tonies.addNewCustomTonieModal.failedToCreate"), + String(error), + t("tonies.customToniesEditorJsonEntry") + ); + } finally { + setSaving(false); + } + }; + + const selectedPic = Form.useWatch("pic", form); + const disablePerFieldInMultiSelect = { + no: isMultiSelectMode, + model: isMultiSelectMode, + title: isMultiSelectMode, + episodes: isMultiSelectMode, + series: false, + release: false, + language: false, + category: false, + pic: false, + audioPairs: isMultiSelectMode, + tracks: isMultiSelectMode, + } as const; + + const tableColumns = useMemo( + () => [ + { + title: t("tonies.customEditor.columns.image", { defaultValue: "Image" }), + key: "pic", + width: 70, + fixed: "left" as const, + render: (_: unknown, row: TableRow) => { + const imageUrl = toPreviewableImageUrl(row.entry.pic); + if (!imageUrl) return "-"; + const isBroken = brokenImageUrls.has(imageUrl); + return ( + + ); + }, + }, + { + title: t("tonies.addNewCustomTonieModal.model"), + key: "model", + width: 220, + fixed: "left" as const, + sorter: true, + sortOrder: tableSortColumn === "model" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => ( + + {row.entry.model} + + ), + }, + { + title: t("tonies.addNewCustomTonieModal.series"), + key: "series", + width: 200, + ellipsis: true, + sorter: true, + sortOrder: tableSortColumn === "series" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.series || "-", + }, + ...(visibleOptionalColumns.includes("title") + ? [ + { + title: t("tonies.addNewCustomTonieModal.formfieldTitle"), + key: "title", + width: 220, + ellipsis: true, + sorter: true, + sortOrder: tableSortColumn === "title" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.title || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("episodes") + ? [ + { + title: t("tonies.addNewCustomTonieModal.episode"), + key: "episodes", + width: 160, + ellipsis: true, + sorter: true, + sortOrder: tableSortColumn === "episodes" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.episodes || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("release") + ? [ + { + title: t("tonies.addNewCustomTonieModal.release"), + key: "release", + width: 120, + sorter: true, + sortOrder: tableSortColumn === "release" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.release || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("language") + ? [ + { + title: t("tonies.addNewCustomTonieModal.language"), + key: "language", + width: 120, + sorter: true, + sortOrder: tableSortColumn === "language" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.language || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("category") + ? [ + { + title: t("tonies.addNewCustomTonieModal.category"), + key: "category", + width: 150, + sorter: true, + sortOrder: tableSortColumn === "category" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.category || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("no") + ? [ + { + title: t("tonies.addNewCustomTonieModal.no"), + key: "no", + width: 90, + sorter: true, + sortOrder: tableSortColumn === "no" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => row.entry.no || "-", + }, + ] + : []), + ...(visibleOptionalColumns.includes("status") + ? [ + { + title: t("tonies.customEditor.columns.status", { defaultValue: "Status" }), + key: "status", + width: 130, + sorter: true, + sortOrder: tableSortColumn === "status" ? tableSortOrder || undefined : undefined, + render: (_: unknown, row: TableRow) => getStatusBadge(row.status), + }, + ] : []), + { + title: t("tonies.customEditor.columns.actions", { defaultValue: "Actions" }), + key: "actions", + width: 130, + render: (_: unknown, row: TableRow) => ( + + + + + +
+
+ {t("tonies.customEditor.modelsTitle", { defaultValue: "Models" })} + + + + {t("tonies.customEditor.optionalColumns", { defaultValue: "Optional columns" })} + + setVisibleOptionalColumns(values.filter((value): value is OptionalColumnKey => typeof value === "string" && [ + "title", + "episodes", + "release", + "language", + "category", + "no", + "status", + ].includes(value))) + } + options={[ + { label: t("tonies.addNewCustomTonieModal.formfieldTitle"), value: "title" }, + { label: t("tonies.addNewCustomTonieModal.episode"), value: "episodes" }, + { label: t("tonies.addNewCustomTonieModal.release"), value: "release" }, + { label: t("tonies.addNewCustomTonieModal.language"), value: "language" }, + { label: t("tonies.addNewCustomTonieModal.category"), value: "category" }, + { label: t("tonies.addNewCustomTonieModal.no"), value: "no" }, + { label: t("tonies.customEditor.columns.status", { defaultValue: "Status" }), value: "status" }, + ]} + /> + + } + > + + + +
+ + + size="small" + pagination={false} + scroll={{ y: 320, x: "max-content" }} + rowKey={(row) => String(row.idx)} + rowSelection={{ + selectedRowKeys: selectedRowIndexes.map((idx) => String(idx)), + onChange: (keys) => applySelectionForVisibleRows(tableRows, keys), + }} + onChange={(_, __, sorter: any) => { + const singleSorter = Array.isArray(sorter) ? sorter[0] : sorter; + if (!singleSorter?.columnKey) return; + const key = String(singleSorter.columnKey) as SortColumnKey; + if ( + !["series", "model", "title", "episodes", "release", "language", "category", "no", "status"].includes( + key + ) + ) { + return; + } + setTableSortColumn(key); + setTableSortOrder(singleSorter.order || null); + }} + onRow={(row: TableRow) => ({ + onClick: () => handleSelectEntry(row.idx), + style: { cursor: "pointer" }, + })} + columns={tableColumns} + dataSource={tableRows} + /> +
+ + 1 + ? t("tonies.customEditor.selection.multiple", { + defaultValue: "{{count}} models selected", + count: selectedRowIndexes.length, + }) + : selectedEntry?.model + ? t("tonies.customEditor.selection.single", { + defaultValue: "Selected model: {{model}}", + model: selectedEntry.model, + }) + : t("tonies.customEditor.selection.none", { defaultValue: "No model selected" }) + } + description={t("tonies.customEditor.selection.description", { + defaultValue: "The edit form below always applies to the current selection.", + })} + /> + {isMultiSelectMode ? ( + + ) : null} + {validationMessages.length > 0 ? ( + + {validationMessages.map((issue) => ( + + - {issue} + + ))} + + + + + + + } + /> + ) : null} + + form={form} layout="vertical" style={{ marginTop: 12 }} disabled={selectedIsDeleted || onlyDeletedSelected}> + + + + + + + + + + + + + + + + + + + + + + + + {t("tonies.addNewCustomTonieModal.pic")} + + + + + } + name="pic" + > + + + + {imagePathOptions.map((path) => ( + + + + + + + + ))} + + + + )} + + + + + + {(fields, { add, remove }) => ( + <> +
+ {fields.map(({ key, name, ...restField }, idx) => ( + + + + + + + + + + + ))} + +
+ + )} +
+
+
+ + + + ); + + return ( + <> + {embedded ? ( +
+
+

{t("tonies.customToniesEditorJsonEntry")}

+ + + + + +
+ {editorBody} +
+ ) : ( + + + + + + } + destroyOnClose + > + {editorBody} + + )} + + setImageManagerOpen(false)} + initialSelection={form.getFieldValue("pic") || ""} + onSelectImage={(path) => { + form.setFieldValue("pic", path); + void collectImagePaths(); + }} + /> + + setPreviewOpen(false)} + footer={null} + > + {previewUrl ? preview : null} + + { + setPreflightOpen(false); + setPendingSavePlan(null); + }} + onOk={handlePreflightConfirm} + okText={t("tonies.customEditor.preflight.confirm", { defaultValue: "Save now" })} + cancelText={t("common.cancel", { defaultValue: "Cancel" })} + confirmLoading={saving} + > + + + {t("tonies.customEditor.preflight.description", { + defaultValue: "Please confirm the scope of this save operation.", + })} + + + {t("tonies.customEditor.preflight.upserts", { + defaultValue: "Updates/new entries: {{count}}", + count: pendingSavePlan?.upsertEntries.length || 0, + })} + + + {t("tonies.customEditor.preflight.renames", { + defaultValue: "Renames: {{count}}", + count: pendingSavePlan?.renameOps.length || 0, + })} + + + {t("tonies.customEditor.preflight.deletes", { + defaultValue: "Deletions: {{count}}", + count: pendingSavePlan?.deleteModels.length || 0, + })} + + + + + ); +}; + +export default ToniesCustomJsonEditor; diff --git a/src/components/tonies/ToniesSubNav.tsx b/src/components/tonies/ToniesSubNav.tsx index a5c605fc..5827a156 100644 --- a/src/components/tonies/ToniesSubNav.tsx +++ b/src/components/tonies/ToniesSubNav.tsx @@ -18,13 +18,13 @@ import i18n from "../../i18n"; import { useTeddyCloud } from "../../contexts/TeddyCloudContext"; import { StyledSubMenu } from "../common/StyledComponents"; -import ToniesCustomJsonEditor from "./ToniesCustomJsonEditor"; import { TeddyCloudSection } from "../../types/pluginsMetaTypes"; +import { useCustomModelsEditorLauncher } from "./hooks/useCustomModelsEditorFeature"; export const ToniesSubNav = () => { const { t } = useTranslation(); const { setNavOpen, setSubNavOpen, setCurrentTCSection, plugins } = useTeddyCloud(); - const [showAddCustomTonieModal, setShowAddCustomTonieModal] = useState(false); + const { launchCustomModelsEditor } = useCustomModelsEditorLauncher(); const [selectedKey, setSelectedKey] = useState(""); const currentLanguage = i18n.language; @@ -32,11 +32,6 @@ export const ToniesSubNav = () => { setCurrentTCSection(t("tonies.tonies.navigationTitle")); }, [currentLanguage]); - const handleAddNewCustomButtonClick = () => { - setShowAddCustomTonieModal(true); - setSelectedKey(""); - }; - const pluginItems = plugins .filter((p) => p.teddyCloudSection === TeddyCloudSection.Tonies) .map((plugin) => ({ @@ -148,6 +143,27 @@ export const ToniesSubNav = () => { icon: React.createElement(UnorderedListOutlined), title: t("tonies.tap.navigationTitle"), }, + { + key: "custom-json", + label: ( + { + setNavOpen(false); + setSubNavOpen(false); + }} + > + {t("tonies.customToniesEditorJsonEntry")} + + ), + onClick: () => { + launchCustomModelsEditor(); + setNavOpen(false); + setSubNavOpen(false); + }, + icon: React.createElement(UserAddOutlined), + title: t("tonies.customToniesEditorJsonEntry"), + }, { key: "teddystudio", label: ( @@ -196,41 +212,9 @@ export const ToniesSubNav = () => { icon: React.createElement(SettingOutlined), title: t("tonies.system-sounds.navigationTitle"), }, - { - key: "custom-json", - label: ( - - ), - onClick: () => { - handleAddNewCustomButtonClick(); - setNavOpen(false); - setSubNavOpen(false); - }, - icon: React.createElement(UserAddOutlined), - title: t("tonies.addToniesCustomJsonEntry"), - }, ...pluginItems, ]; - return ( - <> - - {showAddCustomTonieModal && ( - setShowAddCustomTonieModal(false)} - /> - )} - - ); + return ; }; diff --git a/src/components/tonies/common/hooks/useCreateDirectory.ts b/src/components/tonies/common/hooks/useCreateDirectory.ts index 61d63465..f48832e8 100644 --- a/src/components/tonies/common/hooks/useCreateDirectory.ts +++ b/src/components/tonies/common/hooks/useCreateDirectory.ts @@ -16,6 +16,7 @@ export interface UseDirectoryCreateOptions { directoryTree: DirectoryTreeApi; selectNewNode: boolean; setRebuildList?: React.Dispatch>; + special?: string; } export interface UseDirectoryCreateResult { @@ -41,6 +42,7 @@ export const useDirectoryCreate = ({ directoryTree, selectNewNode, setRebuildList, + special = "library", }: UseDirectoryCreateOptions): UseDirectoryCreateResult => { const { t } = useTranslation(); const { addNotification } = useTeddyCloud(); @@ -100,7 +102,7 @@ export const useDirectoryCreate = ({ const dirFullPath = `${decodeURIComponent(createDirectoryPath)}/${inputValueCreateDirectory}`; try { - api.apiPostTeddyCloudRaw(`/api/dirCreate?special=library`, dirFullPath) + api.apiPostTeddyCloudRaw(`/api/dirCreate?special=${encodeURIComponent(special)}`, dirFullPath) .then((response) => response.text()) .then((text) => { if (text !== "OK") { diff --git a/src/components/tonies/common/hooks/useDirectoryTree.ts b/src/components/tonies/common/hooks/useDirectoryTree.ts index e46ca6af..3390676e 100644 --- a/src/components/tonies/common/hooks/useDirectoryTree.ts +++ b/src/components/tonies/common/hooks/useDirectoryTree.ts @@ -47,7 +47,8 @@ export interface DirectoryTreeApi { onLoadTreeData: (params: { id: string }) => Promise; } -export const useDirectoryTree = (): DirectoryTreeApi => { +export const useDirectoryTree = (special = "library", options?: { skipPreload?: boolean }): DirectoryTreeApi => { + const skipPreload = options?.skipPreload ?? false; const [treeNodeId, setTreeNodeId] = useState(rootTreeNode.id); const [treeData, setTreeData] = useState([rootTreeNode]); const [expandedKeys, setExpandedKeys] = useState([]); @@ -108,8 +109,10 @@ export const useDirectoryTree = (): DirectoryTreeApi => { return Array.from(map.values()); }; - // ---- preload root children ---- + // ---- preload root children (skip when e.g. Image Manager - tree loads on demand when Move modal opens) ---- useEffect(() => { + if (skipPreload) return; + let cancelled = false; const preLoadTreeData = async () => { @@ -117,7 +120,7 @@ export const useDirectoryTree = (): DirectoryTreeApi => { const newPath = getPathFromNodeId(rootTreeNode.id); // usually "" const response = await api.apiGetTeddyCloudApiRaw( - `/api/fileIndexV2?path=${encodeURIComponent(newPath)}&special=library` + `/api/fileIndexV2?path=${encodeURIComponent(newPath)}&special=${encodeURIComponent(special)}` ); const data = await response.json(); @@ -152,7 +155,7 @@ export const useDirectoryTree = (): DirectoryTreeApi => { return () => { cancelled = true; }; - }, [getPathFromNodeId]); + }, [getPathFromNodeId, special, skipPreload]); // ---- lazy loading ---- const onLoadTreeData = useCallback( @@ -165,7 +168,7 @@ export const useDirectoryTree = (): DirectoryTreeApi => { const newPath = getPathFromNodeId(parentId); const response = await api.apiGetTeddyCloudApiRaw( - `/api/fileIndexV2?path=${encodeURIComponent(newPath)}&special=library` + `/api/fileIndexV2?path=${encodeURIComponent(newPath)}&special=${encodeURIComponent(special)}` ); const data = await response.json(); @@ -197,7 +200,7 @@ export const useDirectoryTree = (): DirectoryTreeApi => { } }); }, - [getPathFromNodeId] + [getPathFromNodeId, special] ); // ---- commands ---- diff --git a/src/components/tonies/common/searchs/ToniesJsonSearch.tsx b/src/components/tonies/common/searchs/ToniesJsonSearch.tsx index 93121eac..c601faf0 100644 --- a/src/components/tonies/common/searchs/ToniesJsonSearch.tsx +++ b/src/components/tonies/common/searchs/ToniesJsonSearch.tsx @@ -6,9 +6,9 @@ import { useDebouncedCallback } from "../hooks/useDebouncedCallback"; import { useToniesJsonSearch } from "../hooks/useToniesJsonSearch"; import { useTeddyCloud } from "../../../../contexts/TeddyCloudContext"; import { NotificationTypeEnum } from "../../../../types/teddyCloudNotificationTypes"; -import ToniesCustomJsonEditor from "../../ToniesCustomJsonEditor"; import { SearchDropdownOption, SearchDropdown } from "../../../common/elements/SearchDropdown"; import { canHover } from "../../../../utils/browser/browserUtils"; +import { useCustomModelsEditorLauncher } from "../../hooks/useCustomModelsEditorFeature"; export interface ToniesJsonSearchResult { value: string; @@ -25,6 +25,7 @@ interface ToniesJsonSearchProps { placeholder: string; showAddCustomTonieButton?: boolean; clearInputAfterSelection?: boolean; + onOpenCustomModelEditor?: () => void; onChange: (newValue: string) => void; @@ -35,13 +36,13 @@ export const ToniesJsonSearch: React.FC = ({ placeholder, showAddCustomTonieButton = true, clearInputAfterSelection = true, + onOpenCustomModelEditor, onChange, onSelectResult, }) => { const { t } = useTranslation(); const { addNotification } = useTeddyCloud(); - - const [showAddCustomTonieModal, setShowAddCustomTonieModal] = useState(false); + const { launchCustomModelsEditor } = useCustomModelsEditorLauncher(); const { value, options, search, select, setValue } = useToniesJsonSearch((error) => { addNotification( @@ -104,10 +105,6 @@ export const ToniesJsonSearch: React.FC = ({ } }; - const handleAddNewCustomButtonClick = () => { - setShowAddCustomTonieModal(true); - }; - return ( <> = ({ /> {showAddCustomTonieButton && ( - <> - { - setValue(v); - if (clearInputAfterSelection) { - setSearchText(""); - } else { - setSearchText(v); + + - - + style={{ marginTop: 8 }} + > + {t("tonies.addNewCustomTonie")} + + )} ); diff --git a/src/components/tonies/filebrowser/FileBrowser.tsx b/src/components/tonies/filebrowser/FileBrowser.tsx index c27eafdd..56b80ad9 100644 --- a/src/components/tonies/filebrowser/FileBrowser.tsx +++ b/src/components/tonies/filebrowser/FileBrowser.tsx @@ -107,7 +107,7 @@ export const FileBrowser: React.FC<{ const [downloading, setDownloading] = useState<{ [key: string]: boolean }>({}); - const directoryTree = useDirectoryTree(); + const directoryTree = useDirectoryTree(special); const currentPath = new URLSearchParams(location.search).get("path") || ""; @@ -160,6 +160,7 @@ export const FileBrowser: React.FC<{ directoryTree, selectNewNode: true, setRebuildList, + special, }); const { isTapEditorModalOpen, initialValuesPath, openCreateTap, openEditTap, closeTapEditor, onTapCreateOrSave } = @@ -513,13 +514,12 @@ export const FileBrowser: React.FC<{ minHeight: 32, }} > - {special === "library" ? ( + {special === "library" || special === "custom_img" ? (
{selectedRowKeys.length > 0 ? ( <> - {special === "library" && - files.filter((item) => selectedRowKeys.includes(item.name) && !item.isDir) + {files.filter((item) => selectedRowKeys.includes(item.name) && !item.isDir) .length > 0 ? ( void; + onUploadedFiles?: (files: string[], path: string, special: string) => void; }> = ({ special, initialPath = "", @@ -36,7 +47,9 @@ export const SelectFileFileBrowser: React.FC<{ trackUrl = true, showDirOnly = false, showColumns = undefined, + enableFileManagement = false, onFileSelectChange, + onUploadedFiles, }) => { const { t } = useTranslation(); const { playAudio } = useAudioContext(); @@ -48,6 +61,20 @@ export const SelectFileFileBrowser: React.FC<{ const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [isInformationModalOpen, setIsInformationModalOpen] = useState(false); + + const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] = useState(false); + const [isConfirmMultipleDeleteModalOpen, setIsConfirmMultipleDeleteModalOpen] = useState(false); + const [fileToDelete, setFileToDelete] = useState(null); + const [deletePath, setDeletePath] = useState(""); + const [deleteApiCall, setDeleteApiCall] = useState(""); + const [isMoveFileModalOpen, setIsMoveFileModalOpen] = useState(false); + const [isRenameFileModalOpen, setIsRenameFileModalOpen] = useState(false); + const [currentFile, setCurrentFile] = useState(""); + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [uploadFileList, setUploadFileList] = useState([]); + const [downloading, setDownloading] = useState<{ [key: string]: boolean }>({}); + const [imagePreviewOpen, setImagePreviewOpen] = useState(false); + const [imagePreviewUrl, setImagePreviewUrl] = useState(""); const [currentRecord, setCurrentRecord] = useState(); const [currentAudioUrl, setCurrentAudioUrl] = useState(""); @@ -56,6 +83,7 @@ export const SelectFileFileBrowser: React.FC<{ setPath, files, rebuildList, + setRebuildList, loading, filterText, filterFieldAutoFocus, @@ -81,6 +109,33 @@ export const SelectFileFileBrowser: React.FC<{ initialPath, }); + const directoryTree = useDirectoryTree(special, { + skipPreload: special === "custom_img", + }); + + const { + open: isCreateDirectoryModalOpen, + createDirectoryPath, + createDirectoryInputKey, + hasNewDirectoryInvalidChars, + isCreateDirectoryButtonDisabled, + inputCreateDirectoryRef, + openCreateDirectoryModal, + closeCreateDirectoryModal, + handleCreateDirectoryInputChange, + createDirectory, + } = useDirectoryCreate({ + path, + directoryTree, + selectNewNode: true, + setRebuildList: enableFileManagement ? setRebuildList : undefined, + special, + }); + + const { handleFileDownload } = useFileDownload({ + setDownloading: enableFileManagement ? setDownloading : () => {}, + }); + useEffect(() => { setSelectedRowKeys([]); }, [rebuildList]); @@ -147,9 +202,44 @@ export const SelectFileFileBrowser: React.FC<{ setPath(newPath); }; + const showDeleteConfirmDialog = (fileName: string, pathWithFile: string, apiCall: string) => { + setFileToDelete(fileName); + setDeletePath(decodeURIComponent(pathWithFile)); + setDeleteApiCall(apiCall); + setIsConfirmDeleteModalOpen(true); + }; + + const showMoveDialog = (fileName: string) => { + directoryTree.setTreeNodeId(directoryTree.rootTreeNode.id); + setCurrentFile(fileName || ""); + setIsMoveFileModalOpen(true); + }; + + const showRenameDialog = (fileName: string) => { + setCurrentFile(fileName); + setIsRenameFileModalOpen(true); + }; + + const closeMoveFileModal = () => { + setIsMoveFileModalOpen(false); + directoryTree.setTreeNodeId(directoryTree.rootTreeNode.id); + }; + + const closeRenameFileModal = () => { + setIsRenameFileModalOpen(false); + }; + + const handleMultipleDelete = () => { + setIsConfirmMultipleDeleteModalOpen(true); + }; + + const imageFilesSelected = enableFileManagement + ? files.filter((item) => selectedRowKeys.includes(item.name) && !item.isDir).length + : 0; + // columns const columns = createColumns({ - mode: "select", + mode: enableFileManagement ? "full" : "select", path, special, overlay, @@ -158,13 +248,113 @@ export const SelectFileFileBrowser: React.FC<{ showColumns, defaultSorter, dirNameSorter, + downloading: enableFileManagement ? downloading : undefined, handleDirClick, showInformationModal, playAudio, + handleFileDownload: enableFileManagement ? handleFileDownload : undefined, + showRenameDialog: enableFileManagement ? showRenameDialog : undefined, + showMoveDialog: enableFileManagement ? showMoveDialog : undefined, + showDeleteConfirmDialog: enableFileManagement ? showDeleteConfirmDialog : undefined, + buildContentUrl: special === "custom_img" ? buildContentUrl : undefined, + onImagePreviewClick: + special === "custom_img" + ? (url) => { + setImagePreviewUrl(url); + setImagePreviewOpen(true); + } + : undefined, }); return ( <> + {enableFileManagement && ( + <> + setIsConfirmDeleteModalOpen(false)} + multipleOpen={isConfirmMultipleDeleteModalOpen} + onCloseMultiple={() => setIsConfirmMultipleDeleteModalOpen(false)} + /> + {isCreateDirectoryModalOpen && ( + + )} + setIsUploadModalOpen(false)} + path={path} + special={special} + uploadFileList={uploadFileList} + setUploadFileList={setUploadFileList} + setRebuildList={setRebuildList} + onUploadedFiles={onUploadedFiles} + /> + {isMoveFileModalOpen && ( + {}} + setRebuildList={setRebuildList} + /> + )} + {isRenameFileModalOpen && ( + + )} + + )} + {special === "custom_img" && ( + setImagePreviewOpen(false)} + footer={null} + > + {imagePreviewUrl ? ( + preview + ) : null} + + )} {currentRecord && isInformationModalOpen && ( {t("tonies.currentPath")}
{generateBreadcrumbs(path)}
+ {enableFileManagement && ( +
+ {selectedRowKeys.length > 0 && ( + <> + {imageFilesSelected > 0 && ( + + + + )} + + + + + )} + + +
+ )}
{loading ? : ""} @@ -186,6 +428,7 @@ export const SelectFileFileBrowser: React.FC<{ columns={columns} rowKey={(record) => record.name} pagination={false} + scroll={enableFileManagement ? { x: "max-content" } : undefined} onRow={(record) => ({ onDoubleClick: () => { if (record.isDir) { diff --git a/src/components/tonies/filebrowser/helper/Columns.tsx b/src/components/tonies/filebrowser/helper/Columns.tsx index 3602c5df..ce9d150f 100644 --- a/src/components/tonies/filebrowser/helper/Columns.tsx +++ b/src/components/tonies/filebrowser/helper/Columns.tsx @@ -15,6 +15,7 @@ import { LoadingOutlined, } from "@ant-design/icons"; +import { IMAGE_EXTENSIONS } from "../../../../constants/fileTypes"; import { Record } from "../../../../types/fileBrowserTypes"; import { humanFileSize } from "../../../../utils/files/humanFileSize"; import { ffmpegSupportedExtensions } from "../../../../utils/files/ffmpegSupportedExtensions"; @@ -58,8 +59,13 @@ export interface CreateColumnsOptions { showRenameDialog?: (fileName: string) => void; showMoveDialog?: (fileName: string) => void; showDeleteConfirmDialog?: (fileName: string, fullPath: string, query: string) => void; + buildContentUrl?: (fileName: string, options?: { ogg?: boolean }) => string; + onImagePreviewClick?: (imageUrl: string) => void; } +const isImageFileName = (name: string) => + IMAGE_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext)); + export const createColumns = (options: CreateColumnsOptions): any[] => { const { t } = useTranslation(); const { token } = useToken(); @@ -85,45 +91,68 @@ export const createColumns = (options: CreateColumnsOptions): any[] => { showRenameDialog, showMoveDialog, showDeleteConfirmDialog, + buildContentUrl, + onImagePreviewClick, } = options; + const baseApiUrl = (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APP_TEDDYCLOUD_API_URL) || ""; + + const getPictureSrc = (record: any): string | null => { + if (!record) return null; + if (record.tonieInfo?.picture) return record.tonieInfo.picture; + if (special === "custom_img" && !record.isDir && buildContentUrl && isImageFileName(record.name)) { + const path = buildContentUrl(record.name); + return baseApiUrl ? `${baseApiUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}` : path; + } + return null; + }; + let columns: any[] = [ { - title: mode === "full" ?
: "", + title: mode === "full" ? t("fileBrowser.image", { defaultValue: "Bild" }) : "", dataIndex: ["tonieInfo", "picture"], key: "picture", sorter: undefined, - width: 10, - render: (picture: string, record: any) => ( - <> - {record && record.tonieInfo?.picture ? ( - {t("tonies.content.toniePicture")} showInformationModal(record)} - style={{ - height: 40, - width: 40, - objectFit: "contain", - cursor: "pointer", - marginRight: 8, - }} - /> - ) : ( - <> - )} - {mode === "full" && record.hide ? ( -
- - {t("fileBrowser.hidden")} - -
- ) : ( - "" - )} - - ), + width: 56, + render: (picture: string, record: any) => { + const src = getPictureSrc(record); + return ( + <> + {record && src ? ( + {t("tonies.content.toniePicture")} { + if (record.tonieInfo) { + showInformationModal(record); + } else if (onImagePreviewClick) { + onImagePreviewClick(src); + } + }} + style={{ + height: 40, + width: 40, + objectFit: "contain", + cursor: record.tonieInfo || onImagePreviewClick ? "pointer" : "default", + marginRight: 8, + }} + /> + ) : record?.isDir ? ( + + ) : null} + {mode === "full" && record?.hide ? ( +
+ + {t("fileBrowser.hidden")} + +
+ ) : null} + + ); + }, showOnDirOnly: false, }, @@ -278,6 +307,7 @@ export const createColumns = (options: CreateColumnsOptions): any[] => { dataIndex: "controls", key: "controls", sorter: undefined, + width: 100, render: (name: string, record: any) => { const actions: React.ReactNode[] = []; @@ -422,7 +452,7 @@ export const createColumns = (options: CreateColumnsOptions): any[] => { ); } - if (special === "library" && record.name !== "..") { + if ((special === "library" || special === "custom_img") && record.name !== "..") { if (!record.isDir && showRenameDialog) { actions.push( { } } - return actions; + return
{actions}
; }, showOnDirOnly: false, }; diff --git a/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx b/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx index d69b482e..632354d8 100644 --- a/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx +++ b/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx @@ -129,11 +129,31 @@ export const useFileBrowserCore = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [overlay]); - // fetch directory listing + // fetch directory listing (SWR for custom_img: show cache first, revalidate in background) useEffect(() => { - setLoading(true); - const apiPathParam = mode === "fileBrowser" ? path : encodeURIComponent(path); + const cacheKey = `fileIndexV2:${special}:${overlay || ""}:${apiPathParam}:${showDirOnly}:${filetypeFilter.join(",")}`; + const useSwr = special === "custom_img"; + + let hadCache = false; + if (useSwr) { + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const parsed = JSON.parse(cached) as Record[]; + if (Array.isArray(parsed)) { + setFiles(parsed); + setLoading(false); + hadCache = true; + } + } + } catch { + /* ignore parse errors */ + } + } + if (!hadCache) { + setLoading(true); + } api.apiGetTeddyCloudApiRaw( `/api/fileIndexV2?path=${apiPathParam}&special=${special}` + (overlay ? `&overlay=${overlay}` : "") @@ -163,6 +183,14 @@ export const useFileBrowserCore = ({ }); setFiles(filteredList); + + if (useSwr) { + try { + sessionStorage.setItem(cacheKey, JSON.stringify(filteredList)); + } catch { + /* ignore quota errors */ + } + } }) .catch((error: any) => { // If the requested path is invalid/unavailable -> go back to root @@ -285,6 +313,9 @@ export const useFileBrowserCore = ({ }; const defaultSorter = (a: any, b: any, dataIndex: string | string[]) => { + if (a?.name === "..") return -1; + if (b?.name === "..") return 1; + const fieldA = Array.isArray(dataIndex) ? getFieldValue(a, dataIndex) : a[dataIndex]; const fieldB = Array.isArray(dataIndex) ? getFieldValue(b, dataIndex) : b[dataIndex]; @@ -301,6 +332,8 @@ export const useFileBrowserCore = ({ }; const dirNameSorter = (a: any, b: any) => { + if (a?.name === "..") return -1; + if (b?.name === "..") return 1; if (a.isDir === b.isDir) return defaultSorter(a, b, "name"); return a.isDir ? -1 : 1; }; diff --git a/src/components/tonies/filebrowser/modals/ImageManagerModal.tsx b/src/components/tonies/filebrowser/modals/ImageManagerModal.tsx new file mode 100644 index 00000000..5e2f97ed --- /dev/null +++ b/src/components/tonies/filebrowser/modals/ImageManagerModal.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Empty, List, Modal, Segmented, Space } from "antd"; +import { useTranslation } from "react-i18next"; + +import { TeddyCloudApi } from "../../../../api"; +import { defaultAPIConfig } from "../../../../config/defaultApiConfig"; +import { IMAGE_EXTENSIONS } from "../../../../constants/fileTypes"; +import { SelectFileFileBrowser } from "../SelectFileFileBrowser"; + +const api = new TeddyCloudApi(defaultAPIConfig()); + +type ImageSource = "custom" | "original"; + +const normalizeDirPath = (value: string) => value.replace(/^\/+/, "").replace(/\/+$/, ""); + +const deriveCustomImgDirectory = (pic?: string): string => { + if (!pic || !pic.startsWith("/custom_img/")) return ""; + const normalized = pic.slice("/custom_img/".length); + const segments = normalized.split("/").filter(Boolean); + if (segments.length <= 1) return ""; + return segments.slice(0, -1).join("/"); +}; + +const toCustomImgWebPath = (path: string, fileName: string) => { + const normalizedPath = normalizeDirPath(path); + return normalizedPath ? `/custom_img/${normalizedPath}/${fileName}` : `/custom_img/${fileName}`; +}; + +const isCustomImagePath = (path?: string) => !!path && path.startsWith("/custom_img/"); + +const normalizePreviewPath = (value?: string) => { + const raw = (value || "").trim(); + if (!raw) return ""; + if (/^(https?:\/\/|data:|blob:)/i.test(raw)) return raw; + if (raw.startsWith("/")) return raw; + if (raw.startsWith("custom_img/")) return `/${raw}`; + if (raw.startsWith("img/")) return `/${raw}`; + return raw; +}; + +interface ImageManagerModalProps { + open: boolean; + onClose: () => void; + onSelectImage: (path: string) => void; + initialSelection?: string; + title?: string; +} + +export const ImageManagerModal: React.FC = ({ + open, + onClose, + onSelectImage, + initialSelection = "", + title: titleProp, +}) => { + const { t } = useTranslation(); + const title = titleProp ?? t("tonies.imageManager.title"); + const [source, setSource] = useState("custom"); + const [customPath, setCustomPath] = useState(""); + const [customSelection, setCustomSelection] = useState(""); + const [originalSelection, setOriginalSelection] = useState(""); + const [originalImages, setOriginalImages] = useState([]); + + useEffect(() => { + if (!open) return; + const initialIsCustom = isCustomImagePath(initialSelection); + const initialIsOriginal = initialSelection && !initialIsCustom; + setSource(initialIsCustom ? "custom" : initialIsOriginal ? "original" : "custom"); + setCustomSelection(initialIsCustom ? initialSelection : ""); + setOriginalSelection(initialIsOriginal ? initialSelection : ""); + setCustomPath(initialIsCustom ? deriveCustomImgDirectory(initialSelection) : ""); + }, [initialSelection, open]); + + useEffect(() => { + if (!open || source !== "original") return; + const loadOriginalImages = async () => { + try { + const response = await api.apiGetTeddyCloudApiRaw("/api/toniesJson"); + if (!response.ok) return; + const data = await response.json(); + const normalized = Array.isArray(data) ? data : []; + const pics = normalized + .flatMap((entry: any) => [ + typeof entry?.pic === "string" ? entry.pic : "", + typeof entry?.picture === "string" ? entry.picture : "", + typeof entry?.tonieInfo?.picture === "string" ? entry.tonieInfo.picture : "", + typeof entry?.sourceInfo?.picture === "string" ? entry.sourceInfo.picture : "", + ]) + .map((pic: string) => normalizePreviewPath(pic)) + .filter((pic: string) => pic.length > 0); + setOriginalImages(Array.from(new Set(pics)).sort((a, b) => a.localeCompare(b))); + } catch { + setOriginalImages([]); + } + }; + void loadOriginalImages(); + }, [open, source]); + + const selectedImage = source === "custom" ? customSelection : originalSelection; + + const canConfirm = useMemo(() => selectedImage.trim().length > 0, [selectedImage]); + + return ( + <> + { + if (!canConfirm) return; + onSelectImage(selectedImage); + onClose(); + }} + okButtonProps={{ disabled: !canConfirm }} + okText={t("tonies.imageManager.okText")} + > + + + value={source} + options={[ + { label: t("tonies.imageManager.sourceCustom"), value: "custom" }, + { label: t("tonies.imageManager.sourceOriginal"), value: "original" }, + ]} + onChange={(value) => setSource(value)} + /> + + +
+ { + setCustomPath(path); + if (files.length === 0) return; + setCustomSelection(toCustomImgWebPath(path, files[0].name)); + }} + onUploadedFiles={(files, path) => { + if (files.length > 0) { + setSource("custom"); + setCustomSelection(toCustomImgWebPath(path, files[0])); + } + }} + /> +
+
+ {originalImages.length === 0 ? ( + + ) : ( + ( + setOriginalSelection(item)} + style={{ + cursor: "pointer", + padding: 8, + border: + originalSelection === item ? "1px solid #1677ff" : "1px solid transparent", + borderRadius: 8, + margin: 4, + }} + > + + default + {item} + + + )} + /> + )} +
+
+ + ); +}; + +export default ImageManagerModal; diff --git a/src/components/tonies/filebrowser/modals/UploadFilesModal.tsx b/src/components/tonies/filebrowser/modals/UploadFilesModal.tsx index 103c58ae..c3de40d8 100644 --- a/src/components/tonies/filebrowser/modals/UploadFilesModal.tsx +++ b/src/components/tonies/filebrowser/modals/UploadFilesModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Modal, Upload, Button, theme } from "antd"; import type { UploadFile, UploadProps } from "antd"; import { InboxOutlined } from "@ant-design/icons"; @@ -6,12 +6,29 @@ import { useTranslation } from "react-i18next"; import { TeddyCloudApi } from "../../../../api"; import { defaultAPIConfig } from "../../../../config/defaultApiConfig"; +import { UPLOAD_TIMEOUT_MS } from "../../../../constants/numbers"; import { useTeddyCloud } from "../../../../contexts/TeddyCloudContext"; import { NotificationTypeEnum } from "../../../../types/teddyCloudNotificationTypes"; const api = new TeddyCloudApi(defaultAPIConfig()); const { useToken } = theme; +const normalizePathForQuery = (inputPath: string) => { + const raw = (inputPath || "").trim(); + if (!raw) return ""; + return raw + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => { + try { + return encodeURIComponent(decodeURIComponent(segment)); + } catch { + return encodeURIComponent(segment); + } + }) + .join("/"); +}; + interface UploadFilesModalProps { open: boolean; onClose: () => void; @@ -23,6 +40,7 @@ interface UploadFilesModalProps { setUploadFileList: React.Dispatch[]>>; setRebuildList: React.Dispatch>; + onUploadedFiles?: (files: string[], path: string, special: string) => void; } const UploadFilesModal: React.FC = ({ @@ -34,6 +52,7 @@ const UploadFilesModal: React.FC = ({ setUploadFileList, setRebuildList, + onUploadedFiles, }) => { const { t } = useTranslation(); const { token } = useToken(); @@ -41,6 +60,13 @@ const UploadFilesModal: React.FC = ({ const [uploading, setUploading] = useState(false); + useEffect(() => { + // Ensure stale loading state is cleared when modal closes/reopens. + if (!open) { + setUploading(false); + } + }, [open]); + const uploadDraggerProps: UploadProps = { name: "file", multiple: true, @@ -70,7 +96,10 @@ const UploadFilesModal: React.FC = ({ setUploading(true); let failure = false; + const uploadedFileNames: string[] = []; const key = "uploading-" + files.length + "-" + new Date(); + const encodedPath = normalizePathForQuery(path); + const encodedSpecial = encodeURIComponent(special); for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -81,16 +110,27 @@ const UploadFilesModal: React.FC = ({ ); const formData = new FormData(); - // antd UploadFile has originFileObj - formData.append(file.name as string, file.originFileObj as Blob); + const originalBlob = file.originFileObj as Blob | undefined; + if (!originalBlob) { + failure = true; + setUploadFileList((prevList) => + prevList.map((f) => (f.uid === file.uid ? { ...f, status: "error" } : f)) + ); + continue; + } + // Keep multipart field name stable; send original filename separately. + formData.append("file", originalBlob, file.name); try { - const response = await api.apiPostTeddyCloudFormDataRaw( - `/api/fileUpload?path=${path}&special=${special}`, - formData - ); + const response = await Promise.race([ + api.apiPostTeddyCloudFormDataRaw(`/api/fileUpload?path=${encodedPath}&special=${encodedSpecial}`, formData), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Upload timeout after ${UPLOAD_TIMEOUT_MS}ms`)), UPLOAD_TIMEOUT_MS) + ), + ]); if (response.ok) { setUploadFileList((prevList) => prevList.filter((f) => f.uid !== file.uid)); + uploadedFileNames.push(file.name as string); addNotification( NotificationTypeEnum.Success, t("fileBrowser.upload.uploadedFile"), @@ -111,10 +151,11 @@ const UploadFilesModal: React.FC = ({ } } catch (err) { failure = true; + const errorMessage = err instanceof Error ? err.message : String(err); addNotification( NotificationTypeEnum.Error, t("fileBrowser.upload.uploadedFileFailed"), - t("fileBrowser.upload.uploadFailedForFile", { file: file.name }), + `${t("fileBrowser.upload.uploadFailedForFile", { file: file.name })} (${errorMessage})`, t("fileBrowser.title") ); setUploadFileList((prevList) => @@ -123,9 +164,12 @@ const UploadFilesModal: React.FC = ({ } } - closeLoadingNotification(key); + await closeLoadingNotification(key); setRebuildList((prev) => !prev); + if (uploadedFileNames.length > 0 && onUploadedFiles) { + onUploadedFiles(uploadedFileNames, path, special); + } if (failure) { addNotification( @@ -158,7 +202,14 @@ const UploadFilesModal: React.FC = ({ background: token.colorBgElevated, }} > - + - +
+ + + + +
{showHint && (
(""); + const [customModelEditorStartCreate, setCustomModelEditorStartCreate] = useState(false); // ------------------------ // Form / Input State @@ -453,6 +457,29 @@ export const TonieCard: React.FC<{ setIsEditModalOpen(true); }; + const openCreateModelEditor = () => { + setCustomModelEditorStartCreate(true); + setCustomModelEditorInitialModel(""); + setIsCustomModelEditorOpen(true); + }; + + const openSelectedModelEditor = () => { + if (!selectedModel.trim()) return; + setCustomModelEditorStartCreate(false); + setCustomModelEditorInitialModel(selectedModel.trim()); + setIsCustomModelEditorOpen(true); + }; + + const handleCustomModelCreated = (model: string) => { + const trimmed = model.trim(); + if (!trimmed) return; + setSelectedModel(trimmed); + setInputValidationModel({ validateStatus: "", help: "" }); + setCustomModelEditorInitialModel(trimmed); + setCustomModelEditorStartCreate(false); + setIsCustomModelEditorOpen(false); + }; + const editModalTitle = ( <>

@@ -701,6 +728,17 @@ export const TonieCard: React.FC<{ onSearchModelChange={searchModelResultChanged} hasPendingChanges={hasPendingChanges} onOpenFileSelectModal={showFileSelectModal} + onOpenCreateModelEditor={openCreateModelEditor} + onOpenSelectedModelEditor={openSelectedModelEditor} + canOpenSelectedModelEditor={selectedModel.trim().toLowerCase().startsWith("custom-")} + /> + setIsCustomModelEditorOpen(false)} + tonieCardProps={tonieCard} + startInCreateMode={customModelEditorStartCreate} + initialSelectedModel={customModelEditorInitialModel} + onModelCreated={handleCustomModelCreated} /> ); diff --git a/src/components/tonies/toniecard/modals/EditTonieModal.tsx b/src/components/tonies/toniecard/modals/EditTonieModal.tsx index f017c38b..316f6519 100644 --- a/src/components/tonies/toniecard/modals/EditTonieModal.tsx +++ b/src/components/tonies/toniecard/modals/EditTonieModal.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Button, Divider, Form, Input, Modal, theme } from "antd"; -import { CloseOutlined, FolderOpenOutlined, RollbackOutlined, SaveFilled } from "@ant-design/icons"; +import { Button, Divider, Form, Input, Modal, Space, Tooltip, theme } from "antd"; +import { CloseOutlined, EditOutlined, FolderOpenOutlined, PlusOutlined, RollbackOutlined, SaveFilled } from "@ant-design/icons"; import { ToniesJsonSearch } from "../../common/searchs/ToniesJsonSearch"; import { RadioStreamSearch } from "../search/RadioStreamSearch"; @@ -44,6 +44,9 @@ interface EditTonieModalProps { // File selection onOpenFileSelectModal: () => void; + onOpenCreateModelEditor?: () => void; + onOpenSelectedModelEditor?: () => void; + canOpenSelectedModelEditor?: boolean; } export const EditTonieModal: React.FC = ({ @@ -67,6 +70,9 @@ export const EditTonieModal: React.FC = ({ onSearchModelChange, hasPendingChanges, onOpenFileSelectModal, + onOpenCreateModelEditor, + onOpenSelectedModelEditor, + canOpenSelectedModelEditor = false, }) => { const { t } = useTranslation(); const { token } = useToken(); @@ -186,7 +192,16 @@ export const EditTonieModal: React.FC = ({ clearInputAfterSelection={false} onChange={onSearchModelChange} key={keyTonieArticleSearch} + onOpenCustomModelEditor={onOpenCreateModelEditor} /> + + +

diff --git a/src/constants/fileTypes.ts b/src/constants/fileTypes.ts new file mode 100644 index 00000000..7a28c4fe --- /dev/null +++ b/src/constants/fileTypes.ts @@ -0,0 +1 @@ +export const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]; diff --git a/src/constants/numbers.ts b/src/constants/numbers.ts index 72b303a0..8ac6f104 100644 --- a/src/constants/numbers.ts +++ b/src/constants/numbers.ts @@ -1 +1,2 @@ export const MAX_FILES = 99; +export const UPLOAD_TIMEOUT_MS = 45000; diff --git a/src/pages/tonies/CustomTonieCreatorPage.tsx b/src/pages/tonies/CustomTonieCreatorPage.tsx new file mode 100644 index 00000000..9fa9e211 --- /dev/null +++ b/src/pages/tonies/CustomTonieCreatorPage.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import BreadcrumbWrapper, { StyledContent, StyledLayout, StyledSider } from "../../components/common/StyledComponents"; +import { ToniesSubNav } from "../../components/tonies/ToniesSubNav"; +import ToniesCustomJsonEditorEnhanced from "../../components/tonies/ToniesCustomJsonEditorEnhanced"; + +export const CustomTonieCreatorPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + return ( + <> + + + + + {t("home.navigationTitle")} }, + { title: {t("tonies.navigationTitle")} }, + { title: t("tonies.customToniesEditorJsonEntry") }, + ]} + /> + + navigate("/tonies")} + /> + + + + ); +};