From 546810a6a6a7cd5dc8ccb59bed4bd4d442fa0796 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:27:47 +0200 Subject: [PATCH 01/34] add hideInNav flag to plugins (optional) --- public/translations/de.json | 44 +++++++------- public/translations/en.json | 2 + public/translations/es.json | 44 +++++++------- public/translations/fr.json | 44 +++++++------- public/translations/it.json | 44 +++++++------- public/translations/pl.json | 44 +++++++------- .../buttons/PluginTemplateDownloadButton.tsx | 4 +- src/components/community/CommunitySubNav.tsx | 36 +++++------ .../community/plugincard/PluginCard.tsx | 59 +++++++++++++++---- .../community/pluginlist/PluginList.tsx | 26 ++++---- .../pluginlist/modals/PluginHelpModal.tsx | 4 +- src/components/home/HomeSubNav.tsx | 2 +- src/components/settings/SettingsSubNav.tsx | 2 +- .../tonieboxes/TonieboxesSubNav.tsx | 2 +- src/components/tonies/ToniesSubNav.tsx | 2 +- src/provider/TeddyCloudProvider.tsx | 1 + src/types/pluginsMetaTypes.ts | 1 + 17 files changed, 206 insertions(+), 155 deletions(-) diff --git a/public/translations/de.json b/public/translations/de.json index bb77ab82..5e5574dd 100644 --- a/public/translations/de.json +++ b/public/translations/de.json @@ -21,11 +21,11 @@ "password": "Passwort", "passwordMin": "Mindestens 4 Zeichen", "passwordRequired": "Passwort ist erforderlich", - "username": "Benutzername", - "usernameRequired": "Benutzername ist erforderlich", + "retry": "Erneut versuchen", "statusLoadFailed": "Authentifizierungsstatus konnte nicht geladen werden", "statusLoadFailedDetails": "Der Authentifizierungsstatus konnte nicht geladen werden. Prüfe die Verbindung zu TeddyCloud und versuche es erneut.", - "retry": "Erneut versuchen" + "username": "Benutzername", + "usernameRequired": "Benutzername ist erforderlich" }, "common": { "cancel": "Abbrechen" @@ -142,6 +142,7 @@ "fields": { "author": "Autor des Plugins", "description": "Kurze Zusammenfassung des Plugins", + "hideInNav": "Flag, das angibt, ob das Plugin in der Navigation ausgeblendet werden soll (true/false)", "icon": "Das für das Plugin verwendete Icon. Muss eine gültige Ant Design Icon-Komponente von https://ant.design/components/icon sein, z. B. TrophyOutlined, TagsOutlined", "pluginHomepage": "Homepage des Plugins", "pluginName": "Der Titel, der in Menüs angezeigt wird (erforderlich)", @@ -157,6 +158,7 @@ }, "helpButton": "Hilfe", "helpTextCreatePlugin": "Ein Text darüber, wie man ein Plugin erstellt", + "hidden": "Versteckt", "installedPlugins": "Installierte Plugins", "intro": "Hier findest du deine hinzugefügten TeddyCloud Plugins und kannst neue Plugins von dir oder der TeddyCloud Community hinzufügen!", "navigationTitle": "TC Plugins", @@ -390,10 +392,18 @@ "filter": "Filter im aktuellen Ordner", "help": { "actionItems": { + "assignToTonie": { + "text": "Weist die ausgewählte Datei einem Tonie/Tag als Inhalt zu.", + "title": "Tonie zuweisen" + }, "deleteFileFolder": { "text": "Löscht die Datei oder den Ordner (nur wenn der Ordner leer ist). Hinweis: Wenn diese Datei einem Tonie/Tag zugewiesen ist, wird der Verweis nicht aktualisiert.", "title": "Datei/Ordner löschen" }, + "downloadFile": { + "text": "Lädt die ausgewählte Datei herunter oder lädt TAF-Dateien als OGG-Datei herunter.", + "title": "Datei herunterladen" + }, "duplicateTAPFile": { "text": "Erstellt eine Kopie der ausgewählten TAP-Datei.", "title": "TAP-Datei duplizieren" @@ -406,6 +416,16 @@ "text": "Öffnet den Tonie-Audio-Playlist-Editor, um TAP-Dateien zu verwalten.", "title": "TAP-Playlist bearbeiten" }, + "markAsListened": { + "listened": { + "text": "Die Datei ist derzeit als gehört markiert. Klicke, um sie als nicht gehört zu markieren.", + "title": "Als nicht gehört markieren" + }, + "unlistened": { + "text": "Die Datei ist derzeit als nicht gehört markiert. Klicke, um sie als gehört zu markieren.", + "title": "Als gehört markieren" + } + }, "migrateToAudioIdFolder": { "text": "Verschiebt die TAF-Datei in den Ordner 'by/audioId' der Library und aktualisiert die Quelle in der JSON-Datei.", "title": "TAF-Datei nach AudioId in die Library verschieben" @@ -425,24 +445,6 @@ "renameFile": { "text": "Benennt die Datei um. Hinweis: Wenn diese Datei einem Tonie/Tag zugewiesen ist, wird der Verweis nicht automatisch aktualisiert.", "title": "Datei umbenennen" - }, - "downloadFile": { - "title": "Datei herunterladen", - "text": "Lädt die ausgewählte Datei herunter oder lädt TAF-Dateien als OGG-Datei herunter." - }, - "markAsListened": { - "unlistened": { - "title": "Als gehört markieren", - "text": "Die Datei ist derzeit als nicht gehört markiert. Klicke, um sie als gehört zu markieren." - }, - "listened": { - "title": "Als nicht gehört markieren", - "text": "Die Datei ist derzeit als gehört markiert. Klicke, um sie als nicht gehört zu markieren." - } - }, - "assignToTonie": { - "title": "Tonie zuweisen", - "text": "Weist die ausgewählte Datei einem Tonie/Tag als Inhalt zu." } }, "actionItemsMulti": { diff --git a/public/translations/en.json b/public/translations/en.json index 7f60e20d..41241e66 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -142,6 +142,7 @@ "fields": { "author": "Author of the plugin", "description": "Short summary of the plugin", + "hideInNav": "Flag indicating whether the plugin should be hidden in navigation (true/false)", "icon": "The icon used for the plugin. Must be a valid Ant Design icon component from https://ant.design/components/icon, e.g. TrophyOutlined, TagsOutlined", "pluginHomepage": "Homepage of the plugin", "pluginName": "The title shown in menus (mandatory)", @@ -157,6 +158,7 @@ }, "helpButton": "Help", "helpTextCreatePlugin": "Some Text about how to create a plugin", + "hidden": "Hidden", "installedPlugins": "Installed plugins", "intro": "Here you find your added TeddyCloud plugins and you can add new plugins provided by you or the TeddyCloud community!", "navigationTitle": "TC Plugins", diff --git a/public/translations/es.json b/public/translations/es.json index 62330ec8..abd10683 100644 --- a/public/translations/es.json +++ b/public/translations/es.json @@ -21,11 +21,11 @@ "password": "Contraseña", "passwordMin": "Al menos 4 caracteres", "passwordRequired": "La contraseña es obligatoria", - "username": "Usuario", - "usernameRequired": "El usuario es obligatorio", + "retry": "Reintentar", "statusLoadFailed": "No se pudo cargar el estado de autenticación", "statusLoadFailedDetails": "No se pudo cargar el estado de autenticación. Comprueba la conexión con TeddyCloud e inténtalo de nuevo.", - "retry": "Reintentar" + "username": "Usuario", + "usernameRequired": "El usuario es obligatorio" }, "common": { "cancel": "Cancelar" @@ -142,6 +142,7 @@ "fields": { "author": "Autor del plugin", "description": "Resumen breve del plugin", + "hideInNav": "Indicador que define si el plugin debe ocultarse en la navegación (true/false)", "icon": "El icono utilizado para el plugin. Debe ser un componente de icono válido de Ant Design de https://ant.design/components/icon, p. ej. TrophyOutlined, TagsOutlined", "pluginHomepage": "Página principal del plugin", "pluginName": "El título que aparece en los menús (obligatorio)", @@ -157,6 +158,7 @@ }, "helpButton": "Ayuda", "helpTextCreatePlugin": "Texto sobre cómo crear un plugin", + "hidden": "Oculto", "installedPlugins": "Plugins instalados", "intro": "Aquí puedes ver tus plugins añadidos a TeddyCloud y también agregar nuevos plugins creados por ti o por la comunidad TeddyCloud.", "navigationTitle": "Plugins TC", @@ -390,10 +392,18 @@ "filter": "Filtrar dentro de la carpeta actual", "help": { "actionItems": { + "assignToTonie": { + "text": "Asigna el archivo seleccionado como contenido a un Tonie/Tag.", + "title": "Asignar a Tonie" + }, "deleteFileFolder": { "text": "Elimina el archivo o carpeta (solo si la carpeta está vacía). Nota: Si este archivo está asignado a un Tonie/Tag, la referencia no se actualizará.", "title": "Eliminar archivo/carpeta" }, + "downloadFile": { + "text": "Descarga el archivo seleccionado o, en el caso de archivos TAF, lo descarga como archivo OGG.", + "title": "Descargar archivo" + }, "duplicateTAPFile": { "text": "Crea una copia del archivo TAP seleccionado.", "title": "Duplicar archivo TAP" @@ -406,6 +416,16 @@ "text": "Abre el editor de TeddyAudio Playlist de Tonie para administrar archivos TAP.", "title": "Editar lista de reproducción TAP" }, + "markAsListened": { + "listened": { + "text": "El archivo está marcado actualmente como escuchado. Haz clic para marcarlo como no escuchado.", + "title": "Marcar como no escuchado" + }, + "unlistened": { + "text": "El archivo está marcado actualmente como no escuchado. Haz clic para marcarlo como escuchado.", + "title": "Marcar como escuchado" + } + }, "migrateToAudioIdFolder": { "text": "Mueve el archivo TAF a la carpeta 'by/audioId' de la biblioteca y actualiza la fuente en el archivo JSON.", "title": "Migrar archivo TAF a la biblioteca por AudioId" @@ -425,24 +445,6 @@ "renameFile": { "text": "Renombra el archivo. Nota: Si este archivo está asignado a un Tonie/Tag, la referencia no se actualizará automáticamente.", "title": "Renombrar archivo" - }, - "downloadFile": { - "title": "Descargar archivo", - "text": "Descarga el archivo seleccionado o, en el caso de archivos TAF, lo descarga como archivo OGG." - }, - "markAsListened": { - "unlistened": { - "title": "Marcar como escuchado", - "text": "El archivo está marcado actualmente como no escuchado. Haz clic para marcarlo como escuchado." - }, - "listened": { - "title": "Marcar como no escuchado", - "text": "El archivo está marcado actualmente como escuchado. Haz clic para marcarlo como no escuchado." - } - }, - "assignToTonie": { - "title": "Asignar a Tonie", - "text": "Asigna el archivo seleccionado como contenido a un Tonie/Tag." } }, "actionItemsMulti": { diff --git a/public/translations/fr.json b/public/translations/fr.json index 95c5ae65..1a1f646e 100644 --- a/public/translations/fr.json +++ b/public/translations/fr.json @@ -21,11 +21,11 @@ "password": "Mot de passe", "passwordMin": "Au moins 4 caractères", "passwordRequired": "Le mot de passe est requis", - "username": "Nom d'utilisateur", - "usernameRequired": "Le nom d'utilisateur est requis", + "retry": "Réessayer", "statusLoadFailed": "Impossible de charger l’état d’authentification", "statusLoadFailedDetails": "L’état d’authentification n’a pas pu être chargé. Vérifiez la connexion à TeddyCloud et réessayez.", - "retry": "Réessayer" + "username": "Nom d'utilisateur", + "usernameRequired": "Le nom d'utilisateur est requis" }, "common": { "cancel": "Annuler" @@ -142,6 +142,7 @@ "fields": { "author": "Auteur du plugin", "description": "Résumé court du plugin", + "hideInNav": "Indicateur précisant si le plugin doit être masqué dans la navigation (true/false)", "icon": "L'icône utilisée pour le plugin. Doit être un composant d'icône Ant Design valide depuis https://ant.design/components/icon, par ex. TrophyOutlined, TagsOutlined", "pluginHomepage": "Page d'accueil du plugin", "pluginName": "Le titre affiché dans les menus (obligatoire)", @@ -157,6 +158,7 @@ }, "helpButton": "Aide", "helpTextCreatePlugin": "Un texte sur la création d'un plugin", + "hidden": "Masqué", "installedPlugins": "Plugins installés", "intro": "Vous trouverez ici vos plugins TeddyCloud ajoutés et pouvez ajouter de nouveaux plugins fournis par vous-même ou la communauté TeddyCloud !", "navigationTitle": "Plugins TC", @@ -390,10 +392,18 @@ "filter": "Filtrer dans le dossier actuel", "help": { "actionItems": { + "assignToTonie": { + "text": "Assigne le fichier sélectionné comme contenu à un Tonie/Tag.", + "title": "Assigner à un Tonie" + }, "deleteFileFolder": { "text": "Supprime le fichier ou le dossier (seulement si le dossier est vide). Remarque : si ce fichier est attribué à un Tonie/Tag, la référence ne sera pas mise à jour.", "title": "Supprimer le fichier/dossier" }, + "downloadFile": { + "text": "Télécharge le fichier sélectionné ou, pour les fichiers TAF, le télécharge au format OGG.", + "title": "Télécharger le fichier" + }, "duplicateTAPFile": { "text": "Crée une copie du fichier TAP sélectionné.", "title": "Dupliquer le fichier TAP" @@ -406,6 +416,16 @@ "text": "Ouvre l'éditeur de TeddyAudio Playlist pour gérer les fichiers TAP.", "title": "Modifier la liste de lecture TAP" }, + "markAsListened": { + "listened": { + "text": "Le fichier est actuellement marqué comme écouté. Cliquez pour le marquer comme non écouté.", + "title": "Marquer comme non écouté" + }, + "unlistened": { + "text": "Le fichier est actuellement marqué comme non écouté. Cliquez pour le marquer comme écouté.", + "title": "Marquer comme écouté" + } + }, "migrateToAudioIdFolder": { "text": "Déplace le fichier TAF dans le dossier 'by/audioId' de la bibliothèque et met à jour la source dans le fichier JSON.", "title": "Migrer le fichier TAF dans la bibliothèque par AudioId" @@ -425,24 +445,6 @@ "renameFile": { "text": "Renomme le fichier. Remarque : si ce fichier est attribué à un Tonie/Tag, la référence ne sera pas mise à jour automatiquement.", "title": "Renommer le fichier" - }, - "downloadFile": { - "title": "Télécharger le fichier", - "text": "Télécharge le fichier sélectionné ou, pour les fichiers TAF, le télécharge au format OGG." - }, - "markAsListened": { - "unlistened": { - "title": "Marquer comme écouté", - "text": "Le fichier est actuellement marqué comme non écouté. Cliquez pour le marquer comme écouté." - }, - "listened": { - "title": "Marquer comme non écouté", - "text": "Le fichier est actuellement marqué comme écouté. Cliquez pour le marquer comme non écouté." - } - }, - "assignToTonie": { - "title": "Assigner à un Tonie", - "text": "Assigne le fichier sélectionné comme contenu à un Tonie/Tag." } }, "actionItemsMulti": { diff --git a/public/translations/it.json b/public/translations/it.json index 79b97ed7..e21c595f 100644 --- a/public/translations/it.json +++ b/public/translations/it.json @@ -21,11 +21,11 @@ "password": "Password", "passwordMin": "Almeno 4 caratteri", "passwordRequired": "La password è obbligatoria", - "username": "Nome utente", - "usernameRequired": "Il nome utente è obbligatorio", + "retry": "Riprova", "statusLoadFailed": "Impossibile caricare lo stato di autenticazione", "statusLoadFailedDetails": "Impossibile caricare lo stato di autenticazione. Controlla la connessione a TeddyCloud e riprova.", - "retry": "Riprova" + "username": "Nome utente", + "usernameRequired": "Il nome utente è obbligatorio" }, "common": { "cancel": "Annulla" @@ -142,6 +142,7 @@ "fields": { "author": "Autore del plugin", "description": "Breve descrizione del plugin", + "hideInNav": "Flag che indica se il plugin deve essere nascosto nella navigazione (true/false)", "icon": "L'icona utilizzata per il plugin. Deve essere un componente icona Ant Design valido da https://ant.design/components/icon, ad es. TrophyOutlined, TagsOutlined", "pluginHomepage": "Homepage del plugin", "pluginName": "Il titolo mostrato nei menu (obbligatorio)", @@ -157,6 +158,7 @@ }, "helpButton": "Aiuto", "helpTextCreatePlugin": "Testo su come creare un plugin", + "hidden": "Nascosto", "installedPlugins": "Plugin installati", "intro": "Qui trovi i plugin TeddyCloud che hai aggiunto e puoi aggiungere nuovi plugin forniti da te o dalla comunità TeddyCloud!", "navigationTitle": "Plugin TC", @@ -390,10 +392,18 @@ "filter": "Filtra nella cartella corrente", "help": { "actionItems": { + "assignToTonie": { + "text": "Assegna il file selezionato come contenuto a un Tonie/Tag.", + "title": "Assegna a Tonie" + }, "deleteFileFolder": { "text": "Elimina il file o la cartella (solo se la cartella è vuota). Nota: se questo file è assegnato a un Tonie/Tag, il riferimento non verrà aggiornato.", "title": "Elimina file/cartella" }, + "downloadFile": { + "text": "Scarica il file selezionato oppure, per i file TAF, lo scarica come file OGG.", + "title": "Scarica file" + }, "duplicateTAPFile": { "text": "Crea un duplicato del file TAP selezionato.", "title": "Duplica file TAP" @@ -406,6 +416,16 @@ "text": "Apre l'editor di playlist TeddyAudio per gestire i file TAP.", "title": "Modifica playlist TAP" }, + "markAsListened": { + "listened": { + "text": "Il file è attualmente segnato come ascoltato. Fai clic per segnarlo come non ascoltato.", + "title": "Segna come non ascoltato" + }, + "unlistened": { + "text": "Il file è attualmente segnato come non ascoltato. Fai clic per segnarlo come ascoltato.", + "title": "Segna come ascoltato" + } + }, "migrateToAudioIdFolder": { "text": "Sposta il file TAF nella cartella 'by/audioId' della libreria e aggiorna la sorgente nel file JSON.", "title": "Migra file TAF nella libreria per AudioId" @@ -425,24 +445,6 @@ "renameFile": { "text": "Rinomina il file. Nota: se questo file è assegnato a un Tonie/Tag, il riferimento non verrà aggiornato automaticamente.", "title": "Rinomina file" - }, - "downloadFile": { - "title": "Scarica file", - "text": "Scarica il file selezionato oppure, per i file TAF, lo scarica come file OGG." - }, - "markAsListened": { - "unlistened": { - "title": "Segna come ascoltato", - "text": "Il file è attualmente segnato come non ascoltato. Fai clic per segnarlo come ascoltato." - }, - "listened": { - "title": "Segna come non ascoltato", - "text": "Il file è attualmente segnato come ascoltato. Fai clic per segnarlo come non ascoltato." - } - }, - "assignToTonie": { - "title": "Assegna a Tonie", - "text": "Assegna il file selezionato come contenuto a un Tonie/Tag." } }, "actionItemsMulti": { diff --git a/public/translations/pl.json b/public/translations/pl.json index f77a6d65..c80f5a34 100644 --- a/public/translations/pl.json +++ b/public/translations/pl.json @@ -21,11 +21,11 @@ "password": "Hasło", "passwordMin": "Co najmniej 4 znaki", "passwordRequired": "Hasło jest wymagane", - "username": "Nazwa użytkownika", - "usernameRequired": "Nazwa użytkownika jest wymagana", + "retry": "Spróbuj ponownie", "statusLoadFailed": "Nie udało się wczytać stanu uwierzytelniania", "statusLoadFailedDetails": "Nie udało się wczytać stanu uwierzytelniania. Sprawdź połączenie z TeddyCloud i spróbuj ponownie.", - "retry": "Spróbuj ponownie" + "username": "Nazwa użytkownika", + "usernameRequired": "Nazwa użytkownika jest wymagana" }, "common": { "cancel": "Anuluj" @@ -142,6 +142,7 @@ "fields": { "author": "Autor wtyczki", "description": "Krótki opis wtyczki", + "hideInNav": "Flaga określająca, czy wtyczka ma być ukryta w nawigacji (true/false)", "icon": "Ikona używana dla wtyczki. Musi być prawidłowym komponentem ikony Ant Design z https://ant.design/components/icon, np. TrophyOutlined, TagsOutlined", "pluginHomepage": "Strona domowa wtyczki", "pluginName": "Tytuł wyświetlany w menu (wymagane)", @@ -157,6 +158,7 @@ }, "helpButton": "Pomoc", "helpTextCreatePlugin": "Przykładowy tekst o tym, jak stworzyć wtyczkę", + "hidden": "Ukryta", "installedPlugins": "Zainstalowane wtyczki", "intro": "Tutaj znajdziesz dodane przez Ciebie wtyczki TeddyCloud oraz możesz dodać nowe wtyczki udostępnione przez Ciebie lub społeczność TeddyCloud!", "navigationTitle": "Wtyczki TC", @@ -390,10 +392,18 @@ "filter": "Filtruj w bieżącym folderze", "help": { "actionItems": { + "assignToTonie": { + "text": "Przypisuje wybrany plik jako zawartość do Tonie/Taga.", + "title": "Przypisz do Tonie" + }, "deleteFileFolder": { "text": "Usuwa plik lub folder (tylko jeśli folder jest pusty). Uwaga: jeśli ten plik jest przypisany do Tonie/Taga, odwołanie nie zostanie zaktualizowane.", "title": "Usuń plik/folder" }, + "downloadFile": { + "text": "Pobiera wybrany plik lub, w przypadku plików TAF, pobiera go jako plik OGG.", + "title": "Pobierz plik" + }, "duplicateTAPFile": { "text": "Tworzy duplikat wybranego pliku TAP.", "title": "Duplikuj plik TAP" @@ -406,6 +416,16 @@ "text": "Otwiera edytor playlist TeddyAudio do zarządzania plikami TAP.", "title": "Edytuj playlistę TAP" }, + "markAsListened": { + "listened": { + "text": "Plik jest obecnie oznaczony jako odsłuchany. Kliknij, aby oznaczyć go jako nieodsłuchany.", + "title": "Oznacz jako nieodsłuchane" + }, + "unlistened": { + "text": "Plik jest obecnie oznaczony jako nieodsłuchany. Kliknij, aby oznaczyć go jako odsłuchany.", + "title": "Oznacz jako odsłuchane" + } + }, "migrateToAudioIdFolder": { "text": "Przenosi plik TAF do folderu biblioteki 'by/audioId' i aktualizuje źródło w pliku JSON.", "title": "Migruj plik TAF do biblioteki wg AudioId" @@ -425,24 +445,6 @@ "renameFile": { "text": "Zmienia nazwę pliku. Uwaga: jeśli ten plik jest przypisany do Tonie/Taga, odwołanie nie zostanie zaktualizowane automatycznie.", "title": "Zmień nazwę pliku" - }, - "downloadFile": { - "title": "Pobierz plik", - "text": "Pobiera wybrany plik lub, w przypadku plików TAF, pobiera go jako plik OGG." - }, - "markAsListened": { - "unlistened": { - "title": "Oznacz jako odsłuchane", - "text": "Plik jest obecnie oznaczony jako nieodsłuchany. Kliknij, aby oznaczyć go jako odsłuchany." - }, - "listened": { - "title": "Oznacz jako nieodsłuchane", - "text": "Plik jest obecnie oznaczony jako odsłuchany. Kliknij, aby oznaczyć go jako nieodsłuchany." - } - }, - "assignToTonie": { - "title": "Przypisz do Tonie", - "text": "Przypisuje wybrany plik jako zawartość do Tonie/Taga." } }, "actionItemsMulti": { diff --git a/src/components/common/buttons/PluginTemplateDownloadButton.tsx b/src/components/common/buttons/PluginTemplateDownloadButton.tsx index deafddc5..62de66ee 100644 --- a/src/components/common/buttons/PluginTemplateDownloadButton.tsx +++ b/src/components/common/buttons/PluginTemplateDownloadButton.tsx @@ -12,7 +12,8 @@ const createPluginJson = (): string => "standalone": false, "pluginHomepage": "Homepage of the plugin (optionally)", "teddyCloudSection": "one of: home|tonies|tonieboxes|settings|community (optionally)", - "icon": "name of an icon from Ant Design icons (optional)" + "icon": "name of an icon from Ant Design icons (optional)", + "hideInNav": false }`; const createScriptJs = (): string => @@ -430,6 +431,7 @@ You can add more files as needed, for example: - \`pluginHomepage\`: Optional project or documentation URL - \`teddyCloudSection\`: Optional section of the TeddyCloud UI (home|tonies|tonieboxes|settings|community) - \`icon\`: Optional Ant Design icon name +- \`hideInNav\`: Optional boolean flag to hide the plugin in navigation (true/false, no quotes!) --- diff --git a/src/components/community/CommunitySubNav.tsx b/src/components/community/CommunitySubNav.tsx index ef4eec32..5face314 100644 --- a/src/components/community/CommunitySubNav.tsx +++ b/src/components/community/CommunitySubNav.tsx @@ -48,25 +48,27 @@ export const CommunitySubNav = () => { const { plugins } = useTeddyCloud(); - const pluginItems = plugins.map((plugin) => ({ - key: `tcplugins-${plugin.pluginId}`, - label: ( - { - setNavOpen(false); - setSubNavOpen(false); - }} - > - {plugin.pluginName} - - ), - icon: React.createElement(plugin.icon), - title: plugin.pluginName, - })); + const pluginItems = plugins + .filter((p) => !p.hideInNav) + .map((plugin) => ({ + key: `tcplugins-${plugin.pluginId}`, + label: ( + { + setNavOpen(false); + setSubNavOpen(false); + }} + > + {plugin.pluginName} + + ), + icon: React.createElement(plugin.icon), + title: plugin.pluginName, + })); const filteredPluginItems = plugins - .filter((p) => p.teddyCloudSection === TeddyCloudSection.Community) + .filter((p) => p.teddyCloudSection === TeddyCloudSection.Community && !p.hideInNav) .map((plugin) => ({ key: `plugin-${plugin.pluginId}`, label: ( diff --git a/src/components/community/plugincard/PluginCard.tsx b/src/components/community/plugincard/PluginCard.tsx index 94a8b89a..40f3c0c5 100644 --- a/src/components/community/plugincard/PluginCard.tsx +++ b/src/components/community/plugincard/PluginCard.tsx @@ -1,5 +1,5 @@ -import { Card, Typography, Badge, Tooltip, theme } from "antd"; -import { DesktopOutlined, HomeOutlined, DeleteOutlined } from "@ant-design/icons"; +import { Card, Typography, Badge, Tooltip, theme, Tag } from "antd"; +import { DesktopOutlined, HomeOutlined, DeleteOutlined, TagFilled } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { canHover } from "../../../utils/browser/browserUtils"; import { withBase } from "../../../utils/basePath"; @@ -40,14 +40,40 @@ export const PluginCard: React.FC = ({ const titleContent = plugin.teddyCloudSection ? ( -
-

+
+

{plugin.pluginName}

) : ( -

+

{plugin.pluginName}

); @@ -59,20 +85,24 @@ export const PluginCard: React.FC = ({ key={plugin.pluginId} style={{ width: "100%", - margin: 8, + height: "100%", + minWidth: 0, borderRadius: 8, background: token.colorBgContainerDisabled, display: "flex", flexDirection: "column", - justifyContent: "space-between", + }} + styles={{ + body: { + flex: 1, + }, }} title={
{titleContent} @@ -127,6 +157,13 @@ export const PluginCard: React.FC = ({ e.currentTarget.style.display = "none"; }} /> + {plugin.hideInNav && ( + + + {t("community.plugins.hidden")} + + + )} {plugin.author && `${t("community.plugins.by")} ${plugin.author}`} {plugin.version && ` - v${plugin.version}`} diff --git a/src/components/community/pluginlist/PluginList.tsx b/src/components/community/pluginlist/PluginList.tsx index fa96a0ff..21a9fdf2 100644 --- a/src/components/community/pluginlist/PluginList.tsx +++ b/src/components/community/pluginlist/PluginList.tsx @@ -106,7 +106,7 @@ export const PluginList = () => { {filteredPlugins.length === 0 ? ( ) : ( - + {filteredPlugins.map((plugin) => ( { lg={8} xl={8} xxl={6} + style={{ minWidth: 0, display: "flex" }} > -
- - navigate(`/community/tcplugins/${pluginId}`) - } - onOpenHomepage={(url) => window.open(url, "_blank")} - onDelete={requestDelete} - /> -
+ + navigate(`/community/tcplugins/${pluginId}`) + } + onOpenHomepage={(url) => window.open(url, "_blank")} + onDelete={requestDelete} + /> ))}
diff --git a/src/components/community/pluginlist/modals/PluginHelpModal.tsx b/src/components/community/pluginlist/modals/PluginHelpModal.tsx index ef7230ef..5472aade 100644 --- a/src/components/community/pluginlist/modals/PluginHelpModal.tsx +++ b/src/components/community/pluginlist/modals/PluginHelpModal.tsx @@ -22,6 +22,7 @@ export const PluginHelpModal: React.FC = ({ open, onClose ["pluginHomepage", t("community.plugins.help.fields.pluginHomepage")], ["teddyCloudSection", t("community.plugins.help.fields.teddyCloudSection")], ["icon", t("community.plugins.help.fields.icon")], + ["hideInNav", t("community.plugins.help.fields.hideInNav")], ]; return ( @@ -65,7 +66,8 @@ export const PluginHelpModal: React.FC = ({ open, onClose "version": "Version of the plugin", "pluginHomepage": "Homepage of the plugin", "teddyCloudSection": "tonies", - "icon": "TrophyOutlined" + "icon": "TrophyOutlined", + "hideInNav": false }`} /> diff --git a/src/components/home/HomeSubNav.tsx b/src/components/home/HomeSubNav.tsx index 230806cc..4b77c4d7 100644 --- a/src/components/home/HomeSubNav.tsx +++ b/src/components/home/HomeSubNav.tsx @@ -25,7 +25,7 @@ export const HomeSubNav = () => { }, [currentLanguage]); const pluginItems = plugins - .filter((p) => p.teddyCloudSection === TeddyCloudSection.Home) + .filter((p) => p.teddyCloudSection === TeddyCloudSection.Home && !p.hideInNav) .map((plugin) => ({ key: `plugin-${plugin.pluginId}`, label: ( diff --git a/src/components/settings/SettingsSubNav.tsx b/src/components/settings/SettingsSubNav.tsx index 76d82cdb..87cc81ea 100644 --- a/src/components/settings/SettingsSubNav.tsx +++ b/src/components/settings/SettingsSubNav.tsx @@ -48,7 +48,7 @@ export const SettingsSubNav = () => { }, [currentLanguage]); const pluginItems = plugins - .filter((p) => p.teddyCloudSection === TeddyCloudSection.Settings) + .filter((p) => p.teddyCloudSection === TeddyCloudSection.Settings && !p.hideInNav) .map((plugin) => ({ key: `plugin-${plugin.pluginId}`, label: ( diff --git a/src/components/tonieboxes/TonieboxesSubNav.tsx b/src/components/tonieboxes/TonieboxesSubNav.tsx index 7147b8c4..801395f6 100644 --- a/src/components/tonieboxes/TonieboxesSubNav.tsx +++ b/src/components/tonieboxes/TonieboxesSubNav.tsx @@ -28,7 +28,7 @@ export const TonieboxesSubNav = () => { }, [openKeys, currentLanguage]); const pluginItems = plugins - .filter((p) => p.teddyCloudSection === TeddyCloudSection.Tonieboxes) + .filter((p) => p.teddyCloudSection === TeddyCloudSection.Tonieboxes && !p.hideInNav) .map((plugin) => ({ key: `plugin-${plugin.pluginId}`, label: ( diff --git a/src/components/tonies/ToniesSubNav.tsx b/src/components/tonies/ToniesSubNav.tsx index ca9adbb0..1a5c0c68 100644 --- a/src/components/tonies/ToniesSubNav.tsx +++ b/src/components/tonies/ToniesSubNav.tsx @@ -33,7 +33,7 @@ export const ToniesSubNav = () => { }, [currentLanguage]); const pluginItems = plugins - .filter((p) => p.teddyCloudSection === TeddyCloudSection.Tonies) + .filter((p) => p.teddyCloudSection === TeddyCloudSection.Tonies && !p.hideInNav) .map((plugin) => ({ key: `plugin-${plugin.pluginId}`, label: ( diff --git a/src/provider/TeddyCloudProvider.tsx b/src/provider/TeddyCloudProvider.tsx index c756555e..806f1a50 100644 --- a/src/provider/TeddyCloudProvider.tsx +++ b/src/provider/TeddyCloudProvider.tsx @@ -373,6 +373,7 @@ export function TeddyCloudProvider({ children }: TeddyCloudProviderProps) { ) ? meta.teddyCloudSection : null, + hideInNav: !!meta.hideInNav, icon: (meta.icon && meta.icon in AntIcons ? AntIcons[meta.icon as keyof typeof AntIcons] : AntIcons.CodeSandboxOutlined) as ElementType, diff --git a/src/types/pluginsMetaTypes.ts b/src/types/pluginsMetaTypes.ts index d15658a1..8767e4fd 100644 --- a/src/types/pluginsMetaTypes.ts +++ b/src/types/pluginsMetaTypes.ts @@ -14,6 +14,7 @@ export type PluginMeta = { author: string; version: string; description: string; + hideInNav?: boolean; pluginHomepage?: string; teddyCloudSection?: TeddyCloudSection; icon: ElementType; From fb9e488c143c85b5165a8ce3f0c9549964dc117f Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:32:41 +0200 Subject: [PATCH 02/34] added missing changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc54440a..be7b04bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - gui: Fixed custom Tonie track filtering when `sourceInfo.tracks` is empty but `tonieInfo.tracks` contains track data - gui: Fixed sibling episode assignment for library-root files and improved handling of failed directory requests - gui: Expanded automated test coverage for authentication, API requests and core Tonie/library logic +- gui: Added support for the optional `hideInNav` property in plugin `plugin.json` files to prevent plugins from being shown in the navigation ### Commits From 0f33563e570e969b2adcfa334cff7d9dbe9e5ced Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:42:35 +0200 Subject: [PATCH 03/34] Add hidden plugin filter and count to PluginList --- .../community/pluginlist/PluginList.tsx | 19 ++++++++---- .../pluginlist/hooks/usePluginList.ts | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/components/community/pluginlist/PluginList.tsx b/src/components/community/pluginlist/PluginList.tsx index 21a9fdf2..00e64932 100644 --- a/src/components/community/pluginlist/PluginList.tsx +++ b/src/components/community/pluginlist/PluginList.tsx @@ -21,6 +21,9 @@ export const PluginList = () => { activeSectionFilters, pluginCountBySection, toggleSectionFilter, + hiddenOnly, + setHiddenOnly, + hiddenPluginCount, isVisibleHelpModal, openHelp, @@ -79,29 +82,33 @@ export const PluginList = () => {

{t("community.plugins.installedPlugins")}

-
+
{allSections.map((section) => { const isChecked = activeSectionFilters.includes(section); + return ( toggleSectionFilter(section, checked)} > - - {section.charAt(0).toUpperCase() + section.slice(1)} - + {section.charAt(0).toUpperCase() + section.slice(1)} ); })} + + + + {t("community.plugins.hidden")} + +
{filteredPlugins.length === 0 ? ( diff --git a/src/components/community/pluginlist/hooks/usePluginList.ts b/src/components/community/pluginlist/hooks/usePluginList.ts index 3670fa5c..51202583 100644 --- a/src/components/community/pluginlist/hooks/usePluginList.ts +++ b/src/components/community/pluginlist/hooks/usePluginList.ts @@ -20,6 +20,12 @@ export const usePluginList = () => { const [file, setFile] = useState(null); const [uploading, setUploading] = useState(false); const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [hiddenOnly, setHiddenOnly] = useState(false); + + const hiddenPluginCount = useMemo( + () => plugins.filter((plugin) => plugin.hideInNav).length, + [plugins], + ); const allSections = useMemo( () => @@ -49,14 +55,18 @@ export const usePluginList = () => { [plugins, t], ); - const filteredPlugins = useMemo( - () => - (plugins as TeddyCloudPlugin[]).filter((plugin) => { - const section = plugin.teddyCloudSection || t("community.plugins.filter.unknown"); - return activeSectionFilters.includes(section); - }), - [plugins, activeSectionFilters, t], - ); + const filteredPlugins = useMemo(() => { + return plugins.filter((plugin) => { + const matchesSection = + activeSectionFilters.length === 0 || + (plugin.teddyCloudSection && + activeSectionFilters.includes(plugin.teddyCloudSection)); + + const matchesHidden = !hiddenOnly || plugin.hideInNav; + + return matchesSection && matchesHidden; + }); + }, [plugins, activeSectionFilters, hiddenOnly]); const toggleSectionFilter = (section: string, checked: boolean) => { setActiveSectionFilters((prev) => @@ -166,6 +176,9 @@ export const usePluginList = () => { filteredPlugins, allSections, activeSectionFilters, + hiddenOnly, + setHiddenOnly, + hiddenPluginCount, pluginCountBySection, // filters From e944af9b70582c20d90b56260b42e296780cef80 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:08:25 +0200 Subject: [PATCH 04/34] Add pagination feature to PluginList and translations for pagination UI --- public/translations/de.json | 4 + public/translations/en.json | 4 + public/translations/es.json | 4 + public/translations/fr.json | 4 + public/translations/it.json | 4 + public/translations/pl.json | 4 + .../community/pluginlist/PluginList.tsx | 174 +++++++++++++++--- .../pagination/PluginPagination.tsx | 42 +++++ 8 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 src/components/community/pluginlist/pagination/PluginPagination.tsx diff --git a/public/translations/de.json b/public/translations/de.json index 5e5574dd..3d5a85cf 100644 --- a/public/translations/de.json +++ b/public/translations/de.json @@ -164,6 +164,10 @@ "navigationTitle": "TC Plugins", "ok": "OK", "open": "Plugin öffnen", + "pagination": { + "pageSelector": "Plugins pro Seite", + "showAll": "Alle anzeigen" + }, "plugin": "Plugin", "title": "TeddyCloud Plugins", "upload": { diff --git a/public/translations/en.json b/public/translations/en.json index 41241e66..7e511c81 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -164,6 +164,10 @@ "navigationTitle": "TC Plugins", "ok": "OK", "open": "Open Plugin", + "pagination": { + "pageSelector": "Plugins per Page", + "showAll": "Show All" + }, "plugin": "Plugin", "title": "TeddyCloud Plugins", "upload": { diff --git a/public/translations/es.json b/public/translations/es.json index abd10683..738e1fcc 100644 --- a/public/translations/es.json +++ b/public/translations/es.json @@ -165,6 +165,10 @@ "ok": "OK", "open": "Abrir plugin", "plugin": "Plugin", + "pagination": { + "pageSelector": "Plugins por página", + "showAll": "Mostrar todos" + }, "title": "Plugins de TeddyCloud", "upload": { "add": "Agregar plugin", diff --git a/public/translations/fr.json b/public/translations/fr.json index 1a1f646e..ea023e53 100644 --- a/public/translations/fr.json +++ b/public/translations/fr.json @@ -165,6 +165,10 @@ "ok": "OK", "open": "Ouvrir le plugin", "plugin": "Plugin", + "pagination": { + "pageSelector": "Plugins par page", + "showAll": "Afficher tout" + }, "title": "Plugins TeddyCloud", "upload": { "add": "Ajouter un plugin", diff --git a/public/translations/it.json b/public/translations/it.json index e21c595f..7c38f37a 100644 --- a/public/translations/it.json +++ b/public/translations/it.json @@ -165,6 +165,10 @@ "ok": "OK", "open": "Apri plugin", "plugin": "Plugin", + "pagination": { + "pageSelector": "Plugins per pagina", + "showAll": "Mostra tutti" + }, "title": "Plugin TeddyCloud", "upload": { "add": "Aggiungi plugin", diff --git a/public/translations/pl.json b/public/translations/pl.json index c80f5a34..99275c66 100644 --- a/public/translations/pl.json +++ b/public/translations/pl.json @@ -165,6 +165,10 @@ "ok": "OK", "open": "Otwórz wtyczkę", "plugin": "Wtyczka", + "pagination": { + "pageSelector": "Wtyczki na stronę", + "showAll": "Pokaż wszystkie" + }, "title": "Wtyczki TeddyCloud", "upload": { "add": "Dodaj wtyczkę", diff --git a/src/components/community/pluginlist/PluginList.tsx b/src/components/community/pluginlist/PluginList.tsx index 00e64932..48438820 100644 --- a/src/components/community/pluginlist/PluginList.tsx +++ b/src/components/community/pluginlist/PluginList.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { Alert, Badge, Button, Col, Empty, Row, Tag, Typography } from "antd"; @@ -8,8 +9,12 @@ import { PluginCard } from "../plugincard/PluginCard"; import { PluginDeleteDialog } from "./modals/PluginDeleteModal"; import { PluginHelpModal } from "./modals/PluginHelpModal"; import { PluginUploadModal } from "./modals/PluginUploadModal"; +import PluginPagination from "./pagination/PluginPagination"; +import { scrollToTop } from "../../../utils/browser/browserUtils"; +import { userStorage } from "../../../utils/storage/userStorage"; const { Paragraph } = Typography; +const STORAGE_KEY = "pluginListState"; export const PluginList = () => { const { t } = useTranslation(); @@ -44,6 +49,106 @@ export const PluginList = () => { handleCancelDelete, } = usePluginList(); + const [pageSize, setPageSize] = useState(() => { + const storedState = userStorage.getItem(STORAGE_KEY); + if (storedState) { + try { + const { pageSize: storedPageSize } = JSON.parse(storedState); + if (typeof storedPageSize === "number") return storedPageSize; + } catch (error) { + console.error("Error parsing stored plugin list state:", error); + } + } + return 24; + }); + const [currentPage, setCurrentPage] = useState(1); + const [paginationEnabled, setPaginationEnabled] = useState(true); + const [showAll, setShowAll] = useState(false); + const pluginListRef = useRef(null); + + useEffect(() => { + const storedState = userStorage.getItem(STORAGE_KEY); + if (storedState) { + try { + const { pageSize: storedPageSize, showAll: storedShowAll } = + JSON.parse(storedState); + if (storedShowAll) { + setPageSize(storedPageSize); + handleShowAll(storedPageSize); + } else { + setPageSize(storedPageSize); + handlePageSizeChange(1, storedPageSize); + } + } catch (error) { + console.error("Error parsing stored plugin list state:", error); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const stateToStore = JSON.stringify({ + pageSize, + paginationEnabled, + showAll, + }); + userStorage.setItem(STORAGE_KEY, stateToStore); + }, [pageSize, paginationEnabled, showAll]); + + useEffect(() => { + handlePageSizeChange(1, pageSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pageSize]); + + const handleShowAll = (size?: number) => { + const effectiveSize = size ?? pageSize; + setPageSize(effectiveSize); + setShowAll(true); + setPaginationEnabled(false); + }; + + const handleShowPagination = () => { + setPaginationEnabled(true); + setShowAll(false); + handlePageSizeChange(1, pageSize); + }; + + const handlePageSizeChange = (current: number, size: number) => { + setPageSize(size); + setCurrentPage(current); + setTimeout(() => scrollToTop(pluginListRef.current), 0); + }; + + const handleSectionFilterChange = (section: string, checked: boolean) => { + toggleSectionFilter(section, checked); + setCurrentPage(1); + }; + + const handleHiddenFilterChange = (checked: boolean) => { + setHiddenOnly(checked); + setCurrentPage(1); + }; + + const currentPageData = showAll + ? filteredPlugins + : filteredPlugins.slice((currentPage - 1) * pageSize, currentPage * pageSize); + + const listPagination = ( +
+ {!paginationEnabled ? ( + + ) : ( + handleShowAll()} + /> + )} +
+ ); + return ( <>

{t("community.plugins.title")}

@@ -96,7 +201,9 @@ export const PluginList = () => { > toggleSectionFilter(section, checked)} + onChange={(checked) => + handleSectionFilterChange(section, checked) + } > {section.charAt(0).toUpperCase() + section.slice(1)} @@ -105,38 +212,47 @@ export const PluginList = () => { })} - + {t("community.plugins.hidden")}
- {filteredPlugins.length === 0 ? ( - - ) : ( - - {filteredPlugins.map((plugin) => ( - - - navigate(`/community/tcplugins/${pluginId}`) - } - onOpenHomepage={(url) => window.open(url, "_blank")} - onDelete={requestDelete} - /> - - ))} - - )} + +
+ {filteredPlugins.length === 0 ? ( + + ) : ( + <> + {listPagination} + + + {currentPageData.map((plugin) => ( + + + navigate(`/community/tcplugins/${pluginId}`) + } + onOpenHomepage={(url) => window.open(url, "_blank")} + onDelete={requestDelete} + /> + + ))} + + + {listPagination} + + )} +
void; + total: number; + pageSize: number; + additionalButtonOnClick: () => void; +} + +const PluginPagination: React.FC = ({ + currentPage, + onChange, + total, + pageSize, + additionalButtonOnClick, +}) => { + const { t } = useTranslation(); + + return ( + <> + + + + ); +}; + +export default PluginPagination; From 738334774dc2f03ec869d7692ade50bf96885973 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:24:32 +0200 Subject: [PATCH 05/34] Refactor pagination button styles in PluginList and ToniesList components --- src/components/community/pluginlist/PluginList.tsx | 8 +++++--- .../community/pluginlist/pagination/PluginPagination.tsx | 5 +++-- src/components/tonies/tonieslist/ToniesList.tsx | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/community/pluginlist/PluginList.tsx b/src/components/community/pluginlist/PluginList.tsx index 48438820..c4a0e11f 100644 --- a/src/components/community/pluginlist/PluginList.tsx +++ b/src/components/community/pluginlist/PluginList.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { Alert, Badge, Button, Col, Empty, Row, Tag, Typography } from "antd"; +import { Alert, Badge, Button, Col, Empty, Flex, Row, Tag, Typography } from "antd"; import { AppstoreAddOutlined, QuestionCircleOutlined } from "@ant-design/icons"; import { usePluginList } from "./hooks/usePluginList"; import PluginTemplateDownloadButton from "../../common/buttons/PluginTemplateDownloadButton"; @@ -59,7 +59,7 @@ export const PluginList = () => { console.error("Error parsing stored plugin list state:", error); } } - return 24; + return 12; }); const [currentPage, setCurrentPage] = useState(1); const [paginationEnabled, setPaginationEnabled] = useState(true); @@ -136,7 +136,9 @@ export const PluginList = () => { const listPagination = (
{!paginationEnabled ? ( - + ) : ( = ({ pageSize={pageSize} onChange={onChange} showSizeChanger - pageSizeOptions={["12", "24", "48", "96", "192"]} + pageSizeOptions={["12", "24", "48"]} locale={{ items_per_page: t("community.plugins.pagination.pageSelector") }} - style={{ marginBottom: 0 }} + style={{ marginBottom: 8 }} + styles={{ item: { padding: 0 } }} showLessItems /> + ) : ( Date: Fri, 9 Oct 2026 20:25:16 +0200 Subject: [PATCH 06/34] added changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be7b04bf..0f35a0f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - gui: Fixed sibling episode assignment for library-root files and improved handling of failed directory requests - gui: Expanded automated test coverage for authentication, API requests and core Tonie/library logic - gui: Added support for the optional `hideInNav` property in plugin `plugin.json` files to prevent plugins from being shown in the navigation +- gui: Added pagination in plugin list ### Commits From 2632981f0535f2edf0926d5bdbfc569b39ccbb0c Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:48:49 +0200 Subject: [PATCH 07/34] Refactor PluginCard to remove unnecessary strong tags and update PluginList pagination options --- .../community/plugincard/PluginCard.tsx | 4 +- .../community/pluginlist/PluginList.tsx | 52 ++++++++++++------- .../pagination/PluginPagination.tsx | 3 +- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/src/components/community/plugincard/PluginCard.tsx b/src/components/community/plugincard/PluginCard.tsx index 40f3c0c5..79409546 100644 --- a/src/components/community/plugincard/PluginCard.tsx +++ b/src/components/community/plugincard/PluginCard.tsx @@ -58,7 +58,7 @@ export const PluginCard: React.FC = ({ whiteSpace: "nowrap", }} > - {plugin.pluginName} + {plugin.pluginName}
@@ -74,7 +74,7 @@ export const PluginCard: React.FC = ({ whiteSpace: "nowrap", }} > - {plugin.pluginName} + {plugin.pluginName} ); diff --git a/src/components/community/pluginlist/PluginList.tsx b/src/components/community/pluginlist/PluginList.tsx index c4a0e11f..9337a6e9 100644 --- a/src/components/community/pluginlist/PluginList.tsx +++ b/src/components/community/pluginlist/PluginList.tsx @@ -10,7 +10,6 @@ import { PluginDeleteDialog } from "./modals/PluginDeleteModal"; import { PluginHelpModal } from "./modals/PluginHelpModal"; import { PluginUploadModal } from "./modals/PluginUploadModal"; import PluginPagination from "./pagination/PluginPagination"; -import { scrollToTop } from "../../../utils/browser/browserUtils"; import { userStorage } from "../../../utils/storage/userStorage"; const { Paragraph } = Typography; @@ -74,10 +73,11 @@ export const PluginList = () => { JSON.parse(storedState); if (storedShowAll) { setPageSize(storedPageSize); - handleShowAll(storedPageSize); + setShowAll(true); + setPaginationEnabled(false); } else { setPageSize(storedPageSize); - handlePageSizeChange(1, storedPageSize); + setCurrentPage(1); } } catch (error) { console.error("Error parsing stored plugin list state:", error); @@ -95,11 +95,6 @@ export const PluginList = () => { userStorage.setItem(STORAGE_KEY, stateToStore); }, [pageSize, paginationEnabled, showAll]); - useEffect(() => { - handlePageSizeChange(1, pageSize); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageSize]); - const handleShowAll = (size?: number) => { const effectiveSize = size ?? pageSize; setPageSize(effectiveSize); @@ -116,7 +111,7 @@ export const PluginList = () => { const handlePageSizeChange = (current: number, size: number) => { setPageSize(size); setCurrentPage(current); - setTimeout(() => scrollToTop(pluginListRef.current), 0); + setTimeout(scrollToPluginList, 0); }; const handleSectionFilterChange = (section: string, checked: boolean) => { @@ -129,6 +124,23 @@ export const PluginList = () => { setCurrentPage(1); }; + const scrollToPluginList = () => { + const element = pluginListRef.current; + + if (!element) { + return; + } + + const rect = element.getBoundingClientRect(); + + if (rect.top < 0) { + window.scrollTo({ + top: window.scrollY + rect.top - 16, + behavior: "smooth", + }); + } + }; + const currentPageData = showAll ? filteredPlugins : filteredPlugins.slice((currentPage - 1) * pageSize, currentPage * pageSize); @@ -154,12 +166,6 @@ export const PluginList = () => { return ( <>

{t("community.plugins.title")}

-
{
- + + <>

{t("community.plugins.installedPlugins")}

{allSections.map((section) => { @@ -220,14 +232,14 @@ export const PluginList = () => {
-
+ {filteredPlugins.length === 0 ? ( ) : ( <> {listPagination} - + {currentPageData.map((plugin) => ( { {listPagination} )} -
+ { onConfirm={handleConfirmDelete} onCancel={handleCancelDelete} /> -
+ ); }; diff --git a/src/components/community/pluginlist/pagination/PluginPagination.tsx b/src/components/community/pluginlist/pagination/PluginPagination.tsx index 10fb739f..86302398 100644 --- a/src/components/community/pluginlist/pagination/PluginPagination.tsx +++ b/src/components/community/pluginlist/pagination/PluginPagination.tsx @@ -27,10 +27,9 @@ const PluginPagination: React.FC = ({ pageSize={pageSize} onChange={onChange} showSizeChanger - pageSizeOptions={["12", "24", "48"]} + pageSizeOptions={["6", "12", "24", "48"]} locale={{ items_per_page: t("community.plugins.pagination.pageSelector") }} style={{ marginBottom: 8 }} - styles={{ item: { padding: 0 } }} showLessItems /> - ); - }; - const sanitizeHostname = (input: string) => input.replace(/[^a-zA-Z0-9-.]/g, "").trim(); const handleHostnameChange = (value: string) => { @@ -101,6 +71,7 @@ export const CC3200BoxFlashingGuide: React.FC = () => { const portNumber = Number(port); const isPortInvalid = !isValidAltPort(portNumber) || portNumber === 443; const { createPatch: createPortPatch } = useAltPortCustomPatch(portNumber); + const { createPatch: createHostNamePatch } = useAltUrlCustomPatch(hostname); const prev = () => setCurrentStep((s) => Math.max(0, s - 1)); const next = () => setCurrentStep((s) => Math.min(steps.length - 1, s + 1)); @@ -132,6 +103,8 @@ export const CC3200BoxFlashingGuide: React.FC = () => { hostname={hostname} warningTextHostname={warningTextHostname} onHostnameChange={handleHostnameChange} + onCreateHostNamePatch={createHostNamePatch} + isHostnameInvalid={isHostnameInvalid} port={port} warningTextPort={warningTextPort} onPortChange={handlePortChange} @@ -257,12 +230,7 @@ export const CC3200BoxFlashingGuide: React.FC = () => { }} > {previousButton} -
- -
+
+ {warningTextHostname && (

{warningTextHostname}

From 2c3830d832da0a7199d835e2f0228ff4d29baa6d Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:10:24 +0200 Subject: [PATCH 16/34] fix(translations): update hintPatchFlash messages to clarify hostname and port adjustments --- public/translations/de.json | 2 +- public/translations/en.json | 2 +- public/translations/es.json | 2 +- public/translations/fr.json | 2 +- public/translations/it.json | 2 +- public/translations/pl.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/public/translations/de.json b/public/translations/de.json index 7e9d36a6..839c7e56 100644 --- a/public/translations/de.json +++ b/public/translations/de.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "Das Patchen ist abgeschlossen. Trenne die Toniebox vom UART, entferne den Kurzschluss von J100 und starte die Box neu. Sie sollte sich jetzt mit TeddyCloud verbinden können!", "hintESP32FirmwareReseted": "Das Zurücksetzen sollte abgeschlossen sein. Bitte trenne die Toniebox vom UART, entferne den Kurzschluss von J100 und starte die Box neu. Sie sollte nicht mehr mit deinem Teddycloud verbunden sein. Führe einen Frische-Check durch, um zu testen, ob alles einwandfrei funktioniert.", "hintFlashESP32RevvoxFlasher": "Klicke auf „ESP32 flashen“, wähle falls nötig den COM-Port aus und warte, bis der Schritt abgeschlossen ist!", - "hintPatchFlash": "Um das ausgewählte / gelesene Flash mit dem dem CA-Zertifikat deiner TeddyCloud zu versehen und ggf. weitere Anpassungen wie Hostname der TeddyCloud und/oder der WiFi-Zugangsdaten vorzunehmen, klicke nach Eingabe auf \"Patch\" oder gehe ohne Patching direkt zum nächsten Schritt. ", + "hintPatchFlash": "Um das ausgewählte / gelesene Flash mit dem dem CA-Zertifikat deiner TeddyCloud zu versehen und ggf. weitere Anpassungen wie Hostname und Port der TeddyCloud und/oder der WiFi-Zugangsdaten vorzunehmen, klicke nach Eingabe auf \"Patch\" oder gehe ohne Patching direkt zum nächsten Schritt. ", "hintPatchHost": "Gib den gewünschten Hostnamen deiner TeddyCloud-Instanz ein.", "hintPatchPort": "Port, mit dem sich die Box verbindet. Lass 443 stehen, außer die Box soll TeddyCloud über einen anderen Port erreichen, z. B. über einen Proxy, der nach Port verteilt. CC3235-Boxen und die Toniebox 2 lassen sich nicht patchen: Sie verbinden sich immer mit Port 443.", "hintPatchWifi": "Gib optional die WiFi Zugangsdaten für deine Toniebox ein. Bitte nur setzen, wenn es wirklich nötig ist - normalerweise, wenn deine Toniebox kurz vor dem Patchen mit deinem WLAN verbunden war, ist dies nicht erforderlich.", diff --git a/public/translations/en.json b/public/translations/en.json index 755712cd..d37271b4 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "The patching should be done. Please disconnect the Toniebox from the UART, remove the short circuit from J100 and restart the box. It should now be able to connect to TeddyCloud!", "hintESP32FirmwareReseted": "The reset should be done. Please disconnect the Toniebox from the UART, remove the short circuit from J100 and restart the box. It should be no longer connected to your Teddycloud. Do a freshnesscheck to test if it works fine.", "hintFlashESP32RevvoxFlasher": "Click on \"Flash ESP32\", if necessary choose the COM-Port and wait till the step is done!", - "hintPatchFlash": "To apply the selected/read flash with your TeddyCloud CA certificate and possibly make other adjustments like the TeddyCloud hostname and/or WiFi credentials, click 'Patch' after entering the information or proceed to the next step without patching.", + "hintPatchFlash": "To apply the selected/read flash with your TeddyCloud CA certificate and possibly make other adjustments like the TeddyCloud hostname, port and/or WiFi credentials, click 'Patch' after entering the information or proceed to the next step without patching.", "hintPatchHost": "Enter the desired hostname for your TeddyCloud instance.", "hintPatchPort": "Port the box connects to. Keep 443 unless the box should reach TeddyCloud on another port, e.g. through a proxy that routes by port. CC3235 boxes and the Toniebox 2 cannot be patched: they always connect to port 443.", "hintPatchWifi": "Optionally enter the WiFi credentials for your Toniebox. Please only set this if you really need to - usually, if your Toniebox was connected to your WiFi right before patching, you don't need to.", diff --git a/public/translations/es.json b/public/translations/es.json index 025cb7ce..da3a7be1 100644 --- a/public/translations/es.json +++ b/public/translations/es.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "El parcheo debería estar hecho. Desconecta la Toniebox del UART, quita el cortocircuito de J100 y reinicia la caja. ¡Ahora debería poder conectarse a TeddyCloud!", "hintESP32FirmwareReseted": "El restablecimiento debería estar completado. Por favor, desconecta la Toniebox del UART, retira el cortocircuito de J100 y reinicia la caja. Ya no debería estar conectada a tu Teddycloud. Realiza una comprobación de frescura para verificar que todo funciona bien.", "hintFlashESP32RevvoxFlasher": "Haz clic en \"Flashear ESP32\", elige el puerto COM si es necesario y espera a que el paso termine.", - "hintPatchFlash": "Para aplicar el flash seleccionado/leído con tu certificado CA de TeddyCloud y posiblemente hacer otros ajustes como el nombre de host de TeddyCloud y/o credenciales WiFi, haz clic en 'Parchear' después de ingresar la información o continúa con el siguiente paso sin parchear.", + "hintPatchFlash": "Para aplicar el flash seleccionado/leído con tu certificado CA de TeddyCloud y posiblemente hacer otros ajustes como el nombre de host y el puerto de TeddyCloud y/o credenciales WiFi, haz clic en 'Parchear' después de ingresar la información o continúa con el siguiente paso sin parchear.", "hintPatchHost": "Introduce el nombre de host deseado para tu instancia de TeddyCloud.", "hintPatchPort": "Puerto al que se conecta la Toniebox. Deja 443 salvo que la Toniebox deba conectarse a TeddyCloud a través de otro puerto, por ejemplo mediante un proxy que enruta por puerto. Las Toniebox CC3235 y la Toniebox 2 no se pueden parchear: siempre se conectan al puerto 443.", "hintPatchWifi": "Opcionalmente ingresa las credenciales WiFi para tu Toniebox. Por favor, solo configúralo si realmente es necesario; normalmente, si tu Toniebox estaba conectada a tu WiFi justo antes de aplicar el parche, no necesitas hacerlo.", diff --git a/public/translations/fr.json b/public/translations/fr.json index c5f79d3b..51874120 100644 --- a/public/translations/fr.json +++ b/public/translations/fr.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "Le patchage est terminé. Déconnecte la Toniebox de l'UART, retire le court-circuit du J100 et redémarre la box. Elle devrait maintenant pouvoir se connecter à TeddyCloud !", "hintESP32FirmwareReseted": "La réinitialisation devrait être terminée. Veuillez déconnecter la Toniebox du UART, retirer le court-circuit de J100 et redémarrer la box. Elle ne devrait plus être connectée à ton Teddycloud. Fais une vérification de fraîcheur pour tester si tout fonctionne correctement.", "hintFlashESP32RevvoxFlasher": "Clique sur « Flasher l'ESP32 », choisis le port COM si nécessaire et attends que l'étape soit terminée.", - "hintPatchFlash": "Pour appliquer le flash sélectionné/lu avec le certificat CA de votre TeddyCloud et éventuellement effectuer d'autres ajustements tels que le nom d'hôte de TeddyCloud et/ou les identifiants WiFi, cliquez sur 'Patch' après avoir entré les informations ou passez à l'étape suivante sans patch.", + "hintPatchFlash": "Pour appliquer le flash sélectionné/lu avec le certificat CA de votre TeddyCloud et éventuellement effectuer d'autres ajustements tels que le nom d'hôte et le port de TeddyCloud et/ou les identifiants WiFi, cliquez sur 'Patch' après avoir entré les informations ou passez à l'étape suivante sans patch.", "hintPatchHost": "Entrez le nom d'hôte souhaité pour votre instance TeddyCloud.", "hintPatchPort": "Port auquel la Toniebox se connecte. Garde 443, sauf si la Toniebox doit joindre TeddyCloud sur un autre port, par exemple via un proxy qui route selon le port. Les Toniebox CC3235 et la Toniebox 2 ne peuvent pas être patchées : elles se connectent toujours au port 443.", "hintPatchWifi": "Entrez optionnellement les identifiants WiFi pour votre Toniebox. Merci de ne le faire que si c'est vraiment nécessaire - normalement, si votre Toniebox était connectée à votre WiFi juste avant la mise à jour, ce n'est pas nécessaire.", diff --git a/public/translations/it.json b/public/translations/it.json index ff16efe8..637598c7 100644 --- a/public/translations/it.json +++ b/public/translations/it.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "L'applicazione delle patch dovrebbe essere completata. Scollega il Toniebox dalla UART, rimuovi il cortocircuito da J100 e riavvia il box. Ora dovrebbe potersi connettere a TeddyCloud!", "hintESP32FirmwareReseted": "Il ripristino dovrebbe essere completato. Scollega il Toniebox dalla UART, rimuovi il cortocircuito da J100 e riavvia il box. Non dovrebbe più essere collegato a TeddyCloud. Esegui un freshness check per verificare che funzioni correttamente.", "hintFlashESP32RevvoxFlasher": "Fai clic su \"Flash ESP32\", se necessario scegli la porta COM e attendi il completamento del passaggio!", - "hintPatchFlash": "Per applicare alla flash selezionata/letta il certificato CA TeddyCloud ed eventualmente altre modifiche, come l'hostname di TeddyCloud e/o le credenziali Wi-Fi, fai clic su 'Patch' dopo aver inserito le informazioni oppure passa al passaggio successivo senza applicare patch.", + "hintPatchFlash": "Per applicare alla flash selezionata/letta il certificato CA TeddyCloud ed eventualmente altre modifiche, come l'hostname e il port di TeddyCloud e/o le credenziali Wi-Fi, fai clic su 'Patch' dopo aver inserito le informazioni oppure passa al passaggio successivo senza applicare patch.", "hintPatchHost": "Inserisci l'hostname desiderato per la tua istanza TeddyCloud.", "hintPatchPort": "Porta a cui si connette la Toniebox. Lascia 443, a meno che la Toniebox non debba raggiungere TeddyCloud su un'altra porta, ad esempio tramite un proxy che instrada in base alla porta. Le Toniebox CC3235 e la Toniebox 2 non si possono patchare: si connettono sempre alla porta 443.", "hintPatchWifi": "Facoltativamente inserisci le credenziali Wi-Fi del Toniebox. Impostale solo se realmente necessario: di solito, se il Toniebox era collegato al Wi-Fi subito prima dell'applicazione delle patch, non serve.", diff --git a/public/translations/pl.json b/public/translations/pl.json index 0411e6af..d2a0165b 100644 --- a/public/translations/pl.json +++ b/public/translations/pl.json @@ -1762,7 +1762,7 @@ "hintESP32FirmwareFlashed": "Patchowanie powinno być zakończone. Odłącz Tonieboxa od UART, usuń zwarcie z J100 i zrestartuj boxa. Powinien teraz móc połączyć się z TeddyCloud!", "hintESP32FirmwareReseted": "Reset powinien być zakończony. Odłącz Tonieboxa od UART, usuń zwarcie z J100 i zrestartuj boxa. Nie powinien już być połączony z Twoim Teddycloud. Wykonaj freshnesscheck, aby sprawdzić, czy działa poprawnie.", "hintFlashESP32RevvoxFlasher": "Kliknij \"Flash ESP32\", w razie potrzeby wybierz port COM i poczekaj, aż krok się zakończy!", - "hintPatchFlash": "Aby zastosować wybrany/odczytany flash z certyfikatem CA TeddyCloud i ewentualnie wprowadzić inne zmiany, takie jak nazwa hosta TeddyCloud i/lub dane Wi-Fi, kliknij 'Patch' po wprowadzeniu informacji lub przejdź do następnego kroku bez patchowania.", + "hintPatchFlash": "Aby zastosować wybrany/odczytany flash z certyfikatem CA TeddyCloud i ewentualnie wprowadzić inne zmiany, takie jak nazwa hosta i port TeddyCloud i/lub dane Wi-Fi, kliknij 'Patch' po wprowadzeniu informacji lub przejdź do następnego kroku bez patchowania.", "hintPatchHost": "Wprowadź żądaną nazwę hosta dla swojej instancji TeddyCloud.", "hintPatchPort": "Port, z którym łączy się Toniebox. Pozostaw 443, chyba że Toniebox ma łączyć się z TeddyCloud przez inny port, np. przez proxy kierujące ruch według portu. Toniebox z CC3235 i Toniebox 2 nie można załatać: zawsze łączą się z portem 443.", "hintPatchWifi": "Opcjonalnie wprowadź dane Wi-Fi dla swojego Tonieboxa. Ustaw to tylko, jeśli rzeczywiście musisz - zazwyczaj, jeśli Twój Toniebox był podłączony do Twojej sieci Wi-Fi bezpośrednio przed patchowaniem, nie jest to konieczne.", From 0431fa4b17b7f76af525ab372499a87b313d169a Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:40:45 +0200 Subject: [PATCH 17/34] feat: enhance download functionality with loading indicators for whole and selected file downloads --- .../common/modals/TafTrackDownloadModal.tsx | 57 ++++++++++++++----- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/components/tonies/common/modals/TafTrackDownloadModal.tsx b/src/components/tonies/common/modals/TafTrackDownloadModal.tsx index ff65faa4..f1d07cee 100644 --- a/src/components/tonies/common/modals/TafTrackDownloadModal.tsx +++ b/src/components/tonies/common/modals/TafTrackDownloadModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Checkbox, Flex, Modal, Table, theme } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { DownloadOutlined, PlayCircleOutlined } from "@ant-design/icons"; +import { DownloadOutlined, LoadingOutlined, PlayCircleOutlined } from "@ant-design/icons"; import styled from "styled-components"; import { @@ -74,6 +74,8 @@ export const TafDownloadPanel: React.FC = ({ const canSplit = tracks.length > 1; const allKeys = useMemo(() => tracks.map((track) => track.number), [tracks]); const [selectedKeys, setSelectedKeys] = useState([]); + const [downloadingWholeFile, setDownloadingWholeFile] = useState(false); + const [downloadingSelected, setDownloadingSelected] = useState(false); useEffect(() => { setSelectedKeys([]); @@ -81,11 +83,24 @@ export const TafDownloadPanel: React.FC = ({ const downloadBase = sanitizeDownloadName(baseFilename || "download"); - const downloadWholeFile = () => { - triggerBrowserDownload(buildTafDownloadUrl(contentUrl), `${downloadBase}.ogg`); + const downloadWholeFile = async () => { + if (downloadingWholeFile) { + return; + } + + setDownloadingWholeFile(true); + try { + await triggerBrowserDownload(buildTafDownloadUrl(contentUrl), `${downloadBase}.ogg`); + } finally { + setDownloadingWholeFile(false); + } }; - const downloadSelected = () => { + const downloadSelected = async () => { + if (downloadingSelected) { + return; + } + const selected = tracks.filter((track) => selectedKeys.includes(track.number)); if (selected.length === 0) { return; @@ -96,12 +111,18 @@ export const TafDownloadPanel: React.FC = ({ return `${padTrackNumber(track.number)} ${sanitizeDownloadName(title)}.ogg`; }); const filename = selected.length === 1 ? entries[0] : `${downloadBase}.zip`; - triggerBrowserDownload( - buildTafDownloadUrl(contentUrl, { - tracks: selected.map((track) => track.number), - }), - filename, - ); + + setDownloadingSelected(true); + try { + await triggerBrowserDownload( + buildTafDownloadUrl(contentUrl, { + tracks: selected.map((track) => track.number), + }), + filename, + ); + } finally { + setDownloadingSelected(false); + } }; if (!canSplit) { @@ -121,7 +142,8 @@ export const TafDownloadPanel: React.FC = ({ ))} + + @@ -255,12 +383,6 @@ export const TeddyStudio: React.FC = () => { ) : ( {t("tonies.teddystudio.empty")} )} - = ({ labelSpacingY, printMode, showLabelBorder, - paperSize, - customPaperWidth, - customPaperHeight, - paperMarginTop, - paperMarginLeft, paperLabelImageBleed, } = settings; - useEffect(() => { - const root = document.documentElement; - const paperSizeValue = - paperSize === "Custom" ? `${customPaperWidth} ${customPaperHeight}` : paperSize; - - root.style.setProperty("--paper-size", paperSizeValue); - root.style.setProperty("--paper-margin-top", paperMarginTop); - root.style.setProperty("--paper-margin-left", paperMarginLeft); - }, [paperSize, customPaperWidth, customPaperHeight, paperMarginTop, paperMarginLeft]); - return (
{ + it("writes fixed PDF page dimensions and a valid cross-reference table", () => { + const bytes = buildPdfFromJpegPages([ + { + jpeg: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + pixelWidth: 1, + pixelHeight: 1, + widthPt: 595.276, + heightPt: 841.89, + }, + ]); + const text = new TextDecoder("latin1").decode(bytes); + + expect(text.startsWith("%PDF-1.4")).toBe(true); + expect(text).toContain("/MediaBox [0 0 595.276 841.89]"); + expect(text).toContain("/Filter /DCTDecode"); + expect(text).toContain("/PrintScaling /None"); + expect(text).toContain("xref\n0 6"); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + }); +}); diff --git a/src/components/tonies/teddystudio/print/pdfDocument.ts b/src/components/tonies/teddystudio/print/pdfDocument.ts new file mode 100644 index 00000000..ad93562d --- /dev/null +++ b/src/components/tonies/teddystudio/print/pdfDocument.ts @@ -0,0 +1,118 @@ +export interface PdfPageImage { + jpeg: Uint8Array; + pixelWidth: number; + pixelHeight: number; + widthPt: number; + heightPt: number; +} + +const encoder = new TextEncoder(); + +const ascii = (value: string): Uint8Array => encoder.encode(value); + +const concatBytes = (chunks: Uint8Array[]): Uint8Array => { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + chunks.forEach((chunk) => { + result.set(chunk, offset); + offset += chunk.length; + }); + + return result; +}; + +const formatNumber = (value: number): string => { + const rounded = Math.round(value * 1000) / 1000; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(3).replace(/0+$/, ""); +}; + +/** + * Minimal PDF writer for page-sized JPEG images. This intentionally has no runtime dependency on + * a PDF library: the page geometry is fixed in PDF points and the browser is only used to create + * the page bitmap, not to lay out or print HTML. + */ +export const buildPdfFromJpegPages = (pages: PdfPageImage[]): Uint8Array => { + if (pages.length === 0) { + throw new Error("Cannot build an empty PDF."); + } + + const objectCount = 2 + pages.length * 3; + const objectChunks: Uint8Array[][] = Array.from({ length: objectCount + 1 }, () => []); + + objectChunks[1] = [ + ascii("<< /Type /Catalog /Pages 2 0 R /ViewerPreferences << /PrintScaling /None >> >>"), + ]; + + const pageObjectIds = pages.map((_, index) => 3 + index * 3); + objectChunks[2] = [ + ascii( + `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds + .map((id) => `${id} 0 R`) + .join(" ")}] >>`, + ), + ]; + + pages.forEach((page, index) => { + const pageObjectId = 3 + index * 3; + const imageObjectId = pageObjectId + 1; + const contentObjectId = pageObjectId + 2; + const pageWidth = formatNumber(page.widthPt); + const pageHeight = formatNumber(page.heightPt); + + objectChunks[pageObjectId] = [ + ascii( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] ` + + `/Resources << /XObject << /Im0 ${imageObjectId} 0 R >> >> ` + + `/Contents ${contentObjectId} 0 R >>`, + ), + ]; + + objectChunks[imageObjectId] = [ + ascii( + `<< /Type /XObject /Subtype /Image /Width ${page.pixelWidth} /Height ${page.pixelHeight} ` + + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`, + ), + page.jpeg, + ascii("\nendstream"), + ]; + + const content = `q\n${pageWidth} 0 0 ${pageHeight} 0 0 cm\n` + "/Im0 Do\n" + "Q\n"; + const contentBytes = ascii(content); + objectChunks[contentObjectId] = [ + ascii(`<< /Length ${contentBytes.length} >>\nstream\n`), + contentBytes, + ascii("endstream"), + ]; + }); + + const chunks: Uint8Array[] = [ascii("%PDF-1.4\n")]; + const offsets = new Array(objectCount + 1).fill(0); + let byteOffset = chunks[0].length; + + for (let objectId = 1; objectId <= objectCount; objectId++) { + offsets[objectId] = byteOffset; + const header = ascii(`${objectId} 0 obj\n`); + const footer = ascii("\nendobj\n"); + const body = objectChunks[objectId]; + + chunks.push(header, ...body, footer); + byteOffset += + header.length + body.reduce((sum, chunk) => sum + chunk.length, 0) + footer.length; + } + + const xrefOffset = byteOffset; + const xrefLines = ["xref", `0 ${objectCount + 1}`, "0000000000 65535 f "]; + for (let objectId = 1; objectId <= objectCount; objectId++) { + xrefLines.push(`${String(offsets[objectId]).padStart(10, "0")} 00000 n `); + } + + const trailer = + `${xrefLines.join("\n")}\n` + + `trailer\n<< /Size ${objectCount + 1} /Root 1 0 R >>\n` + + `startxref\n${xrefOffset}\n%%EOF\n`; + chunks.push(ascii(trailer)); + + return concatBytes(chunks); +}; diff --git a/src/components/tonies/teddystudio/print/printFile.test.ts b/src/components/tonies/teddystudio/print/printFile.test.ts new file mode 100644 index 00000000..ae4a37e8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFile.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { createTeddyStudioPdfFilename } from "./printFile"; + +describe("createTeddyStudioPdfFilename", () => { + it("uses local date and time in a filesystem-safe filename", () => { + const date = new Date(2026, 9, 17, 8, 49, 48); + + expect(createTeddyStudioPdfFilename(date)).toBe( + "teddystudio_labels_2026-10-17_08-49-48.pdf", + ); + }); +}); diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts new file mode 100644 index 00000000..5be67899 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFile.ts @@ -0,0 +1,10 @@ +const pad2 = (value: number): string => String(value).padStart(2, "0"); + +/** + * Generates a filesystem-safe local timestamp for TeddyStudio PDF exports. + * Example: teddystudio_labels_2026-10-17_08-49-48.pdf + */ +export const createTeddyStudioPdfFilename = (date = new Date()): string => + `teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2( + date.getHours(), + )}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`; diff --git a/src/components/tonies/teddystudio/print/printFonts.ts b/src/components/tonies/teddystudio/print/printFonts.ts new file mode 100644 index 00000000..17cf77af --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFonts.ts @@ -0,0 +1,222 @@ +const LATIN_RANGE = + "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"; +const LATIN_EXT_RANGE = + "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"; + +type BundledFontSource = { + url: string; + unicodeRange: string; +}; + +type MutableFontFaceSet = FontFaceSet & { + add: (font: FontFace) => FontFaceSet; +}; + +const BUNDLED_FONTS: Record = { + "Atkinson Hyperlegible": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Fredoka: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/fredoka-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/fredoka-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Bubblegum Sans": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/bubblegum-sans-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/bubblegum-sans-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Patrick Hand": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/patrick-hand-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/patrick-hand-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Indie Flower": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/indie-flower-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/indie-flower-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Schoolbell: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/schoolbell-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + ], + Inter: [ + { + url: new URL("../../../../assets/fonts/teddystudio/inter-latin.woff2", import.meta.url) + .href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/inter-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Roboto: [ + { + url: new URL("../../../../assets/fonts/teddystudio/roboto-latin.woff2", import.meta.url) + .href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/roboto-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Open Sans": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/open-sans-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/open-sans-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Merriweather: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/merriweather-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/merriweather-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], +}; + +const loadedBundledFonts = new Map>(); + +const primaryFamilyName = (fontFamily: string): string => { + const first = fontFamily.split(",", 1)[0]?.trim() || fontFamily.trim(); + return first.replace(/^['"]|['"]$/g, ""); +}; + +const loadBundledFont = (family: string): Promise => { + const cached = loadedBundledFonts.get(family); + if (cached) return cached; + + const sources = BUNDLED_FONTS[family]; + if (!sources || typeof FontFace === "undefined" || !document.fonts) { + return Promise.resolve(); + } + + const promise = Promise.all( + sources.map(async ({ url, unicodeRange }) => { + const face = new FontFace(family, `url("${url}") format("woff2")`, { + style: "normal", + weight: "400", + unicodeRange, + }); + await face.load(); + const fontSet = document.fonts as MutableFontFaceSet; + if (typeof fontSet.add === "function") { + fontSet.add(face); + } + }), + ).then(() => undefined); + + loadedBundledFonts.set(family, promise); + return promise; +}; + +/** + * Ensures that fonts used by the canvas renderer are available without relying on @font-face CSS. + * System fonts still resolve through the browser/OS as usual. + */ +export const ensurePrintFonts = async (fontFamilies: Iterable): Promise => { + const families = Array.from(new Set(fontFamilies)).filter(Boolean); + + await Promise.all( + families.map(async (fontFamily) => { + const primary = primaryFamilyName(fontFamily); + await loadBundledFont(primary); + if (document.fonts) { + await document.fonts.load(`16px ${fontFamily}`); + } + }), + ); + + if (document.fonts) { + await document.fonts.ready; + } +}; diff --git a/src/components/tonies/teddystudio/print/printLayout.test.ts b/src/components/tonies/teddystudio/print/printLayout.test.ts new file mode 100644 index 00000000..ac5ea0a8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printLayout.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { SettingsState } from "../hooks/useSettings"; +import { createPrintLayout } from "./printLayout"; + +const settings = (patch: Partial = {}): SettingsState => ({ + diameter: "40mm", + labelShape: "round", + printMode: "ImageAndText", + width: "50mm", + height: "30mm", + labelSpacingX: "5mm", + labelSpacingY: "5mm", + labelBackgroundColor: "#ffffff", + paperSize: "A4", + customPaperWidth: "210mm", + customPaperHeight: "297mm", + paperMarginTop: "10mm", + paperMarginLeft: "10mm", + paperLabelImageBleed: "0mm", + textFontSize: "14px", + fontFamily: "sans-serif", + imagePosition: "center", + imageScale: 1, + imageBottom: 0, + imageLeft: 0, + contentPadding: "0mm", + showLanguageFlag: false, + showModelNo: false, + showSeriesOnImageLabel: false, + seriesOnImageLabelRotationDeg: 0, + seriesOnImageLabelFontSize: "12px", + printTrackListInsteadTitle: false, + showLabelBorder: true, + selectedPaper: undefined, + ...patch, +}); + +describe("createPrintLayout", () => { + it("keeps image/text labels together and wraps groups using millimetres", () => { + const pages = createPrintLayout(3, settings()); + + expect(pages).toHaveLength(1); + expect(pages[0].placements).toEqual([ + { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 0, kind: "text", xMm: 55, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 1, kind: "image", xMm: 100, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 1, kind: "text", xMm: 145, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 2, kind: "image", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 }, + { itemIndex: 2, kind: "text", xMm: 55, yMm: 55, widthMm: 40, heightMm: 40 }, + ]); + }); + + it("lays out one label per item in OnlyImage mode", () => { + const pages = createPrintLayout(5, settings({ printMode: "OnlyImage" })); + + expect(pages[0].placements.map(({ xMm, yMm }) => [xMm, yMm])).toEqual([ + [10, 10], + [55, 10], + [100, 10], + [145, 10], + [10, 55], + ]); + }); + + it("stacks image/text labels if the pair cannot fit horizontally", () => { + const pages = createPrintLayout( + 1, + settings({ + paperSize: "Custom", + customPaperWidth: "60mm", + customPaperHeight: "120mm", + }), + ); + + expect(pages[0].placements).toEqual([ + { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 0, kind: "text", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 }, + ]); + }); + + it("starts another PDF page when the next group no longer fits vertically", () => { + const pages = createPrintLayout( + 2, + settings({ + printMode: "OnlyImage", + paperSize: "Custom", + customPaperWidth: "55mm", + customPaperHeight: "55mm", + paperMarginTop: "10mm", + paperMarginLeft: "10mm", + }), + ); + + expect(pages).toHaveLength(2); + expect(pages[0].placements[0]).toMatchObject({ itemIndex: 0, xMm: 10, yMm: 10 }); + expect(pages[1].placements[0]).toMatchObject({ itemIndex: 1, xMm: 10, yMm: 10 }); + }); +}); diff --git a/src/components/tonies/teddystudio/print/printLayout.ts b/src/components/tonies/teddystudio/print/printLayout.ts new file mode 100644 index 00000000..7a4b3ccc --- /dev/null +++ b/src/components/tonies/teddystudio/print/printLayout.ts @@ -0,0 +1,173 @@ +import type { SettingsState } from "../hooks/useSettings"; + +export type PrintLabelKind = "image" | "text"; + +export interface PrintLabelPlacement { + itemIndex: number; + kind: PrintLabelKind; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; +} + +export interface PrintPageLayout { + widthMm: number; + heightMm: number; + placements: PrintLabelPlacement[]; +} + +const PAPER_SIZES_MM: Record, [number, number]> = { + A4: [210, 297], + A5: [148, 210], + Letter: [215.9, 279.4], +}; + +const EPSILON = 0.001; + +export const parseMm = (value: string, settingName: string): number => { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid ${settingName}: ${value}`); + } + return parsed; +}; + +export const getPaperSizeMm = (settings: SettingsState): [number, number] => { + if (settings.paperSize === "Custom") { + const width = parseMm(settings.customPaperWidth, "custom paper width"); + const height = parseMm(settings.customPaperHeight, "custom paper height"); + if (width <= 0 || height <= 0) { + throw new Error("Custom paper dimensions must be greater than zero."); + } + return [width, height]; + } + + return PAPER_SIZES_MM[settings.paperSize]; +}; + +const getLabelSizeMm = (settings: SettingsState): [number, number] => { + if (settings.labelShape === "round") { + const diameter = parseMm(settings.diameter, "label diameter"); + if (diameter <= 0) { + throw new Error("Label diameter must be greater than zero."); + } + return [diameter, diameter]; + } + + const width = parseMm(settings.width, "label width"); + const height = parseMm(settings.height, "label height"); + if (width <= 0 || height <= 0) { + throw new Error("Label dimensions must be greater than zero."); + } + return [width, height]; +}; + +const getKinds = (settings: SettingsState): PrintLabelKind[] => { + switch (settings.printMode) { + case "OnlyImage": + return ["image"]; + case "OnlyText": + return ["text"]; + case "ImageAndText": + default: + return ["image", "text"]; + } +}; + +/** + * Builds the physical page/label geometry used by the PDF renderer. + * All values are in millimetres; no DOM or CSS measurements are involved. + */ +export const createPrintLayout = ( + itemCount: number, + settings: SettingsState, +): PrintPageLayout[] => { + const [pageWidthMm, pageHeightMm] = getPaperSizeMm(settings); + const [labelWidthMm, labelHeightMm] = getLabelSizeMm(settings); + const marginLeftMm = parseMm(settings.paperMarginLeft, "left paper margin"); + const marginTopMm = parseMm(settings.paperMarginTop, "top paper margin"); + const spacingXmm = parseMm(settings.labelSpacingX, "horizontal label spacing"); + const spacingYmm = parseMm(settings.labelSpacingY, "vertical label spacing"); + const kinds = getKinds(settings); + + const usableWidthMm = pageWidthMm - marginLeftMm; + const usableHeightMm = pageHeightMm - marginTopMm; + + if (usableWidthMm <= 0 || usableHeightMm <= 0) { + throw new Error("Paper margins leave no printable area."); + } + if (labelWidthMm > usableWidthMm + EPSILON || labelHeightMm > usableHeightMm + EPSILON) { + throw new Error("The configured label does not fit on the selected paper size."); + } + + const horizontalGroupWidthMm = + kinds.length * labelWidthMm + Math.max(0, kinds.length - 1) * spacingXmm; + const stackGroup = kinds.length > 1 && horizontalGroupWidthMm > usableWidthMm + EPSILON; + const groupWidthMm = stackGroup ? labelWidthMm : horizontalGroupWidthMm; + const groupHeightMm = stackGroup + ? kinds.length * labelHeightMm + Math.max(0, kinds.length - 1) * spacingYmm + : labelHeightMm; + + if (groupHeightMm > usableHeightMm + EPSILON) { + throw new Error( + "The configured image/text label pair does not fit on the selected paper size.", + ); + } + + const pages: PrintPageLayout[] = []; + let currentPage: PrintPageLayout = { + widthMm: pageWidthMm, + heightMm: pageHeightMm, + placements: [], + }; + pages.push(currentPage); + + let xMm = marginLeftMm; + let yMm = marginTopMm; + let rowHeightMm = 0; + + const startNewPage = () => { + currentPage = { + widthMm: pageWidthMm, + heightMm: pageHeightMm, + placements: [], + }; + pages.push(currentPage); + xMm = marginLeftMm; + yMm = marginTopMm; + rowHeightMm = 0; + }; + + const startNewRow = () => { + xMm = marginLeftMm; + yMm += rowHeightMm + spacingYmm; + rowHeightMm = 0; + }; + + for (let itemIndex = 0; itemIndex < itemCount; itemIndex++) { + if (xMm > marginLeftMm + EPSILON && xMm + groupWidthMm > pageWidthMm + EPSILON) { + startNewRow(); + } + + if (yMm + groupHeightMm > pageHeightMm + EPSILON) { + startNewPage(); + } + + kinds.forEach((kind, kindIndex) => { + currentPage.placements.push({ + itemIndex, + kind, + xMm: stackGroup ? xMm : xMm + kindIndex * (labelWidthMm + spacingXmm), + yMm: stackGroup ? yMm + kindIndex * (labelHeightMm + spacingYmm) : yMm, + widthMm: labelWidthMm, + heightMm: labelHeightMm, + }); + }); + + rowHeightMm = Math.max(rowHeightMm, groupHeightMm); + xMm += groupWidthMm + spacingXmm; + } + + return pages; +}; diff --git a/src/components/tonies/teddystudio/print/teddyStudioPdf.ts b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts new file mode 100644 index 00000000..b4b986a8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts @@ -0,0 +1,679 @@ +import type { MergedItem } from "../hooks/useCustomItems"; +import type { SettingsState } from "../hooks/useSettings"; +import { buildEffectiveSettings, type LabelOverridesById } from "../types/labelOverrides"; +import { getContrastTextColor } from "../../../../utils/helper"; +import { withBase } from "../../../../utils/basePath"; +import { toImageSrc } from "../../common/utils/imagePathUtils"; +import { loadFlagAsset } from "../../../common/icons/languageFlagAssets"; +import { buildPdfFromJpegPages, type PdfPageImage } from "./pdfDocument"; +import { createPrintLayout, parseMm, type PrintLabelPlacement } from "./printLayout"; +import { ensurePrintFonts } from "./printFonts"; + +const PRINT_DPI = 300; +const CSS_DPI = 96; +const MM_PER_INCH = 25.4; +const PT_PER_INCH = 72; + +const mmToCanvasPx = (mm: number): number => (mm * PRINT_DPI) / MM_PER_INCH; +const cssPxToCanvasPx = (px: number): number => (px * PRINT_DPI) / CSS_DPI; +const mmToPt = (mm: number): number => (mm * PT_PER_INCH) / MM_PER_INCH; + +const cssPx = (value: string | number, fallback = 14): number => { + const parsed = typeof value === "number" ? value : Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const canvasToJpeg = async (canvas: HTMLCanvasElement): Promise => { + const blob = await new Promise((resolve, reject) => { + try { + canvas.toBlob( + (value) => + value ? resolve(value) : reject(new Error("Could not encode print page.")), + "image/jpeg", + 0.96, + ); + } catch (error) { + reject(error); + } + }); + return new Uint8Array(await blob.arrayBuffer()); +}; + +const loadHtmlImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Could not load image: ${url}`)); + image.src = url; + }); + +export interface TeddyStudioPdfWarning { + code: "image-load-failed" | "font-load-failed"; + message: string; + url?: string; +} + +class ImageCache { + private readonly cache = new Map>(); + + constructor(private readonly onWarning?: (warning: TeddyStudioPdfWarning) => void) {} + + load(rawUrl: string): Promise { + const url = withBase(rawUrl); + const cached = this.cache.get(url); + if (cached) return cached; + + const promise = this.loadUncached(url); + this.cache.set(url, promise); + return promise; + } + + private async loadUncached(url: string): Promise { + try { + if (url.startsWith("blob:") || url.startsWith("data:")) { + return await loadHtmlImage(url); + } + + const response = await fetch(url, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + return await loadHtmlImage(objectUrl); + } finally { + URL.revokeObjectURL(objectUrl); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.onWarning?.({ + code: "image-load-failed", + message: `Could not load image ${url}: ${reason}`, + url, + }); + return null; + } + } +} + +const objectPositionFactors = (position: string): [number, number] => { + const normalized = (position || "center").toLowerCase(); + const x = normalized.includes("left") ? 0 : normalized.includes("right") ? 1 : 0.5; + const y = normalized.includes("top") ? 0 : normalized.includes("bottom") ? 1 : 0.5; + return [x, y]; +}; + +const beginLabelPath = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, +) => { + ctx.beginPath(); + if (round) { + ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + } else { + ctx.rect(x, y, width, height); + } +}; + +const withLabelClip = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, + draw: () => void, +) => { + ctx.save(); + beginLabelPath(ctx, x, y, width, height, round); + ctx.clip(); + draw(); + ctx.restore(); +}; + +const drawCoverImage = ( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, + position: string, + scale: number, + offsetLeftCssPx: number, + offsetBottomCssPx: number, +) => { + const [positionX, positionY] = objectPositionFactors(position); + const imageWidth = image.naturalWidth || image.width; + const imageHeight = image.naturalHeight || image.height; + const targetRatio = width / height; + const sourceRatio = imageWidth / imageHeight; + + let sourceWidth = imageWidth; + let sourceHeight = imageHeight; + if (sourceRatio > targetRatio) { + sourceWidth = imageHeight * targetRatio; + } else { + sourceHeight = imageWidth / targetRatio; + } + + const sourceX = Math.max(0, (imageWidth - sourceWidth) * positionX); + const sourceY = Math.max(0, (imageHeight - sourceHeight) * positionY); + const safeScale = Number.isFinite(scale) && scale >= 0 ? scale : 1; + const scaledWidth = width * safeScale; + const scaledHeight = height * safeScale; + const offsetLeft = cssPxToCanvasPx(offsetLeftCssPx); + const offsetBottom = cssPxToCanvasPx(offsetBottomCssPx); + const targetX = x + offsetLeft - (scaledWidth - width) / 2; + const targetY = y - offsetBottom - (scaledHeight - height) / 2; + + if (scaledWidth <= 0 || scaledHeight <= 0) return; + + ctx.drawImage( + image, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + targetX, + targetY, + scaledWidth, + scaledHeight, + ); +}; + +const breakLongWord = (ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] => { + const parts: string[] = []; + let current = ""; + + for (const char of word) { + const candidate = current + char; + if (current && ctx.measureText(candidate).width > maxWidth) { + parts.push(current); + current = char; + } else { + current = candidate; + } + } + + if (current) parts.push(current); + return parts.length ? parts : [word]; +}; + +const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => { + if (!text) return []; + + const result: string[] = []; + + text.split("\n").forEach((paragraph) => { + if (paragraph === "") { + result.push(""); + return; + } + + let line = ""; + paragraph.split(/\s+/).forEach((word) => { + if (ctx.measureText(word).width <= maxWidth) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + result.push(line); + line = word; + } else { + line = candidate; + } + return; + } + + if (line) { + result.push(line); + line = ""; + } + + const parts = breakLongWord(ctx, word, maxWidth); + result.push(...parts.slice(0, -1)); + line = parts.at(-1) ?? ""; + }); + + if (line) result.push(line); + }); + + return result; +}; + +const drawCenteredLines = ( + ctx: CanvasRenderingContext2D, + lines: string[], + x: number, + centerY: number, + lineHeight: number, +) => { + if (!lines.length) return; + const totalHeight = lines.length * lineHeight; + let y = centerY - totalHeight / 2 + lineHeight / 2; + + lines.forEach((line) => { + ctx.fillText(line, x, y); + y += lineHeight; + }); +}; + +const fitCircularFontSize = ( + ctx: CanvasRenderingContext2D, + text: string, + fontFamily: string, + minFontPx: number, + maxFontPx: number, + outerRadius: number, +): number => { + let low = minFontPx; + let high = Math.max(minFontPx, maxFontPx); + let best = minFontPx; + + for (let i = 0; i < 12; i++) { + const current = (low + high) / 2; + ctx.font = `${current}px ${fontFamily}`; + const radius = Math.max(1, outerRadius - current * 0.75); + const fits = ctx.measureText(text).width <= Math.PI * 2 * radius; + if (fits) { + best = current; + low = current; + } else { + high = current; + } + } + + return best; +}; + +const drawCircularText = ( + ctx: CanvasRenderingContext2D, + text: string, + centerX: number, + centerY: number, + size: number, + requestedFontSize: number, + color: string, + fontFamily: string, + rotateDeg: number, +) => { + if (!text) return; + + const padding = cssPxToCanvasPx(4); + const outerRadius = Math.max(1, (size - 2 * padding) / 2); + const fontSize = fitCircularFontSize( + ctx, + text, + fontFamily, + cssPxToCanvasPx(10), + requestedFontSize, + outerRadius, + ); + const textRadius = Math.max(1, outerRadius - fontSize * 0.75); + + ctx.save(); + ctx.font = `${fontSize}px ${fontFamily}`; + ctx.fillStyle = color; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const characters = Array.from(text); + const rawWidths = characters.map((char) => ctx.measureText(char).width); + const rawTotalWidth = rawWidths.reduce((sum, width) => sum + width, 0); + const measuredWidth = ctx.measureText(text).width; + const kerningScale = rawTotalWidth > 0 ? measuredWidth / rawTotalWidth : 1; + const widths = rawWidths.map((width) => width * kerningScale); + const totalWidth = widths.reduce((sum, width) => sum + width, 0); + let distance = -totalWidth / 2; + const centerAngle = (270 + rotateDeg) * (Math.PI / 180); + + characters.forEach((char, index) => { + const width = widths[index]; + const charDistance = distance + width / 2; + const angle = centerAngle + charDistance / textRadius; + const x = centerX + Math.cos(angle) * textRadius; + const y = centerY + Math.sin(angle) * textRadius; + + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle + Math.PI / 2); + ctx.fillText(char, 0, 0); + ctx.restore(); + + distance += width; + }); + + ctx.restore(); +}; + +const drawLabelBorder = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, + color: string, +) => { + const lineWidth = cssPxToCanvasPx(1); + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + beginLabelPath( + ctx, + x + lineWidth / 2, + y + lineWidth / 2, + Math.max(0, width - lineWidth), + Math.max(0, height - lineWidth), + round, + ); + ctx.stroke(); + ctx.restore(); +}; + +const drawImageLabel = async ( + ctx: CanvasRenderingContext2D, + item: MergedItem, + placement: PrintLabelPlacement, + settings: SettingsState, + imageCache: ImageCache, +) => { + const x = mmToCanvasPx(placement.xMm); + const y = mmToCanvasPx(placement.yMm); + const width = mmToCanvasPx(placement.widthMm); + const height = mmToCanvasPx(placement.heightMm); + const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed")); + const round = settings.labelShape === "round"; + const backgroundColor = settings.labelBackgroundColor || "#ffffff"; + const textColor = getContrastTextColor(backgroundColor); + const bleedX = x - bleed; + const bleedY = y - bleed; + const bleedWidth = width + bleed * 2; + const bleedHeight = height + bleed * 2; + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + ctx.fillStyle = backgroundColor; + ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight); + }); + + if (item.pic) { + const image = await imageCache.load(toImageSrc(item.pic)); + if (image) { + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + drawCoverImage( + ctx, + image, + bleedX, + bleedY, + bleedWidth, + bleedHeight, + settings.imagePosition, + settings.imageScale, + settings.imageLeft, + settings.imageBottom, + ); + }); + } + } + + if (settings.showSeriesOnImageLabel) { + const labelText = `${item.text ?? ""}${item.episodes ? ` - ${item.episodes}` : ""}`; + const fontFamily = settings.fontFamily || "sans-serif"; + const requestedFontSize = cssPxToCanvasPx( + cssPx(settings.seriesOnImageLabelFontSize || settings.textFontSize, 12), + ); + + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + if (round) { + drawCircularText( + ctx, + labelText, + x + width / 2, + y + height / 2, + cssPxToCanvasPx(150), + requestedFontSize, + textColor, + fontFamily, + settings.seriesOnImageLabelRotationDeg || 0, + ); + } else { + withLabelClip(ctx, x, y, width, height, false, () => { + ctx.font = `${requestedFontSize}px ${fontFamily}`; + ctx.fillText(labelText, x + width / 2, y + height / 2); + }); + } + } + + if (settings.showLabelBorder) { + drawLabelBorder(ctx, x, y, width, height, round, textColor); + } +}; + +const loadFlagImage = async ( + language: string, + imageCache: ImageCache, +): Promise => { + const code = language.toUpperCase().split("-")[1]; + const flagUrl = await loadFlagAsset(code || "UNKNOWN"); + const fallbackUrl = flagUrl ?? (await loadFlagAsset("UNKNOWN")); + return fallbackUrl ? imageCache.load(fallbackUrl) : null; +}; + +const drawFlagImage = ( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + height: number, +) => { + const naturalWidth = image.naturalWidth || image.width || 3; + const naturalHeight = image.naturalHeight || image.height || 2; + const width = height * (naturalWidth / naturalHeight); + ctx.drawImage(image, x - width / 2, y, width, height); +}; + +const drawTextLabel = async ( + ctx: CanvasRenderingContext2D, + item: MergedItem, + placement: PrintLabelPlacement, + settings: SettingsState, + imageCache: ImageCache, +) => { + const x = mmToCanvasPx(placement.xMm); + const y = mmToCanvasPx(placement.yMm); + const width = mmToCanvasPx(placement.widthMm); + const height = mmToCanvasPx(placement.heightMm); + const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed")); + const round = settings.labelShape === "round"; + const backgroundColor = settings.labelBackgroundColor || "#ffffff"; + const textColor = getContrastTextColor(backgroundColor); + const fontFamily = settings.fontFamily || "sans-serif"; + const fontSize = cssPxToCanvasPx(cssPx(settings.textFontSize, 14)); + const smallFontSize = fontSize * 0.833333; + const lineHeight = fontSize * 1.2; + const smallLineHeight = smallFontSize * 1.2; + const contentPadding = mmToCanvasPx( + parseMm(settings.contentPadding || "0mm", "content padding"), + ); + const bleedX = x - bleed; + const bleedY = y - bleed; + const bleedWidth = width + bleed * 2; + const bleedHeight = height + bleed * 2; + const flagImage = + !settings.printTrackListInsteadTitle && settings.showLanguageFlag && item.language + ? await loadFlagImage(item.language, imageCache) + : null; + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + ctx.fillStyle = backgroundColor; + ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight); + }); + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + const innerLeft = x + contentPadding + cssPxToCanvasPx(8); + const innerRight = x + width - contentPadding - cssPxToCanvasPx(8); + const innerWidth = Math.max(cssPxToCanvasPx(4), innerRight - innerLeft); + const topY = y + contentPadding + cssPxToCanvasPx(2); + const bottomY = y + height - contentPadding - cssPxToCanvasPx(2); + + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const topBandHeight = Math.max(cssPxToCanvasPx(12), fontSize); + const bottomBandHeight = Math.max(cssPxToCanvasPx(12), smallFontSize); + const middleTop = topY + topBandHeight; + const middleBottom = bottomY - bottomBandHeight; + const middleCenter = (middleTop + middleBottom) / 2; + + if (flagImage) { + drawFlagImage(ctx, flagImage, x + width / 2, topY, fontSize); + } + + if (settings.printTrackListInsteadTitle) { + ctx.font = `${fontSize}px ${fontFamily}`; + const lines = wrapText(ctx, item.trackTitles.join("\n"), innerWidth); + drawCenteredLines(ctx, lines, x + width / 2, (topY + bottomY) / 2, lineHeight); + } else { + const title = item.text ?? ""; + const episodes = item.episodes ?? ""; + ctx.font = `bold ${fontSize}px ${fontFamily}`; + const titleLines = wrapText(ctx, title, innerWidth); + ctx.font = `${fontSize}px ${fontFamily}`; + const episodeLines = wrapText(ctx, episodes, innerWidth); + const gap = titleLines.length && episodeLines.length ? cssPxToCanvasPx(4) : 0; + const totalHeight = + titleLines.length * lineHeight + episodeLines.length * lineHeight + gap; + let lineY = middleCenter - totalHeight / 2 + lineHeight / 2; + + ctx.font = `bold ${fontSize}px ${fontFamily}`; + titleLines.forEach((line) => { + ctx.fillText(line, x + width / 2, lineY); + lineY += lineHeight; + }); + lineY += gap; + ctx.font = `${fontSize}px ${fontFamily}`; + episodeLines.forEach((line) => { + ctx.fillText(line, x + width / 2, lineY); + lineY += lineHeight; + }); + } + + if (!settings.printTrackListInsteadTitle && settings.showModelNo && item.model) { + ctx.font = `${smallFontSize}px ${fontFamily}`; + const modelLines = wrapText(ctx, item.model, innerWidth); + if (modelLines.length) { + ctx.fillText(modelLines[0], x + width / 2, bottomY - smallLineHeight / 2); + } + } + }); + + if (settings.showLabelBorder) { + drawLabelBorder(ctx, x, y, width, height, round, textColor); + } +}; + +const renderPage = async ( + page: ReturnType[number], + items: MergedItem[], + settings: SettingsState, + labelOverridesById: LabelOverridesById, + imageCache: ImageCache, +): Promise => { + const pixelWidth = Math.max(1, Math.round(mmToCanvasPx(page.widthMm))); + const pixelHeight = Math.max(1, Math.round(mmToCanvasPx(page.heightMm))); + const canvas = document.createElement("canvas"); + canvas.width = pixelWidth; + canvas.height = pixelHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Canvas is not available in this browser."); + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, pixelWidth, pixelHeight); + + for (const placement of page.placements) { + const item = items[placement.itemIndex]; + if (!item) continue; + const id = item.id ?? String(placement.itemIndex); + const effectiveSettings = buildEffectiveSettings(settings, labelOverridesById[id]); + + if (placement.kind === "image") { + await drawImageLabel(ctx, item, placement, effectiveSettings, imageCache); + } else { + await drawTextLabel(ctx, item, placement, effectiveSettings, imageCache); + } + } + + return { + jpeg: await canvasToJpeg(canvas), + pixelWidth, + pixelHeight, + widthPt: mmToPt(page.widthMm), + heightPt: mmToPt(page.heightMm), + }; +}; + +export interface TeddyStudioPdfOptions { + items: MergedItem[]; + settings: SettingsState; + labelOverridesById?: LabelOverridesById; + onWarning?: (warning: TeddyStudioPdfWarning) => void; +} + +/** + * Creates a print-ready PDF entirely in the frontend. The PDF page and label geometry is based on + * millimetres, independent of browser print CSS, @page support or DOM measurements. + */ +export const createTeddyStudioPdf = async ({ + items, + settings, + labelOverridesById = {}, + onWarning, +}: TeddyStudioPdfOptions): Promise => { + if (items.length === 0) { + throw new Error("There are no labels to print."); + } + + const fontFamilies = new Set([settings.fontFamily || "sans-serif"]); + for (const item of items) { + const override = labelOverridesById[item.id]; + const effective = buildEffectiveSettings(settings, override); + fontFamilies.add(effective.fontFamily || "sans-serif"); + } + try { + await ensurePrintFonts(fontFamilies); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + onWarning?.({ + code: "font-load-failed", + message: `Could not load one or more print fonts: ${reason}`, + }); + } + + const layout = createPrintLayout(items.length, settings); + const imageCache = new ImageCache(onWarning); + const pages: PdfPageImage[] = []; + + for (const page of layout) { + pages.push(await renderPage(page, items, settings, labelOverridesById, imageCache)); + } + + const bytes = buildPdfFromJpegPages(pages); + const buffer = new ArrayBuffer(bytes.byteLength); + + new Uint8Array(buffer).set(bytes); + return new Blob([buffer], { type: "application/pdf" }); +}; diff --git a/src/components/tonies/teddystudio/styles/labels.css b/src/components/tonies/teddystudio/styles/labels.css new file mode 100644 index 00000000..8eba49f6 --- /dev/null +++ b/src/components/tonies/teddystudio/styles/labels.css @@ -0,0 +1,116 @@ +.labelElement { + position: relative; + width: var(--labelElement-width); + height: var(--labelElement-height); + border-radius: var(--labelElement-radius); + + display: flex; + justify-content: center; + align-items: center; + + overflow: visible; + isolation: isolate; + + font-size: var(--text-font-size, 14px); + color: black; +} + +.labelElement .labelTintBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + z-index: 0; +} + +.labelElementText { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: hidden; + padding: 0px; + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + + z-index: 0; +} + +.labelElement .labelBg { + position: absolute; + inset: 0; + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + z-index: 0; +} + +.labelElement .labelImageBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + z-index: 1; + background-color: var(--label-background-color, #ffffff); + border-radius: var(--labelElement-radius); + overflow: hidden; +} + +.labelElement .labelImage { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + object-position: var(--image-position, center); +} + +.labelElement .labelClip { + position: absolute; + inset: 0; + border-radius: var(--labelElement-radius); + background: transparent; + z-index: 2; + overflow: visible; +} + +.labelElement .labelContentBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + z-index: 3; + pointer-events: none; + overflow: hidden; + border-radius: var(--labelElement-radius); +} + +.labelElement .labelClip::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border: var(--label-border-width, 1px) solid var(--label-border-color, black); + border-radius: var(--labelElement-radius); + box-sizing: border-box; + z-index: 10; +} + +.labelElement .labelOverlay { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.traveltonieCouple { + border-radius: 16px; + border: 1px dashed var(--traveltonie-border-color, #d9d9d9); +} From 7030d5d3a962e5608eaa66bc3a445abbc0757d87 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:33:08 +0200 Subject: [PATCH 19/34] auto print after pdf blob is opened --- .../tonies/teddystudio/TeddyStudio.tsx | 36 +---- .../tonies/teddystudio/print/printFile.ts | 142 ++++++++++++++++++ 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/components/tonies/teddystudio/TeddyStudio.tsx b/src/components/tonies/teddystudio/TeddyStudio.tsx index 645afe72..ff569b3c 100644 --- a/src/components/tonies/teddystudio/TeddyStudio.tsx +++ b/src/components/tonies/teddystudio/TeddyStudio.tsx @@ -23,7 +23,7 @@ import { CustomImages } from "./input/CustomImages"; import { EditLabelModal } from "./modals/EditLabelModal"; import { LabelOverridesById, LabelOverrides } from "./types/labelOverrides"; import { createTeddyStudioPdf } from "./print/teddyStudioPdf"; -import { createTeddyStudioPdfFilename } from "./print/printFile"; +import { createTeddyStudioPdfFilename, downloadPdfBlob, printPdfBlob } from "./print/printFile"; import SelectImageModal from "../common/modals/SelectImageModal"; const { Paragraph } = Typography; @@ -158,40 +158,16 @@ export const TeddyStudio: React.FC = () => { }); const filename = createTeddyStudioPdfFilename(); - const pdfFile = - typeof File !== "undefined" - ? new File([pdf], filename, { type: "application/pdf" }) - : new Blob([pdf], { type: "application/pdf" }); - const url = URL.createObjectURL(pdfFile); if (action === "download") { - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); + downloadPdfBlob(pdf, filename); } else { - // Open the PDF only after it has been fully generated. If the browser blocks the - // asynchronous popup, fall back to a regular download instead of failing. - const printWindow = window.open(url, "_blank"); - if (printWindow) { - try { - printWindow.opener = null; - } catch { - // Nothing to do. The PDF itself is already open. - } - } else { - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); + const printResult = await printPdfBlob(pdf, filename); + if (printResult === "downloaded") { addNotification( NotificationTypeEnum.Warning, t("tonies.teddystudio.printPage"), - "The browser blocked opening the finished PDF in a new tab. The PDF was downloaded instead.", + "The browser could not print or open the finished PDF. The PDF was downloaded instead.", t("tonies.teddystudio.navigationTitle"), undefined, false, @@ -216,8 +192,6 @@ export const TeddyStudio: React.FC = () => { false, ); } - - window.setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000); } catch (error) { const description = error instanceof Error ? error.message : String(error); addNotification( diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts index 5be67899..3c20acf6 100644 --- a/src/components/tonies/teddystudio/print/printFile.ts +++ b/src/components/tonies/teddystudio/print/printFile.ts @@ -1,5 +1,18 @@ const pad2 = (value: number): string => String(value).padStart(2, "0"); +const revokeObjectUrlLater = (url: string, delayMs = 5 * 60 * 1000) => { + window.setTimeout(() => URL.revokeObjectURL(url), delayMs); +}; + +const downloadObjectUrl = (url: string, filename: string) => { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + /** * Generates a filesystem-safe local timestamp for TeddyStudio PDF exports. * Example: teddystudio_labels_2026-10-17_08-49-48.pdf @@ -8,3 +21,132 @@ export const createTeddyStudioPdfFilename = (date = new Date()): string => `teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2( date.getHours(), )}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`; + +export const downloadPdfBlob = (blob: Blob, filename: string): void => { + const url = URL.createObjectURL(blob); + downloadObjectUrl(url, filename); + revokeObjectUrlLater(url); +}; + +export type PrintPdfBlobResult = "printed" | "opened" | "downloaded"; + +/** + * Loads the finished PDF into a hidden iframe and asks the browser to print that PDF directly. + * If the embedded PDF viewer cannot be printed, the PDF is opened in a new tab instead. If that + * popup is blocked as well, the PDF is downloaded as the final fallback. + */ +export const printPdfBlob = ( + blob: Blob, + filename: string, + options: { + loadTimeoutMs?: number; + printDelayMs?: number; + } = {}, +): Promise => { + const { loadTimeoutMs = 10000, printDelayMs = 300 } = options; + const url = URL.createObjectURL(blob); + + return new Promise((resolve) => { + const iframe = document.createElement("iframe"); + let finished = false; + let loadTimeout: number | undefined; + let printTimer: number | undefined; + + iframe.style.position = "fixed"; + iframe.style.left = "-10000px"; + iframe.style.top = "-10000px"; + iframe.style.width = "1px"; + iframe.style.height = "1px"; + iframe.style.border = "0"; + iframe.style.opacity = "0"; + iframe.style.pointerEvents = "none"; + iframe.setAttribute("aria-hidden", "true"); + + const removeFrame = () => { + if (loadTimeout !== undefined) { + window.clearTimeout(loadTimeout); + } + if (printTimer !== undefined) { + window.clearTimeout(printTimer); + } + iframe.remove(); + }; + + const finish = (result: PrintPdfBlobResult) => { + if (finished) return; + finished = true; + resolve(result); + }; + + const fallback = () => { + if (finished) return; + removeFrame(); + + const printWindow = window.open(url, "_blank"); + if (printWindow) { + try { + printWindow.opener = null; + } catch { + // The PDF is already open. Nothing else is required here. + } + revokeObjectUrlLater(url); + finish("opened"); + return; + } + + downloadObjectUrl(url, filename); + revokeObjectUrlLater(url); + finish("downloaded"); + }; + + iframe.onload = () => { + if (finished) return; + if (loadTimeout !== undefined) { + window.clearTimeout(loadTimeout); + loadTimeout = undefined; + } + + printTimer = window.setTimeout(() => { + try { + const frameWindow = iframe.contentWindow; + if (!frameWindow) { + fallback(); + return; + } + + frameWindow.addEventListener( + "afterprint", + () => { + removeFrame(); + URL.revokeObjectURL(url); + }, + { once: true }, + ); + + frameWindow.focus(); + frameWindow.print(); + + // Some PDF viewers do not emit afterprint. Keep the object URL alive long + // enough for the viewer and clean it up later as a fallback. + window.setTimeout( + () => { + removeFrame(); + URL.revokeObjectURL(url); + }, + 5 * 60 * 1000, + ); + + finish("printed"); + } catch { + fallback(); + } + }, printDelayMs); + }; + + iframe.onerror = fallback; + loadTimeout = window.setTimeout(fallback, loadTimeoutMs); + + document.body.appendChild(iframe); + iframe.src = url; + }); +}; From f7277072a81624722adc6caed615e2702fc77c61 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:51:55 +0200 Subject: [PATCH 20/34] chore: update i18next and react-i18next dependencies to latest versions --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index f35eeca6..ec733525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +20,13 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.4.7", + "i18next": "^26.5.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.3", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-i18next": "^17.0.20", + "react-i18next": "^17.1.0", "react-markdown": "^10.1.0", "react-minimal-pie-chart": "^9.1.2", "react-router-dom": "^7.18.4", @@ -4592,9 +4592,9 @@ } }, "node_modules/i18next": { - "version": "26.4.7", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz", - "integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==", + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.0.tgz", + "integrity": "sha512-OwFFD3ER/BSWsv40teDtkhG7fwgncd7cq1CX7el02IENW9ZiwfWLnMjsQGD/q3SExrxp0+QxwUqXL8F9BKu5+w==", "funding": [ { "type": "individual", @@ -6458,9 +6458,9 @@ "license": "MIT" }, "node_modules/react-i18next": { - "version": "17.0.20", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz", - "integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz", + "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", diff --git a/package.json b/package.json index f707197d..7a5b4155 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.4.7", + "i18next": "^26.5.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.3", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-i18next": "^17.0.20", + "react-i18next": "^17.1.0", "react-markdown": "^10.1.0", "react-minimal-pie-chart": "^9.1.2", "react-router-dom": "^7.18.4", From f4b16dcffd66f8eb0d2bef619771a85c391820a2 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:01:14 +0200 Subject: [PATCH 21/34] feat: add loading spinner and disable repeated clicks during TonieCard background downloads --- CHANGELOG.md | 1 + src/components/tonies/toniecard/TonieCard.tsx | 10 +++++++++- .../tonies/toniecard/hooks/useTonieCardActions.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdfb6924..90da759c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ - gui: Connected the "edit metadata" action of TAF files in the file browser with the Custom Model Editor, incl. track rows from the TAF header with their duration and a hint when a custom model overrides an original tonie [https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/341](https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/341) - gui: Track names in the Custom Model Editor keep their position, unnamed tracks in between are no longer dropped - gui: Box setup can create a port patch for CC3200 boxes (altPort.custom.json) and patch the port of ESP32 boxes, e.g. for a proxy that routes boxes by port (not possible for CC3235 boxes and the Toniebox 2) +- gui: Show a loading spinner and prevent repeated clicks while a TonieCard background download is in progress ### Commits diff --git a/src/components/tonies/toniecard/TonieCard.tsx b/src/components/tonies/toniecard/TonieCard.tsx index 6cb5c16f..c305fd21 100644 --- a/src/components/tonies/toniecard/TonieCard.tsx +++ b/src/components/tonies/toniecard/TonieCard.tsx @@ -6,6 +6,7 @@ import { DownloadOutlined, EditOutlined, InfoCircleOutlined, + LoadingOutlined, PlayCircleOutlined, RetweetOutlined, StopOutlined, @@ -238,7 +239,12 @@ export const TonieCard: React.FC<{ overlay, }); - const { handleLiveClick, handleNoCloudClick, handleBackgroundDownload } = useTonieCardActions({ + const { + handleLiveClick, + handleNoCloudClick, + handleBackgroundDownload, + isBackgroundDownloading, + } = useTonieCardActions({ tonieCard, overlay, modelTitle, @@ -492,6 +498,8 @@ export const TonieCard: React.FC<{
+ ) : isBackgroundDownloading ? ( + ) : ( ) diff --git a/src/components/tonies/toniecard/hooks/useTonieCardActions.ts b/src/components/tonies/toniecard/hooks/useTonieCardActions.ts index 1db960ca..dc507216 100644 --- a/src/components/tonies/toniecard/hooks/useTonieCardActions.ts +++ b/src/components/tonies/toniecard/hooks/useTonieCardActions.ts @@ -1,3 +1,4 @@ +import { useState } from "react"; import { defaultAPIConfig } from "../../../../config/defaultApiConfig"; import { TeddyCloudApi } from "../../../../api"; import { NotificationTypeEnum } from "../../../../types/teddyCloudNotificationTypes"; @@ -31,6 +32,8 @@ export const useTonieCardActions = ({ closeLoadingNotification, fetchUpdatedTonieCard, }: UseTonieCardActionsParams) => { + const [isBackgroundDownloading, setIsBackgroundDownloading] = useState(false); + const notifySuccess = (message: string, description: string) => { addNotification(NotificationTypeEnum.Success, message, description, t("tonies.title")); }; @@ -122,9 +125,10 @@ export const useTonieCardActions = ({ const handleBackgroundDownload = async () => { const path = tonieCard.downloadTriggerUrl; - if (!path) return; + if (!path || isBackgroundDownloading) return; const key = "loading" + tonieCard.ruid; + setIsBackgroundDownloading(true); try { addLoadingNotification( @@ -159,6 +163,8 @@ export const useTonieCardActions = ({ }).replace(' "" ', ""), error, ); + } finally { + setIsBackgroundDownloading(false); } }; @@ -166,5 +172,6 @@ export const useTonieCardActions = ({ handleLiveClick, handleNoCloudClick, handleBackgroundDownload, + isBackgroundDownloading, }; }; From 86731980ba5cd7d7b59ca9caf33ee56e4f6829ac Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:32:11 +0200 Subject: [PATCH 22/34] feat: update audio play handling to show player display on play and hide on end --- src/components/common/footer/AudioPlayerFooter.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/common/footer/AudioPlayerFooter.tsx b/src/components/common/footer/AudioPlayerFooter.tsx index 09bf71f6..c7b855ec 100644 --- a/src/components/common/footer/AudioPlayerFooter.tsx +++ b/src/components/common/footer/AudioPlayerFooter.tsx @@ -192,7 +192,10 @@ const AudioPlayerFooter: React.FC = ({ onVisibilityChang setVolume(lastVolume); }; - const handleAudioPlay = () => setIsPlaying(true); + const handleAudioPlay = () => { + setIsPlaying(true); + setAudioPlayerDisplay("flex"); + }; const handleAudioPause = () => setIsPlaying(false); const handleAudioEnded = () => setIsPlaying(false); @@ -207,6 +210,7 @@ const AudioPlayerFooter: React.FC = ({ onVisibilityChang } globalAudio.load(); } + setIsPlaying(false); setAudioPlayerDisplay("none"); onVisibilityChange(false); }; From a29e265be0509659a7697345f68b95314fae0559 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:27:15 +0200 Subject: [PATCH 23/34] feat: refactor file download handling to improve query parameter construction and file name generation --- CHANGELOG.md | 1 + .../filebrowser/hooks/useFileDownload.ts | 38 ++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90da759c..0cfff02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - gui: Track names in the Custom Model Editor keep their position, unnamed tracks in between are no longer dropped - gui: Box setup can create a port patch for CC3200 boxes (altPort.custom.json) and patch the port of ESP32 boxes, e.g. for a proxy that routes boxes by port (not possible for CC3235 boxes and the Toniebox 2) - gui: Show a loading spinner and prevent repeated clicks while a TonieCard background download is in progress +- gui: fix filedownload in content. 500304E0.json files were downloaded as invalid ogg files. ### Commits diff --git a/src/components/tonies/filebrowser/hooks/useFileDownload.ts b/src/components/tonies/filebrowser/hooks/useFileDownload.ts index 85fe7499..9613f1d3 100644 --- a/src/components/tonies/filebrowser/hooks/useFileDownload.ts +++ b/src/components/tonies/filebrowser/hooks/useFileDownload.ts @@ -22,22 +22,32 @@ export function useFileDownload({ setDownloading }: UseFileDownloadParams) { special: string, overlay?: string, ) => { + console.log("handleFileDownload", record, path, special, overlay); + const params = new URLSearchParams(); + if (record.name.toLowerCase().endsWith(".taf")) { + params.set("ogg", "true"); + } + if (special) { + params.set("special", special); + } + if (overlay) { + params.set("overlay", overlay); + } + const query = params.toString(); + const fileUrl = encodeURI("/content/" + decodeURIComponent(path) + "/" + record.name) + - "?" + - (record.name.toLowerCase().endsWith(".taf") ? "ogg=true&" : "") + - "special=" + - special + - (overlay ? `&overlay=${overlay}` : ""); - - let fileName = - record.tonieInfo?.series || record.tonieInfo?.episode - ? `${record.tonieInfo.series || ""}${record.tonieInfo.episode ? " - " + record.tonieInfo.episode : ""}` - : record.name; - - if (!record.tonieInfo?.series && !record.tonieInfo?.episode && fileName.endsWith(".taf")) { - fileName = fileName.replace(/\.taf$/i, ".ogg"); - } + (query ? `?${query}` : ""); + + const isTaf = record.name.toLowerCase().endsWith(".taf"); + + const tonieName = [record.tonieInfo?.series, record.tonieInfo?.episode] + .filter(Boolean) + .join(" - "); + + const fileName = isTaf + ? `${tonieName || record.name.replace(/\.taf$/i, "")}.ogg` + : record.name; setDownloading((prev) => ({ ...prev, [record.name]: true })); From 6b6595cf77abe09023f316625983c8e8d851727d Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:54:23 +0200 Subject: [PATCH 24/34] fix: ogg and selected track download in content section works now, no 502 error anymore. --- .../tonies/filebrowser/FileBrowser.tsx | 62 +++++++++++++++++-- .../filebrowser/hooks/useFileBrowserCore.tsx | 45 +++++++++----- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/src/components/tonies/filebrowser/FileBrowser.tsx b/src/components/tonies/filebrowser/FileBrowser.tsx index 6b43e6bc..ebd0a711 100644 --- a/src/components/tonies/filebrowser/FileBrowser.tsx +++ b/src/components/tonies/filebrowser/FileBrowser.tsx @@ -57,9 +57,13 @@ import { CustomModelEditor } from "../custommodel/CustomModelEditor"; import { UnusedTAFsModal } from "./modals/UnusedTAFsModal"; import CustomJsonSnippetModal from "./modals/CustomJsonSnippetModal"; import { backendUrl, withBase } from "../../../utils/basePath"; +import { TeddyCloudApi } from "../../../api"; +import { defaultAPIConfig } from "../../../config/defaultApiConfig"; const { Paragraph } = Typography; +const api = new TeddyCloudApi(defaultAPIConfig()); + const { useToken } = theme; export const FileBrowser: React.FC<{ @@ -208,12 +212,60 @@ export const FileBrowser: React.FC<{ }); // information modal - const showInformationModal = (record: any) => { - if (!record.isDir && record.tonieInfo?.tracks) { - setCurrentRecord(record); - setCurrentAudioUrl(buildContentUrl(record.name, { ogg: true })); - setIsInformationModalOpen(true); + const buildLibraryTafContentUrl = (source: unknown): string | null => { + if (typeof source !== "string" || !source.toLowerCase().startsWith("lib://")) { + return null; + } + + let libraryPath = source.slice("lib://".length).replace(/^\/+/, ""); + try { + libraryPath = decodeURIComponent(libraryPath); + } catch { + // Keep the original path if it is not URI encoded correctly. + } + + libraryPath = libraryPath.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); + if (!libraryPath.toLowerCase().endsWith(".taf")) { + return null; + } + + const pathParts = libraryPath.split("/").filter(Boolean); + const fileName = pathParts.pop(); + if (!fileName) { + return null; + } + + return buildContentUrl(fileName, { + ogg: true, + path: pathParts.join("/"), + special: "library", + }); + }; + + const showInformationModal = async (record: Record) => { + if (record.isDir || !record.tonieInfo?.tracks) { + return; } + + let audioUrl = buildContentUrl(record.name, { ogg: true }); + + if (record.name.toLowerCase().match(/500304e0\.json$/)) { + try { + const response = await api.apiGetTeddyCloudApiRaw(buildContentUrl(record.name)); + const contentJson = await response.json(); + const sourceAudioUrl = buildLibraryTafContentUrl(contentJson?.source); + + if (sourceAudioUrl) { + audioUrl = sourceAudioUrl; + } + } catch (error) { + console.warn("Could not resolve audio source from content JSON", error); + } + } + + setCurrentRecord(record); + setCurrentAudioUrl(audioUrl); + setIsInformationModalOpen(true); }; // json viewer diff --git a/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx b/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx index 103f88c9..316f3d68 100644 --- a/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx +++ b/src/components/tonies/filebrowser/hooks/useFileBrowserCore.tsx @@ -49,7 +49,10 @@ interface UseFileBrowserCoreResult { generateBreadcrumbs: (currentPath: string) => React.ReactNode; buildDirPath: (dirPath: string) => string; - buildContentUrl: (fileName: string, options?: { ogg?: boolean }) => string; + buildContentUrl: ( + fileName: string, + options?: { ogg?: boolean; path?: string; special?: string }, + ) => string; defaultSorter: (a: any, b: any, dataIndex: string | string[]) => number; dirNameSorter: (a: any, b: any) => number; @@ -421,22 +424,31 @@ export const useFileBrowserCore = ({ return path ? `${path}/${dirPath}` : dirPath; }; - const buildContentUrl = (fileName: string, options?: { ogg?: boolean }): string => { - const { ogg = false } = options || {}; + const buildContentUrl = ( + fileName: string, + options?: { ogg?: boolean; path?: string; special?: string }, + ): string => { + const { ogg = false, path: pathOverride, special: specialOverride } = options || {}; const encodedPath = - mode === "fileBrowser" - ? path || "" - : path - ? path - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/") - : ""; - + pathOverride !== undefined + ? pathOverride + .split("/") + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join("/") + : mode === "fileBrowser" + ? path || "" + : path + ? path + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/") + : ""; + const targetSpecial = specialOverride !== undefined ? specialOverride : special; const encodedName = encodeURIComponent(fileName); - if (special === "custom_img") { + if (targetSpecial === "custom_img") { return encodedPath ? `/custom_img/${encodedPath}/${encodedName}` : `/custom_img/${encodedName}`; @@ -446,10 +458,13 @@ export const useFileBrowserCore = ({ const params = new URLSearchParams(); if (ogg) params.set("ogg", "true"); - params.set("special", special); + if (targetSpecial) params.set("special", targetSpecial); if (overlay) params.set("overlay", overlay); - url += `?${params.toString()}`; + const query = params.toString(); + if (query) { + url += `?${query}`; + } return url; }; From c82c70ded2c9a6f33c53d9531d02bb8b15b8d0f2 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:10:21 +0200 Subject: [PATCH 25/34] fix: Download trackselection on tonies overview works now fine --- .../common/modals/TonieInformationModal.tsx | 51 +++++++++++++++++-- src/utils/downloads/tafDownload.ts | 7 ++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/components/tonies/common/modals/TonieInformationModal.tsx b/src/components/tonies/common/modals/TonieInformationModal.tsx index 05dcb2b6..02cab184 100644 --- a/src/components/tonies/common/modals/TonieInformationModal.tsx +++ b/src/components/tonies/common/modals/TonieInformationModal.tsx @@ -140,13 +140,56 @@ const TonieInformationModal: React.FC = ({ displayTrackSeconds, displayLengthSeconds, ); + const librarySourceContentUrl = (() => { + if (!("source" in tonieCardOrTAFRecord)) { + return null; + } + + const source = tonieCardOrTAFRecord.source?.trim(); + if (!source?.toLowerCase().startsWith("lib://")) { + return null; + } + + let libraryPath = source.slice("lib://".length).replace(/^\/+/, ""); + try { + libraryPath = decodeURIComponent(libraryPath); + } catch { + // Keep the original path if it is not URI encoded correctly. + } + + libraryPath = libraryPath.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); + if (!libraryPath.toLowerCase().endsWith(".taf")) { + return null; + } + + const pathParts = libraryPath.split("/").filter(Boolean); + const fileName = pathParts.pop(); + if (!fileName) { + return null; + } + + const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); + const params = new URLSearchParams(); + params.set("ogg", "true"); + params.set("special", "library"); + if (overlay) { + params.set("overlay", overlay); + } + + return withBase( + `/content/${encodedPath ? `${encodedPath}/` : ""}${encodeURIComponent(fileName)}?${params.toString()}`, + ); + })(); + const canDownloadAudio = - "audioUrl" in tonieCardOrTAFRecord && - (!("exists" in tonieCardOrTAFRecord) || tonieCardOrTAFRecord.exists); + librarySourceContentUrl !== null || + ("audioUrl" in tonieCardOrTAFRecord && + (!("exists" in tonieCardOrTAFRecord) || tonieCardOrTAFRecord.exists)); const downloadContentUrl = - canDownloadAudio && "audioUrl" in tonieCardOrTAFRecord + librarySourceContentUrl ?? + ("audioUrl" in tonieCardOrTAFRecord ? withBase(toSameOriginUrl(tonieCardOrTAFRecord.audioUrl)) - : ""; + : ""); const trackSecondsMatchSourceTracks = ( tonieCardOrTAFRecord: TonieCardTAFRecord, diff --git a/src/utils/downloads/tafDownload.ts b/src/utils/downloads/tafDownload.ts index 55351a98..fc53ce7b 100644 --- a/src/utils/downloads/tafDownload.ts +++ b/src/utils/downloads/tafDownload.ts @@ -53,9 +53,12 @@ export function buildTafDownloadUrl( parsed.searchParams.delete("filename"); parsed.searchParams.delete("entry"); - // Track titles stay out of the query. The server only accepts 255 characters, - // and a multi-track name list exceeds that and is dropped as an empty reply. + // /content/download/... serves the already prepared audio stream and does not + // support exporting individual TAF tracks. For a track selection, address the + // underlying cached TAF through the regular /content/... route instead. if (options.tracks && options.tracks.length > 0) { + parsed.pathname = parsed.pathname.replace("/content/download/", "/content/"); + parsed.searchParams.delete("skip_header"); parsed.searchParams.set("tracks", options.tracks.join(",")); } From ec0200d8a6855555a0652a4456b14f9e98490218 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:19:32 +0200 Subject: [PATCH 26/34] fix: update dependency for selected keys reset to use selection scope --- src/components/tonies/common/modals/TafTrackDownloadModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/tonies/common/modals/TafTrackDownloadModal.tsx b/src/components/tonies/common/modals/TafTrackDownloadModal.tsx index f1d07cee..b5d7a649 100644 --- a/src/components/tonies/common/modals/TafTrackDownloadModal.tsx +++ b/src/components/tonies/common/modals/TafTrackDownloadModal.tsx @@ -73,13 +73,14 @@ export const TafDownloadPanel: React.FC = ({ const { token } = useToken(); const canSplit = tracks.length > 1; const allKeys = useMemo(() => tracks.map((track) => track.number), [tracks]); + const selectionScope = `${contentUrl}|${allKeys.join(",")}`; const [selectedKeys, setSelectedKeys] = useState([]); const [downloadingWholeFile, setDownloadingWholeFile] = useState(false); const [downloadingSelected, setDownloadingSelected] = useState(false); useEffect(() => { setSelectedKeys([]); - }, [allKeys]); + }, [selectionScope]); const downloadBase = sanitizeDownloadName(baseFilename || "download"); From 159626ebc65decfdede9e322f493bb57e1706144 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:34:33 +0200 Subject: [PATCH 27/34] chore: updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfff02e..1b2b7ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ - gui: Track names in the Custom Model Editor keep their position, unnamed tracks in between are no longer dropped - gui: Box setup can create a port patch for CC3200 boxes (altPort.custom.json) and patch the port of ESP32 boxes, e.g. for a proxy that routes boxes by port (not possible for CC3235 boxes and the Toniebox 2) - gui: Show a loading spinner and prevent repeated clicks while a TonieCard background download is in progress -- gui: fix filedownload in content. 500304E0.json files were downloaded as invalid ogg files. +- gui: Fixed TAF track downloads in the TonieInformationModal to use the correct linked/source TAF for Content and Tonies views, including uncached Tonies, and keep track selections across re-renders, screen size changes and audio player updates ### Commits From b4d064d653f8e4fc6f8a97237444b67364914705 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:23:22 +0200 Subject: [PATCH 28/34] fix gh-286 / feat: add print layout functionality using pdf creation for Teddy Studio labels - Added printLayout.ts for handling label size, paper size, and layout calculations. - Created printLayout.test.ts for unit tests covering various layout scenarios. - Developed teddyStudioPdf.ts to generate PDF documents from label layouts, including image and text rendering. --- CHANGELOG.md | 1 + public/translations/de.json | 1 + public/translations/en.json | 1 + public/translations/es.json | 1 + public/translations/fr.json | 1 + public/translations/it.json | 1 + public/translations/pl.json | 1 + .../common/icons/LanguageFlagIcon.tsx | 9 +- .../common/icons/languageFlagAssets.ts | 8 + .../tonies/teddystudio/TeddyStudio.tsx | 142 +++- .../tonies/teddystudio/grid/LabelGrid.tsx | 19 +- .../teddystudio/print/pdfDocument.test.ts | 24 + .../tonies/teddystudio/print/pdfDocument.ts | 118 +++ .../teddystudio/print/printFile.test.ts | 12 + .../tonies/teddystudio/print/printFile.ts | 10 + .../tonies/teddystudio/print/printFonts.ts | 222 ++++++ .../teddystudio/print/printLayout.test.ts | 98 +++ .../tonies/teddystudio/print/printLayout.ts | 173 +++++ .../teddystudio/print/teddyStudioPdf.ts | 679 ++++++++++++++++++ .../tonies/teddystudio/styles/labels.css | 116 +++ 20 files changed, 1602 insertions(+), 35 deletions(-) create mode 100644 src/components/common/icons/languageFlagAssets.ts create mode 100644 src/components/tonies/teddystudio/print/pdfDocument.test.ts create mode 100644 src/components/tonies/teddystudio/print/pdfDocument.ts create mode 100644 src/components/tonies/teddystudio/print/printFile.test.ts create mode 100644 src/components/tonies/teddystudio/print/printFile.ts create mode 100644 src/components/tonies/teddystudio/print/printFonts.ts create mode 100644 src/components/tonies/teddystudio/print/printLayout.test.ts create mode 100644 src/components/tonies/teddystudio/print/printLayout.ts create mode 100644 src/components/tonies/teddystudio/print/teddyStudioPdf.ts create mode 100644 src/components/tonies/teddystudio/styles/labels.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2b7ed0..294ea2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - gui: Box setup can create a port patch for CC3200 boxes (altPort.custom.json) and patch the port of ESP32 boxes, e.g. for a proxy that routes boxes by port (not possible for CC3235 boxes and the Toniebox 2) - gui: Show a loading spinner and prevent repeated clicks while a TonieCard background download is in progress - gui: Fixed TAF track downloads in the TonieInformationModal to use the correct linked/source TAF for Content and Tonies views, including uncached Tonies, and keep track selections across re-renders, screen size changes and audio player updates +- gui: TeddyStudio: Reworked label printing to generate a browser-independent PDF instead of relying on CSS print layouts, including PDF preview and download functionality [https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/286](https://github.com/toniebox-reverse-engineering/teddycloud_web/issues/286) ### Commits diff --git a/public/translations/de.json b/public/translations/de.json index 839c7e56..717b200b 100644 --- a/public/translations/de.json +++ b/public/translations/de.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Gib einen Titel ein, der gedruckt werden soll", "customImageUpload": "Temporäres Bild hinzufügen (ohne Library)", "diameter": "Innendurchmesser", + "downloadPdf": "PDF herunterladen", "empty": "Leer - Bitte wähle zuerst Tonies oder Tags aus oder füge ein eigenes Bild hinzu!", "episodes": "Episoden", "fontFamily": "Schriftart", diff --git a/public/translations/en.json b/public/translations/en.json index d37271b4..a1229b78 100644 --- a/public/translations/en.json +++ b/public/translations/en.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Enter a title to be printed", "customImageUpload": "Add temporary image (without Library)", "diameter": "Inner diameter", + "downloadPdf": "Download PDF", "empty": "Empty - Please select Tonies or Tags or add custom image first!", "episodes": "Episodes", "fontFamily": "Label font", diff --git a/public/translations/es.json b/public/translations/es.json index da3a7be1..668c7146 100644 --- a/public/translations/es.json +++ b/public/translations/es.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Introduce un título para imprimir", "customImageUpload": "Añadir imagen temporal (sin Biblioteca)", "diameter": "Diámetro interior", + "downloadPdf": "Descargar PDF", "empty": "Vacío - ¡Primero selecciona Tonies o Tags o añade una imagen propia!", "episodes": "Episodios", "fontFamily": "Fuente de etiqueta", diff --git a/public/translations/fr.json b/public/translations/fr.json index 51874120..9db89572 100644 --- a/public/translations/fr.json +++ b/public/translations/fr.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Saisis un titre à imprimer", "customImageUpload": "Ajouter image temporaire (sans Bibliothèque)", "diameter": "Diamètre intérieur", + "downloadPdf": "Télécharger le PDF", "empty": "Vide - Sélectionne d'abord des Tonies ou Tags ou ajoute une image personnalisée !", "episodes": "Épisodes", "fontFamily": "Police de l'étiquette", diff --git a/public/translations/it.json b/public/translations/it.json index 637598c7..78b2fbd0 100644 --- a/public/translations/it.json +++ b/public/translations/it.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Inserisci un titolo da stampare", "customImageUpload": "Aggiungi immagine temporanea (senza libreria)", "diameter": "Diametro interno", + "downloadPdf": "Scarica PDF", "empty": "Vuoto - Seleziona prima Tonie o Tag oppure aggiungi un'immagine personalizzata!", "episodes": "Episodi", "fontFamily": "Carattere etichetta", diff --git a/public/translations/pl.json b/public/translations/pl.json index d2a0165b..35794a23 100644 --- a/public/translations/pl.json +++ b/public/translations/pl.json @@ -2486,6 +2486,7 @@ "customImageTitle": "Wpisz tytuł do wydrukowania", "customImageUpload": "Dodaj tymczasowy obraz (bez biblioteki)", "diameter": "Średnica wewnętrzna", + "downloadPdf": "Pobierz PDF", "empty": "Pusto - najpierw wybierz Tonies lub Tagi albo dodaj niestandardowy obraz!", "episodes": "Odcinki", "fontFamily": "Czcionka etykiety", diff --git a/src/components/common/icons/LanguageFlagIcon.tsx b/src/components/common/icons/LanguageFlagIcon.tsx index afa65caa..9cad7fcd 100644 --- a/src/components/common/icons/LanguageFlagIcon.tsx +++ b/src/components/common/icons/LanguageFlagIcon.tsx @@ -1,13 +1,6 @@ import React, { useEffect, useState } from "react"; -async function loadFlagAsset(code: string): Promise { - try { - const mod = await import(`../../../assets/country-flags-3x2/${code}.svg`); - return mod.default; - } catch { - return null; - } -} +import { loadFlagAsset } from "./languageFlagAssets"; interface LanguageFlagIconProps extends React.ImgHTMLAttributes { name: string; diff --git a/src/components/common/icons/languageFlagAssets.ts b/src/components/common/icons/languageFlagAssets.ts new file mode 100644 index 00000000..1155c48e --- /dev/null +++ b/src/components/common/icons/languageFlagAssets.ts @@ -0,0 +1,8 @@ +export async function loadFlagAsset(code: string): Promise { + try { + const mod = await import(`../../../assets/country-flags-3x2/${code}.svg`); + return mod.default; + } catch { + return null; + } +} diff --git a/src/components/tonies/teddystudio/TeddyStudio.tsx b/src/components/tonies/teddystudio/TeddyStudio.tsx index 32722dd2..645afe72 100644 --- a/src/components/tonies/teddystudio/TeddyStudio.tsx +++ b/src/components/tonies/teddystudio/TeddyStudio.tsx @@ -1,6 +1,12 @@ import React, { useState } from "react"; -import { Alert, Button, Collapse, Divider, Typography } from "antd"; -import { ClearOutlined, PrinterOutlined, UnorderedListOutlined } from "@ant-design/icons"; +import { Button, Collapse, Divider, Typography } from "antd"; +import { + ClearOutlined, + DownloadOutlined, + LoadingOutlined, + PrinterOutlined, + UnorderedListOutlined, +} from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { useTeddyCloud } from "../../../provider/TeddyCloudProvider"; @@ -16,6 +22,8 @@ import { BulkAddToniesModal } from "./input/BulkAddToniesModal"; import { CustomImages } from "./input/CustomImages"; import { EditLabelModal } from "./modals/EditLabelModal"; import { LabelOverridesById, LabelOverrides } from "./types/labelOverrides"; +import { createTeddyStudioPdf } from "./print/teddyStudioPdf"; +import { createTeddyStudioPdfFilename } from "./print/printFile"; import SelectImageModal from "../common/modals/SelectImageModal"; const { Paragraph } = Typography; @@ -26,6 +34,7 @@ export const TeddyStudio: React.FC = () => { const { handleSearch } = useData(); const [editIndex, setEditIndex] = useState(null); + const [preparingPdfAction, setPreparingPdfAction] = useState<"print" | "download" | null>(null); const { customItems, @@ -130,6 +139,105 @@ export const TeddyStudio: React.FC = () => { ); }; + const handlePdfAction = async (action: "print" | "download") => { + if (preparingPdfAction !== null || mergedResults.length === 0) return; + + setPreparingPdfAction(action); + + try { + const warnings = new Map(); + const pdf = await createTeddyStudioPdf({ + items: mergedResults, + settings, + labelOverridesById, + onWarning: (warning) => { + const key = `${warning.code}:${warning.url ?? warning.message}`; + warnings.set(key, warning.message); + console.warn(`[TeddyStudio PDF] ${warning.message}`); + }, + }); + + const filename = createTeddyStudioPdfFilename(); + const pdfFile = + typeof File !== "undefined" + ? new File([pdf], filename, { type: "application/pdf" }) + : new Blob([pdf], { type: "application/pdf" }); + const url = URL.createObjectURL(pdfFile); + + if (action === "download") { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + } else { + // Open the PDF only after it has been fully generated. If the browser blocks the + // asynchronous popup, fall back to a regular download instead of failing. + const printWindow = window.open(url, "_blank"); + if (printWindow) { + try { + printWindow.opener = null; + } catch { + // Nothing to do. The PDF itself is already open. + } + } else { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + addNotification( + NotificationTypeEnum.Warning, + t("tonies.teddystudio.printPage"), + "The browser blocked opening the finished PDF in a new tab. The PDF was downloaded instead.", + t("tonies.teddystudio.navigationTitle"), + undefined, + false, + ); + } + } + + if (warnings.size > 0) { + const descriptions = Array.from(warnings.values()); + const description = + descriptions.length === 1 + ? descriptions[0] + : `${descriptions.length} resources could not be loaded while creating the PDF. The affected content was skipped.`; + addNotification( + NotificationTypeEnum.Warning, + action === "print" + ? t("tonies.teddystudio.printPage") + : t("tonies.teddystudio.downloadPdf"), + description, + t("tonies.teddystudio.navigationTitle"), + undefined, + false, + ); + } + + window.setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000); + } catch (error) { + const description = error instanceof Error ? error.message : String(error); + addNotification( + NotificationTypeEnum.Error, + action === "print" + ? t("tonies.teddystudio.printPage") + : t("tonies.teddystudio.downloadPdf", { defaultValue: "Download PDF" }), + description, + t("tonies.teddystudio.navigationTitle"), + undefined, + false, + ); + } finally { + setPreparingPdfAction(null); + } + }; + + const handlePrintPage = () => handlePdfAction("print"); + const handleDownloadPdf = () => handlePdfAction("download"); + const currentItem = editIndex !== null ? mergedResults[editIndex] : null; const labelSettingsSummary = `${ @@ -243,10 +351,30 @@ export const TeddyStudio: React.FC = () => { > {t("tonies.teddystudio.clear")} + @@ -255,12 +383,6 @@ export const TeddyStudio: React.FC = () => { ) : ( {t("tonies.teddystudio.empty")} )} - = ({ labelSpacingY, printMode, showLabelBorder, - paperSize, - customPaperWidth, - customPaperHeight, - paperMarginTop, - paperMarginLeft, paperLabelImageBleed, } = settings; - useEffect(() => { - const root = document.documentElement; - const paperSizeValue = - paperSize === "Custom" ? `${customPaperWidth} ${customPaperHeight}` : paperSize; - - root.style.setProperty("--paper-size", paperSizeValue); - root.style.setProperty("--paper-margin-top", paperMarginTop); - root.style.setProperty("--paper-margin-left", paperMarginLeft); - }, [paperSize, customPaperWidth, customPaperHeight, paperMarginTop, paperMarginLeft]); - return (
{ + it("writes fixed PDF page dimensions and a valid cross-reference table", () => { + const bytes = buildPdfFromJpegPages([ + { + jpeg: new Uint8Array([0xff, 0xd8, 0xff, 0xd9]), + pixelWidth: 1, + pixelHeight: 1, + widthPt: 595.276, + heightPt: 841.89, + }, + ]); + const text = new TextDecoder("latin1").decode(bytes); + + expect(text.startsWith("%PDF-1.4")).toBe(true); + expect(text).toContain("/MediaBox [0 0 595.276 841.89]"); + expect(text).toContain("/Filter /DCTDecode"); + expect(text).toContain("/PrintScaling /None"); + expect(text).toContain("xref\n0 6"); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + }); +}); diff --git a/src/components/tonies/teddystudio/print/pdfDocument.ts b/src/components/tonies/teddystudio/print/pdfDocument.ts new file mode 100644 index 00000000..ad93562d --- /dev/null +++ b/src/components/tonies/teddystudio/print/pdfDocument.ts @@ -0,0 +1,118 @@ +export interface PdfPageImage { + jpeg: Uint8Array; + pixelWidth: number; + pixelHeight: number; + widthPt: number; + heightPt: number; +} + +const encoder = new TextEncoder(); + +const ascii = (value: string): Uint8Array => encoder.encode(value); + +const concatBytes = (chunks: Uint8Array[]): Uint8Array => { + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + + chunks.forEach((chunk) => { + result.set(chunk, offset); + offset += chunk.length; + }); + + return result; +}; + +const formatNumber = (value: number): string => { + const rounded = Math.round(value * 1000) / 1000; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(3).replace(/0+$/, ""); +}; + +/** + * Minimal PDF writer for page-sized JPEG images. This intentionally has no runtime dependency on + * a PDF library: the page geometry is fixed in PDF points and the browser is only used to create + * the page bitmap, not to lay out or print HTML. + */ +export const buildPdfFromJpegPages = (pages: PdfPageImage[]): Uint8Array => { + if (pages.length === 0) { + throw new Error("Cannot build an empty PDF."); + } + + const objectCount = 2 + pages.length * 3; + const objectChunks: Uint8Array[][] = Array.from({ length: objectCount + 1 }, () => []); + + objectChunks[1] = [ + ascii("<< /Type /Catalog /Pages 2 0 R /ViewerPreferences << /PrintScaling /None >> >>"), + ]; + + const pageObjectIds = pages.map((_, index) => 3 + index * 3); + objectChunks[2] = [ + ascii( + `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds + .map((id) => `${id} 0 R`) + .join(" ")}] >>`, + ), + ]; + + pages.forEach((page, index) => { + const pageObjectId = 3 + index * 3; + const imageObjectId = pageObjectId + 1; + const contentObjectId = pageObjectId + 2; + const pageWidth = formatNumber(page.widthPt); + const pageHeight = formatNumber(page.heightPt); + + objectChunks[pageObjectId] = [ + ascii( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] ` + + `/Resources << /XObject << /Im0 ${imageObjectId} 0 R >> >> ` + + `/Contents ${contentObjectId} 0 R >>`, + ), + ]; + + objectChunks[imageObjectId] = [ + ascii( + `<< /Type /XObject /Subtype /Image /Width ${page.pixelWidth} /Height ${page.pixelHeight} ` + + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`, + ), + page.jpeg, + ascii("\nendstream"), + ]; + + const content = `q\n${pageWidth} 0 0 ${pageHeight} 0 0 cm\n` + "/Im0 Do\n" + "Q\n"; + const contentBytes = ascii(content); + objectChunks[contentObjectId] = [ + ascii(`<< /Length ${contentBytes.length} >>\nstream\n`), + contentBytes, + ascii("endstream"), + ]; + }); + + const chunks: Uint8Array[] = [ascii("%PDF-1.4\n")]; + const offsets = new Array(objectCount + 1).fill(0); + let byteOffset = chunks[0].length; + + for (let objectId = 1; objectId <= objectCount; objectId++) { + offsets[objectId] = byteOffset; + const header = ascii(`${objectId} 0 obj\n`); + const footer = ascii("\nendobj\n"); + const body = objectChunks[objectId]; + + chunks.push(header, ...body, footer); + byteOffset += + header.length + body.reduce((sum, chunk) => sum + chunk.length, 0) + footer.length; + } + + const xrefOffset = byteOffset; + const xrefLines = ["xref", `0 ${objectCount + 1}`, "0000000000 65535 f "]; + for (let objectId = 1; objectId <= objectCount; objectId++) { + xrefLines.push(`${String(offsets[objectId]).padStart(10, "0")} 00000 n `); + } + + const trailer = + `${xrefLines.join("\n")}\n` + + `trailer\n<< /Size ${objectCount + 1} /Root 1 0 R >>\n` + + `startxref\n${xrefOffset}\n%%EOF\n`; + chunks.push(ascii(trailer)); + + return concatBytes(chunks); +}; diff --git a/src/components/tonies/teddystudio/print/printFile.test.ts b/src/components/tonies/teddystudio/print/printFile.test.ts new file mode 100644 index 00000000..ae4a37e8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFile.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { createTeddyStudioPdfFilename } from "./printFile"; + +describe("createTeddyStudioPdfFilename", () => { + it("uses local date and time in a filesystem-safe filename", () => { + const date = new Date(2026, 9, 17, 8, 49, 48); + + expect(createTeddyStudioPdfFilename(date)).toBe( + "teddystudio_labels_2026-10-17_08-49-48.pdf", + ); + }); +}); diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts new file mode 100644 index 00000000..5be67899 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFile.ts @@ -0,0 +1,10 @@ +const pad2 = (value: number): string => String(value).padStart(2, "0"); + +/** + * Generates a filesystem-safe local timestamp for TeddyStudio PDF exports. + * Example: teddystudio_labels_2026-10-17_08-49-48.pdf + */ +export const createTeddyStudioPdfFilename = (date = new Date()): string => + `teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2( + date.getHours(), + )}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`; diff --git a/src/components/tonies/teddystudio/print/printFonts.ts b/src/components/tonies/teddystudio/print/printFonts.ts new file mode 100644 index 00000000..17cf77af --- /dev/null +++ b/src/components/tonies/teddystudio/print/printFonts.ts @@ -0,0 +1,222 @@ +const LATIN_RANGE = + "U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD"; +const LATIN_EXT_RANGE = + "U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"; + +type BundledFontSource = { + url: string; + unicodeRange: string; +}; + +type MutableFontFaceSet = FontFaceSet & { + add: (font: FontFace) => FontFaceSet; +}; + +const BUNDLED_FONTS: Record = { + "Atkinson Hyperlegible": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/atkinson-hyperlegible-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Fredoka: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/fredoka-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/fredoka-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Bubblegum Sans": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/bubblegum-sans-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/bubblegum-sans-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Patrick Hand": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/patrick-hand-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/patrick-hand-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Indie Flower": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/indie-flower-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/indie-flower-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Schoolbell: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/schoolbell-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + ], + Inter: [ + { + url: new URL("../../../../assets/fonts/teddystudio/inter-latin.woff2", import.meta.url) + .href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/inter-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Roboto: [ + { + url: new URL("../../../../assets/fonts/teddystudio/roboto-latin.woff2", import.meta.url) + .href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/roboto-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + "Open Sans": [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/open-sans-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/open-sans-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], + Merriweather: [ + { + url: new URL( + "../../../../assets/fonts/teddystudio/merriweather-latin.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_RANGE, + }, + { + url: new URL( + "../../../../assets/fonts/teddystudio/merriweather-latin-ext.woff2", + import.meta.url, + ).href, + unicodeRange: LATIN_EXT_RANGE, + }, + ], +}; + +const loadedBundledFonts = new Map>(); + +const primaryFamilyName = (fontFamily: string): string => { + const first = fontFamily.split(",", 1)[0]?.trim() || fontFamily.trim(); + return first.replace(/^['"]|['"]$/g, ""); +}; + +const loadBundledFont = (family: string): Promise => { + const cached = loadedBundledFonts.get(family); + if (cached) return cached; + + const sources = BUNDLED_FONTS[family]; + if (!sources || typeof FontFace === "undefined" || !document.fonts) { + return Promise.resolve(); + } + + const promise = Promise.all( + sources.map(async ({ url, unicodeRange }) => { + const face = new FontFace(family, `url("${url}") format("woff2")`, { + style: "normal", + weight: "400", + unicodeRange, + }); + await face.load(); + const fontSet = document.fonts as MutableFontFaceSet; + if (typeof fontSet.add === "function") { + fontSet.add(face); + } + }), + ).then(() => undefined); + + loadedBundledFonts.set(family, promise); + return promise; +}; + +/** + * Ensures that fonts used by the canvas renderer are available without relying on @font-face CSS. + * System fonts still resolve through the browser/OS as usual. + */ +export const ensurePrintFonts = async (fontFamilies: Iterable): Promise => { + const families = Array.from(new Set(fontFamilies)).filter(Boolean); + + await Promise.all( + families.map(async (fontFamily) => { + const primary = primaryFamilyName(fontFamily); + await loadBundledFont(primary); + if (document.fonts) { + await document.fonts.load(`16px ${fontFamily}`); + } + }), + ); + + if (document.fonts) { + await document.fonts.ready; + } +}; diff --git a/src/components/tonies/teddystudio/print/printLayout.test.ts b/src/components/tonies/teddystudio/print/printLayout.test.ts new file mode 100644 index 00000000..ac5ea0a8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/printLayout.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { SettingsState } from "../hooks/useSettings"; +import { createPrintLayout } from "./printLayout"; + +const settings = (patch: Partial = {}): SettingsState => ({ + diameter: "40mm", + labelShape: "round", + printMode: "ImageAndText", + width: "50mm", + height: "30mm", + labelSpacingX: "5mm", + labelSpacingY: "5mm", + labelBackgroundColor: "#ffffff", + paperSize: "A4", + customPaperWidth: "210mm", + customPaperHeight: "297mm", + paperMarginTop: "10mm", + paperMarginLeft: "10mm", + paperLabelImageBleed: "0mm", + textFontSize: "14px", + fontFamily: "sans-serif", + imagePosition: "center", + imageScale: 1, + imageBottom: 0, + imageLeft: 0, + contentPadding: "0mm", + showLanguageFlag: false, + showModelNo: false, + showSeriesOnImageLabel: false, + seriesOnImageLabelRotationDeg: 0, + seriesOnImageLabelFontSize: "12px", + printTrackListInsteadTitle: false, + showLabelBorder: true, + selectedPaper: undefined, + ...patch, +}); + +describe("createPrintLayout", () => { + it("keeps image/text labels together and wraps groups using millimetres", () => { + const pages = createPrintLayout(3, settings()); + + expect(pages).toHaveLength(1); + expect(pages[0].placements).toEqual([ + { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 0, kind: "text", xMm: 55, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 1, kind: "image", xMm: 100, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 1, kind: "text", xMm: 145, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 2, kind: "image", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 }, + { itemIndex: 2, kind: "text", xMm: 55, yMm: 55, widthMm: 40, heightMm: 40 }, + ]); + }); + + it("lays out one label per item in OnlyImage mode", () => { + const pages = createPrintLayout(5, settings({ printMode: "OnlyImage" })); + + expect(pages[0].placements.map(({ xMm, yMm }) => [xMm, yMm])).toEqual([ + [10, 10], + [55, 10], + [100, 10], + [145, 10], + [10, 55], + ]); + }); + + it("stacks image/text labels if the pair cannot fit horizontally", () => { + const pages = createPrintLayout( + 1, + settings({ + paperSize: "Custom", + customPaperWidth: "60mm", + customPaperHeight: "120mm", + }), + ); + + expect(pages[0].placements).toEqual([ + { itemIndex: 0, kind: "image", xMm: 10, yMm: 10, widthMm: 40, heightMm: 40 }, + { itemIndex: 0, kind: "text", xMm: 10, yMm: 55, widthMm: 40, heightMm: 40 }, + ]); + }); + + it("starts another PDF page when the next group no longer fits vertically", () => { + const pages = createPrintLayout( + 2, + settings({ + printMode: "OnlyImage", + paperSize: "Custom", + customPaperWidth: "55mm", + customPaperHeight: "55mm", + paperMarginTop: "10mm", + paperMarginLeft: "10mm", + }), + ); + + expect(pages).toHaveLength(2); + expect(pages[0].placements[0]).toMatchObject({ itemIndex: 0, xMm: 10, yMm: 10 }); + expect(pages[1].placements[0]).toMatchObject({ itemIndex: 1, xMm: 10, yMm: 10 }); + }); +}); diff --git a/src/components/tonies/teddystudio/print/printLayout.ts b/src/components/tonies/teddystudio/print/printLayout.ts new file mode 100644 index 00000000..7a4b3ccc --- /dev/null +++ b/src/components/tonies/teddystudio/print/printLayout.ts @@ -0,0 +1,173 @@ +import type { SettingsState } from "../hooks/useSettings"; + +export type PrintLabelKind = "image" | "text"; + +export interface PrintLabelPlacement { + itemIndex: number; + kind: PrintLabelKind; + xMm: number; + yMm: number; + widthMm: number; + heightMm: number; +} + +export interface PrintPageLayout { + widthMm: number; + heightMm: number; + placements: PrintLabelPlacement[]; +} + +const PAPER_SIZES_MM: Record, [number, number]> = { + A4: [210, 297], + A5: [148, 210], + Letter: [215.9, 279.4], +}; + +const EPSILON = 0.001; + +export const parseMm = (value: string, settingName: string): number => { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid ${settingName}: ${value}`); + } + return parsed; +}; + +export const getPaperSizeMm = (settings: SettingsState): [number, number] => { + if (settings.paperSize === "Custom") { + const width = parseMm(settings.customPaperWidth, "custom paper width"); + const height = parseMm(settings.customPaperHeight, "custom paper height"); + if (width <= 0 || height <= 0) { + throw new Error("Custom paper dimensions must be greater than zero."); + } + return [width, height]; + } + + return PAPER_SIZES_MM[settings.paperSize]; +}; + +const getLabelSizeMm = (settings: SettingsState): [number, number] => { + if (settings.labelShape === "round") { + const diameter = parseMm(settings.diameter, "label diameter"); + if (diameter <= 0) { + throw new Error("Label diameter must be greater than zero."); + } + return [diameter, diameter]; + } + + const width = parseMm(settings.width, "label width"); + const height = parseMm(settings.height, "label height"); + if (width <= 0 || height <= 0) { + throw new Error("Label dimensions must be greater than zero."); + } + return [width, height]; +}; + +const getKinds = (settings: SettingsState): PrintLabelKind[] => { + switch (settings.printMode) { + case "OnlyImage": + return ["image"]; + case "OnlyText": + return ["text"]; + case "ImageAndText": + default: + return ["image", "text"]; + } +}; + +/** + * Builds the physical page/label geometry used by the PDF renderer. + * All values are in millimetres; no DOM or CSS measurements are involved. + */ +export const createPrintLayout = ( + itemCount: number, + settings: SettingsState, +): PrintPageLayout[] => { + const [pageWidthMm, pageHeightMm] = getPaperSizeMm(settings); + const [labelWidthMm, labelHeightMm] = getLabelSizeMm(settings); + const marginLeftMm = parseMm(settings.paperMarginLeft, "left paper margin"); + const marginTopMm = parseMm(settings.paperMarginTop, "top paper margin"); + const spacingXmm = parseMm(settings.labelSpacingX, "horizontal label spacing"); + const spacingYmm = parseMm(settings.labelSpacingY, "vertical label spacing"); + const kinds = getKinds(settings); + + const usableWidthMm = pageWidthMm - marginLeftMm; + const usableHeightMm = pageHeightMm - marginTopMm; + + if (usableWidthMm <= 0 || usableHeightMm <= 0) { + throw new Error("Paper margins leave no printable area."); + } + if (labelWidthMm > usableWidthMm + EPSILON || labelHeightMm > usableHeightMm + EPSILON) { + throw new Error("The configured label does not fit on the selected paper size."); + } + + const horizontalGroupWidthMm = + kinds.length * labelWidthMm + Math.max(0, kinds.length - 1) * spacingXmm; + const stackGroup = kinds.length > 1 && horizontalGroupWidthMm > usableWidthMm + EPSILON; + const groupWidthMm = stackGroup ? labelWidthMm : horizontalGroupWidthMm; + const groupHeightMm = stackGroup + ? kinds.length * labelHeightMm + Math.max(0, kinds.length - 1) * spacingYmm + : labelHeightMm; + + if (groupHeightMm > usableHeightMm + EPSILON) { + throw new Error( + "The configured image/text label pair does not fit on the selected paper size.", + ); + } + + const pages: PrintPageLayout[] = []; + let currentPage: PrintPageLayout = { + widthMm: pageWidthMm, + heightMm: pageHeightMm, + placements: [], + }; + pages.push(currentPage); + + let xMm = marginLeftMm; + let yMm = marginTopMm; + let rowHeightMm = 0; + + const startNewPage = () => { + currentPage = { + widthMm: pageWidthMm, + heightMm: pageHeightMm, + placements: [], + }; + pages.push(currentPage); + xMm = marginLeftMm; + yMm = marginTopMm; + rowHeightMm = 0; + }; + + const startNewRow = () => { + xMm = marginLeftMm; + yMm += rowHeightMm + spacingYmm; + rowHeightMm = 0; + }; + + for (let itemIndex = 0; itemIndex < itemCount; itemIndex++) { + if (xMm > marginLeftMm + EPSILON && xMm + groupWidthMm > pageWidthMm + EPSILON) { + startNewRow(); + } + + if (yMm + groupHeightMm > pageHeightMm + EPSILON) { + startNewPage(); + } + + kinds.forEach((kind, kindIndex) => { + currentPage.placements.push({ + itemIndex, + kind, + xMm: stackGroup ? xMm : xMm + kindIndex * (labelWidthMm + spacingXmm), + yMm: stackGroup ? yMm + kindIndex * (labelHeightMm + spacingYmm) : yMm, + widthMm: labelWidthMm, + heightMm: labelHeightMm, + }); + }); + + rowHeightMm = Math.max(rowHeightMm, groupHeightMm); + xMm += groupWidthMm + spacingXmm; + } + + return pages; +}; diff --git a/src/components/tonies/teddystudio/print/teddyStudioPdf.ts b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts new file mode 100644 index 00000000..b4b986a8 --- /dev/null +++ b/src/components/tonies/teddystudio/print/teddyStudioPdf.ts @@ -0,0 +1,679 @@ +import type { MergedItem } from "../hooks/useCustomItems"; +import type { SettingsState } from "../hooks/useSettings"; +import { buildEffectiveSettings, type LabelOverridesById } from "../types/labelOverrides"; +import { getContrastTextColor } from "../../../../utils/helper"; +import { withBase } from "../../../../utils/basePath"; +import { toImageSrc } from "../../common/utils/imagePathUtils"; +import { loadFlagAsset } from "../../../common/icons/languageFlagAssets"; +import { buildPdfFromJpegPages, type PdfPageImage } from "./pdfDocument"; +import { createPrintLayout, parseMm, type PrintLabelPlacement } from "./printLayout"; +import { ensurePrintFonts } from "./printFonts"; + +const PRINT_DPI = 300; +const CSS_DPI = 96; +const MM_PER_INCH = 25.4; +const PT_PER_INCH = 72; + +const mmToCanvasPx = (mm: number): number => (mm * PRINT_DPI) / MM_PER_INCH; +const cssPxToCanvasPx = (px: number): number => (px * PRINT_DPI) / CSS_DPI; +const mmToPt = (mm: number): number => (mm * PT_PER_INCH) / MM_PER_INCH; + +const cssPx = (value: string | number, fallback = 14): number => { + const parsed = typeof value === "number" ? value : Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const canvasToJpeg = async (canvas: HTMLCanvasElement): Promise => { + const blob = await new Promise((resolve, reject) => { + try { + canvas.toBlob( + (value) => + value ? resolve(value) : reject(new Error("Could not encode print page.")), + "image/jpeg", + 0.96, + ); + } catch (error) { + reject(error); + } + }); + return new Uint8Array(await blob.arrayBuffer()); +}; + +const loadHtmlImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Could not load image: ${url}`)); + image.src = url; + }); + +export interface TeddyStudioPdfWarning { + code: "image-load-failed" | "font-load-failed"; + message: string; + url?: string; +} + +class ImageCache { + private readonly cache = new Map>(); + + constructor(private readonly onWarning?: (warning: TeddyStudioPdfWarning) => void) {} + + load(rawUrl: string): Promise { + const url = withBase(rawUrl); + const cached = this.cache.get(url); + if (cached) return cached; + + const promise = this.loadUncached(url); + this.cache.set(url, promise); + return promise; + } + + private async loadUncached(url: string): Promise { + try { + if (url.startsWith("blob:") || url.startsWith("data:")) { + return await loadHtmlImage(url); + } + + const response = await fetch(url, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + try { + return await loadHtmlImage(objectUrl); + } finally { + URL.revokeObjectURL(objectUrl); + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.onWarning?.({ + code: "image-load-failed", + message: `Could not load image ${url}: ${reason}`, + url, + }); + return null; + } + } +} + +const objectPositionFactors = (position: string): [number, number] => { + const normalized = (position || "center").toLowerCase(); + const x = normalized.includes("left") ? 0 : normalized.includes("right") ? 1 : 0.5; + const y = normalized.includes("top") ? 0 : normalized.includes("bottom") ? 1 : 0.5; + return [x, y]; +}; + +const beginLabelPath = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, +) => { + ctx.beginPath(); + if (round) { + ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2); + } else { + ctx.rect(x, y, width, height); + } +}; + +const withLabelClip = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, + draw: () => void, +) => { + ctx.save(); + beginLabelPath(ctx, x, y, width, height, round); + ctx.clip(); + draw(); + ctx.restore(); +}; + +const drawCoverImage = ( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + width: number, + height: number, + position: string, + scale: number, + offsetLeftCssPx: number, + offsetBottomCssPx: number, +) => { + const [positionX, positionY] = objectPositionFactors(position); + const imageWidth = image.naturalWidth || image.width; + const imageHeight = image.naturalHeight || image.height; + const targetRatio = width / height; + const sourceRatio = imageWidth / imageHeight; + + let sourceWidth = imageWidth; + let sourceHeight = imageHeight; + if (sourceRatio > targetRatio) { + sourceWidth = imageHeight * targetRatio; + } else { + sourceHeight = imageWidth / targetRatio; + } + + const sourceX = Math.max(0, (imageWidth - sourceWidth) * positionX); + const sourceY = Math.max(0, (imageHeight - sourceHeight) * positionY); + const safeScale = Number.isFinite(scale) && scale >= 0 ? scale : 1; + const scaledWidth = width * safeScale; + const scaledHeight = height * safeScale; + const offsetLeft = cssPxToCanvasPx(offsetLeftCssPx); + const offsetBottom = cssPxToCanvasPx(offsetBottomCssPx); + const targetX = x + offsetLeft - (scaledWidth - width) / 2; + const targetY = y - offsetBottom - (scaledHeight - height) / 2; + + if (scaledWidth <= 0 || scaledHeight <= 0) return; + + ctx.drawImage( + image, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + targetX, + targetY, + scaledWidth, + scaledHeight, + ); +}; + +const breakLongWord = (ctx: CanvasRenderingContext2D, word: string, maxWidth: number): string[] => { + const parts: string[] = []; + let current = ""; + + for (const char of word) { + const candidate = current + char; + if (current && ctx.measureText(candidate).width > maxWidth) { + parts.push(current); + current = char; + } else { + current = candidate; + } + } + + if (current) parts.push(current); + return parts.length ? parts : [word]; +}; + +const wrapText = (ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] => { + if (!text) return []; + + const result: string[] = []; + + text.split("\n").forEach((paragraph) => { + if (paragraph === "") { + result.push(""); + return; + } + + let line = ""; + paragraph.split(/\s+/).forEach((word) => { + if (ctx.measureText(word).width <= maxWidth) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + result.push(line); + line = word; + } else { + line = candidate; + } + return; + } + + if (line) { + result.push(line); + line = ""; + } + + const parts = breakLongWord(ctx, word, maxWidth); + result.push(...parts.slice(0, -1)); + line = parts.at(-1) ?? ""; + }); + + if (line) result.push(line); + }); + + return result; +}; + +const drawCenteredLines = ( + ctx: CanvasRenderingContext2D, + lines: string[], + x: number, + centerY: number, + lineHeight: number, +) => { + if (!lines.length) return; + const totalHeight = lines.length * lineHeight; + let y = centerY - totalHeight / 2 + lineHeight / 2; + + lines.forEach((line) => { + ctx.fillText(line, x, y); + y += lineHeight; + }); +}; + +const fitCircularFontSize = ( + ctx: CanvasRenderingContext2D, + text: string, + fontFamily: string, + minFontPx: number, + maxFontPx: number, + outerRadius: number, +): number => { + let low = minFontPx; + let high = Math.max(minFontPx, maxFontPx); + let best = minFontPx; + + for (let i = 0; i < 12; i++) { + const current = (low + high) / 2; + ctx.font = `${current}px ${fontFamily}`; + const radius = Math.max(1, outerRadius - current * 0.75); + const fits = ctx.measureText(text).width <= Math.PI * 2 * radius; + if (fits) { + best = current; + low = current; + } else { + high = current; + } + } + + return best; +}; + +const drawCircularText = ( + ctx: CanvasRenderingContext2D, + text: string, + centerX: number, + centerY: number, + size: number, + requestedFontSize: number, + color: string, + fontFamily: string, + rotateDeg: number, +) => { + if (!text) return; + + const padding = cssPxToCanvasPx(4); + const outerRadius = Math.max(1, (size - 2 * padding) / 2); + const fontSize = fitCircularFontSize( + ctx, + text, + fontFamily, + cssPxToCanvasPx(10), + requestedFontSize, + outerRadius, + ); + const textRadius = Math.max(1, outerRadius - fontSize * 0.75); + + ctx.save(); + ctx.font = `${fontSize}px ${fontFamily}`; + ctx.fillStyle = color; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const characters = Array.from(text); + const rawWidths = characters.map((char) => ctx.measureText(char).width); + const rawTotalWidth = rawWidths.reduce((sum, width) => sum + width, 0); + const measuredWidth = ctx.measureText(text).width; + const kerningScale = rawTotalWidth > 0 ? measuredWidth / rawTotalWidth : 1; + const widths = rawWidths.map((width) => width * kerningScale); + const totalWidth = widths.reduce((sum, width) => sum + width, 0); + let distance = -totalWidth / 2; + const centerAngle = (270 + rotateDeg) * (Math.PI / 180); + + characters.forEach((char, index) => { + const width = widths[index]; + const charDistance = distance + width / 2; + const angle = centerAngle + charDistance / textRadius; + const x = centerX + Math.cos(angle) * textRadius; + const y = centerY + Math.sin(angle) * textRadius; + + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle + Math.PI / 2); + ctx.fillText(char, 0, 0); + ctx.restore(); + + distance += width; + }); + + ctx.restore(); +}; + +const drawLabelBorder = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + round: boolean, + color: string, +) => { + const lineWidth = cssPxToCanvasPx(1); + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + beginLabelPath( + ctx, + x + lineWidth / 2, + y + lineWidth / 2, + Math.max(0, width - lineWidth), + Math.max(0, height - lineWidth), + round, + ); + ctx.stroke(); + ctx.restore(); +}; + +const drawImageLabel = async ( + ctx: CanvasRenderingContext2D, + item: MergedItem, + placement: PrintLabelPlacement, + settings: SettingsState, + imageCache: ImageCache, +) => { + const x = mmToCanvasPx(placement.xMm); + const y = mmToCanvasPx(placement.yMm); + const width = mmToCanvasPx(placement.widthMm); + const height = mmToCanvasPx(placement.heightMm); + const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed")); + const round = settings.labelShape === "round"; + const backgroundColor = settings.labelBackgroundColor || "#ffffff"; + const textColor = getContrastTextColor(backgroundColor); + const bleedX = x - bleed; + const bleedY = y - bleed; + const bleedWidth = width + bleed * 2; + const bleedHeight = height + bleed * 2; + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + ctx.fillStyle = backgroundColor; + ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight); + }); + + if (item.pic) { + const image = await imageCache.load(toImageSrc(item.pic)); + if (image) { + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + drawCoverImage( + ctx, + image, + bleedX, + bleedY, + bleedWidth, + bleedHeight, + settings.imagePosition, + settings.imageScale, + settings.imageLeft, + settings.imageBottom, + ); + }); + } + } + + if (settings.showSeriesOnImageLabel) { + const labelText = `${item.text ?? ""}${item.episodes ? ` - ${item.episodes}` : ""}`; + const fontFamily = settings.fontFamily || "sans-serif"; + const requestedFontSize = cssPxToCanvasPx( + cssPx(settings.seriesOnImageLabelFontSize || settings.textFontSize, 12), + ); + + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + if (round) { + drawCircularText( + ctx, + labelText, + x + width / 2, + y + height / 2, + cssPxToCanvasPx(150), + requestedFontSize, + textColor, + fontFamily, + settings.seriesOnImageLabelRotationDeg || 0, + ); + } else { + withLabelClip(ctx, x, y, width, height, false, () => { + ctx.font = `${requestedFontSize}px ${fontFamily}`; + ctx.fillText(labelText, x + width / 2, y + height / 2); + }); + } + } + + if (settings.showLabelBorder) { + drawLabelBorder(ctx, x, y, width, height, round, textColor); + } +}; + +const loadFlagImage = async ( + language: string, + imageCache: ImageCache, +): Promise => { + const code = language.toUpperCase().split("-")[1]; + const flagUrl = await loadFlagAsset(code || "UNKNOWN"); + const fallbackUrl = flagUrl ?? (await loadFlagAsset("UNKNOWN")); + return fallbackUrl ? imageCache.load(fallbackUrl) : null; +}; + +const drawFlagImage = ( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + x: number, + y: number, + height: number, +) => { + const naturalWidth = image.naturalWidth || image.width || 3; + const naturalHeight = image.naturalHeight || image.height || 2; + const width = height * (naturalWidth / naturalHeight); + ctx.drawImage(image, x - width / 2, y, width, height); +}; + +const drawTextLabel = async ( + ctx: CanvasRenderingContext2D, + item: MergedItem, + placement: PrintLabelPlacement, + settings: SettingsState, + imageCache: ImageCache, +) => { + const x = mmToCanvasPx(placement.xMm); + const y = mmToCanvasPx(placement.yMm); + const width = mmToCanvasPx(placement.widthMm); + const height = mmToCanvasPx(placement.heightMm); + const bleed = mmToCanvasPx(parseMm(settings.paperLabelImageBleed, "label image bleed")); + const round = settings.labelShape === "round"; + const backgroundColor = settings.labelBackgroundColor || "#ffffff"; + const textColor = getContrastTextColor(backgroundColor); + const fontFamily = settings.fontFamily || "sans-serif"; + const fontSize = cssPxToCanvasPx(cssPx(settings.textFontSize, 14)); + const smallFontSize = fontSize * 0.833333; + const lineHeight = fontSize * 1.2; + const smallLineHeight = smallFontSize * 1.2; + const contentPadding = mmToCanvasPx( + parseMm(settings.contentPadding || "0mm", "content padding"), + ); + const bleedX = x - bleed; + const bleedY = y - bleed; + const bleedWidth = width + bleed * 2; + const bleedHeight = height + bleed * 2; + const flagImage = + !settings.printTrackListInsteadTitle && settings.showLanguageFlag && item.language + ? await loadFlagImage(item.language, imageCache) + : null; + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + ctx.fillStyle = backgroundColor; + ctx.fillRect(bleedX, bleedY, bleedWidth, bleedHeight); + }); + + withLabelClip(ctx, bleedX, bleedY, bleedWidth, bleedHeight, round, () => { + const innerLeft = x + contentPadding + cssPxToCanvasPx(8); + const innerRight = x + width - contentPadding - cssPxToCanvasPx(8); + const innerWidth = Math.max(cssPxToCanvasPx(4), innerRight - innerLeft); + const topY = y + contentPadding + cssPxToCanvasPx(2); + const bottomY = y + height - contentPadding - cssPxToCanvasPx(2); + + ctx.fillStyle = textColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const topBandHeight = Math.max(cssPxToCanvasPx(12), fontSize); + const bottomBandHeight = Math.max(cssPxToCanvasPx(12), smallFontSize); + const middleTop = topY + topBandHeight; + const middleBottom = bottomY - bottomBandHeight; + const middleCenter = (middleTop + middleBottom) / 2; + + if (flagImage) { + drawFlagImage(ctx, flagImage, x + width / 2, topY, fontSize); + } + + if (settings.printTrackListInsteadTitle) { + ctx.font = `${fontSize}px ${fontFamily}`; + const lines = wrapText(ctx, item.trackTitles.join("\n"), innerWidth); + drawCenteredLines(ctx, lines, x + width / 2, (topY + bottomY) / 2, lineHeight); + } else { + const title = item.text ?? ""; + const episodes = item.episodes ?? ""; + ctx.font = `bold ${fontSize}px ${fontFamily}`; + const titleLines = wrapText(ctx, title, innerWidth); + ctx.font = `${fontSize}px ${fontFamily}`; + const episodeLines = wrapText(ctx, episodes, innerWidth); + const gap = titleLines.length && episodeLines.length ? cssPxToCanvasPx(4) : 0; + const totalHeight = + titleLines.length * lineHeight + episodeLines.length * lineHeight + gap; + let lineY = middleCenter - totalHeight / 2 + lineHeight / 2; + + ctx.font = `bold ${fontSize}px ${fontFamily}`; + titleLines.forEach((line) => { + ctx.fillText(line, x + width / 2, lineY); + lineY += lineHeight; + }); + lineY += gap; + ctx.font = `${fontSize}px ${fontFamily}`; + episodeLines.forEach((line) => { + ctx.fillText(line, x + width / 2, lineY); + lineY += lineHeight; + }); + } + + if (!settings.printTrackListInsteadTitle && settings.showModelNo && item.model) { + ctx.font = `${smallFontSize}px ${fontFamily}`; + const modelLines = wrapText(ctx, item.model, innerWidth); + if (modelLines.length) { + ctx.fillText(modelLines[0], x + width / 2, bottomY - smallLineHeight / 2); + } + } + }); + + if (settings.showLabelBorder) { + drawLabelBorder(ctx, x, y, width, height, round, textColor); + } +}; + +const renderPage = async ( + page: ReturnType[number], + items: MergedItem[], + settings: SettingsState, + labelOverridesById: LabelOverridesById, + imageCache: ImageCache, +): Promise => { + const pixelWidth = Math.max(1, Math.round(mmToCanvasPx(page.widthMm))); + const pixelHeight = Math.max(1, Math.round(mmToCanvasPx(page.heightMm))); + const canvas = document.createElement("canvas"); + canvas.width = pixelWidth; + canvas.height = pixelHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Canvas is not available in this browser."); + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, pixelWidth, pixelHeight); + + for (const placement of page.placements) { + const item = items[placement.itemIndex]; + if (!item) continue; + const id = item.id ?? String(placement.itemIndex); + const effectiveSettings = buildEffectiveSettings(settings, labelOverridesById[id]); + + if (placement.kind === "image") { + await drawImageLabel(ctx, item, placement, effectiveSettings, imageCache); + } else { + await drawTextLabel(ctx, item, placement, effectiveSettings, imageCache); + } + } + + return { + jpeg: await canvasToJpeg(canvas), + pixelWidth, + pixelHeight, + widthPt: mmToPt(page.widthMm), + heightPt: mmToPt(page.heightMm), + }; +}; + +export interface TeddyStudioPdfOptions { + items: MergedItem[]; + settings: SettingsState; + labelOverridesById?: LabelOverridesById; + onWarning?: (warning: TeddyStudioPdfWarning) => void; +} + +/** + * Creates a print-ready PDF entirely in the frontend. The PDF page and label geometry is based on + * millimetres, independent of browser print CSS, @page support or DOM measurements. + */ +export const createTeddyStudioPdf = async ({ + items, + settings, + labelOverridesById = {}, + onWarning, +}: TeddyStudioPdfOptions): Promise => { + if (items.length === 0) { + throw new Error("There are no labels to print."); + } + + const fontFamilies = new Set([settings.fontFamily || "sans-serif"]); + for (const item of items) { + const override = labelOverridesById[item.id]; + const effective = buildEffectiveSettings(settings, override); + fontFamilies.add(effective.fontFamily || "sans-serif"); + } + try { + await ensurePrintFonts(fontFamilies); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + onWarning?.({ + code: "font-load-failed", + message: `Could not load one or more print fonts: ${reason}`, + }); + } + + const layout = createPrintLayout(items.length, settings); + const imageCache = new ImageCache(onWarning); + const pages: PdfPageImage[] = []; + + for (const page of layout) { + pages.push(await renderPage(page, items, settings, labelOverridesById, imageCache)); + } + + const bytes = buildPdfFromJpegPages(pages); + const buffer = new ArrayBuffer(bytes.byteLength); + + new Uint8Array(buffer).set(bytes); + return new Blob([buffer], { type: "application/pdf" }); +}; diff --git a/src/components/tonies/teddystudio/styles/labels.css b/src/components/tonies/teddystudio/styles/labels.css new file mode 100644 index 00000000..8eba49f6 --- /dev/null +++ b/src/components/tonies/teddystudio/styles/labels.css @@ -0,0 +1,116 @@ +.labelElement { + position: relative; + width: var(--labelElement-width); + height: var(--labelElement-height); + border-radius: var(--labelElement-radius); + + display: flex; + justify-content: center; + align-items: center; + + overflow: visible; + isolation: isolate; + + font-size: var(--text-font-size, 14px); + color: black; +} + +.labelElement .labelTintBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + z-index: 0; +} + +.labelElementText { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + overflow: hidden; + padding: 0px; + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + + z-index: 0; +} + +.labelElement .labelBg { + position: absolute; + inset: 0; + border-radius: var(--labelElement-radius); + background-color: var(--label-background-color, #ffffff); + z-index: 0; +} + +.labelElement .labelImageBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + z-index: 1; + background-color: var(--label-background-color, #ffffff); + border-radius: var(--labelElement-radius); + overflow: hidden; +} + +.labelElement .labelImage { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + object-position: var(--image-position, center); +} + +.labelElement .labelClip { + position: absolute; + inset: 0; + border-radius: var(--labelElement-radius); + background: transparent; + z-index: 2; + overflow: visible; +} + +.labelElement .labelContentBleed { + position: absolute; + top: calc(-1 * var(--paper-label-image-bleed, 0mm)); + left: calc(-1 * var(--paper-label-image-bleed, 0mm)); + right: calc(-1 * var(--paper-label-image-bleed, 0mm)); + bottom: calc(-1 * var(--paper-label-image-bleed, 0mm)); + z-index: 3; + pointer-events: none; + overflow: hidden; + border-radius: var(--labelElement-radius); +} + +.labelElement .labelClip::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border: var(--label-border-width, 1px) solid var(--label-border-color, black); + border-radius: var(--labelElement-radius); + box-sizing: border-box; + z-index: 10; +} + +.labelElement .labelOverlay { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.traveltonieCouple { + border-radius: 16px; + border: 1px dashed var(--traveltonie-border-color, #d9d9d9); +} From 1bc5ba8492ca8dd32805678e53dfaea070e389e1 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:33:08 +0200 Subject: [PATCH 29/34] auto print after pdf blob is opened --- .../tonies/teddystudio/TeddyStudio.tsx | 36 +---- .../tonies/teddystudio/print/printFile.ts | 142 ++++++++++++++++++ 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/components/tonies/teddystudio/TeddyStudio.tsx b/src/components/tonies/teddystudio/TeddyStudio.tsx index 645afe72..ff569b3c 100644 --- a/src/components/tonies/teddystudio/TeddyStudio.tsx +++ b/src/components/tonies/teddystudio/TeddyStudio.tsx @@ -23,7 +23,7 @@ import { CustomImages } from "./input/CustomImages"; import { EditLabelModal } from "./modals/EditLabelModal"; import { LabelOverridesById, LabelOverrides } from "./types/labelOverrides"; import { createTeddyStudioPdf } from "./print/teddyStudioPdf"; -import { createTeddyStudioPdfFilename } from "./print/printFile"; +import { createTeddyStudioPdfFilename, downloadPdfBlob, printPdfBlob } from "./print/printFile"; import SelectImageModal from "../common/modals/SelectImageModal"; const { Paragraph } = Typography; @@ -158,40 +158,16 @@ export const TeddyStudio: React.FC = () => { }); const filename = createTeddyStudioPdfFilename(); - const pdfFile = - typeof File !== "undefined" - ? new File([pdf], filename, { type: "application/pdf" }) - : new Blob([pdf], { type: "application/pdf" }); - const url = URL.createObjectURL(pdfFile); if (action === "download") { - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); + downloadPdfBlob(pdf, filename); } else { - // Open the PDF only after it has been fully generated. If the browser blocks the - // asynchronous popup, fall back to a regular download instead of failing. - const printWindow = window.open(url, "_blank"); - if (printWindow) { - try { - printWindow.opener = null; - } catch { - // Nothing to do. The PDF itself is already open. - } - } else { - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); + const printResult = await printPdfBlob(pdf, filename); + if (printResult === "downloaded") { addNotification( NotificationTypeEnum.Warning, t("tonies.teddystudio.printPage"), - "The browser blocked opening the finished PDF in a new tab. The PDF was downloaded instead.", + "The browser could not print or open the finished PDF. The PDF was downloaded instead.", t("tonies.teddystudio.navigationTitle"), undefined, false, @@ -216,8 +192,6 @@ export const TeddyStudio: React.FC = () => { false, ); } - - window.setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000); } catch (error) { const description = error instanceof Error ? error.message : String(error); addNotification( diff --git a/src/components/tonies/teddystudio/print/printFile.ts b/src/components/tonies/teddystudio/print/printFile.ts index 5be67899..3c20acf6 100644 --- a/src/components/tonies/teddystudio/print/printFile.ts +++ b/src/components/tonies/teddystudio/print/printFile.ts @@ -1,5 +1,18 @@ const pad2 = (value: number): string => String(value).padStart(2, "0"); +const revokeObjectUrlLater = (url: string, delayMs = 5 * 60 * 1000) => { + window.setTimeout(() => URL.revokeObjectURL(url), delayMs); +}; + +const downloadObjectUrl = (url: string, filename: string) => { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + /** * Generates a filesystem-safe local timestamp for TeddyStudio PDF exports. * Example: teddystudio_labels_2026-10-17_08-49-48.pdf @@ -8,3 +21,132 @@ export const createTeddyStudioPdfFilename = (date = new Date()): string => `teddystudio_labels_${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}_${pad2( date.getHours(), )}-${pad2(date.getMinutes())}-${pad2(date.getSeconds())}.pdf`; + +export const downloadPdfBlob = (blob: Blob, filename: string): void => { + const url = URL.createObjectURL(blob); + downloadObjectUrl(url, filename); + revokeObjectUrlLater(url); +}; + +export type PrintPdfBlobResult = "printed" | "opened" | "downloaded"; + +/** + * Loads the finished PDF into a hidden iframe and asks the browser to print that PDF directly. + * If the embedded PDF viewer cannot be printed, the PDF is opened in a new tab instead. If that + * popup is blocked as well, the PDF is downloaded as the final fallback. + */ +export const printPdfBlob = ( + blob: Blob, + filename: string, + options: { + loadTimeoutMs?: number; + printDelayMs?: number; + } = {}, +): Promise => { + const { loadTimeoutMs = 10000, printDelayMs = 300 } = options; + const url = URL.createObjectURL(blob); + + return new Promise((resolve) => { + const iframe = document.createElement("iframe"); + let finished = false; + let loadTimeout: number | undefined; + let printTimer: number | undefined; + + iframe.style.position = "fixed"; + iframe.style.left = "-10000px"; + iframe.style.top = "-10000px"; + iframe.style.width = "1px"; + iframe.style.height = "1px"; + iframe.style.border = "0"; + iframe.style.opacity = "0"; + iframe.style.pointerEvents = "none"; + iframe.setAttribute("aria-hidden", "true"); + + const removeFrame = () => { + if (loadTimeout !== undefined) { + window.clearTimeout(loadTimeout); + } + if (printTimer !== undefined) { + window.clearTimeout(printTimer); + } + iframe.remove(); + }; + + const finish = (result: PrintPdfBlobResult) => { + if (finished) return; + finished = true; + resolve(result); + }; + + const fallback = () => { + if (finished) return; + removeFrame(); + + const printWindow = window.open(url, "_blank"); + if (printWindow) { + try { + printWindow.opener = null; + } catch { + // The PDF is already open. Nothing else is required here. + } + revokeObjectUrlLater(url); + finish("opened"); + return; + } + + downloadObjectUrl(url, filename); + revokeObjectUrlLater(url); + finish("downloaded"); + }; + + iframe.onload = () => { + if (finished) return; + if (loadTimeout !== undefined) { + window.clearTimeout(loadTimeout); + loadTimeout = undefined; + } + + printTimer = window.setTimeout(() => { + try { + const frameWindow = iframe.contentWindow; + if (!frameWindow) { + fallback(); + return; + } + + frameWindow.addEventListener( + "afterprint", + () => { + removeFrame(); + URL.revokeObjectURL(url); + }, + { once: true }, + ); + + frameWindow.focus(); + frameWindow.print(); + + // Some PDF viewers do not emit afterprint. Keep the object URL alive long + // enough for the viewer and clean it up later as a fallback. + window.setTimeout( + () => { + removeFrame(); + URL.revokeObjectURL(url); + }, + 5 * 60 * 1000, + ); + + finish("printed"); + } catch { + fallback(); + } + }, printDelayMs); + }; + + iframe.onerror = fallback; + loadTimeout = window.setTimeout(fallback, loadTimeoutMs); + + document.body.appendChild(iframe); + iframe.src = url; + }); +}; From 3819bfb27cffe3d920c7127d6863db05577637d8 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:51:55 +0200 Subject: [PATCH 30/34] chore: update i18next and react-i18next dependencies to latest versions --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index f35eeca6..ec733525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,13 +20,13 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.4.7", + "i18next": "^26.5.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.3", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-i18next": "^17.0.20", + "react-i18next": "^17.1.0", "react-markdown": "^10.1.0", "react-minimal-pie-chart": "^9.1.2", "react-router-dom": "^7.18.4", @@ -4592,9 +4592,9 @@ } }, "node_modules/i18next": { - "version": "26.4.7", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz", - "integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==", + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.0.tgz", + "integrity": "sha512-OwFFD3ER/BSWsv40teDtkhG7fwgncd7cq1CX7el02IENW9ZiwfWLnMjsQGD/q3SExrxp0+QxwUqXL8F9BKu5+w==", "funding": [ { "type": "individual", @@ -6458,9 +6458,9 @@ "license": "MIT" }, "node_modules/react-i18next": { - "version": "17.0.20", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz", - "integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz", + "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", diff --git a/package.json b/package.json index f707197d..7a5b4155 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,13 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.4.7", + "i18next": "^26.5.0", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^4.0.3", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", - "react-i18next": "^17.0.20", + "react-i18next": "^17.1.0", "react-markdown": "^10.1.0", "react-minimal-pie-chart": "^9.1.2", "react-router-dom": "^7.18.4", From b9c33bbfeb2cea093899bd78aec6c354cf561836 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:03:42 +0200 Subject: [PATCH 31/34] reverts c82c70ded2c9a6f33c53d9531d02bb8b15b8d0f2 --- .../common/modals/TonieInformationModal.tsx | 50 ++----------------- src/utils/downloads/tafDownload.ts | 7 +-- 2 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/components/tonies/common/modals/TonieInformationModal.tsx b/src/components/tonies/common/modals/TonieInformationModal.tsx index 02cab184..19e12427 100644 --- a/src/components/tonies/common/modals/TonieInformationModal.tsx +++ b/src/components/tonies/common/modals/TonieInformationModal.tsx @@ -140,56 +140,14 @@ const TonieInformationModal: React.FC = ({ displayTrackSeconds, displayLengthSeconds, ); - const librarySourceContentUrl = (() => { - if (!("source" in tonieCardOrTAFRecord)) { - return null; - } - - const source = tonieCardOrTAFRecord.source?.trim(); - if (!source?.toLowerCase().startsWith("lib://")) { - return null; - } - - let libraryPath = source.slice("lib://".length).replace(/^\/+/, ""); - try { - libraryPath = decodeURIComponent(libraryPath); - } catch { - // Keep the original path if it is not URI encoded correctly. - } - - libraryPath = libraryPath.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); - if (!libraryPath.toLowerCase().endsWith(".taf")) { - return null; - } - - const pathParts = libraryPath.split("/").filter(Boolean); - const fileName = pathParts.pop(); - if (!fileName) { - return null; - } - - const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/"); - const params = new URLSearchParams(); - params.set("ogg", "true"); - params.set("special", "library"); - if (overlay) { - params.set("overlay", overlay); - } - - return withBase( - `/content/${encodedPath ? `${encodedPath}/` : ""}${encodeURIComponent(fileName)}?${params.toString()}`, - ); - })(); const canDownloadAudio = - librarySourceContentUrl !== null || - ("audioUrl" in tonieCardOrTAFRecord && - (!("exists" in tonieCardOrTAFRecord) || tonieCardOrTAFRecord.exists)); + "audioUrl" in tonieCardOrTAFRecord && + (!("exists" in tonieCardOrTAFRecord) || tonieCardOrTAFRecord.exists); const downloadContentUrl = - librarySourceContentUrl ?? - ("audioUrl" in tonieCardOrTAFRecord + canDownloadAudio && "audioUrl" in tonieCardOrTAFRecord ? withBase(toSameOriginUrl(tonieCardOrTAFRecord.audioUrl)) - : ""); + : ""; const trackSecondsMatchSourceTracks = ( tonieCardOrTAFRecord: TonieCardTAFRecord, diff --git a/src/utils/downloads/tafDownload.ts b/src/utils/downloads/tafDownload.ts index fc53ce7b..55351a98 100644 --- a/src/utils/downloads/tafDownload.ts +++ b/src/utils/downloads/tafDownload.ts @@ -53,12 +53,9 @@ export function buildTafDownloadUrl( parsed.searchParams.delete("filename"); parsed.searchParams.delete("entry"); - // /content/download/... serves the already prepared audio stream and does not - // support exporting individual TAF tracks. For a track selection, address the - // underlying cached TAF through the regular /content/... route instead. + // Track titles stay out of the query. The server only accepts 255 characters, + // and a multi-track name list exceeds that and is dropped as an empty reply. if (options.tracks && options.tracks.length > 0) { - parsed.pathname = parsed.pathname.replace("/content/download/", "/content/"); - parsed.searchParams.delete("skip_header"); parsed.searchParams.set("tracks", options.tracks.join(",")); } From f36bd933d4c5177336c0543a3bde403b28f9b389 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:44:30 +0200 Subject: [PATCH 32/34] chore: update i18next and i18next-http-backend to latest versions --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec733525..cc658f43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,9 +20,9 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.5.0", + "i18next": "^26.5.3", "i18next-browser-languagedetector": "^8.2.1", - "i18next-http-backend": "^4.0.3", + "i18next-http-backend": "^4.0.4", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", @@ -4592,9 +4592,9 @@ } }, "node_modules/i18next": { - "version": "26.5.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.0.tgz", - "integrity": "sha512-OwFFD3ER/BSWsv40teDtkhG7fwgncd7cq1CX7el02IENW9ZiwfWLnMjsQGD/q3SExrxp0+QxwUqXL8F9BKu5+w==", + "version": "26.5.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.3.tgz", + "integrity": "sha512-6pRwOwJ6lBCBB66fqNZaSoUmvsALHMord7FL6GsDhubT1CrPabTMEJIqddm0nn2YEbKofdI/3VAtGrQPuztY1Q==", "funding": [ { "type": "individual", @@ -4629,9 +4629,9 @@ } }, "node_modules/i18next-http-backend": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.3.tgz", - "integrity": "sha512-B5GJysCsJwqiDQRpUcikWsut82Vmekix58WIyiCpYtxaZ/wY8kwJCicMRvVcMoIa1x84siiRl5vBt6iCBVO6zg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.4.tgz", + "integrity": "sha512-nG9OQKvUsPiTVUBg1rCGmvmWhzxC9GOxVbGdTYyejHVMSUjutt1hiBNVSzhB/ZE7keL9Xkb880kUfhDovV5T5g==", "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 7a5b4155..2a1d9568 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "@vitejs/plugin-react": "^6.1.2", "antd": "^6.6.6", "formik": "^2.4.9", - "i18next": "^26.5.0", + "i18next": "^26.5.3", "i18next-browser-languagedetector": "^8.2.1", - "i18next-http-backend": "^4.0.3", + "i18next-http-backend": "^4.0.4", "jszip": "^3.10.2", "react": "^19.3.1", "react-dom": "^19.3.1", From 69231be7f4bc9f7c750530b926b0ea5efe9367b6 Mon Sep 17 00:00:00 2001 From: henryk86 <166761451+henryk86@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:53:36 +0200 Subject: [PATCH 33/34] fix: prevent button rendering in library when isTapList is true --- src/components/tonies/filebrowser/FileBrowser.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tonies/filebrowser/FileBrowser.tsx b/src/components/tonies/filebrowser/FileBrowser.tsx index ebd0a711..2675bfa7 100644 --- a/src/components/tonies/filebrowser/FileBrowser.tsx +++ b/src/components/tonies/filebrowser/FileBrowser.tsx @@ -830,7 +830,7 @@ export const FileBrowser: React.FC<{ {t("fileBrowser.upload.showUploadFilesDragNDrop")}
- {special === "library" && ( + {special === "library" && !isTapList && ( )} - {special === "library" && ( + {special === "library" && !isTapList && (