From 8588475d037b2303a0d1ac1c376c5f215a8c05dd Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 3 Apr 2026 22:32:37 +0500 Subject: [PATCH] feat: in-reader settings, more like this, reading history, audio filter, feed page - Add floating gear button + theme/font/size drawer in chapter reader - Add 'More like this' horizontal scroll row on book detail page - Add reading history tab on profile page (chronological progress timeline) - Add audio-available badge + filter toggle on catalogue (GET /api/audio/slugs) - Fix discover card entry animation pop (split entry vs snap-back cubic-bezier) - Add dedicated /feed page showing books followed users are reading - Add Feed nav link (desktop + mobile) in all 5 locales --- ui/messages/en.json | 14 +- ui/messages/fr.json | 14 +- ui/messages/id.json | 14 +- ui/messages/pt.json | 14 +- ui/messages/ru.json | 14 +- ui/src/lib/paraglide/messages/_index.js | 13 +- .../paraglide/messages/admin_nav_analytics.js | 35 ++++- .../lib/paraglide/messages/admin_nav_audio.js | 35 ++++- .../paraglide/messages/admin_nav_changelog.js | 35 ++++- .../paraglide/messages/admin_nav_errors.js | 35 ++++- .../paraglide/messages/admin_nav_feedback.js | 35 ++++- .../lib/paraglide/messages/admin_nav_logs.js | 35 ++++- .../lib/paraglide/messages/admin_nav_push.js | 35 ++++- .../paraglide/messages/admin_nav_scrape.js | 35 ++++- .../messages/admin_nav_translation.js | 35 ++++- .../paraglide/messages/admin_nav_uptime.js | 35 ++++- ui/src/lib/server/pocketbase.ts | 9 ++ ui/src/routes/+layout.svelte | 65 +++++---- ui/src/routes/api/audio/slugs/+server.ts | 16 ++ ui/src/routes/books/[slug]/+page.svelte | 49 +++++++ .../books/[slug]/chapters/[n]/+page.svelte | 138 +++++++++++++++++- ui/src/routes/catalogue/+page.svelte | 58 +++++++- ui/src/routes/discover/+page.svelte | 6 +- ui/src/routes/feed/+page.server.ts | 18 +++ ui/src/routes/feed/+page.svelte | 122 ++++++++++++++++ ui/src/routes/profile/+page.server.ts | 29 +++- ui/src/routes/profile/+page.svelte | 61 +++++++- 27 files changed, 900 insertions(+), 104 deletions(-) create mode 100644 ui/src/routes/api/audio/slugs/+server.ts create mode 100644 ui/src/routes/feed/+page.server.ts create mode 100644 ui/src/routes/feed/+page.svelte diff --git a/ui/messages/en.json b/ui/messages/en.json index bd77a59..6cfaa87 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -3,6 +3,7 @@ "nav_library": "Library", "nav_catalogue": "Catalogue", + "nav_feed": "Feed", "nav_feedback": "Feedback", "nav_admin": "Admin", "nav_profile": "Profile", @@ -419,5 +420,16 @@ "profile_text_size_sm": "Small", "profile_text_size_md": "Normal", "profile_text_size_lg": "Large", - "profile_text_size_xl": "X-Large" + "profile_text_size_xl": "X-Large", + + "feed_page_title": "Feed — LibNovel", + "feed_heading": "Following Feed", + "feed_subheading": "Books your followed users are reading", + "feed_empty_heading": "Nothing here yet", + "feed_empty_body": "Follow other readers to see what they're reading.", + "feed_not_logged_in": "Sign in to see your feed.", + "feed_reader_label": "reading", + "feed_chapters_label": "{n} chapters", + "feed_browse_cta": "Browse catalogue", + "feed_find_users_cta": "Discover readers" } diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 2ff4529..e5b581c 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -3,6 +3,7 @@ "nav_library": "Bibliothèque", "nav_catalogue": "Catalogue", + "nav_feed": "Fil", "nav_feedback": "Retour", "nav_admin": "Admin", "nav_profile": "Profil", @@ -418,5 +419,16 @@ "profile_text_size_sm": "Petit", "profile_text_size_md": "Normal", "profile_text_size_lg": "Grand", - "profile_text_size_xl": "Très grand" + "profile_text_size_xl": "Très grand", + + "feed_page_title": "Fil — LibNovel", + "feed_heading": "Fil d'abonnements", + "feed_subheading": "Livres lus par vos abonnements", + "feed_empty_heading": "Rien encore", + "feed_empty_body": "Suivez d'autres lecteurs pour voir ce qu'ils lisent.", + "feed_not_logged_in": "Connectez-vous pour voir votre fil.", + "feed_reader_label": "lit", + "feed_chapters_label": "{n} chapitres", + "feed_browse_cta": "Parcourir le catalogue", + "feed_find_users_cta": "Trouver des lecteurs" } diff --git a/ui/messages/id.json b/ui/messages/id.json index 0368342..114f65d 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -3,6 +3,7 @@ "nav_library": "Perpustakaan", "nav_catalogue": "Katalog", + "nav_feed": "Umpan", "nav_feedback": "Masukan", "nav_admin": "Admin", "nav_profile": "Profil", @@ -418,5 +419,16 @@ "profile_text_size_sm": "Kecil", "profile_text_size_md": "Normal", "profile_text_size_lg": "Besar", - "profile_text_size_xl": "Sangat Besar" + "profile_text_size_xl": "Sangat Besar", + + "feed_page_title": "Umpan — LibNovel", + "feed_heading": "Umpan Ikutan", + "feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti", + "feed_empty_heading": "Belum ada apa-apa", + "feed_empty_body": "Ikuti pembaca lain untuk melihat apa yang mereka baca.", + "feed_not_logged_in": "Masuk untuk melihat umpan Anda.", + "feed_reader_label": "membaca", + "feed_chapters_label": "{n} bab", + "feed_browse_cta": "Jelajahi katalog", + "feed_find_users_cta": "Temukan pembaca" } diff --git a/ui/messages/pt.json b/ui/messages/pt.json index d9329ad..268064f 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -3,6 +3,7 @@ "nav_library": "Biblioteca", "nav_catalogue": "Catálogo", + "nav_feed": "Feed", "nav_feedback": "Feedback", "nav_admin": "Admin", "nav_profile": "Perfil", @@ -418,5 +419,16 @@ "profile_text_size_sm": "Pequeno", "profile_text_size_md": "Normal", "profile_text_size_lg": "Grande", - "profile_text_size_xl": "Muito grande" + "profile_text_size_xl": "Muito grande", + + "feed_page_title": "Feed — LibNovel", + "feed_heading": "Feed de seguidos", + "feed_subheading": "Livros que seus seguidos estão lendo", + "feed_empty_heading": "Nada aqui ainda", + "feed_empty_body": "Siga outros leitores para ver o que estão lendo.", + "feed_not_logged_in": "Faça login para ver seu feed.", + "feed_reader_label": "lendo", + "feed_chapters_label": "{n} capítulos", + "feed_browse_cta": "Ver catálogo", + "feed_find_users_cta": "Encontrar leitores" } diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 9b696e6..aa4f0e6 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -3,6 +3,7 @@ "nav_library": "Библиотека", "nav_catalogue": "Каталог", + "nav_feed": "Лента", "nav_feedback": "Обратная связь", "nav_admin": "Админ", "nav_profile": "Профиль", @@ -418,5 +419,16 @@ "profile_text_size_sm": "Маленький", "profile_text_size_md": "Нормальный", "profile_text_size_lg": "Большой", - "profile_text_size_xl": "Очень большой" + "profile_text_size_xl": "Очень большой", + + "feed_page_title": "Лента — LibNovel", + "feed_heading": "Лента подписок", + "feed_subheading": "Книги, которые читают ваши подписки", + "feed_empty_heading": "Пока ничего нет", + "feed_empty_body": "Подпишитесь на других читателей, чтобы видеть, что они читают.", + "feed_not_logged_in": "Войдите, чтобы видеть свою ленту.", + "feed_reader_label": "читает", + "feed_chapters_label": "{n} глав", + "feed_browse_cta": "Каталог", + "feed_find_users_cta": "Найти читателей" } diff --git a/ui/src/lib/paraglide/messages/_index.js b/ui/src/lib/paraglide/messages/_index.js index 35b320c..5325700 100644 --- a/ui/src/lib/paraglide/messages/_index.js +++ b/ui/src/lib/paraglide/messages/_index.js @@ -2,6 +2,7 @@ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */ export * from './nav_library.js' export * from './nav_catalogue.js' +export * from './nav_feed.js' export * from './nav_feedback.js' export * from './nav_admin.js' export * from './nav_profile.js' @@ -383,4 +384,14 @@ export * from './profile_text_size.js' export * from './profile_text_size_sm.js' export * from './profile_text_size_md.js' export * from './profile_text_size_lg.js' -export * from './profile_text_size_xl.js' \ No newline at end of file +export * from './profile_text_size_xl.js' +export * from './feed_page_title.js' +export * from './feed_heading.js' +export * from './feed_subheading.js' +export * from './feed_empty_heading.js' +export * from './feed_empty_body.js' +export * from './feed_not_logged_in.js' +export * from './feed_reader_label.js' +export * from './feed_chapters_label.js' +export * from './feed_browse_cta.js' +export * from './feed_find_users_cta.js' \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_analytics.js b/ui/src/lib/paraglide/messages/admin_nav_analytics.js index f35ce49..e66c772 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_analytics.js +++ b/ui/src/lib/paraglide/messages/admin_nav_analytics.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_AnalyticsInputs */ -const en_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytics`); -const ru_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Аналитика`); -const id_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analitik`); -const pt_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Análise`); -const fr_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytique`); +const en_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Analytics`) +}; +const ru_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Аналитика`) +}; + +const id_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Analitik`) +}; + +const pt_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Análise`) +}; + +const fr_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Analytique`) +}; + +/** +* | output | +* | --- | +* | "Analytics" | +* +* @param {Admin_Nav_AnalyticsInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_analytics(inputs) @@ -18,4 +41,4 @@ export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInpu if (locale === "id") return id_admin_nav_analytics(inputs) if (locale === "pt") return pt_admin_nav_analytics(inputs) return fr_admin_nav_analytics(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_audio.js b/ui/src/lib/paraglide/messages/admin_nav_audio.js index cd18471..2b6acc6 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_audio.js +++ b/ui/src/lib/paraglide/messages/admin_nav_audio.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_AudioInputs */ -const en_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`); -const ru_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Аудио`); -const id_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`); -const pt_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Áudio`); -const fr_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`); +const en_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Audio`) +}; +const ru_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Аудио`) +}; + +const id_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Audio`) +}; + +const pt_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Áudio`) +}; + +const fr_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Audio`) +}; + +/** +* | output | +* | --- | +* | "Audio" | +* +* @param {Admin_Nav_AudioInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_audio(inputs) @@ -18,4 +41,4 @@ export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, opti if (locale === "id") return id_admin_nav_audio(inputs) if (locale === "pt") return pt_admin_nav_audio(inputs) return fr_admin_nav_audio(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_changelog.js b/ui/src/lib/paraglide/messages/admin_nav_changelog.js index e430c14..604854d 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_changelog.js +++ b/ui/src/lib/paraglide/messages/admin_nav_changelog.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_ChangelogInputs */ -const en_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Changelog`); -const ru_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Изменения`); -const id_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Perubahan`); -const pt_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Alterações`); -const fr_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Modifications`); +const en_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Changelog`) +}; +const ru_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Изменения`) +}; + +const id_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Perubahan`) +}; + +const pt_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Alterações`) +}; + +const fr_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Modifications`) +}; + +/** +* | output | +* | --- | +* | "Changelog" | +* +* @param {Admin_Nav_ChangelogInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_changelog(inputs) @@ -18,4 +41,4 @@ export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInpu if (locale === "id") return id_admin_nav_changelog(inputs) if (locale === "pt") return pt_admin_nav_changelog(inputs) return fr_admin_nav_changelog(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_errors.js b/ui/src/lib/paraglide/messages/admin_nav_errors.js index e879dc0..e697bf9 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_errors.js +++ b/ui/src/lib/paraglide/messages/admin_nav_errors.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_ErrorsInputs */ -const en_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Errors`); -const ru_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Ошибки`); -const id_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Kesalahan`); -const pt_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erros`); -const fr_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erreurs`); +const en_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Errors`) +}; +const ru_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Ошибки`) +}; + +const id_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Kesalahan`) +}; + +const pt_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Erros`) +}; + +const fr_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Erreurs`) +}; + +/** +* | output | +* | --- | +* | "Errors" | +* +* @param {Admin_Nav_ErrorsInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_errors(inputs) @@ -18,4 +41,4 @@ export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, op if (locale === "id") return id_admin_nav_errors(inputs) if (locale === "pt") return pt_admin_nav_errors(inputs) return fr_admin_nav_errors(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_feedback.js b/ui/src/lib/paraglide/messages/admin_nav_feedback.js index 9b1b784..5f6a1c7 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_feedback.js +++ b/ui/src/lib/paraglide/messages/admin_nav_feedback.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_FeedbackInputs */ -const en_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`); -const ru_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Отзывы`); -const id_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Masukan`); -const pt_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`); -const fr_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Retours`); +const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Feedback`) +}; +const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Отзывы`) +}; + +const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Masukan`) +}; + +const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Feedback`) +}; + +const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Retours`) +}; + +/** +* | output | +* | --- | +* | "Feedback" | +* +* @param {Admin_Nav_FeedbackInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_feedback(inputs) @@ -18,4 +41,4 @@ export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs if (locale === "id") return id_admin_nav_feedback(inputs) if (locale === "pt") return pt_admin_nav_feedback(inputs) return fr_admin_nav_feedback(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_logs.js b/ui/src/lib/paraglide/messages/admin_nav_logs.js index 7444a79..ebe6ba6 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_logs.js +++ b/ui/src/lib/paraglide/messages/admin_nav_logs.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_LogsInputs */ -const en_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`); -const ru_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Логи`); -const id_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Log`); -const pt_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`); -const fr_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Journaux`); +const en_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Logs`) +}; +const ru_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Логи`) +}; + +const id_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Log`) +}; + +const pt_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Logs`) +}; + +const fr_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Journaux`) +}; + +/** +* | output | +* | --- | +* | "Logs" | +* +* @param {Admin_Nav_LogsInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_logs(inputs) @@ -18,4 +41,4 @@ export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, option if (locale === "id") return id_admin_nav_logs(inputs) if (locale === "pt") return pt_admin_nav_logs(inputs) return fr_admin_nav_logs(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_push.js b/ui/src/lib/paraglide/messages/admin_nav_push.js index d85d16b..796995f 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_push.js +++ b/ui/src/lib/paraglide/messages/admin_nav_push.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_PushInputs */ -const en_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Push`); -const ru_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Уведомления`); -const id_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifikasi`); -const pt_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notificações`); -const fr_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifications`); +const en_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Push`) +}; +const ru_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Уведомления`) +}; + +const id_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Notifikasi`) +}; + +const pt_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Notificações`) +}; + +const fr_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Notifications`) +}; + +/** +* | output | +* | --- | +* | "Push" | +* +* @param {Admin_Nav_PushInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_push(inputs) @@ -18,4 +41,4 @@ export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, option if (locale === "id") return id_admin_nav_push(inputs) if (locale === "pt") return pt_admin_nav_push(inputs) return fr_admin_nav_push(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_scrape.js b/ui/src/lib/paraglide/messages/admin_nav_scrape.js index 192696a..0f63d3b 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_scrape.js +++ b/ui/src/lib/paraglide/messages/admin_nav_scrape.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_ScrapeInputs */ -const en_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`); -const ru_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Скрейпинг`); -const id_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`); -const pt_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`); -const fr_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`); +const en_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Scrape`) +}; +const ru_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Скрейпинг`) +}; + +const id_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Scrape`) +}; + +const pt_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Scrape`) +}; + +const fr_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Scrape`) +}; + +/** +* | output | +* | --- | +* | "Scrape" | +* +* @param {Admin_Nav_ScrapeInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_scrape(inputs) @@ -18,4 +41,4 @@ export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, op if (locale === "id") return id_admin_nav_scrape(inputs) if (locale === "pt") return pt_admin_nav_scrape(inputs) return fr_admin_nav_scrape(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_translation.js b/ui/src/lib/paraglide/messages/admin_nav_translation.js index bf59389..5063633 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_translation.js +++ b/ui/src/lib/paraglide/messages/admin_nav_translation.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_TranslationInputs */ -const en_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Translation`); -const ru_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Перевод`); -const id_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Terjemahan`); -const pt_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Tradução`); -const fr_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Traduction`); +const en_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Translation`) +}; +const ru_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Перевод`) +}; + +const id_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Terjemahan`) +}; + +const pt_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Tradução`) +}; + +const fr_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Traduction`) +}; + +/** +* | output | +* | --- | +* | "Translation" | +* +* @param {Admin_Nav_TranslationInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_translation(inputs) @@ -18,4 +41,4 @@ export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_Translation if (locale === "id") return id_admin_nav_translation(inputs) if (locale === "pt") return pt_admin_nav_translation(inputs) return fr_admin_nav_translation(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/paraglide/messages/admin_nav_uptime.js b/ui/src/lib/paraglide/messages/admin_nav_uptime.js index 2d90530..f30a2f4 100644 --- a/ui/src/lib/paraglide/messages/admin_nav_uptime.js +++ b/ui/src/lib/paraglide/messages/admin_nav_uptime.js @@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js'; /** @typedef {{}} Admin_Nav_UptimeInputs */ -const en_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`); -const ru_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Мониторинг`); -const id_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`); -const pt_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`); -const fr_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Disponibilité`); +const en_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Uptime`) +}; +const ru_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Мониторинг`) +}; + +const id_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Uptime`) +}; + +const pt_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Uptime`) +}; + +const fr_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => { + return /** @type {LocalizedString} */ (`Disponibilité`) +}; + +/** +* | output | +* | --- | +* | "Uptime" | +* +* @param {Admin_Nav_UptimeInputs} inputs +* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options +* @returns {LocalizedString} +*/ export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata} */ ((inputs = {}, options = {}) => { const locale = experimentalStaticLocale ?? options.locale ?? getLocale() if (locale === "en") return en_admin_nav_uptime(inputs) @@ -18,4 +41,4 @@ export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, op if (locale === "id") return id_admin_nav_uptime(inputs) if (locale === "pt") return pt_admin_nav_uptime(inputs) return fr_admin_nav_uptime(inputs) -}); +}); \ No newline at end of file diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 85a1dbe..ae310b6 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -1034,6 +1034,15 @@ export async function listAudioJobs(): Promise { return listAll('audio_jobs', '', '-started'); } +/** + * Returns the set of book slugs that have at least one completed audio job. + * Used by the catalogue page to show audio-available badges. + */ +export async function getSlugsWithAudio(): Promise> { + const jobs = await listAll('audio_jobs', 'status="done"', 'slug'); + return new Set(jobs.map((j) => j.slug)); +} + // ─── Translation jobs ───────────────────────────────────────────────────────── export interface TranslationJob { diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index f09825e..283ba17 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -345,18 +345,24 @@ > {m.nav_library()} - - + + +
+ + {#if audioSlugs.size > 0} + + {/if} + {#if data.isAdmin}
-{#if novels.length === 0} +{#if displayedNovels.length === 0}
-

{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}

+

{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : filterAudioOnly ? 'No books with audio found.' : m.catalogue_no_results()}

{#if isSearchView} {m.catalogue_no_results_try()} @@ -470,6 +502,8 @@ {:else} {m.catalogue_rank_run_scrape_user()} {/if} + {:else if filterAudioOnly} + {:else} {m.catalogue_no_results_filters()} {/if} @@ -479,7 +513,7 @@ {:else if view === 'grid'}

- {#each novels as novel} + {#each displayedNovels as novel} {@const isLoading = loadingSlug === novel.slug} {/if} + + {#if audioSlugs.has(novel.slug)} + + + + + + {/if} {#if isLoading}
@@ -564,7 +606,7 @@ {:else}
- {#each novels as novel} + {#each displayedNovels as novel} {@const isLoading = loadingSlug === novel.slug}
★ {novel.rating} {/if} + {#if audioSlugs.has(novel.slug)} + + + + + Audio + + {/if} {#if novel.genres?.length} {#each novel.genres.slice(0, 3) as genre} {genre} diff --git a/ui/src/routes/discover/+page.svelte b/ui/src/routes/discover/+page.svelte index 57d5847..c160639 100644 --- a/ui/src/routes/discover/+page.svelte +++ b/ui/src/routes/discover/+page.svelte @@ -160,9 +160,11 @@ const activeTransition = $derived( isDragging ? 'none' - : (transitioning || entryTransition) + : transitioning ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' - : 'none' + : entryTransition + ? 'transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94)' + : 'none' ); function onPointerDown(e: PointerEvent) { diff --git a/ui/src/routes/feed/+page.server.ts b/ui/src/routes/feed/+page.server.ts new file mode 100644 index 0000000..a8ece8b --- /dev/null +++ b/ui/src/routes/feed/+page.server.ts @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { getSubscriptionFeed } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; +import type { Book } from '$lib/server/pocketbase'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) { + redirect(302, '/login?next=/feed'); + } + + const feed = await getSubscriptionFeed(locals.user.id, 50).catch((e) => { + log.error('feed', 'failed to load subscription feed', { err: String(e) }); + return [] as Array<{ book: Book; readerUsername: string }>; + }); + + return { feed }; +}; diff --git a/ui/src/routes/feed/+page.svelte b/ui/src/routes/feed/+page.svelte new file mode 100644 index 0000000..321f605 --- /dev/null +++ b/ui/src/routes/feed/+page.svelte @@ -0,0 +1,122 @@ + + + + {m.feed_page_title()} + + + +
+

{m.feed_heading()}

+

{m.feed_subheading()}

+
+ +{#if feed.length === 0} + +
+{:else} + +
+ {#each feed as item} + {@const book = item.book} + {@const genres = Array.isArray(book.genres) ? book.genres : (book.genres ? [book.genres] : [])} + +
goto(`/books/${book.slug}`)} + onkeydown={(e) => { if (e.key === 'Enter') goto(`/books/${book.slug}`); }} + class="group flex gap-3 bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 hover:border-zinc-500 transition-colors cursor-pointer" + > + +
+ {#if book.cover} + {book.title} + {:else} +
+ + + +
+ {/if} +
+ + +
+

+ {book.title} +

+ {#if book.author} +

{book.author}

+ {/if} + + + + + +
+ {#if book.status} + {book.status} + {/if} + {#if book.total_chapters} + {m.feed_chapters_label({ n: String(book.total_chapters) })} + {/if} + {#each genres.slice(0, 2) as genre} + {genre} + {/each} +
+
+
+ {/each} +
+{/if} diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index 066f288..3c59c7c 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -1,6 +1,12 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase'; +import { + listUserSessions, + getUserByUsername, + getUserStats, + allProgress, + getBooksBySlugs +} from '$lib/server/pocketbase'; import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; @@ -34,6 +40,24 @@ export const load: PageServerLoad = async ({ locals }) => { log.warn('profile', 'load failed (non-fatal)', { err: String(e) }); } + // Reading history — last 50 progress entries with book metadata + let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = []; + try { + const progress = await allProgress(locals.sessionId, locals.user.id); + const recent = progress.slice(0, 50); + const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug))); + const bookMap = new Map(books.map((b) => [b.slug, b])); + history = recent.map((p) => ({ + slug: p.slug, + chapter: p.chapter, + updated: p.updated, + title: bookMap.get(p.slug)?.title ?? p.slug, + cover: bookMap.get(p.slug)?.cover ?? null + })); + } catch (e) { + log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) }); + } + return { user: locals.user, avatarUrl, @@ -51,6 +75,7 @@ export const load: PageServerLoad = async ({ locals }) => { created_at: s.created_at, last_seen: s.last_seen, is_current: s.session_id === locals.user!.authSessionId - })) + })), + history }; }; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index c6daa5b..f197c2b 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -185,7 +185,7 @@ }); // ── Tab ────────────────────────────────────────────────────────────────────── - let activeTab = $state<'profile' | 'stats'>('profile'); + let activeTab = $state<'profile' | 'stats' | 'history'>('profile'); // ── Sessions ───────────────────────────────────────────────────────────────── type Session = { @@ -322,7 +322,7 @@
- {#each (['profile', 'stats'] as const) as tab} + {#each (['profile', 'stats', 'history'] as const) as tab} {/each}
@@ -658,4 +658,59 @@
{/if} + {#if activeTab === 'history'} + + {/if} +