-
{$_('book.tags')}
+
{label ?? $_('book.tags')}
- {#each tags as tag (tag)}
+ {#each chips as chip (chip)}
- {tag}
+ {chip}
{#if !disabled}
- {#if !fetchSuggestions}
-
{$_('book.tagsHint')}
+ {#if hint !== undefined || !fetchSuggestions}
+
{hint ?? $_('book.tagsHint')}
{/if}
diff --git a/frontend/src/lib/components/TagInput.test.ts b/frontend/src/lib/components/TagInput.test.ts
index b70fbed2..1f7d6778 100644
--- a/frontend/src/lib/components/TagInput.test.ts
+++ b/frontend/src/lib/components/TagInput.test.ts
@@ -383,3 +383,63 @@ describe('TagInput', () => {
expect(style).toContain('width:');
});
});
+
+describe('TagInput (list mode)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ cleanup();
+ });
+
+ it('renders chips from values prop', () => {
+ render(TagInput, { props: { values: ['Frank Herbert', 'Neil Gaiman'] } });
+
+ expect(screen.getByText('Frank Herbert')).toBeInTheDocument();
+ expect(screen.getByText('Neil Gaiman')).toBeInTheDocument();
+ });
+
+ it('does not split on commas in list mode', async () => {
+ render(TagInput, { props: { values: [] } });
+
+ const input = screen.getByRole('textbox');
+ await fireEvent.input(input, { target: { value: 'Asimov, Isaac' } });
+ await fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(screen.getByText('Asimov, Isaac')).toBeInTheDocument();
+ expect(screen.queryByText('Asimov')).not.toBeInTheDocument();
+ });
+
+ it('emits the list via bindable values on Enter', async () => {
+ const { component } = render(TagInput, { props: { values: ['Frank Herbert'] } });
+
+ const input = screen.getByRole('textbox');
+ await fireEvent.input(input, { target: { value: 'Brian Herbert' } });
+ await fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(screen.getByText('Brian Herbert')).toBeInTheDocument();
+ });
+
+ it('prevents duplicate chips case-insensitively', async () => {
+ render(TagInput, { props: { values: ['Frank Herbert'] } });
+
+ const input = screen.getByRole('textbox');
+ await fireEvent.input(input, { target: { value: 'frank herbert' } });
+ await fireEvent.keyDown(input, { key: 'Enter' });
+
+ expect(screen.getAllByText(/Frank Herbert/i)).toHaveLength(1);
+ expect(input).toHaveValue('');
+ });
+
+ it('removes a chip via its remove button', async () => {
+ render(TagInput, { props: { values: ['Frank Herbert', 'Brian Herbert'] } });
+
+ const removeButtons = screen.getAllByLabelText('Remove');
+ await fireEvent.click(removeButtons[0]);
+
+ expect(screen.queryByText('Frank Herbert')).not.toBeInTheDocument();
+ expect(screen.getByText('Brian Herbert')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index 520c3bdd..f55bc157 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -59,8 +59,10 @@
"noRating": "Keine Bewertung",
"topRated": "Am besten bewertet",
"worstRated": "Am schlechtesten bewertet",
- "showMore": "Mehr anzeigen"
- },
+ "showMore": "Mehr anzeigen",
+ "totalBooksAndAuthors": "Bücher insgesamt",
+ "booksFromAuthors": "von {authors} {authors, plural, one {Autor} other {Autoren}}"
+ },
"dashboard": {
"title": "Lese-Dashboard",
"subtitle": "Ein schneller Überblick über dein Lesen",
@@ -76,13 +78,26 @@
"noSearchResults": "Keine Bücher gefunden",
"noCurrentlyReading": "Du liest aktuell kein Buch.",
"noNextToRead": "Noch keine Bücher in deiner Wunschliste.",
- "popularTags": "Beliebte Tags"
+ "popularTags": "Beliebte Tags",
+ "gamificationTitle": "Streaks & Ziele",
+ "currentStreak": "Aktueller Streak",
+ "currentStreakHint": "Aufeinanderfolgende Tage mit Leseaktivität",
+ "longestStreak": "Längster Streak",
+ "longestStreakDateRange": "{start} – {end}",
+ "goalPagesPerDay": "Seiten pro Tag",
+ "goalPagesPerMonth": "Seiten pro Monat",
+ "goalBooksPerMonth": "Bücher pro Monat",
+ "goalBooksPerYear": "Bücher pro Jahr",
+ "goalReached": "Ziel erreicht",
+ "goalOf": "von {target}",
+ "goalProgress": "{current} von {target}"
},
"status": {
"want_to_read": "Möchte ich lesen",
"currently_reading": "Lese ich gerade",
"read": "Gelesen",
- "did_not_finish": "Abgebrochen"
+ "did_not_finish": "Abgebrochen",
+ "all": "Alle Bücher"
},
"common": {
"all": "Alle",
@@ -180,7 +195,10 @@
"progressPromptTitle": "Lesefortschritt setzen?",
"progressPromptMessage": "Lesefortschritt für \"{title}\" auf 100% setzen?",
"progressPromptSet": "Auf 100% setzen",
- "progressPromptSkip": "Überspringen"
+ "progressPromptSkip": "Überspringen",
+ "authorsPlaceholder": "Autor eingeben und Enter drücken",
+ "authorsHint": "Enter fügt einen Autor hinzu. Mit Rücktaste entfernt man den letzten.",
+ "authorsLabel": "Autor(en)"
},
"addModal": {
"manual": "Manuell",
@@ -291,7 +309,15 @@
"resultsCount": "{count, plural, one {Ergebnis} other {Ergebnisse}} gefunden",
"noResults": "Keine Ergebnisse gefunden",
"noResultsFor": "Keine Ergebnisse für \"{query}\" gefunden",
- "tryDifferentQuery": "Versuche einen anderen Suchbegriff"
+ "tryDifferentQuery": "Versuche einen anderen Suchbegriff",
+ "help": {
+ "title": "Suchsyntax",
+ "intro": "Verwende Feld-Präfixe, um in einem bestimmten Feld zu suchen. Präfixe sind immer auf Englisch.",
+ "multiWord": "Setze mehrteilige Werte in doppelte Anführungszeichen, z. B. author:\"Marlen Haushofer\".",
+ "combine": "Du kannst mehrere Präfixe kombinieren; die Ergebnisse müssen allen entsprechen.",
+ "negate": "Stelle einem Begriff ein - voran, um ihn auszuschließen, z. B. tag:cars -tag:audi.",
+ "possessionValues": "Possession-Werte: owned, borrowed, digital_access, to_acquire"
+ }
},
"languages": {
"en": "Englisch",
@@ -440,7 +466,8 @@
"exportNoDatasets": "Wähle mindestens einen Datensatz zum Exportieren aus.",
"importMappingNameConflict": "Ein Mapping mit diesem Namen existiert bereits.",
"importMappingNotFound": "Import-Mapping nicht gefunden.",
- "importFileNotFound": "Importdatei nicht gefunden. Bitte lade die Datei erneut hoch."
+ "importFileNotFound": "Importdatei nicht gefunden. Bitte lade die Datei erneut hoch.",
+ "importInvalidDelimiter": "CSV-Trennzeichen muss ein einzelnes Zeichen sein."
},
"oidc": {
"orContinueWith": "oder weiter mit",
@@ -463,6 +490,22 @@
"profileSaveFailed": "Profil konnte nicht gespeichert werden",
"passwordChangeSuccess": "Passwort geändert",
"passwordChangeFailed": "Passwort konnte nicht geändert werden",
+ "goals": {
+ "title": "Leseziele",
+ "subtitle": "Aktiviere Ziele, um sie spielerisch auf dem Dashboard zu verfolgen.",
+ "dashboardToggle": "Lese-Serien & Ziele auf dem Dashboard anzeigen",
+ "pagesPerDay": "Seiten pro Tag",
+ "pagesPerMonth": "Seiten pro Monat",
+ "booksPerMonth": "Bücher pro Monat",
+ "booksPerYear": "Bücher pro Jahr",
+ "target": "Ziel",
+ "pagesPerDayTarget": "Ziel: Seiten pro Tag",
+ "pagesPerMonthTarget": "Ziel: Seiten pro Monat",
+ "booksPerMonthTarget": "Ziel: Bücher pro Monat",
+ "booksPerYearTarget": "Ziel: Bücher pro Jahr",
+ "saveSuccess": "Leseziele gespeichert",
+ "invalidTarget": "{name} muss eine ganze Zahl von mindestens 1 sein."
+ },
"dataManagement": {
"title": "Meine Daten verwalten",
"description": "Exportiere deine Bibliothek oder importiere Bücher aus CSV/JSON.",
@@ -606,11 +649,13 @@
"validateFailed": "Validierung fehlgeschlagen.",
"previewFailed": "Vorschau konnte nicht geladen werden.",
"executeFailed": "Import fehlgeschlagen."
- }
+ },
+ "authorsHint": "Ein einzelner Autor (getrennt mit ;, & oder und) oder ein Array mit Namen — jeder Eintrag wird ein Autor.",
+ "delimiterLabel": "CSV-Trennzeichen"
}
},
- "dataHygiene": {
- "authorRequired": "Autor darf nicht leer sein.",
+ "dataHygiene": {
+ "authorRequired": "Autor darf nicht leer sein.",
"pageCountPositive": "Seitenzahl muss größer als 0 sein.",
"title": "Datenpflege",
"description": "Finde und korrigiere Bücher mit fehlenden Metadaten in deiner Bibliothek.",
@@ -702,8 +747,8 @@
"allDoneSub": "Jedes Buch in deiner Bibliothek hat jetzt ein Cover.",
"loadingBook": "Nächstes Buch wird geladen...",
"loadingCandidates": "Cover-Quellen werden durchsucht...",
- "keyboardHint": "Tipp: Drücke 1\u20139, um ein Cover auszuwählen, \u2192 zum Überspringen",
+ "keyboardHint": "Tipp: Drücke 1–9, um ein Cover auszuwählen, → zum Überspringen",
"candidatesError": "Cover-Suche fehlgeschlagen. Du kannst weiterhin den manuellen Import verwenden.",
"retry": "Wiederholen"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index 163ce136..eabd2185 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -23,6 +23,8 @@
"busiestMonth": "Busiest Month",
"avgPageCount": "Avg Pages/Book",
"mostPopularLanguage": "Most Popular Language",
+ "totalBooksAndAuthors": "Total Books",
+ "booksFromAuthors": "from {authors} {authors, plural, one {author} other {authors}}",
"languageDistribution": "Books by Language",
"statusDistribution": "Books by Status",
"acquisitionStatusDistribution": "Books by Ownership",
@@ -76,13 +78,26 @@
"noSearchResults": "No books found",
"noCurrentlyReading": "You are not currently reading a book.",
"noNextToRead": "No books in your want-to-read list yet.",
- "popularTags": "Popular Tags"
+ "popularTags": "Popular Tags",
+ "gamificationTitle": "Reading Streaks & Goals",
+ "currentStreak": "Current Streak",
+ "currentStreakHint": "Consecutive days with reading activity",
+ "longestStreak": "Longest Streak",
+ "longestStreakDateRange": "{start} – {end}",
+ "goalPagesPerDay": "Pages per Day",
+ "goalPagesPerMonth": "Pages per Month",
+ "goalBooksPerMonth": "Books per Month",
+ "goalBooksPerYear": "Books per Year",
+ "goalReached": "Goal reached",
+ "goalOf": "of {target}",
+ "goalProgress": "{current} of {target}"
},
"status": {
"want_to_read": "Want to Read",
"currently_reading": "Currently Reading",
"read": "Read",
- "did_not_finish": "Did Not Finish"
+ "did_not_finish": "Did Not Finish",
+ "all": "All Books"
},
"common": {
"all": "All",
@@ -145,7 +160,7 @@
"blurb": "Description",
"about": "About",
"dateStarted": "Date started",
- "acquisitionStatus": "Availability",
+ "acquisitionStatus": "Possession",
"selectAcquisitionStatus": "Select availability...",
"startDatePromptTitle": "Set a start date?",
"startDatePromptMessage": "This book has no start date. Set it to today or choose another date before marking it as read.",
@@ -180,7 +195,10 @@
"progressPromptTitle": "Set Reading Progress?",
"progressPromptMessage": "Set the reading progress for \"{title}\" to 100%?",
"progressPromptSet": "Set to 100%",
- "progressPromptSkip": "Skip"
+ "progressPromptSkip": "Skip",
+ "authorsPlaceholder": "Type an author and press Enter",
+ "authorsLabel": "Author(s)",
+ "authorsHint": "Press Enter to add authors. Backspace removes the last author."
},
"addModal": {
"manual": "Manual",
@@ -291,7 +309,15 @@
"resultsCount": "{count, plural, one {result} other {results}} found",
"noResults": "No results found",
"noResultsFor": "No results found for \"{query}\"",
- "tryDifferentQuery": "Try a different search term"
+ "tryDifferentQuery": "Try a different search term",
+ "help": {
+ "title": "Search syntax",
+ "intro": "Use field prefixes to search in a specific field. Prefixes are always English.",
+ "multiWord": "Wrap multi-word values in double quotes, e.g. author:\"Marlen Haushofer\".",
+ "combine": "You can combine multiple prefixes; results must match all of them.",
+ "negate": "Prefix any term with - to exclude it, e.g. tag:cars -tag:audi.",
+ "possessionValues": "possession values: owned, borrowed, digital_access, to_acquire"
+ }
},
"languages": {
"en": "English",
@@ -440,7 +466,8 @@
"tooManyBooksSelected": "Too many books selected. Please select at most {max} at a time.",
"importMappingNameConflict": "A mapping with this name already exists.",
"importMappingNotFound": "Import mapping not found.",
- "importFileNotFound": "Import file not found. Please upload the file again."
+ "importFileNotFound": "Import file not found. Please upload the file again.",
+ "importInvalidDelimiter": "CSV delimiter must be a single character."
},
"oidc": {
"orContinueWith": "or continue with",
@@ -463,6 +490,22 @@
"profileSaveFailed": "Failed to save profile",
"passwordChangeSuccess": "Password changed",
"passwordChangeFailed": "Failed to change password",
+ "goals": {
+ "title": "Reading Goals",
+ "subtitle": "Enable goals to track them playfully on your dashboard.",
+ "dashboardToggle": "Show reading streaks & goals on dashboard",
+ "pagesPerDay": "Pages per Day",
+ "pagesPerMonth": "Pages per Month",
+ "booksPerMonth": "Books per Month",
+ "booksPerYear": "Books per Year",
+ "target": "Target",
+ "pagesPerDayTarget": "Pages per Day target",
+ "pagesPerMonthTarget": "Pages per Month target",
+ "booksPerMonthTarget": "Books per Month target",
+ "booksPerYearTarget": "Books per Year target",
+ "saveSuccess": "Reading goals saved",
+ "invalidTarget": "{name} must be a whole number of at least 1."
+ },
"dataManagement": {
"title": "Manage my data",
"description": "Export your library or import books from a CSV/JSON file.",
@@ -532,6 +575,7 @@
"import": {
"title": "Import",
"description": "Upload one CSV or JSON file, map fields, validate, then import.",
+ "delimiterLabel": "CSV delimiter",
"parse": "Parse file",
"parsing": "Parsing...",
"fileSummary": "Rows: {rows}, fields: {fields}",
@@ -565,6 +609,7 @@
"requiredField": "= required field",
"changeFile": "Change file",
"coverUrlHint": "Expects an HTTP(S) URL to an image. Local file paths and base64 data are not supported.",
+ "authorsHint": "Map a single author string (separated with ;, & or and) or an array of names — each entry becomes one author.",
"transformHelp": "Available parameters and examples",
"transformHelpValue": "The raw value of the mapped source field",
"transformHelpRow": "All source fields as a dict, e.g. row['title']",
@@ -609,10 +654,10 @@
}
}
},
- "dataHygiene": {
- "authorRequired": "Author cannot be empty.",
- "pageCountPositive": "Page count must be greater than 0.",
- "title": "Data Hygiene",
+ "dataHygiene": {
+ "authorRequired": "Author cannot be empty.",
+ "pageCountPositive": "Page count must be greater than 0.",
+ "title": "Data Hygiene",
"description": "Find and fix books with missing metadata in your library.",
"attributes": {
"author": "Author",
@@ -702,8 +747,8 @@
"allDoneSub": "Every book in your library now has a cover image.",
"loadingBook": "Loading next book...",
"loadingCandidates": "Searching cover sources...",
- "keyboardHint": "Tip: Press 1\u20139 to select a cover, \u2192 to skip",
+ "keyboardHint": "Tip: Press 1–9 to select a cover, → to skip",
"candidatesError": "Cover search failed. You can still use manual import.",
"retry": "Retry"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json
index 9cd2edcc..23a14f13 100644
--- a/frontend/src/lib/i18n/locales/es.json
+++ b/frontend/src/lib/i18n/locales/es.json
@@ -59,7 +59,9 @@
"noRating": "Sin valoración",
"topRated": "Mejor valorados",
"worstRated": "Peor valorados",
- "showMore": "Mostrar más"
+ "showMore": "Mostrar más",
+ "totalBooksAndAuthors": "Libros en total",
+ "booksFromAuthors": "de {authors} {authors, plural, one {autor} other {autores}}"
},
"dashboard": {
"title": "Panel de lectura",
@@ -76,13 +78,26 @@
"noSearchResults": "No se encontraron libros",
"noCurrentlyReading": "No estás leyendo ningún libro actualmente.",
"noNextToRead": "Aún no hay libros en tu lista de deseos.",
- "popularTags": "Etiquetas populares"
+ "popularTags": "Etiquetas populares",
+ "gamificationTitle": "Rachas de lectura y metas",
+ "currentStreak": "Racha actual",
+ "currentStreakHint": "Días consecutivos con actividad de lectura",
+ "longestStreak": "Racha más larga",
+ "longestStreakDateRange": "{start} – {end}",
+ "goalPagesPerDay": "Páginas por día",
+ "goalPagesPerMonth": "Páginas por mes",
+ "goalBooksPerMonth": "Libros por mes",
+ "goalBooksPerYear": "Libros por año",
+ "goalReached": "Meta alcanzada",
+ "goalOf": "de {target}",
+ "goalProgress": "{current} de {target}"
},
"status": {
"want_to_read": "Quiero leer",
"currently_reading": "Leyendo",
"read": "Leído",
- "did_not_finish": "Abandonado"
+ "did_not_finish": "Abandonado",
+ "all": "Todos los libros"
},
"common": {
"all": "Todos",
@@ -180,7 +195,10 @@
"progressPromptTitle": "¿Establecer progreso de lectura?",
"progressPromptMessage": "¿Establecer el progreso de \"{title}\" al 100%?",
"progressPromptSet": "Establecer al 100%",
- "progressPromptSkip": "Saltar"
+ "progressPromptSkip": "Saltar",
+ "authorsPlaceholder": "Escribe un autor y pulsa Enter",
+ "authorsHint": "Pulsa Enter para añadir autores. Retroceso elimina el último.",
+ "authorsLabel": "Autor(es)"
},
"addModal": {
"manual": "Manual",
@@ -291,7 +309,15 @@
"resultsCount": "{count, plural, one {resultado} other {resultados}} encontrados",
"noResults": "No se encontraron resultados",
"noResultsFor": "No se encontraron resultados para \"{query}\"",
- "tryDifferentQuery": "Prueba con un término de búsqueda diferente"
+ "tryDifferentQuery": "Prueba con un término de búsqueda diferente",
+ "help": {
+ "title": "Sintaxis de búsqueda",
+ "intro": "Usa prefijos de campo para buscar en un campo concreto. Los prefijos siempre están en inglés.",
+ "multiWord": "Envuelve los valores de varias palabras entre comillas dobles, p. ej. author:\"Marlen Haushofer\".",
+ "combine": "Puedes combinar varios prefijos; los resultados deben coincidir con todos.",
+ "negate": "Antepón - a cualquier término para excluirlo, p. ej. tag:cars -tag:audi.",
+ "possessionValues": "valores de posesión: owned, borrowed, digital_access, to_acquire"
+ }
},
"languages": {
"en": "Inglés",
@@ -440,7 +466,8 @@
"tooManyBooksSelected": "Demasiados libros seleccionados. Selecciona como máximo {max} a la vez.",
"importMappingNameConflict": "Ya existe una asignación con este nombre.",
"importMappingNotFound": "Asignación de importación no encontrada.",
- "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo."
+ "importFileNotFound": "Archivo de importación no encontrado. Vuelve a subir el archivo.",
+ "importInvalidDelimiter": "El delimitador CSV debe ser un solo carácter."
},
"oidc": {
"orContinueWith": "o continuar con",
@@ -463,6 +490,22 @@
"profileSaveFailed": "Error al guardar el perfil",
"passwordChangeSuccess": "Contraseña cambiada",
"passwordChangeFailed": "Error al cambiar la contraseña",
+ "goals": {
+ "title": "Metas de lectura",
+ "subtitle": "Activa metas para seguirlas de forma lúdica en el panel.",
+ "dashboardToggle": "Mostrar rachas y metas de lectura en el panel",
+ "pagesPerDay": "Páginas por día",
+ "pagesPerMonth": "Páginas por mes",
+ "booksPerMonth": "Libros por mes",
+ "booksPerYear": "Libros por año",
+ "target": "Meta",
+ "pagesPerDayTarget": "Meta de páginas por día",
+ "pagesPerMonthTarget": "Meta de páginas por mes",
+ "booksPerMonthTarget": "Meta de libros por mes",
+ "booksPerYearTarget": "Meta de libros por año",
+ "saveSuccess": "Metas de lectura guardadas",
+ "invalidTarget": "{name} debe ser un número entero de al menos 1."
+ },
"dataManagement": {
"title": "Gestionar mis datos",
"description": "Exporta tu biblioteca o importa libros desde un archivo CSV/JSON.",
@@ -606,7 +649,9 @@
"validateFailed": "Validación fallida.",
"previewFailed": "Error al cargar la vista previa.",
"executeFailed": "Importación fallida."
- }
+ },
+ "authorsHint": "Asigna una sola cadena de autor (separada por ;, & o y) o un array de nombres: cada entrada se convierte en un autor.",
+ "delimiterLabel": "Delimitador CSV"
}
},
"dataHygiene": {
@@ -702,8 +747,8 @@
"allDoneSub": "Todos los libros de tu biblioteca tienen ahora una imagen de portada.",
"loadingBook": "Cargando siguiente libro...",
"loadingCandidates": "Buscando fuentes de portadas...",
- "keyboardHint": "Consejo: pulsa 1\u20139 para seleccionar una portada, \u2192 para saltar",
+ "keyboardHint": "Consejo: pulsa 1–9 para seleccionar una portada, → para saltar",
"candidatesError": "Búsqueda de portadas fallida. Aún puedes usar la importación manual.",
"retry": "Reintentar"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json
index f66d0537..9d27572c 100644
--- a/frontend/src/lib/i18n/locales/fr.json
+++ b/frontend/src/lib/i18n/locales/fr.json
@@ -59,7 +59,9 @@
"noRating": "Aucune note",
"topRated": "Les mieux notés",
"worstRated": "Les moins bien notés",
- "showMore": "Afficher plus"
+ "showMore": "Afficher plus",
+ "totalBooksAndAuthors": "Livres au total",
+ "booksFromAuthors": "de {authors} {authors, plural, one {auteur} other {auteurs}}"
},
"dashboard": {
"title": "Tableau de bord de lecture",
@@ -76,13 +78,26 @@
"noSearchResults": "Aucun livre trouvé",
"noCurrentlyReading": "Tu ne lis aucun livre actuellement.",
"noNextToRead": "Aucun livre dans ta liste d'envies.",
- "popularTags": "Étiquettes populaires"
+ "popularTags": "Étiquettes populaires",
+ "gamificationTitle": "Séries de lecture & objectifs",
+ "currentStreak": "Série actuelle",
+ "currentStreakHint": "Jours consécutifs avec activité de lecture",
+ "longestStreak": "Série la plus longue",
+ "longestStreakDateRange": "{start} – {end}",
+ "goalPagesPerDay": "Pages par jour",
+ "goalPagesPerMonth": "Pages par mois",
+ "goalBooksPerMonth": "Livres par mois",
+ "goalBooksPerYear": "Livres par an",
+ "goalReached": "Objectif atteint",
+ "goalOf": "sur {target}",
+ "goalProgress": "{current} sur {target}"
},
"status": {
"want_to_read": "À lire",
"currently_reading": "En cours",
"read": "Lu",
- "did_not_finish": "Abandonné"
+ "did_not_finish": "Abandonné",
+ "all": "Tous les livres"
},
"common": {
"all": "Tous",
@@ -180,7 +195,10 @@
"progressPromptTitle": "Définir la progression ?",
"progressPromptMessage": "Définir la progression de \"{title}\" à 100 % ?",
"progressPromptSet": "Définir à 100 %",
- "progressPromptSkip": "Passer"
+ "progressPromptSkip": "Passer",
+ "authorsPlaceholder": "Saisis un auteur et appuie sur Entrée",
+ "authorsHint": "Entrée pour ajouter des auteurs. Retour arrière supprime le dernier.",
+ "authorsLabel": "Auteur(s)"
},
"addModal": {
"manual": "Manuel",
@@ -291,7 +309,15 @@
"resultsCount": "{count, plural, one {résultat} other {résultats}} trouvés",
"noResults": "Aucun résultat trouvé",
"noResultsFor": "Aucun résultat trouvé pour \"{query}\"",
- "tryDifferentQuery": "Essaie un autre terme de recherche"
+ "tryDifferentQuery": "Essaie un autre terme de recherche",
+ "help": {
+ "title": "Syntaxe de recherche",
+ "intro": "Utilise des préfixes de champ pour rechercher dans un champ précis. Les préfixes sont toujours en anglais.",
+ "multiWord": "Place les valeurs multi-mots entre guillemets doubles, p. ex. author:\"Marlen Haushofer\".",
+ "combine": "Tu peux combiner plusieurs préfixes ; les résultats doivent correspondre à tous.",
+ "negate": "Préfixe tout terme par - pour l'exclure, p. ex. tag:cars -tag:audi.",
+ "possessionValues": "valeurs de possession : owned, borrowed, digital_access, to_acquire"
+ }
},
"languages": {
"en": "Anglais",
@@ -440,7 +466,8 @@
"tooManyBooksSelected": "Trop de livres sélectionnés. Sélectionne au maximum {max} à la fois.",
"importMappingNameConflict": "Un mappage avec ce nom existe déjà.",
"importMappingNotFound": "Mappage d'importation introuvable.",
- "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau."
+ "importFileNotFound": "Fichier d'importation introuvable. Veuillez téléverser le fichier à nouveau.",
+ "importInvalidDelimiter": "Le séparateur CSV doit être un seul caractère."
},
"oidc": {
"orContinueWith": "ou continuer avec",
@@ -463,6 +490,22 @@
"profileSaveFailed": "Échec de l'enregistrement du profil",
"passwordChangeSuccess": "Mot de passe modifié",
"passwordChangeFailed": "Échec de la modification du mot de passe",
+ "goals": {
+ "title": "Objectifs de lecture",
+ "subtitle": "Activez des objectifs pour les suivre de façon ludique sur votre tableau de bord.",
+ "dashboardToggle": "Afficher les séries et objectifs de lecture sur le tableau de bord",
+ "pagesPerDay": "Pages par jour",
+ "pagesPerMonth": "Pages par mois",
+ "booksPerMonth": "Livres par mois",
+ "booksPerYear": "Livres par an",
+ "target": "Objectif",
+ "pagesPerDayTarget": "Objectif de pages par jour",
+ "pagesPerMonthTarget": "Objectif de pages par mois",
+ "booksPerMonthTarget": "Objectif de livres par mois",
+ "booksPerYearTarget": "Objectif de livres par an",
+ "saveSuccess": "Objectifs de lecture enregistrés",
+ "invalidTarget": "{name} doit être un nombre entier d'au moins 1."
+ },
"dataManagement": {
"title": "Gérer mes données",
"description": "Exporte ta bibliothèque ou importe des livres depuis un fichier CSV/JSON.",
@@ -606,7 +649,9 @@
"validateFailed": "Échec de la validation.",
"previewFailed": "Échec du chargement de l'aperçu.",
"executeFailed": "Échec de l'importation."
- }
+ },
+ "authorsHint": "Mappez une chaîne d'auteur unique (séparée par ;, & ou et) ou un tableau de noms — chaque entrée devient un auteur.",
+ "delimiterLabel": "Séparateur CSV"
}
},
"dataHygiene": {
@@ -702,8 +747,8 @@
"allDoneSub": "Chaque livre de ta bibliothèque a maintenant une image de couverture.",
"loadingBook": "Chargement du livre suivant...",
"loadingCandidates": "Recherche de sources de couvertures...",
- "keyboardHint": "Astuce : appuie sur 1\u20139 pour sélectionner une couverture, \u2192 pour passer",
+ "keyboardHint": "Astuce : appuie sur 1–9 pour sélectionner une couverture, → pour passer",
"candidatesError": "Recherche de couverture échouée. Tu peux toujours utiliser l'importation manuelle.",
"retry": "Réessayer"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json
index c9c42e21..d9f7ee4f 100644
--- a/frontend/src/lib/i18n/locales/zh.json
+++ b/frontend/src/lib/i18n/locales/zh.json
@@ -59,7 +59,9 @@
"noRating": "暂无评分",
"topRated": "评分最高",
"worstRated": "评分最低",
- "showMore": "显示更多"
+ "showMore": "显示更多",
+ "totalBooksAndAuthors": "书籍总数",
+ "booksFromAuthors": "来自 {authors} 位不同的作者"
},
"dashboard": {
"title": "阅读仪表盘",
@@ -76,13 +78,26 @@
"noSearchResults": "未找到图书",
"noCurrentlyReading": "你当前没有在读的图书。",
"noNextToRead": "你的想读列表还没有图书。",
- "popularTags": "热门标签"
+ "popularTags": "热门标签",
+ "gamificationTitle": "阅读连续天数与目标",
+ "currentStreak": "当前连续",
+ "currentStreakHint": "有阅读活动的连续天数",
+ "longestStreak": "最长连续",
+ "longestStreakDateRange": "{start} – {end}",
+ "goalPagesPerDay": "每天页数",
+ "goalPagesPerMonth": "每月页数",
+ "goalBooksPerMonth": "每月本书",
+ "goalBooksPerYear": "每年本书",
+ "goalReached": "目标达成",
+ "goalOf": "共 {target}",
+ "goalProgress": "{current} / {target}"
},
"status": {
"want_to_read": "想读",
"currently_reading": "在读",
"read": "已读",
- "did_not_finish": "弃读"
+ "did_not_finish": "弃读",
+ "all": "所有书籍"
},
"common": {
"all": "全部",
@@ -180,7 +195,10 @@
"progressPromptTitle": "设置阅读进度?",
"progressPromptMessage": "将 \"{title}\" 的阅读进度设置为 100%?",
"progressPromptSet": "设为 100%",
- "progressPromptSkip": "跳过"
+ "progressPromptSkip": "跳过",
+ "authorsPlaceholder": "输入作者并按回车",
+ "authorsHint": "按回车添加作者。按退格键删除上一个。",
+ "authorsLabel": "作者"
},
"addModal": {
"manual": "手动添加",
@@ -291,7 +309,15 @@
"resultsCount": "找到 {count} 个结果",
"noResults": "未找到结果",
"noResultsFor": "未找到 \"{query}\" 的结果",
- "tryDifferentQuery": "尝试其他搜索词"
+ "tryDifferentQuery": "尝试其他搜索词",
+ "help": {
+ "title": "搜索语法",
+ "intro": "使用字段前缀在特定字段中搜索。前缀始终为英文。",
+ "multiWord": "多词值请用双引号括起来,例如 author:\"Marlen Haushofer\"。",
+ "combine": "可以组合多个前缀;结果必须满足所有条件。",
+ "negate": "在任何词条前加 - 以将其排除,例如 tag:cars -tag:audi。",
+ "possessionValues": "持有值:owned、borrowed、digital_access、to_acquire"
+ }
},
"languages": {
"en": "英语",
@@ -440,7 +466,8 @@
"tooManyBooksSelected": "选择的图书过多。一次最多选择 {max} 本。",
"importMappingNameConflict": "同名映射已存在。",
"importMappingNotFound": "未找到导入映射。",
- "importFileNotFound": "未找到导入文件。请重新上传文件。"
+ "importFileNotFound": "未找到导入文件。请重新上传文件。",
+ "importInvalidDelimiter": "CSV 分隔符必须是单个字符。"
},
"oidc": {
"orContinueWith": "或继续使用",
@@ -463,6 +490,22 @@
"profileSaveFailed": "保存个人资料失败",
"passwordChangeSuccess": "密码已更改",
"passwordChangeFailed": "更改密码失败",
+ "goals": {
+ "title": "阅读目标",
+ "subtitle": "启用目标,即可在仪表板上趣味追踪进度。",
+ "dashboardToggle": "在仪表板上显示阅读连续天数与目标",
+ "pagesPerDay": "每天页数",
+ "pagesPerMonth": "每月页数",
+ "booksPerMonth": "每月本书",
+ "booksPerYear": "每年本书",
+ "target": "目标",
+ "pagesPerDayTarget": "每天页数目标",
+ "pagesPerMonthTarget": "每月页数目标",
+ "booksPerMonthTarget": "每月本书目标",
+ "booksPerYearTarget": "每年本书目标",
+ "saveSuccess": "阅读目标已保存",
+ "invalidTarget": "{name} 必须是至少为 1 的整数。"
+ },
"dataManagement": {
"title": "管理我的数据",
"description": "导出你的书库或从 CSV/JSON 文件导入图书。",
@@ -606,7 +649,9 @@
"validateFailed": "验证失败。",
"previewFailed": "加载预览失败。",
"executeFailed": "导入失败。"
- }
+ },
+ "authorsHint": "映射单个作者字符串(用 ;、& 或 和 分隔)或名称数组 — 每个条目成为一个作者。",
+ "delimiterLabel": "CSV 分隔符"
}
},
"dataHygiene": {
@@ -702,8 +747,8 @@
"allDoneSub": "你书库中的每本书现在都有封面。",
"loadingBook": "正在加载下一本书...",
"loadingCandidates": "正在搜索封面来源...",
- "keyboardHint": "提示:按 1\u20139 选择封面,按 \u2192 跳过",
+ "keyboardHint": "提示:按 1–9 选择封面,按 → 跳过",
"candidatesError": "封面搜索失败。你仍可使用手动导入。",
"retry": "重试"
}
-}
+}
\ No newline at end of file
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index fba0e4c0..249d3705 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -6,6 +6,7 @@ export interface Book {
title: string;
subtitle: string | null;
author: string | null;
+ authors: string[];
isbn: string | null;
cover_url: string | null;
publisher: string | null;
@@ -32,6 +33,7 @@ export interface BookImportCandidate {
title: string;
subtitle: string | null;
author: string | null;
+ authors: string[] | null;
isbn: string | null;
cover_url: string | null;
publisher: string | null;
@@ -158,12 +160,15 @@ export interface TopRatedBook {
book_id: number;
title: string;
author: string | null;
+ authors: string[];
rating: number;
reading_status: ReadingStatus;
cover_url: string | null;
}
export interface StatisticsResponse {
+ total_books: number;
+ total_authors: number;
avg_books_per_month: number | null;
busiest_month: string | null;
busiest_month_count: number | null;
@@ -214,6 +219,33 @@ export interface UserSettings {
timezone: string;
theme: string;
custom_theme: string | null;
+ goal_pages_per_day_enabled: boolean;
+ goal_pages_per_day: number;
+ goal_pages_per_month_enabled: boolean;
+ goal_pages_per_month: number;
+ goal_books_per_month_enabled: boolean;
+ goal_books_per_month: number;
+ goal_books_per_year_enabled: boolean;
+ goal_books_per_year: number;
+ gamification_enabled: boolean;
+}
+
+export type GoalType = 'pages_per_day' | 'pages_per_month' | 'books_per_month' | 'books_per_year';
+
+export interface GoalProgress {
+ type: GoalType;
+ target: number;
+ current: number;
+ reached: boolean;
+}
+
+export interface GamificationResponse {
+ enabled: boolean;
+ current_streak: number;
+ longest_streak: number;
+ longest_streak_start: string | null;
+ longest_streak_end: string | null;
+ goals: GoalProgress[];
}
export interface ApiKeyMeta {
@@ -245,6 +277,7 @@ export interface DataResetResponse {
deleted: {
books: number;
tags: number;
+ authors: number;
progress_entries: number;
};
}
@@ -344,8 +377,8 @@ export interface ImportFieldConfig {
export interface DataImportPreviewRow {
row_number: number;
- source: Record
;
- transformed: Record;
+ source: Record;
+ transformed: Record;
errors: string[];
}
@@ -377,6 +410,7 @@ export interface HygieneMissingBook {
id: number;
title: string;
author: string | null;
+ authors: string[];
isbn: string | null;
publisher: string | null;
published_year: number | null;
diff --git a/frontend/src/lib/utils/authors.test.ts b/frontend/src/lib/utils/authors.test.ts
new file mode 100644
index 00000000..81967863
--- /dev/null
+++ b/frontend/src/lib/utils/authors.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { formatAuthors } from './authors';
+
+describe('formatAuthors', () => {
+ it('joins multiple authors with "; "', () => {
+ expect(formatAuthors(['Terry Pratchett', 'Neil Gaiman'])).toBe('Terry Pratchett; Neil Gaiman');
+ });
+
+ it('returns the single author as-is', () => {
+ expect(formatAuthors(['Frank Herbert'])).toBe('Frank Herbert');
+ });
+
+ it('returns the fallback for empty/null/undefined', () => {
+ expect(formatAuthors([])).toBe('—');
+ expect(formatAuthors(null)).toBe('—');
+ expect(formatAuthors(undefined)).toBe('—');
+ });
+
+ it('supports a custom fallback', () => {
+ expect(formatAuthors([], 'Unknown')).toBe('Unknown');
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/lib/utils/authors.ts b/frontend/src/lib/utils/authors.ts
new file mode 100644
index 00000000..b9f30536
--- /dev/null
+++ b/frontend/src/lib/utils/authors.ts
@@ -0,0 +1,7 @@
+export function formatAuthors(
+ authors: string[] | null | undefined,
+ fallback: string | null | undefined = '—'
+): string {
+ if (!authors || authors.length === 0) return fallback || '—';
+ return authors.join('; ');
+}
\ No newline at end of file
diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte
index 14cc8b14..c7ca372b 100644
--- a/frontend/src/routes/dashboard/+page.svelte
+++ b/frontend/src/routes/dashboard/+page.svelte
@@ -2,18 +2,23 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
- import type { Book, DashboardQuote, LibraryStats } from '$lib/types';
+ import type { Book, DashboardQuote, GamificationResponse, LibraryStats } from '$lib/types';
import { api } from '$lib/api';
import { _ } from '$lib/i18n';
import { toasts } from '$lib/toasts';
import { shouldShowActionToast } from '$lib/errors';
import { isQuoteServiceEnabled } from '$lib/stores/timezone';
+ import { formatAuthors } from '$lib/utils/authors';
import BookCard from '$lib/components/BookCard.svelte';
import BookDetailDialog from '$lib/components/BookDetailDialog.svelte';
import BookDrawer from '$lib/components/BookDrawer.svelte';
+ import GamificationCard from '$lib/components/GamificationCard.svelte';
+ import SearchHelp from '$lib/components/SearchHelp.svelte';
import { Search, X } from '@lucide/svelte';
let loading = $state(true);
+ let gamification = $state(null);
+ let gamificationLoading = $state(true);
let stats = $state({
total_books: 0,
books_read: 0,
@@ -98,7 +103,7 @@ import { Search, X } from '@lucide/svelte';
stats = statsData;
currentlyReading = readingResponse.books.slice(0, 5);
- nextToRead = wantToReadResponse.books.slice(0, 5);
+ nextToRead = shuffle(wantToReadResponse.books).slice(0, 5);
const allBooks = [...currentlyReading, ...nextToRead];
void loadProgressForBooks(allBooks);
@@ -111,10 +116,35 @@ import { Search, X } from '@lucide/svelte';
loading = false;
}
+ await loadGamification(true);
await loadQuote();
await loadTagCloud();
}
+ function shuffle(items: T[]): T[] {
+ const copy = [...items];
+ for (let i = copy.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [copy[i], copy[j]] = [copy[j], copy[i]];
+ }
+ return copy;
+ }
+
+ async function loadGamification(showLoading = false) {
+ // Gamification is optional: a failure must not blank the dashboard.
+ // Once the section is disabled it stays hidden until the dashboard
+ // is (re)loaded, so repeated progress-change callbacks are no-ops.
+ if (gamification && gamification.enabled === false) return;
+ if (showLoading) gamificationLoading = true;
+ try {
+ gamification = await api.statistics.gamification();
+ } catch {
+ gamification = { enabled: false, current_streak: 0, longest_streak: 0, longest_streak_start: null, longest_streak_end: null, goals: [] };
+ } finally {
+ if (showLoading) gamificationLoading = false;
+ }
+ }
+
async function loadTagCloud() {
try {
tagCloud = await api.books.tagCloud(50);
@@ -179,6 +209,7 @@ import { Search, X } from '@lucide/svelte';
function handleProgressChange(bookId: number, currentPage: number) {
progressMap = { ...progressMap, [bookId]: currentPage };
+ void loadGamification();
}
function handleDelete(id: number) {
@@ -300,7 +331,7 @@ import { Search, X } from '@lucide/svelte';
-
+
{$_('dashboard.title')}
{$_('dashboard.subtitle')}
@@ -311,8 +342,9 @@ import { Search, X } from '@lucide/svelte';
{$_('dashboard.searchAllBooks')}
-
-
+
+
+
{book.title}
{#if book.author}
-
{book.author}
+
{formatAuthors(book.authors, book.author)}
{/if}
{$_(STATUS_LABEL_KEYS[book.reading_status])}
@@ -389,6 +421,8 @@ import { Search, X } from '@lucide/svelte';
{/if}
{/if}
+
+
@@ -451,6 +485,17 @@ import { Search, X } from '@lucide/svelte';
+ {#if gamification === null || gamification.enabled}
+
+ {/if}
+
diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte
index 82494e00..c2a0529d 100644
--- a/frontend/src/routes/data-hygiene/+page.svelte
+++ b/frontend/src/routes/data-hygiene/+page.svelte
@@ -9,6 +9,7 @@
import BookDrawer from '$lib/components/BookDrawer.svelte';
import SuggestionInput from '$lib/components/SuggestionInput.svelte';
import { LoaderCircle, X } from '@lucide/svelte';
+ import { formatAuthors } from '$lib/utils/authors';
import type { Book, HygieneAttribute, HygieneMissingBook } from '$lib/types';
const ATTRIBUTES: { key: HygieneAttribute; labelKey: string }[] = [
@@ -329,7 +330,7 @@
/>
{$_('book.title')} |
- {$_('book.author')} |
+ {$_('book.authorsLabel')} |
{$_('book.isbn')} |
{$_('book.publisher')} |
{$_('dataHygiene.tableHeaderMissing')} |
@@ -351,7 +352,7 @@
{book.title} |
-
{book.author || '—'} |
+
{formatAuthors(book.authors, book.author || '—')} |
{book.isbn || '—'} |
{book.publisher || '—'} |
diff --git a/frontend/src/routes/data-hygiene/page.test.ts b/frontend/src/routes/data-hygiene/page.test.ts
index 17062dc3..5d62533b 100644
--- a/frontend/src/routes/data-hygiene/page.test.ts
+++ b/frontend/src/routes/data-hygiene/page.test.ts
@@ -31,6 +31,7 @@ function mockBook(id: number, overrides?: Partial): HygieneM
id,
title: `Book ${id}`,
author: id % 2 === 0 ? `Author ${id}` : null,
+ authors: id % 2 === 0 ? [`Author ${id}`] : [],
isbn: id % 3 === 0 ? `978${String(id).padStart(10, '0')}` : null,
publisher: id % 2 === 0 ? 'Publisher' : null,
published_year: null,
diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte
index f664d2c4..a3e3fd68 100644
--- a/frontend/src/routes/library/+page.svelte
+++ b/frontend/src/routes/library/+page.svelte
@@ -13,10 +13,11 @@
import BookDrawer from '$lib/components/BookDrawer.svelte';
import AddBookModal from '$lib/components/AddBookModal.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
- import { BookOpen as BookOpenIcon, Book as BookIcon, Check, X } from '@lucide/svelte';
+ import SearchHelp from '$lib/components/SearchHelp.svelte';
+ import { BookOpen as BookOpenIcon, Book as BookIcon, Check, Library, X } from '@lucide/svelte';
type Tab = {
- status: ReadingStatus;
+ status: ReadingStatus | 'all';
labelKey: string;
Icon: typeof BookOpenIcon;
};
@@ -25,20 +26,22 @@
{ status: 'want_to_read', labelKey: 'status.want_to_read', Icon: BookOpenIcon },
{ status: 'currently_reading', labelKey: 'status.currently_reading', Icon: BookIcon },
{ status: 'read', labelKey: 'status.read', Icon: Check },
- { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X }
+ { status: 'did_not_finish', labelKey: 'status.did_not_finish', Icon: X },
+ { status: 'all', labelKey: 'status.all', Icon: Library }
];
const STATUS_LABEL_KEYS: Record = {
want_to_read: 'status.want_to_read',
currently_reading: 'status.currently_reading',
read: 'status.read',
- did_not_finish: 'status.did_not_finish'
+ did_not_finish: 'status.did_not_finish',
+ all: 'status.all'
};
const PAGE_SIZE = 40;
- let activeStatus = $derived(
- ($page.url.searchParams.get('status') as ReadingStatus) ?? 'want_to_read'
+ let activeStatus = $derived(
+ ($page.url.searchParams.get('status') as ReadingStatus | 'all') ?? 'want_to_read'
);
let requestedBookId = $derived.by(() => {
const raw = $page.url.searchParams.get('bookId');
@@ -84,7 +87,7 @@
return numberFormatter.format(value);
}
- function getStatusCount(status: ReadingStatus): number | null {
+ function getStatusCount(status: ReadingStatus | 'all'): number | null {
if (!statusCounts) return null;
switch (status) {
case 'want_to_read':
@@ -95,6 +98,8 @@
return statusCounts.books_read;
case 'did_not_finish':
return statusCounts.books_did_not_finish;
+ case 'all':
+ return statusCounts.total_books;
}
}
@@ -111,7 +116,7 @@
let drawerOpen = $state(false);
let addBookOpen = $state(false);
- function changeTab(status: ReadingStatus) {
+ function changeTab(status: ReadingStatus | 'all') {
if (status === activeStatus) return;
void goto(`/library?status=${status}`);
}
@@ -155,7 +160,7 @@
loading = true;
try {
const response = await api.books.list({
- status: activeStatus,
+ status: activeStatus === 'all' ? undefined : activeStatus,
acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined,
q: searchQuery || undefined,
smart_sort: smartSort,
@@ -189,7 +194,7 @@
loadingMore = true;
try {
const response = await api.books.list({
- status: activeStatus,
+ status: activeStatus === 'all' ? undefined : activeStatus,
acquisition_status: activeStatus === 'want_to_read' && acquisitionFilter ? acquisitionFilter : undefined,
q: searchQuery || undefined,
smart_sort: smartSort,
@@ -251,7 +256,7 @@
function handleSave(updated: Book) {
selectedBook = updated;
- if (updated.reading_status !== activeStatus) {
+ if (activeStatus !== 'all' && updated.reading_status !== activeStatus) {
detailOpen = false;
drawerOpen = false;
books = books.filter((b) => b.id !== updated.id);
@@ -273,7 +278,7 @@
}
function handleAdded(book: Book) {
- if (book.reading_status === activeStatus) {
+ if (activeStatus === 'all' || book.reading_status === activeStatus) {
books = [book, ...books];
}
addBookOpen = false;
@@ -347,6 +352,7 @@
placeholder={$_('common.searchBooks')}
onSearch={(q) => (searchQuery = q)}
/>
+
{#if searchQuery}
{totalCount} {totalCount === 1 ? $_('common.result') : $_('common.results')}
@@ -399,18 +405,20 @@
-
-
+
+
diff --git a/frontend/src/routes/search/page.test.ts b/frontend/src/routes/search/page.test.ts
index 0725cdf5..4fd0db34 100644
--- a/frontend/src/routes/search/page.test.ts
+++ b/frontend/src/routes/search/page.test.ts
@@ -48,6 +48,7 @@ function createMockBook(id: number, overrides?: Partial): Book {
title: `Book ${id}`,
subtitle: null,
author: 'Test Author',
+ authors: ['Test Author'],
isbn: null,
cover_url: null,
publisher: null,
@@ -99,12 +100,30 @@ describe('SearchPage', () => {
});
});
+ it('passes prefixed query from URL to the API unchanged', async () => {
+ mockPage.setUrl('http://localhost:5173/search?q=author%3A%22Marlen%20Haushofer%22%20-tag%3Acars');
+
+ mockBooksList.mockResolvedValue({ total: 0, books: [] });
+
+ render(SearchPage);
+
+ await waitFor(() => {
+ expect(mockBooksList).toHaveBeenCalledWith(
+ expect.objectContaining({
+ q: 'author:"Marlen Haushofer" -tag:cars',
+ offset: 0,
+ limit: 40
+ })
+ );
+ });
+ });
+
it('displays search results', async () => {
mockPage.setUrl('http://localhost:5173/search?q=Dune');
mockBooksList.mockResolvedValue({
total: 1,
- books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert' })]
+ books: [createMockBook(1, { title: 'Dune', author: 'Frank Herbert', authors: ['Frank Herbert'] })]
});
render(SearchPage);
diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte
index 5283483c..bfe4cea4 100644
--- a/frontend/src/routes/statistics/+page.svelte
+++ b/frontend/src/routes/statistics/+page.svelte
@@ -230,7 +230,7 @@
{:else}
-
+
{$_('statistics.avgBooksPerMonth')}
{formatNumber(stats.avg_books_per_month, 2, 1)}
@@ -251,6 +251,11 @@
{formatNumber(stats.most_popular_language_count, 0)}
+
+ {$_('statistics.totalBooksAndAuthors')}
+ {formatNumber(stats.total_books, 0)}
+ {$_('statistics.booksFromAuthors', { values: { authors: stats.total_authors } })}
+
{$_('statistics.sectionDistributions')}
diff --git a/frontend/src/routes/statistics/page.test.ts b/frontend/src/routes/statistics/page.test.ts
index 359cd5af..0503d774 100644
--- a/frontend/src/routes/statistics/page.test.ts
+++ b/frontend/src/routes/statistics/page.test.ts
@@ -16,6 +16,8 @@ vi.mock('$lib/api', () => ({
function createMockStats(overrides?: Partial): StatisticsResponse {
return {
+ total_books: 3,
+ total_authors: 2,
avg_books_per_month: 1,
busiest_month: '2026-01',
busiest_month_count: 2,
diff --git a/frontend/src/routes/timeline/+page.svelte b/frontend/src/routes/timeline/+page.svelte
index b0a81846..31b8e63f 100644
--- a/frontend/src/routes/timeline/+page.svelte
+++ b/frontend/src/routes/timeline/+page.svelte
@@ -7,6 +7,7 @@
import { toasts } from '$lib/toasts';
import { shouldShowActionToast } from '$lib/errors';
import { getTimezone } from '$lib/stores/timezone';
+ import { formatAuthors } from '$lib/utils/authors';
import BookDetailDialog from '$lib/components/BookDetailDialog.svelte';
import BookDrawer from '$lib/components/BookDrawer.svelte';
@@ -259,7 +260,7 @@
{item.book.title}
{#if item.book.author}
- {item.book.author}
+ {formatAuthors(item.book.authors, item.book.author)}
{/if}
{#if item.book.rating}
{stars(item.book.rating)}
|