Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a3a61a3ef |
384
ui/messages/en.json
Normal file
384
ui/messages/en.json
Normal file
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Library",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profile",
|
||||
"nav_sign_in": "Sign in",
|
||||
"nav_sign_out": "Sign out",
|
||||
"nav_toggle_menu": "Toggle menu",
|
||||
"nav_admin_panel": "Admin panel",
|
||||
|
||||
"footer_library": "Library",
|
||||
"footer_catalogue": "Catalogue",
|
||||
"footer_feedback": "Feedback",
|
||||
"footer_disclaimer": "Disclaimer",
|
||||
"footer_privacy": "Privacy",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Books",
|
||||
"home_stat_chapters": "Chapters",
|
||||
"home_stat_in_progress": "In progress",
|
||||
"home_continue_reading": "Continue Reading",
|
||||
"home_view_all": "View all",
|
||||
"home_recently_updated": "Recently Updated",
|
||||
"home_from_following": "From People You Follow",
|
||||
"home_empty_title": "Your library is empty",
|
||||
"home_empty_body": "Discover novels and scrape them into your library.",
|
||||
"home_discover_novels": "Discover Novels",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "ch.{n}",
|
||||
|
||||
"player_generating": "Generating… {percent}%",
|
||||
"player_loading": "Loading…",
|
||||
"player_chapters": "Chapters",
|
||||
"player_chapter_n": "Chapter {n}",
|
||||
"player_toggle_chapter_list": "Toggle chapter list",
|
||||
"player_chapter_list_label": "Chapter list",
|
||||
"player_close_chapter_list": "Close chapter list",
|
||||
"player_rewind_15": "Rewind 15 seconds",
|
||||
"player_skip_30": "Skip 30 seconds",
|
||||
"player_back_15": "Back 15s",
|
||||
"player_forward_30": "Forward 30s",
|
||||
"player_play": "Play",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Playback speed {speed}x",
|
||||
"player_change_speed": "Change playback speed",
|
||||
"player_auto_next_on": "Auto-next on",
|
||||
"player_auto_next_off": "Auto-next off",
|
||||
"player_auto_next_ready": "Auto-next on — Ch.{n} ready",
|
||||
"player_auto_next_preparing": "Auto-next on — preparing Ch.{n}…",
|
||||
"player_auto_next_aria": "Auto-next {state}",
|
||||
"player_go_to_chapter": "Go to chapter",
|
||||
"player_close": "Close player",
|
||||
|
||||
"login_page_title": "Sign in — libnovel",
|
||||
"login_heading": "Sign in to libnovel",
|
||||
"login_subheading": "Choose a provider to continue",
|
||||
"login_continue_google": "Continue with Google",
|
||||
"login_continue_github": "Continue with GitHub",
|
||||
"login_terms_notice": "By signing in you agree to our terms of service.",
|
||||
"login_error_oauth_state": "Sign-in was cancelled or expired. Please try again.",
|
||||
"login_error_oauth_failed": "Could not connect to the provider. Please try again.",
|
||||
"login_error_oauth_no_email": "Your account has no verified email address. Please add one and retry.",
|
||||
|
||||
"books_page_title": "Library — libnovel",
|
||||
"books_heading": "Your Library",
|
||||
"books_empty_title": "No books yet",
|
||||
"books_empty_body": "Add books to your library by visiting a book page.",
|
||||
"books_browse_catalogue": "Browse Catalogue",
|
||||
"books_chapter_count": "{n} chapters",
|
||||
"books_last_read": "Last read: Ch.{n}",
|
||||
"books_reading_progress": "Ch.{current} / {total}",
|
||||
"books_remove": "Remove",
|
||||
|
||||
"catalogue_page_title": "Catalogue — libnovel",
|
||||
"catalogue_heading": "Catalogue",
|
||||
"catalogue_search_placeholder": "Search novels…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Sort",
|
||||
"catalogue_sort_popular": "Popular",
|
||||
"catalogue_sort_new": "New",
|
||||
"catalogue_sort_top_rated": "Top Rated",
|
||||
"catalogue_sort_rank": "Rank",
|
||||
"catalogue_status_all": "All",
|
||||
"catalogue_status_ongoing": "Ongoing",
|
||||
"catalogue_status_completed": "Completed",
|
||||
"catalogue_genre_all": "All genres",
|
||||
"catalogue_clear_filters": "Clear",
|
||||
"catalogue_reset": "Reset",
|
||||
"catalogue_no_results": "No novels found.",
|
||||
"catalogue_loading": "Loading…",
|
||||
"catalogue_load_more": "Load more",
|
||||
"catalogue_results_count": "{n} results",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_add_to_library": "Add to Library",
|
||||
"book_detail_remove_from_library": "Remove from Library",
|
||||
"book_detail_read_now": "Read Now",
|
||||
"book_detail_continue_reading": "Continue Reading",
|
||||
"book_detail_start_reading": "Start Reading",
|
||||
"book_detail_chapters": "{n} Chapters",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Author",
|
||||
"book_detail_genres": "Genres",
|
||||
"book_detail_description": "Description",
|
||||
"book_detail_source": "Source",
|
||||
"book_detail_rescrape": "Re-scrape",
|
||||
"book_detail_scraping": "Scraping…",
|
||||
"book_detail_in_library": "In Library",
|
||||
|
||||
"chapters_page_title": "Chapters — {title}",
|
||||
"chapters_heading": "Chapters",
|
||||
"chapters_back_to_book": "Back to book",
|
||||
"chapters_reading_now": "Reading",
|
||||
"chapters_empty": "No chapters scraped yet.",
|
||||
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Play narration",
|
||||
"reader_generating_audio": "Generating audio…",
|
||||
"reader_audio_error": "Audio generation failed.",
|
||||
"reader_prev_chapter": "Previous chapter",
|
||||
"reader_next_chapter": "Next chapter",
|
||||
"reader_back_to_chapters": "Back to chapters",
|
||||
"reader_chapter_n": "Chapter {n}",
|
||||
"reader_change_voice": "Change voice",
|
||||
"reader_voice_panel_title": "Select voice",
|
||||
"reader_voice_kokoro": "Kokoro voices",
|
||||
"reader_voice_pocket": "Pocket-TTS voices",
|
||||
"reader_voice_play_sample": "Play sample",
|
||||
"reader_voice_stop_sample": "Stop sample",
|
||||
"reader_voice_selected": "Selected",
|
||||
"reader_close_voice_panel": "Close voice panel",
|
||||
"reader_auto_next": "Auto-next",
|
||||
"reader_speed": "Speed",
|
||||
"reader_preview_notice": "Preview — this chapter has not been fully scraped.",
|
||||
|
||||
"profile_page_title": "Profile — libnovel",
|
||||
"profile_heading": "Profile",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Change avatar",
|
||||
"profile_username": "Username",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Change password",
|
||||
"profile_current_password": "Current password",
|
||||
"profile_new_password": "New password",
|
||||
"profile_confirm_password": "Confirm password",
|
||||
"profile_save_password": "Save password",
|
||||
"profile_appearance_heading": "Appearance",
|
||||
"profile_theme_label": "Theme",
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Slate",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_reading_heading": "Reading settings",
|
||||
"profile_voice_label": "Default voice",
|
||||
"profile_speed_label": "Playback speed",
|
||||
"profile_auto_next_label": "Auto-next chapter",
|
||||
"profile_save_settings": "Save settings",
|
||||
"profile_settings_saved": "Settings saved.",
|
||||
"profile_settings_error": "Failed to save settings.",
|
||||
"profile_password_saved": "Password changed.",
|
||||
"profile_password_error": "Failed to change password.",
|
||||
"profile_sessions_heading": "Active sessions",
|
||||
"profile_sign_out_all": "Sign out all other devices",
|
||||
"profile_joined": "Joined {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "{username}'s Library",
|
||||
"user_follow": "Follow",
|
||||
"user_unfollow": "Unfollow",
|
||||
"user_followers": "{n} followers",
|
||||
"user_following": "{n} following",
|
||||
"user_library_empty": "No books in library.",
|
||||
|
||||
"error_not_found_title": "Page not found",
|
||||
"error_not_found_body": "The page you're looking for doesn't exist.",
|
||||
"error_generic_title": "Something went wrong",
|
||||
"error_go_home": "Go home",
|
||||
"error_status": "Error {status}",
|
||||
|
||||
"admin_scrape_page_title": "Scrape — Admin",
|
||||
"admin_scrape_heading": "Scrape",
|
||||
"admin_scrape_catalogue": "Scrape Catalogue",
|
||||
"admin_scrape_book": "Scrape Book",
|
||||
"admin_scrape_url_placeholder": "novelfire.net book URL",
|
||||
"admin_scrape_range": "Chapter range",
|
||||
"admin_scrape_from": "From",
|
||||
"admin_scrape_to": "To",
|
||||
"admin_scrape_submit": "Scrape",
|
||||
"admin_scrape_cancel": "Cancel",
|
||||
"admin_scrape_status_pending": "Pending",
|
||||
"admin_scrape_status_running": "Running",
|
||||
"admin_scrape_status_done": "Done",
|
||||
"admin_scrape_status_failed": "Failed",
|
||||
"admin_scrape_status_cancelled": "Cancelled",
|
||||
"admin_tasks_heading": "Recent tasks",
|
||||
"admin_tasks_empty": "No tasks yet.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Audio Jobs",
|
||||
"admin_audio_empty": "No audio jobs.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Comments",
|
||||
"comments_empty": "No comments yet. Be the first!",
|
||||
"comments_placeholder": "Write a comment…",
|
||||
"comments_submit": "Post",
|
||||
"comments_login_prompt": "Sign in to comment.",
|
||||
"comments_vote_up": "Upvote",
|
||||
"comments_vote_down": "Downvote",
|
||||
"comments_delete": "Delete",
|
||||
"comments_reply": "Reply",
|
||||
"comments_show_replies": "Show {n} replies",
|
||||
"comments_hide_replies": "Hide replies",
|
||||
"comments_edited": "edited",
|
||||
"comments_deleted": "[deleted]",
|
||||
|
||||
"disclaimer_page_title": "Disclaimer — libnovel",
|
||||
"privacy_page_title": "Privacy Policy — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Terms of Service — libnovel",
|
||||
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Error",
|
||||
"common_save": "Save",
|
||||
"common_cancel": "Cancel",
|
||||
"common_close": "Close",
|
||||
"common_search": "Search",
|
||||
"common_back": "Back",
|
||||
"common_next": "Next",
|
||||
"common_previous": "Previous",
|
||||
"common_yes": "Yes",
|
||||
"common_no": "No",
|
||||
"common_on": "on",
|
||||
"common_off": "off",
|
||||
|
||||
"locale_switcher_label": "Language",
|
||||
|
||||
"books_empty_library": "Your library is empty.",
|
||||
"books_empty_discover": "Books you start reading or save from",
|
||||
"books_empty_discover_link": "Discover",
|
||||
"books_empty_discover_suffix": "will appear here.",
|
||||
"books_count": "{n} book{s}",
|
||||
|
||||
"catalogue_sort_updated": "Updated",
|
||||
"catalogue_search_button": "Search",
|
||||
"catalogue_refresh": "Refresh",
|
||||
"catalogue_refreshing": "Queuing\u2026",
|
||||
"catalogue_refresh_mobile": "Refresh catalogue",
|
||||
"catalogue_all_loaded": "All novels loaded",
|
||||
"catalogue_scroll_top": "Back to top",
|
||||
"catalogue_view_grid": "Grid view",
|
||||
"catalogue_view_list": "List view",
|
||||
"catalogue_browse_source": "Browse novels from novelfire.net",
|
||||
"catalogue_search_results": "{n} result{s} for \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} local, {remote} from novelfire)",
|
||||
"catalogue_rank_ranked": "{n} novels ranked from last catalogue scrape",
|
||||
"catalogue_rank_no_data": "No ranking data.",
|
||||
"catalogue_rank_no_data_body": "No ranking data \u2014 run a full catalogue scrape to populate",
|
||||
"catalogue_rank_run_scrape_admin": "Click Refresh catalogue above to trigger a full catalogue scrape.",
|
||||
"catalogue_rank_run_scrape_user": "Ask an admin to run a catalogue scrape.",
|
||||
"catalogue_scrape_queued_flash": "Full catalogue scrape queued. Library and ranking will update as books are processed.",
|
||||
"catalogue_scrape_busy_flash": "A scrape job is already running. Check back once it finishes.",
|
||||
"catalogue_scrape_error_flash": "Failed to queue scrape. Check that the scraper service is reachable.",
|
||||
"catalogue_filters_label": "Filters",
|
||||
"catalogue_apply": "Apply",
|
||||
"catalogue_filter_rank_note": "Genre & status filters apply to Browse only",
|
||||
"catalogue_no_results_search": "No results found.",
|
||||
"catalogue_no_results_try": "Try a different search term.",
|
||||
"catalogue_no_results_filters": "Try different filters or check back later.",
|
||||
"catalogue_scrape_queued_badge": "Queued",
|
||||
"catalogue_scrape_busy_badge": "Scraper busy",
|
||||
"catalogue_scrape_busy_list": "Busy",
|
||||
"catalogue_scrape_forbidden_badge": "Forbidden",
|
||||
"catalogue_scrape_novel_button": "Scrape",
|
||||
"catalogue_scraping_novel": "Scraping\u2026",
|
||||
|
||||
"book_detail_not_in_library": "not in library",
|
||||
"book_detail_continue_ch": "Continue ch.{n}",
|
||||
"book_detail_start_ch1": "Start from ch.1",
|
||||
"book_detail_preview_ch1": "Preview ch.1",
|
||||
"book_detail_reading_ch": "Reading ch.{n} of {total}",
|
||||
"book_detail_n_chapters": "{n} chapters",
|
||||
"book_detail_rescraping": "Queuing\u2026",
|
||||
"book_detail_from_chapter": "From chapter",
|
||||
"book_detail_to_chapter": "To chapter (optional)",
|
||||
"book_detail_range_queuing": "Queuing\u2026",
|
||||
"book_detail_scrape_range": "Scrape range",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
|
||||
"book_detail_scraping_home": "\u2190 Home",
|
||||
"book_detail_rescrape_book": "Rescrape book",
|
||||
"book_detail_less": "Less",
|
||||
"book_detail_more": "More",
|
||||
|
||||
"chapters_search_placeholder": "Search chapters\u2026",
|
||||
"chapters_jump_to": "Jump to Ch.{n}",
|
||||
"chapters_no_match": "No chapters match \"{q}\"",
|
||||
"chapters_none_available": "No chapters available yet.",
|
||||
"chapters_reading_indicator": "reading",
|
||||
"chapters_result_count": "{n} results",
|
||||
|
||||
"reader_fetching_chapter": "Fetching chapter\u2026",
|
||||
"reader_words": "{n} words",
|
||||
"reader_preview_audio_notice": "Preview chapter \u2014 audio not available for books outside the library.",
|
||||
|
||||
"profile_click_to_change": "Click avatar to change photo",
|
||||
"profile_tts_voice": "TTS voice",
|
||||
"profile_auto_advance": "Auto-advance to next chapter",
|
||||
"profile_saving": "Saving\u2026",
|
||||
"profile_saved": "Saved!",
|
||||
"profile_session_this": "This session",
|
||||
"profile_session_signed_in": "Signed in {date}",
|
||||
"profile_session_last_seen": "\u00b7 Last seen {date}",
|
||||
"profile_session_sign_out": "Sign out",
|
||||
"profile_session_end": "End",
|
||||
"profile_session_unrecognised": "These are all devices currently signed into your account. End any session you don\u2019t recognise.",
|
||||
"profile_no_sessions": "No session records found. Sessions are tracked from the next login.",
|
||||
"profile_change_password_heading": "Change password",
|
||||
"profile_update_password": "Update password",
|
||||
"profile_updating": "Updating\u2026",
|
||||
"profile_password_changed_ok": "Password changed successfully.",
|
||||
"profile_playback_speed": "Playback speed \u2014 {speed}x",
|
||||
|
||||
"user_currently_reading": "Currently Reading",
|
||||
"user_library_count": "Library ({n})",
|
||||
"user_joined": "Joined {date}",
|
||||
"user_followers_label": "followers",
|
||||
"user_following_label": "following",
|
||||
"user_no_books": "No books in library yet.",
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Tools",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
"admin_scrape_full_catalogue": "Full catalogue",
|
||||
"admin_scrape_single_book": "Single book",
|
||||
"admin_scrape_quick_genres": "Quick genres",
|
||||
"admin_scrape_task_history": "Task history",
|
||||
"admin_scrape_filter_placeholder": "Filter by kind, status or URL\u2026",
|
||||
"admin_scrape_no_matching": "No matching tasks.",
|
||||
"admin_scrape_start": "Start scrape",
|
||||
"admin_scrape_queuing": "Queuing\u2026",
|
||||
"admin_scrape_running": "Running\u2026",
|
||||
|
||||
"admin_audio_filter_jobs": "Filter by slug, voice or status\u2026",
|
||||
"admin_audio_filter_cache": "Filter by slug, chapter or voice\u2026",
|
||||
"admin_audio_no_matching_jobs": "No matching jobs.",
|
||||
"admin_audio_no_jobs": "No audio jobs yet.",
|
||||
"admin_audio_cache_empty": "Audio cache is empty.",
|
||||
"admin_audio_no_cache_results": "No results.",
|
||||
|
||||
"admin_changelog_gitea": "Gitea releases",
|
||||
"admin_changelog_no_releases": "No releases found.",
|
||||
"admin_changelog_load_error": "Could not load releases: {error}",
|
||||
|
||||
"comments_top": "Top",
|
||||
"comments_new": "New",
|
||||
"comments_posting": "Posting\u2026",
|
||||
"comments_login_link": "Log in",
|
||||
"comments_login_suffix": "to leave a comment.",
|
||||
"comments_anonymous": "Anonymous",
|
||||
|
||||
"reader_audio_narration": "Audio Narration",
|
||||
"reader_playing": "Playing \u2014 controls below",
|
||||
"reader_paused": "Paused \u2014 controls below",
|
||||
"reader_ch_ready": "Ch.{n} ready",
|
||||
"reader_ch_preparing": "Preparing Ch.{n}\u2026 {percent}%",
|
||||
"reader_ch_generate_on_nav": "Ch.{n} will generate on navigate",
|
||||
"reader_now_playing": "Now playing: {title}",
|
||||
"reader_load_this_chapter": "Load this chapter",
|
||||
"reader_generate_samples": "Generate missing samples",
|
||||
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
|
||||
"reader_choose_voice": "Choose Voice",
|
||||
"reader_generating_narration": "Generating narration\u2026"
|
||||
}
|
||||
383
ui/messages/fr.json
Normal file
383
ui/messages/fr.json
Normal file
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Bibliothèque",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feedback": "Retour",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
"nav_sign_in": "Connexion",
|
||||
"nav_sign_out": "Déconnexion",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panneau admin",
|
||||
|
||||
"footer_library": "Bibliothèque",
|
||||
"footer_catalogue": "Catalogue",
|
||||
"footer_feedback": "Retour",
|
||||
"footer_disclaimer": "Avertissement",
|
||||
"footer_privacy": "Confidentialité",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livres",
|
||||
"home_stat_chapters": "Chapitres",
|
||||
"home_stat_in_progress": "En cours",
|
||||
"home_continue_reading": "Continuer la lecture",
|
||||
"home_view_all": "Voir tout",
|
||||
"home_recently_updated": "Récemment mis à jour",
|
||||
"home_from_following": "Des personnes que vous suivez",
|
||||
"home_empty_title": "Votre bibliothèque est vide",
|
||||
"home_empty_body": "Découvrez des romans et ajoutez-les à votre bibliothèque.",
|
||||
"home_discover_novels": "Découvrir des romans",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "ch.{n}",
|
||||
|
||||
"player_generating": "Génération… {percent}%",
|
||||
"player_loading": "Chargement…",
|
||||
"player_chapters": "Chapitres",
|
||||
"player_chapter_n": "Chapitre {n}",
|
||||
"player_toggle_chapter_list": "Liste des chapitres",
|
||||
"player_chapter_list_label": "Liste des chapitres",
|
||||
"player_close_chapter_list": "Fermer la liste des chapitres",
|
||||
"player_rewind_15": "Reculer de 15 secondes",
|
||||
"player_skip_30": "Avancer de 30 secondes",
|
||||
"player_back_15": "−15 s",
|
||||
"player_forward_30": "+30 s",
|
||||
"player_play": "Lecture",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Vitesse {speed}x",
|
||||
"player_change_speed": "Changer la vitesse",
|
||||
"player_auto_next_on": "Suivant auto activé",
|
||||
"player_auto_next_off": "Suivant auto désactivé",
|
||||
"player_auto_next_ready": "Suivant auto — Ch.{n} prêt",
|
||||
"player_auto_next_preparing": "Suivant auto — préparation Ch.{n}…",
|
||||
"player_auto_next_aria": "Suivant auto {state}",
|
||||
"player_go_to_chapter": "Aller au chapitre",
|
||||
"player_close": "Fermer le lecteur",
|
||||
|
||||
"login_page_title": "Connexion — libnovel",
|
||||
"login_heading": "Se connecter à libnovel",
|
||||
"login_subheading": "Choisissez un fournisseur pour continuer",
|
||||
"login_continue_google": "Continuer avec Google",
|
||||
"login_continue_github": "Continuer avec GitHub",
|
||||
"login_terms_notice": "En vous connectant, vous acceptez nos conditions d'utilisation.",
|
||||
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
|
||||
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
|
||||
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
|
||||
|
||||
"books_page_title": "Bibliothèque — libnovel",
|
||||
"books_heading": "Votre bibliothèque",
|
||||
"books_empty_title": "Aucun livre pour l'instant",
|
||||
"books_empty_body": "Ajoutez des livres à votre bibliothèque en visitant une page de livre.",
|
||||
"books_browse_catalogue": "Parcourir le catalogue",
|
||||
"books_chapter_count": "{n} chapitres",
|
||||
"books_last_read": "Dernier lu : Ch.{n}",
|
||||
"books_reading_progress": "Ch.{current} / {total}",
|
||||
"books_remove": "Supprimer",
|
||||
|
||||
"catalogue_page_title": "Catalogue — libnovel",
|
||||
"catalogue_heading": "Catalogue",
|
||||
"catalogue_search_placeholder": "Rechercher des romans…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Statut",
|
||||
"catalogue_filter_sort": "Trier",
|
||||
"catalogue_sort_popular": "Populaire",
|
||||
"catalogue_sort_new": "Nouveau",
|
||||
"catalogue_sort_top_rated": "Mieux notés",
|
||||
"catalogue_sort_rank": "Rang",
|
||||
"catalogue_status_all": "Tous",
|
||||
"catalogue_status_ongoing": "En cours",
|
||||
"catalogue_status_completed": "Terminé",
|
||||
"catalogue_genre_all": "Tous les genres",
|
||||
"catalogue_clear_filters": "Effacer",
|
||||
"catalogue_reset": "Réinitialiser",
|
||||
"catalogue_no_results": "Aucun roman trouvé.",
|
||||
"catalogue_loading": "Chargement…",
|
||||
"catalogue_load_more": "Charger plus",
|
||||
"catalogue_results_count": "{n} résultats",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_add_to_library": "Ajouter à la bibliothèque",
|
||||
"book_detail_remove_from_library": "Retirer de la bibliothèque",
|
||||
"book_detail_read_now": "Lire maintenant",
|
||||
"book_detail_continue_reading": "Continuer la lecture",
|
||||
"book_detail_start_reading": "Commencer la lecture",
|
||||
"book_detail_chapters": "{n} chapitres",
|
||||
"book_detail_status": "Statut",
|
||||
"book_detail_author": "Auteur",
|
||||
"book_detail_genres": "Genres",
|
||||
"book_detail_description": "Description",
|
||||
"book_detail_source": "Source",
|
||||
"book_detail_rescrape": "Réextraire",
|
||||
"book_detail_scraping": "Extraction en cours…",
|
||||
"book_detail_in_library": "Dans la bibliothèque",
|
||||
|
||||
"chapters_page_title": "Chapitres — {title}",
|
||||
"chapters_heading": "Chapitres",
|
||||
"chapters_back_to_book": "Retour au livre",
|
||||
"chapters_reading_now": "En cours de lecture",
|
||||
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
|
||||
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Lire la narration",
|
||||
"reader_generating_audio": "Génération audio…",
|
||||
"reader_audio_error": "Échec de la génération audio.",
|
||||
"reader_prev_chapter": "Chapitre précédent",
|
||||
"reader_next_chapter": "Chapitre suivant",
|
||||
"reader_back_to_chapters": "Retour aux chapitres",
|
||||
"reader_chapter_n": "Chapitre {n}",
|
||||
"reader_change_voice": "Changer de voix",
|
||||
"reader_voice_panel_title": "Sélectionner une voix",
|
||||
"reader_voice_kokoro": "Voix Kokoro",
|
||||
"reader_voice_pocket": "Voix Pocket-TTS",
|
||||
"reader_voice_play_sample": "Écouter un extrait",
|
||||
"reader_voice_stop_sample": "Arrêter l'extrait",
|
||||
"reader_voice_selected": "Sélectionné",
|
||||
"reader_close_voice_panel": "Fermer le panneau vocal",
|
||||
"reader_auto_next": "Suivant auto",
|
||||
"reader_speed": "Vitesse",
|
||||
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Changer l'avatar",
|
||||
"profile_username": "Nom d'utilisateur",
|
||||
"profile_email": "E-mail",
|
||||
"profile_change_password": "Changer le mot de passe",
|
||||
"profile_current_password": "Mot de passe actuel",
|
||||
"profile_new_password": "Nouveau mot de passe",
|
||||
"profile_confirm_password": "Confirmer le mot de passe",
|
||||
"profile_save_password": "Enregistrer le mot de passe",
|
||||
"profile_appearance_heading": "Apparence",
|
||||
"profile_theme_label": "Thème",
|
||||
"profile_theme_amber": "Ambre",
|
||||
"profile_theme_slate": "Ardoise",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_reading_heading": "Paramètres de lecture",
|
||||
"profile_voice_label": "Voix par défaut",
|
||||
"profile_speed_label": "Vitesse de lecture",
|
||||
"profile_auto_next_label": "Chapitre suivant automatique",
|
||||
"profile_save_settings": "Enregistrer les paramètres",
|
||||
"profile_settings_saved": "Paramètres enregistrés.",
|
||||
"profile_settings_error": "Impossible d'enregistrer les paramètres.",
|
||||
"profile_password_saved": "Mot de passe modifié.",
|
||||
"profile_password_error": "Impossible de modifier le mot de passe.",
|
||||
"profile_sessions_heading": "Sessions actives",
|
||||
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
|
||||
"profile_joined": "Inscrit le {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Bibliothèque de {username}",
|
||||
"user_follow": "Suivre",
|
||||
"user_unfollow": "Ne plus suivre",
|
||||
"user_followers": "{n} abonnés",
|
||||
"user_following": "{n} abonnements",
|
||||
"user_library_empty": "Aucun livre dans la bibliothèque.",
|
||||
|
||||
"error_not_found_title": "Page introuvable",
|
||||
"error_not_found_body": "La page que vous cherchez n'existe pas.",
|
||||
"error_generic_title": "Une erreur s'est produite",
|
||||
"error_go_home": "Accueil",
|
||||
"error_status": "Erreur {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extraction — Admin",
|
||||
"admin_scrape_heading": "Extraction",
|
||||
"admin_scrape_catalogue": "Extraire le catalogue",
|
||||
"admin_scrape_book": "Extraire un livre",
|
||||
"admin_scrape_url_placeholder": "URL du livre sur novelfire.net",
|
||||
"admin_scrape_range": "Plage de chapitres",
|
||||
"admin_scrape_from": "De",
|
||||
"admin_scrape_to": "À",
|
||||
"admin_scrape_submit": "Extraire",
|
||||
"admin_scrape_cancel": "Annuler",
|
||||
"admin_scrape_status_pending": "En attente",
|
||||
"admin_scrape_status_running": "En cours",
|
||||
"admin_scrape_status_done": "Terminé",
|
||||
"admin_scrape_status_failed": "Échoué",
|
||||
"admin_scrape_status_cancelled": "Annulé",
|
||||
"admin_tasks_heading": "Tâches récentes",
|
||||
"admin_tasks_empty": "Aucune tâche pour l'instant.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tâches audio",
|
||||
"admin_audio_empty": "Aucune tâche audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Commentaires",
|
||||
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
|
||||
"comments_placeholder": "Écrire un commentaire…",
|
||||
"comments_submit": "Publier",
|
||||
"comments_login_prompt": "Connectez-vous pour commenter.",
|
||||
"comments_vote_up": "Vote positif",
|
||||
"comments_vote_down": "Vote négatif",
|
||||
"comments_delete": "Supprimer",
|
||||
"comments_reply": "Répondre",
|
||||
"comments_show_replies": "Afficher {n} réponses",
|
||||
"comments_hide_replies": "Masquer les réponses",
|
||||
"comments_edited": "modifié",
|
||||
"comments_deleted": "[supprimé]",
|
||||
|
||||
"disclaimer_page_title": "Avertissement — libnovel",
|
||||
"privacy_page_title": "Politique de confidentialité — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Conditions d'utilisation — libnovel",
|
||||
|
||||
"common_loading": "Chargement…",
|
||||
"common_error": "Erreur",
|
||||
"common_save": "Enregistrer",
|
||||
"common_cancel": "Annuler",
|
||||
"common_close": "Fermer",
|
||||
"common_search": "Rechercher",
|
||||
"common_back": "Retour",
|
||||
"common_next": "Suivant",
|
||||
"common_previous": "Précédent",
|
||||
"common_yes": "Oui",
|
||||
"common_no": "Non",
|
||||
"common_on": "activé",
|
||||
"common_off": "désactivé",
|
||||
|
||||
"locale_switcher_label": "Langue",
|
||||
|
||||
"books_empty_library": "Votre bibliothèque est vide.",
|
||||
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
|
||||
"books_empty_discover_link": "Découvrir",
|
||||
"books_empty_discover_suffix": "apparaîtront ici.",
|
||||
"books_count": "{n} livre{s}",
|
||||
|
||||
"catalogue_sort_updated": "Mis à jour",
|
||||
"catalogue_search_button": "Rechercher",
|
||||
"catalogue_refresh": "Actualiser",
|
||||
"catalogue_refreshing": "En file d'attente…",
|
||||
"catalogue_refresh_mobile": "Actualiser le catalogue",
|
||||
"catalogue_all_loaded": "Tous les romans chargés",
|
||||
"catalogue_scroll_top": "Retour en haut",
|
||||
"catalogue_view_grid": "Vue grille",
|
||||
"catalogue_view_list": "Vue liste",
|
||||
"catalogue_browse_source": "Parcourir les romans de novelfire.net",
|
||||
"catalogue_search_results": "{n} résultat{s} pour « {q} »",
|
||||
"catalogue_search_local_count": "({local} local, {remote} depuis novelfire)",
|
||||
"catalogue_rank_ranked": "{n} romans classés depuis le dernier scrape du catalogue",
|
||||
"catalogue_rank_no_data": "Aucune donnée de classement.",
|
||||
"catalogue_rank_no_data_body": "Aucune donnée de classement — lancez un scrape complet du catalogue pour remplir",
|
||||
"catalogue_rank_run_scrape_admin": "Cliquez sur Actualiser le catalogue ci-dessus pour déclencher un scrape complet.",
|
||||
"catalogue_rank_run_scrape_user": "Demandez à un administrateur d'effectuer un scrape du catalogue.",
|
||||
"catalogue_scrape_queued_flash": "Scrape complet du catalogue en file d'attente. La bibliothèque et le classement seront mis à jour au fur et à mesure du traitement des livres.",
|
||||
"catalogue_scrape_busy_flash": "Un job de scrape est déjà en cours. Revenez une fois terminé.",
|
||||
"catalogue_scrape_error_flash": "Échec de la mise en file d'attente du scrape. Vérifiez que le service de scraper est accessible.",
|
||||
"catalogue_filters_label": "Filtres",
|
||||
"catalogue_apply": "Appliquer",
|
||||
"catalogue_filter_rank_note": "Les filtres genre et statut s'appliquent uniquement à Parcourir",
|
||||
"catalogue_no_results_search": "Aucun résultat trouvé.",
|
||||
"catalogue_no_results_try": "Essayez un autre terme de recherche.",
|
||||
"catalogue_no_results_filters": "Essayez d'autres filtres ou revenez plus tard.",
|
||||
"catalogue_scrape_queued_badge": "En file",
|
||||
"catalogue_scrape_busy_badge": "Scraper occupé",
|
||||
"catalogue_scrape_busy_list": "Occupé",
|
||||
"catalogue_scrape_forbidden_badge": "Interdit",
|
||||
"catalogue_scrape_novel_button": "Extraire",
|
||||
"catalogue_scraping_novel": "Extraction…",
|
||||
|
||||
"book_detail_not_in_library": "pas dans la bibliothèque",
|
||||
"book_detail_continue_ch": "Continuer ch.{n}",
|
||||
"book_detail_start_ch1": "Commencer au ch.1",
|
||||
"book_detail_preview_ch1": "Aperçu ch.1",
|
||||
"book_detail_reading_ch": "Lecture ch.{n} sur {total}",
|
||||
"book_detail_n_chapters": "{n} chapitres",
|
||||
"book_detail_rescraping": "En file d'attente…",
|
||||
"book_detail_from_chapter": "À partir du chapitre",
|
||||
"book_detail_to_chapter": "Jusqu'au chapitre (optionnel)",
|
||||
"book_detail_range_queuing": "En file d'attente…",
|
||||
"book_detail_scrape_range": "Plage d'extraction",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
|
||||
"book_detail_scraping_home": "← Accueil",
|
||||
"book_detail_rescrape_book": "Réextraire le livre",
|
||||
"book_detail_less": "Moins",
|
||||
"book_detail_more": "Plus",
|
||||
|
||||
"chapters_search_placeholder": "Rechercher des chapitres…",
|
||||
"chapters_jump_to": "Aller au Ch.{n}",
|
||||
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
|
||||
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
|
||||
"chapters_reading_indicator": "en cours",
|
||||
"chapters_result_count": "{n} résultats",
|
||||
|
||||
"reader_fetching_chapter": "Récupération du chapitre…",
|
||||
"reader_words": "{n} mots",
|
||||
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
|
||||
|
||||
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
|
||||
"profile_tts_voice": "Voix TTS",
|
||||
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
|
||||
"profile_saving": "Enregistrement…",
|
||||
"profile_saved": "Enregistré !",
|
||||
"profile_session_this": "Cette session",
|
||||
"profile_session_signed_in": "Connecté le {date}",
|
||||
"profile_session_last_seen": "· Dernière activité {date}",
|
||||
"profile_session_sign_out": "Se déconnecter",
|
||||
"profile_session_end": "Terminer",
|
||||
"profile_session_unrecognised": "Ce sont tous les appareils connectés à votre compte. Terminez toute session que vous ne reconnaissez pas.",
|
||||
"profile_no_sessions": "Aucun enregistrement de session trouvé. Les sessions sont suivies dès la prochaine connexion.",
|
||||
"profile_change_password_heading": "Changer le mot de passe",
|
||||
"profile_update_password": "Mettre à jour le mot de passe",
|
||||
"profile_updating": "Mise à jour…",
|
||||
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
|
||||
"profile_playback_speed": "Vitesse de lecture — {speed}x",
|
||||
|
||||
"user_currently_reading": "En cours de lecture",
|
||||
"user_library_count": "Bibliothèque ({n})",
|
||||
"user_joined": "Inscrit le {date}",
|
||||
"user_followers_label": "abonnés",
|
||||
"user_following_label": "abonnements",
|
||||
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Outils",
|
||||
|
||||
"admin_scrape_status_idle": "Inactif",
|
||||
"admin_scrape_full_catalogue": "Catalogue complet",
|
||||
"admin_scrape_single_book": "Livre unique",
|
||||
"admin_scrape_quick_genres": "Genres rapides",
|
||||
"admin_scrape_task_history": "Historique des tâches",
|
||||
"admin_scrape_filter_placeholder": "Filtrer par type, statut ou URL…",
|
||||
"admin_scrape_no_matching": "Aucune tâche correspondante.",
|
||||
"admin_scrape_start": "Démarrer l'extraction",
|
||||
"admin_scrape_queuing": "En file d'attente…",
|
||||
"admin_scrape_running": "En cours…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
|
||||
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
|
||||
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
|
||||
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
|
||||
"admin_audio_cache_empty": "Cache audio vide.",
|
||||
"admin_audio_no_cache_results": "Aucun résultat.",
|
||||
|
||||
"admin_changelog_gitea": "Releases Gitea",
|
||||
"admin_changelog_no_releases": "Aucune release trouvée.",
|
||||
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
|
||||
|
||||
"comments_top": "Les meilleures",
|
||||
"comments_new": "Nouvelles",
|
||||
"comments_posting": "Publication…",
|
||||
"comments_login_link": "Connectez-vous",
|
||||
"comments_login_suffix": "pour laisser un commentaire.",
|
||||
"comments_anonymous": "Anonyme",
|
||||
|
||||
"reader_audio_narration": "Narration Audio",
|
||||
"reader_playing": "Lecture en cours — contrôles ci-dessous",
|
||||
"reader_paused": "En pause — contrôles ci-dessous",
|
||||
"reader_ch_ready": "Ch.{n} prêt",
|
||||
"reader_ch_preparing": "Préparation Ch.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Ch.{n} sera généré lors de la navigation",
|
||||
"reader_now_playing": "En cours : {title}",
|
||||
"reader_load_this_chapter": "Charger ce chapitre",
|
||||
"reader_generate_samples": "Générer les échantillons manquants",
|
||||
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
|
||||
"reader_choose_voice": "Choisir une voix",
|
||||
"reader_generating_narration": "Génération de la narration…"
|
||||
}
|
||||
383
ui/messages/id.json
Normal file
383
ui/messages/id.json
Normal file
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Perpustakaan",
|
||||
"nav_catalogue": "Katalog",
|
||||
"nav_feedback": "Masukan",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
"nav_sign_in": "Masuk",
|
||||
"nav_sign_out": "Keluar",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panel admin",
|
||||
|
||||
"footer_library": "Perpustakaan",
|
||||
"footer_catalogue": "Katalog",
|
||||
"footer_feedback": "Masukan",
|
||||
"footer_disclaimer": "Penyangkalan",
|
||||
"footer_privacy": "Privasi",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Buku",
|
||||
"home_stat_chapters": "Bab",
|
||||
"home_stat_in_progress": "Sedang dibaca",
|
||||
"home_continue_reading": "Lanjutkan Membaca",
|
||||
"home_view_all": "Lihat semua",
|
||||
"home_recently_updated": "Baru Diperbarui",
|
||||
"home_from_following": "Dari Orang yang Kamu Ikuti",
|
||||
"home_empty_title": "Perpustakaanmu kosong",
|
||||
"home_empty_body": "Temukan novel dan tambahkan ke perpustakaanmu.",
|
||||
"home_discover_novels": "Temukan Novel",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "bab.{n}",
|
||||
|
||||
"player_generating": "Membuat… {percent}%",
|
||||
"player_loading": "Memuat…",
|
||||
"player_chapters": "Bab",
|
||||
"player_chapter_n": "Bab {n}",
|
||||
"player_toggle_chapter_list": "Daftar bab",
|
||||
"player_chapter_list_label": "Daftar bab",
|
||||
"player_close_chapter_list": "Tutup daftar bab",
|
||||
"player_rewind_15": "Mundur 15 detik",
|
||||
"player_skip_30": "Maju 30 detik",
|
||||
"player_back_15": "−15 dtk",
|
||||
"player_forward_30": "+30 dtk",
|
||||
"player_play": "Putar",
|
||||
"player_pause": "Jeda",
|
||||
"player_speed_label": "Kecepatan {speed}x",
|
||||
"player_change_speed": "Ubah kecepatan",
|
||||
"player_auto_next_on": "Auto-lanjut aktif",
|
||||
"player_auto_next_off": "Auto-lanjut nonaktif",
|
||||
"player_auto_next_ready": "Auto-lanjut — Bab.{n} siap",
|
||||
"player_auto_next_preparing": "Auto-lanjut — menyiapkan Bab.{n}…",
|
||||
"player_auto_next_aria": "Auto-lanjut {state}",
|
||||
"player_go_to_chapter": "Pergi ke bab",
|
||||
"player_close": "Tutup pemutar",
|
||||
|
||||
"login_page_title": "Masuk — libnovel",
|
||||
"login_heading": "Masuk ke libnovel",
|
||||
"login_subheading": "Pilih penyedia untuk melanjutkan",
|
||||
"login_continue_google": "Lanjutkan dengan Google",
|
||||
"login_continue_github": "Lanjutkan dengan GitHub",
|
||||
"login_terms_notice": "Dengan masuk, kamu menyetujui syarat layanan kami.",
|
||||
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
|
||||
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
|
||||
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
|
||||
|
||||
"books_page_title": "Perpustakaan — libnovel",
|
||||
"books_heading": "Perpustakaanmu",
|
||||
"books_empty_title": "Belum ada buku",
|
||||
"books_empty_body": "Tambahkan buku ke perpustakaanmu dengan mengunjungi halaman buku.",
|
||||
"books_browse_catalogue": "Jelajahi Katalog",
|
||||
"books_chapter_count": "{n} bab",
|
||||
"books_last_read": "Terakhir: Bab.{n}",
|
||||
"books_reading_progress": "Bab.{current} / {total}",
|
||||
"books_remove": "Hapus",
|
||||
|
||||
"catalogue_page_title": "Katalog — libnovel",
|
||||
"catalogue_heading": "Katalog",
|
||||
"catalogue_search_placeholder": "Cari novel…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Urutkan",
|
||||
"catalogue_sort_popular": "Populer",
|
||||
"catalogue_sort_new": "Terbaru",
|
||||
"catalogue_sort_top_rated": "Nilai Tertinggi",
|
||||
"catalogue_sort_rank": "Peringkat",
|
||||
"catalogue_status_all": "Semua",
|
||||
"catalogue_status_ongoing": "Berlangsung",
|
||||
"catalogue_status_completed": "Selesai",
|
||||
"catalogue_genre_all": "Semua genre",
|
||||
"catalogue_clear_filters": "Hapus",
|
||||
"catalogue_reset": "Atur ulang",
|
||||
"catalogue_no_results": "Novel tidak ditemukan.",
|
||||
"catalogue_loading": "Memuat…",
|
||||
"catalogue_load_more": "Muat lebih banyak",
|
||||
"catalogue_results_count": "{n} hasil",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_add_to_library": "Tambah ke Perpustakaan",
|
||||
"book_detail_remove_from_library": "Hapus dari Perpustakaan",
|
||||
"book_detail_read_now": "Baca Sekarang",
|
||||
"book_detail_continue_reading": "Lanjutkan Membaca",
|
||||
"book_detail_start_reading": "Mulai Membaca",
|
||||
"book_detail_chapters": "{n} Bab",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Penulis",
|
||||
"book_detail_genres": "Genre",
|
||||
"book_detail_description": "Deskripsi",
|
||||
"book_detail_source": "Sumber",
|
||||
"book_detail_rescrape": "Perbarui",
|
||||
"book_detail_scraping": "Memperbarui…",
|
||||
"book_detail_in_library": "Ada di Perpustakaan",
|
||||
|
||||
"chapters_page_title": "Bab — {title}",
|
||||
"chapters_heading": "Bab",
|
||||
"chapters_back_to_book": "Kembali ke buku",
|
||||
"chapters_reading_now": "Sedang dibaca",
|
||||
"chapters_empty": "Belum ada bab yang diambil.",
|
||||
|
||||
"reader_page_title": "{title} — Bab.{n} — libnovel",
|
||||
"reader_play_narration": "Putar narasi",
|
||||
"reader_generating_audio": "Membuat audio…",
|
||||
"reader_audio_error": "Pembuatan audio gagal.",
|
||||
"reader_prev_chapter": "Bab sebelumnya",
|
||||
"reader_next_chapter": "Bab berikutnya",
|
||||
"reader_back_to_chapters": "Kembali ke daftar bab",
|
||||
"reader_chapter_n": "Bab {n}",
|
||||
"reader_change_voice": "Ganti suara",
|
||||
"reader_voice_panel_title": "Pilih suara",
|
||||
"reader_voice_kokoro": "Suara Kokoro",
|
||||
"reader_voice_pocket": "Suara Pocket-TTS",
|
||||
"reader_voice_play_sample": "Putar sampel",
|
||||
"reader_voice_stop_sample": "Hentikan sampel",
|
||||
"reader_voice_selected": "Dipilih",
|
||||
"reader_close_voice_panel": "Tutup panel suara",
|
||||
"reader_auto_next": "Auto-lanjut",
|
||||
"reader_speed": "Kecepatan",
|
||||
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Ubah avatar",
|
||||
"profile_username": "Nama pengguna",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Ubah kata sandi",
|
||||
"profile_current_password": "Kata sandi saat ini",
|
||||
"profile_new_password": "Kata sandi baru",
|
||||
"profile_confirm_password": "Konfirmasi kata sandi",
|
||||
"profile_save_password": "Simpan kata sandi",
|
||||
"profile_appearance_heading": "Tampilan",
|
||||
"profile_theme_label": "Tema",
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Abu-abu",
|
||||
"profile_theme_rose": "Mawar",
|
||||
"profile_reading_heading": "Pengaturan membaca",
|
||||
"profile_voice_label": "Suara default",
|
||||
"profile_speed_label": "Kecepatan pemutaran",
|
||||
"profile_auto_next_label": "Auto-lanjut bab",
|
||||
"profile_save_settings": "Simpan pengaturan",
|
||||
"profile_settings_saved": "Pengaturan disimpan.",
|
||||
"profile_settings_error": "Gagal menyimpan pengaturan.",
|
||||
"profile_password_saved": "Kata sandi diubah.",
|
||||
"profile_password_error": "Gagal mengubah kata sandi.",
|
||||
"profile_sessions_heading": "Sesi aktif",
|
||||
"profile_sign_out_all": "Keluar dari semua perangkat lain",
|
||||
"profile_joined": "Bergabung {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Perpustakaan {username}",
|
||||
"user_follow": "Ikuti",
|
||||
"user_unfollow": "Berhenti mengikuti",
|
||||
"user_followers": "{n} pengikut",
|
||||
"user_following": "{n} mengikuti",
|
||||
"user_library_empty": "Tidak ada buku di perpustakaan.",
|
||||
|
||||
"error_not_found_title": "Halaman tidak ditemukan",
|
||||
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
|
||||
"error_generic_title": "Terjadi kesalahan",
|
||||
"error_go_home": "Ke beranda",
|
||||
"error_status": "Error {status}",
|
||||
|
||||
"admin_scrape_page_title": "Scrape — Admin",
|
||||
"admin_scrape_heading": "Scrape",
|
||||
"admin_scrape_catalogue": "Scrape Katalog",
|
||||
"admin_scrape_book": "Scrape Buku",
|
||||
"admin_scrape_url_placeholder": "URL buku di novelfire.net",
|
||||
"admin_scrape_range": "Rentang bab",
|
||||
"admin_scrape_from": "Dari",
|
||||
"admin_scrape_to": "Sampai",
|
||||
"admin_scrape_submit": "Scrape",
|
||||
"admin_scrape_cancel": "Batal",
|
||||
"admin_scrape_status_pending": "Menunggu",
|
||||
"admin_scrape_status_running": "Berjalan",
|
||||
"admin_scrape_status_done": "Selesai",
|
||||
"admin_scrape_status_failed": "Gagal",
|
||||
"admin_scrape_status_cancelled": "Dibatalkan",
|
||||
"admin_tasks_heading": "Tugas terbaru",
|
||||
"admin_tasks_empty": "Belum ada tugas.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tugas Audio",
|
||||
"admin_audio_empty": "Tidak ada tugas audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Komentar",
|
||||
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
|
||||
"comments_placeholder": "Tulis komentar…",
|
||||
"comments_submit": "Kirim",
|
||||
"comments_login_prompt": "Masuk untuk berkomentar.",
|
||||
"comments_vote_up": "Suka",
|
||||
"comments_vote_down": "Tidak suka",
|
||||
"comments_delete": "Hapus",
|
||||
"comments_reply": "Balas",
|
||||
"comments_show_replies": "Tampilkan {n} balasan",
|
||||
"comments_hide_replies": "Sembunyikan balasan",
|
||||
"comments_edited": "diedit",
|
||||
"comments_deleted": "[dihapus]",
|
||||
|
||||
"disclaimer_page_title": "Penyangkalan — libnovel",
|
||||
"privacy_page_title": "Kebijakan Privasi — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Syarat Layanan — libnovel",
|
||||
|
||||
"common_loading": "Memuat…",
|
||||
"common_error": "Error",
|
||||
"common_save": "Simpan",
|
||||
"common_cancel": "Batal",
|
||||
"common_close": "Tutup",
|
||||
"common_search": "Cari",
|
||||
"common_back": "Kembali",
|
||||
"common_next": "Berikutnya",
|
||||
"common_previous": "Sebelumnya",
|
||||
"common_yes": "Ya",
|
||||
"common_no": "Tidak",
|
||||
"common_on": "aktif",
|
||||
"common_off": "nonaktif",
|
||||
|
||||
"locale_switcher_label": "Bahasa",
|
||||
|
||||
"books_empty_library": "Perpustakaanmu kosong.",
|
||||
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
|
||||
"books_empty_discover_link": "Temukan",
|
||||
"books_empty_discover_suffix": "akan muncul di sini.",
|
||||
"books_count": "{n} buku",
|
||||
|
||||
"catalogue_sort_updated": "Diperbarui",
|
||||
"catalogue_search_button": "Cari",
|
||||
"catalogue_refresh": "Segarkan",
|
||||
"catalogue_refreshing": "Mengantri…",
|
||||
"catalogue_refresh_mobile": "Segarkan katalog",
|
||||
"catalogue_all_loaded": "Semua novel telah dimuat",
|
||||
"catalogue_scroll_top": "Kembali ke atas",
|
||||
"catalogue_view_grid": "Tampilan kisi",
|
||||
"catalogue_view_list": "Tampilan daftar",
|
||||
"catalogue_browse_source": "Jelajahi novel dari novelfire.net",
|
||||
"catalogue_search_results": "{n} hasil untuk \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} lokal, {remote} dari novelfire)",
|
||||
"catalogue_rank_ranked": "{n} novel diurutkan dari scrape katalog terakhir",
|
||||
"catalogue_rank_no_data": "Tidak ada data peringkat.",
|
||||
"catalogue_rank_no_data_body": "Tidak ada data peringkat — jalankan scrape katalog penuh untuk mengisi",
|
||||
"catalogue_rank_run_scrape_admin": "Klik Segarkan katalog di atas untuk memicu scrape katalog penuh.",
|
||||
"catalogue_rank_run_scrape_user": "Minta admin untuk menjalankan scrape katalog.",
|
||||
"catalogue_scrape_queued_flash": "Scrape katalog penuh diantrekan. Perpustakaan dan peringkat akan diperbarui saat buku diproses.",
|
||||
"catalogue_scrape_busy_flash": "Pekerjaan scrape sedang berjalan. Periksa kembali setelah selesai.",
|
||||
"catalogue_scrape_error_flash": "Gagal mengantrekan scrape. Pastikan layanan scraper dapat dijangkau.",
|
||||
"catalogue_filters_label": "Filter",
|
||||
"catalogue_apply": "Terapkan",
|
||||
"catalogue_filter_rank_note": "Filter genre & status hanya berlaku untuk Jelajahi",
|
||||
"catalogue_no_results_search": "Tidak ada hasil.",
|
||||
"catalogue_no_results_try": "Coba kata kunci lain.",
|
||||
"catalogue_no_results_filters": "Coba filter lain atau periksa kembali nanti.",
|
||||
"catalogue_scrape_queued_badge": "Diantrekan",
|
||||
"catalogue_scrape_busy_badge": "Scraper sibuk",
|
||||
"catalogue_scrape_busy_list": "Sibuk",
|
||||
"catalogue_scrape_forbidden_badge": "Terlarang",
|
||||
"catalogue_scrape_novel_button": "Scrape",
|
||||
"catalogue_scraping_novel": "Scraping…",
|
||||
|
||||
"book_detail_not_in_library": "tidak di perpustakaan",
|
||||
"book_detail_continue_ch": "Lanjutkan bab.{n}",
|
||||
"book_detail_start_ch1": "Mulai dari bab.1",
|
||||
"book_detail_preview_ch1": "Pratinjau bab.1",
|
||||
"book_detail_reading_ch": "Membaca bab.{n} dari {total}",
|
||||
"book_detail_n_chapters": "{n} bab",
|
||||
"book_detail_rescraping": "Mengantri…",
|
||||
"book_detail_from_chapter": "Dari bab",
|
||||
"book_detail_to_chapter": "Sampai bab (opsional)",
|
||||
"book_detail_range_queuing": "Mengantri…",
|
||||
"book_detail_scrape_range": "Rentang scrape",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
|
||||
"book_detail_scraping_home": "← Beranda",
|
||||
"book_detail_rescrape_book": "Scrape ulang buku",
|
||||
"book_detail_less": "Lebih sedikit",
|
||||
"book_detail_more": "Selengkapnya",
|
||||
|
||||
"chapters_search_placeholder": "Cari bab…",
|
||||
"chapters_jump_to": "Loncat ke Bab.{n}",
|
||||
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
|
||||
"chapters_none_available": "Belum ada bab tersedia.",
|
||||
"chapters_reading_indicator": "sedang dibaca",
|
||||
"chapters_result_count": "{n} hasil",
|
||||
|
||||
"reader_fetching_chapter": "Mengambil bab…",
|
||||
"reader_words": "{n} kata",
|
||||
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
|
||||
|
||||
"profile_click_to_change": "Klik avatar untuk mengganti foto",
|
||||
"profile_tts_voice": "Suara TTS",
|
||||
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
|
||||
"profile_saving": "Menyimpan…",
|
||||
"profile_saved": "Tersimpan!",
|
||||
"profile_session_this": "Sesi ini",
|
||||
"profile_session_signed_in": "Masuk {date}",
|
||||
"profile_session_last_seen": "· Terakhir dilihat {date}",
|
||||
"profile_session_sign_out": "Keluar",
|
||||
"profile_session_end": "Akhiri",
|
||||
"profile_session_unrecognised": "Ini semua perangkat yang masuk ke akunmu. Akhiri sesi yang tidak kamu kenali.",
|
||||
"profile_no_sessions": "Tidak ada catatan sesi. Sesi dilacak mulai login berikutnya.",
|
||||
"profile_change_password_heading": "Ubah kata sandi",
|
||||
"profile_update_password": "Perbarui kata sandi",
|
||||
"profile_updating": "Memperbarui…",
|
||||
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
|
||||
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
|
||||
|
||||
"user_currently_reading": "Sedang Dibaca",
|
||||
"user_library_count": "Perpustakaan ({n})",
|
||||
"user_joined": "Bergabung {date}",
|
||||
"user_followers_label": "pengikut",
|
||||
"user_following_label": "mengikuti",
|
||||
"user_no_books": "Belum ada buku di perpustakaan.",
|
||||
|
||||
"admin_pages_label": "Halaman",
|
||||
"admin_tools_label": "Alat",
|
||||
|
||||
"admin_scrape_status_idle": "Menunggu",
|
||||
"admin_scrape_full_catalogue": "Katalog penuh",
|
||||
"admin_scrape_single_book": "Satu buku",
|
||||
"admin_scrape_quick_genres": "Genre cepat",
|
||||
"admin_scrape_task_history": "Riwayat tugas",
|
||||
"admin_scrape_filter_placeholder": "Filter berdasarkan jenis, status, atau URL…",
|
||||
"admin_scrape_no_matching": "Tidak ada tugas yang cocok.",
|
||||
"admin_scrape_start": "Mulai scrape",
|
||||
"admin_scrape_queuing": "Mengantri…",
|
||||
"admin_scrape_running": "Berjalan…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
|
||||
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
|
||||
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
|
||||
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
|
||||
"admin_audio_cache_empty": "Cache audio kosong.",
|
||||
"admin_audio_no_cache_results": "Tidak ada hasil.",
|
||||
|
||||
"admin_changelog_gitea": "Rilis Gitea",
|
||||
"admin_changelog_no_releases": "Tidak ada rilis.",
|
||||
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
|
||||
|
||||
"comments_top": "Teratas",
|
||||
"comments_new": "Terbaru",
|
||||
"comments_posting": "Mengirim…",
|
||||
"comments_login_link": "Masuk",
|
||||
"comments_login_suffix": "untuk meninggalkan komentar.",
|
||||
"comments_anonymous": "Anonim",
|
||||
|
||||
"reader_audio_narration": "Narasi Audio",
|
||||
"reader_playing": "Memutar — kontrol di bawah",
|
||||
"reader_paused": "Dijeda — kontrol di bawah",
|
||||
"reader_ch_ready": "Bab.{n} siap",
|
||||
"reader_ch_preparing": "Menyiapkan Bab.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Bab.{n} akan dihasilkan saat navigasi",
|
||||
"reader_now_playing": "Sedang diputar: {title}",
|
||||
"reader_load_this_chapter": "Muat bab ini",
|
||||
"reader_generate_samples": "Hasilkan sampel yang hilang",
|
||||
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
|
||||
"reader_choose_voice": "Pilih Suara",
|
||||
"reader_generating_narration": "Membuat narasi…"
|
||||
}
|
||||
383
ui/messages/pt-BR.json
Normal file
383
ui/messages/pt-BR.json
Normal file
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Biblioteca",
|
||||
"nav_catalogue": "Catálogo",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Perfil",
|
||||
"nav_sign_in": "Entrar",
|
||||
"nav_sign_out": "Sair",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Painel admin",
|
||||
|
||||
"footer_library": "Biblioteca",
|
||||
"footer_catalogue": "Catálogo",
|
||||
"footer_feedback": "Feedback",
|
||||
"footer_disclaimer": "Aviso legal",
|
||||
"footer_privacy": "Privacidade",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livros",
|
||||
"home_stat_chapters": "Capítulos",
|
||||
"home_stat_in_progress": "Em andamento",
|
||||
"home_continue_reading": "Continuar Lendo",
|
||||
"home_view_all": "Ver tudo",
|
||||
"home_recently_updated": "Atualizados Recentemente",
|
||||
"home_from_following": "De Quem Você Segue",
|
||||
"home_empty_title": "Sua biblioteca está vazia",
|
||||
"home_empty_body": "Descubra romances e adicione à sua biblioteca.",
|
||||
"home_discover_novels": "Descobrir Romances",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "cap.{n}",
|
||||
|
||||
"player_generating": "Gerando… {percent}%",
|
||||
"player_loading": "Carregando…",
|
||||
"player_chapters": "Capítulos",
|
||||
"player_chapter_n": "Capítulo {n}",
|
||||
"player_toggle_chapter_list": "Lista de capítulos",
|
||||
"player_chapter_list_label": "Lista de capítulos",
|
||||
"player_close_chapter_list": "Fechar lista de capítulos",
|
||||
"player_rewind_15": "Voltar 15 segundos",
|
||||
"player_skip_30": "Avançar 30 segundos",
|
||||
"player_back_15": "−15 s",
|
||||
"player_forward_30": "+30 s",
|
||||
"player_play": "Reproduzir",
|
||||
"player_pause": "Pausar",
|
||||
"player_speed_label": "Velocidade {speed}x",
|
||||
"player_change_speed": "Mudar velocidade",
|
||||
"player_auto_next_on": "Próximo automático ativado",
|
||||
"player_auto_next_off": "Próximo automático desativado",
|
||||
"player_auto_next_ready": "Próximo automático — Cap.{n} pronto",
|
||||
"player_auto_next_preparing": "Próximo automático — preparando Cap.{n}…",
|
||||
"player_auto_next_aria": "Próximo automático {state}",
|
||||
"player_go_to_chapter": "Ir para capítulo",
|
||||
"player_close": "Fechar player",
|
||||
|
||||
"login_page_title": "Entrar — libnovel",
|
||||
"login_heading": "Entrar no libnovel",
|
||||
"login_subheading": "Escolha um provedor para continuar",
|
||||
"login_continue_google": "Continuar com Google",
|
||||
"login_continue_github": "Continuar com GitHub",
|
||||
"login_terms_notice": "Ao entrar, você concorda com nossos termos de serviço.",
|
||||
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
|
||||
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
|
||||
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
|
||||
|
||||
"books_page_title": "Biblioteca — libnovel",
|
||||
"books_heading": "Sua Biblioteca",
|
||||
"books_empty_title": "Nenhum livro ainda",
|
||||
"books_empty_body": "Adicione livros à sua biblioteca visitando a página de um livro.",
|
||||
"books_browse_catalogue": "Explorar Catálogo",
|
||||
"books_chapter_count": "{n} capítulos",
|
||||
"books_last_read": "Último: Cap.{n}",
|
||||
"books_reading_progress": "Cap.{current} / {total}",
|
||||
"books_remove": "Remover",
|
||||
|
||||
"catalogue_page_title": "Catálogo — libnovel",
|
||||
"catalogue_heading": "Catálogo",
|
||||
"catalogue_search_placeholder": "Pesquisar romances…",
|
||||
"catalogue_filter_genre": "Gênero",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Ordenar",
|
||||
"catalogue_sort_popular": "Popular",
|
||||
"catalogue_sort_new": "Novo",
|
||||
"catalogue_sort_top_rated": "Mais Bem Avaliados",
|
||||
"catalogue_sort_rank": "Ranking",
|
||||
"catalogue_status_all": "Todos",
|
||||
"catalogue_status_ongoing": "Em andamento",
|
||||
"catalogue_status_completed": "Concluído",
|
||||
"catalogue_genre_all": "Todos os gêneros",
|
||||
"catalogue_clear_filters": "Limpar",
|
||||
"catalogue_reset": "Redefinir",
|
||||
"catalogue_no_results": "Nenhum romance encontrado.",
|
||||
"catalogue_loading": "Carregando…",
|
||||
"catalogue_load_more": "Carregar mais",
|
||||
"catalogue_results_count": "{n} resultados",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_add_to_library": "Adicionar à Biblioteca",
|
||||
"book_detail_remove_from_library": "Remover da Biblioteca",
|
||||
"book_detail_read_now": "Ler Agora",
|
||||
"book_detail_continue_reading": "Continuar Lendo",
|
||||
"book_detail_start_reading": "Começar a Ler",
|
||||
"book_detail_chapters": "{n} Capítulos",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Autor",
|
||||
"book_detail_genres": "Gêneros",
|
||||
"book_detail_description": "Descrição",
|
||||
"book_detail_source": "Fonte",
|
||||
"book_detail_rescrape": "Atualizar",
|
||||
"book_detail_scraping": "Atualizando…",
|
||||
"book_detail_in_library": "Na Biblioteca",
|
||||
|
||||
"chapters_page_title": "Capítulos — {title}",
|
||||
"chapters_heading": "Capítulos",
|
||||
"chapters_back_to_book": "Voltar ao livro",
|
||||
"chapters_reading_now": "Lendo",
|
||||
"chapters_empty": "Nenhum capítulo extraído ainda.",
|
||||
|
||||
"reader_page_title": "{title} — Cap.{n} — libnovel",
|
||||
"reader_play_narration": "Reproduzir narração",
|
||||
"reader_generating_audio": "Gerando áudio…",
|
||||
"reader_audio_error": "Falha na geração de áudio.",
|
||||
"reader_prev_chapter": "Capítulo anterior",
|
||||
"reader_next_chapter": "Próximo capítulo",
|
||||
"reader_back_to_chapters": "Voltar aos capítulos",
|
||||
"reader_chapter_n": "Capítulo {n}",
|
||||
"reader_change_voice": "Mudar voz",
|
||||
"reader_voice_panel_title": "Selecionar voz",
|
||||
"reader_voice_kokoro": "Vozes Kokoro",
|
||||
"reader_voice_pocket": "Vozes Pocket-TTS",
|
||||
"reader_voice_play_sample": "Reproduzir amostra",
|
||||
"reader_voice_stop_sample": "Parar amostra",
|
||||
"reader_voice_selected": "Selecionado",
|
||||
"reader_close_voice_panel": "Fechar painel de voz",
|
||||
"reader_auto_next": "Próximo automático",
|
||||
"reader_speed": "Velocidade",
|
||||
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
|
||||
|
||||
"profile_page_title": "Perfil — libnovel",
|
||||
"profile_heading": "Perfil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Mudar avatar",
|
||||
"profile_username": "Nome de usuário",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Mudar senha",
|
||||
"profile_current_password": "Senha atual",
|
||||
"profile_new_password": "Nova senha",
|
||||
"profile_confirm_password": "Confirmar senha",
|
||||
"profile_save_password": "Salvar senha",
|
||||
"profile_appearance_heading": "Aparência",
|
||||
"profile_theme_label": "Tema",
|
||||
"profile_theme_amber": "Âmbar",
|
||||
"profile_theme_slate": "Ardósia",
|
||||
"profile_theme_rose": "Rosa",
|
||||
"profile_reading_heading": "Configurações de leitura",
|
||||
"profile_voice_label": "Voz padrão",
|
||||
"profile_speed_label": "Velocidade de reprodução",
|
||||
"profile_auto_next_label": "Próximo capítulo automático",
|
||||
"profile_save_settings": "Salvar configurações",
|
||||
"profile_settings_saved": "Configurações salvas.",
|
||||
"profile_settings_error": "Falha ao salvar configurações.",
|
||||
"profile_password_saved": "Senha alterada.",
|
||||
"profile_password_error": "Falha ao alterar a senha.",
|
||||
"profile_sessions_heading": "Sessões ativas",
|
||||
"profile_sign_out_all": "Sair de todos os outros dispositivos",
|
||||
"profile_joined": "Entrou em {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Biblioteca de {username}",
|
||||
"user_follow": "Seguir",
|
||||
"user_unfollow": "Deixar de seguir",
|
||||
"user_followers": "{n} seguidores",
|
||||
"user_following": "{n} seguindo",
|
||||
"user_library_empty": "Nenhum livro na biblioteca.",
|
||||
|
||||
"error_not_found_title": "Página não encontrada",
|
||||
"error_not_found_body": "A página que você procura não existe.",
|
||||
"error_generic_title": "Algo deu errado",
|
||||
"error_go_home": "Ir para início",
|
||||
"error_status": "Erro {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extração — Admin",
|
||||
"admin_scrape_heading": "Extração",
|
||||
"admin_scrape_catalogue": "Extrair Catálogo",
|
||||
"admin_scrape_book": "Extrair Livro",
|
||||
"admin_scrape_url_placeholder": "URL do livro em novelfire.net",
|
||||
"admin_scrape_range": "Intervalo de capítulos",
|
||||
"admin_scrape_from": "De",
|
||||
"admin_scrape_to": "Até",
|
||||
"admin_scrape_submit": "Extrair",
|
||||
"admin_scrape_cancel": "Cancelar",
|
||||
"admin_scrape_status_pending": "Pendente",
|
||||
"admin_scrape_status_running": "Em execução",
|
||||
"admin_scrape_status_done": "Concluído",
|
||||
"admin_scrape_status_failed": "Falhou",
|
||||
"admin_scrape_status_cancelled": "Cancelado",
|
||||
"admin_tasks_heading": "Tarefas recentes",
|
||||
"admin_tasks_empty": "Nenhuma tarefa ainda.",
|
||||
|
||||
"admin_audio_page_title": "Áudio — Admin",
|
||||
"admin_audio_heading": "Tarefas de Áudio",
|
||||
"admin_audio_empty": "Nenhuma tarefa de áudio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Comentários",
|
||||
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
|
||||
"comments_placeholder": "Escreva um comentário…",
|
||||
"comments_submit": "Publicar",
|
||||
"comments_login_prompt": "Entre para comentar.",
|
||||
"comments_vote_up": "Votar positivo",
|
||||
"comments_vote_down": "Votar negativo",
|
||||
"comments_delete": "Excluir",
|
||||
"comments_reply": "Responder",
|
||||
"comments_show_replies": "Mostrar {n} respostas",
|
||||
"comments_hide_replies": "Ocultar respostas",
|
||||
"comments_edited": "editado",
|
||||
"comments_deleted": "[excluído]",
|
||||
|
||||
"disclaimer_page_title": "Aviso Legal — libnovel",
|
||||
"privacy_page_title": "Política de Privacidade — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Termos de Serviço — libnovel",
|
||||
|
||||
"common_loading": "Carregando…",
|
||||
"common_error": "Erro",
|
||||
"common_save": "Salvar",
|
||||
"common_cancel": "Cancelar",
|
||||
"common_close": "Fechar",
|
||||
"common_search": "Pesquisar",
|
||||
"common_back": "Voltar",
|
||||
"common_next": "Próximo",
|
||||
"common_previous": "Anterior",
|
||||
"common_yes": "Sim",
|
||||
"common_no": "Não",
|
||||
"common_on": "ativado",
|
||||
"common_off": "desativado",
|
||||
|
||||
"locale_switcher_label": "Idioma",
|
||||
|
||||
"books_empty_library": "Sua biblioteca está vazia.",
|
||||
"books_empty_discover": "Livros que você começar a ler ou salvar de",
|
||||
"books_empty_discover_link": "Descobrir",
|
||||
"books_empty_discover_suffix": "aparecerão aqui.",
|
||||
"books_count": "{n} livro{s}",
|
||||
|
||||
"catalogue_sort_updated": "Atualizado",
|
||||
"catalogue_search_button": "Pesquisar",
|
||||
"catalogue_refresh": "Atualizar",
|
||||
"catalogue_refreshing": "Na fila…",
|
||||
"catalogue_refresh_mobile": "Atualizar catálogo",
|
||||
"catalogue_all_loaded": "Todos os romances carregados",
|
||||
"catalogue_scroll_top": "Voltar ao topo",
|
||||
"catalogue_view_grid": "Visualização em grade",
|
||||
"catalogue_view_list": "Visualização em lista",
|
||||
"catalogue_browse_source": "Explorar romances do novelfire.net",
|
||||
"catalogue_search_results": "{n} resultado{s} para \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} local, {remote} do novelfire)",
|
||||
"catalogue_rank_ranked": "{n} romances classificados do último scrape do catálogo",
|
||||
"catalogue_rank_no_data": "Sem dados de classificação.",
|
||||
"catalogue_rank_no_data_body": "Sem dados de classificação — execute um scrape completo do catálogo para preencher",
|
||||
"catalogue_rank_run_scrape_admin": "Clique em Atualizar catálogo acima para acionar um scrape completo.",
|
||||
"catalogue_rank_run_scrape_user": "Peça a um administrador para executar um scrape do catálogo.",
|
||||
"catalogue_scrape_queued_flash": "Scrape completo do catálogo na fila. A biblioteca e a classificação serão atualizadas conforme os livros forem processados.",
|
||||
"catalogue_scrape_busy_flash": "Um job de scrape já está em execução. Volte quando terminar.",
|
||||
"catalogue_scrape_error_flash": "Falha ao enfileirar o scrape. Verifique se o serviço de scraper está acessível.",
|
||||
"catalogue_filters_label": "Filtros",
|
||||
"catalogue_apply": "Aplicar",
|
||||
"catalogue_filter_rank_note": "Filtros de gênero e status se aplicam apenas a Explorar",
|
||||
"catalogue_no_results_search": "Nenhum resultado encontrado.",
|
||||
"catalogue_no_results_try": "Tente um termo de pesquisa diferente.",
|
||||
"catalogue_no_results_filters": "Tente filtros diferentes ou volte mais tarde.",
|
||||
"catalogue_scrape_queued_badge": "Na fila",
|
||||
"catalogue_scrape_busy_badge": "Scraper ocupado",
|
||||
"catalogue_scrape_busy_list": "Ocupado",
|
||||
"catalogue_scrape_forbidden_badge": "Proibido",
|
||||
"catalogue_scrape_novel_button": "Extrair",
|
||||
"catalogue_scraping_novel": "Extraindo…",
|
||||
|
||||
"book_detail_not_in_library": "não está na biblioteca",
|
||||
"book_detail_continue_ch": "Continuar cap.{n}",
|
||||
"book_detail_start_ch1": "Começar pelo cap.1",
|
||||
"book_detail_preview_ch1": "Prévia do cap.1",
|
||||
"book_detail_reading_ch": "Lendo cap.{n} de {total}",
|
||||
"book_detail_n_chapters": "{n} capítulos",
|
||||
"book_detail_rescraping": "Na fila…",
|
||||
"book_detail_from_chapter": "A partir do capítulo",
|
||||
"book_detail_to_chapter": "Até o capítulo (opcional)",
|
||||
"book_detail_range_queuing": "Na fila…",
|
||||
"book_detail_scrape_range": "Intervalo de extração",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
|
||||
"book_detail_scraping_home": "← Início",
|
||||
"book_detail_rescrape_book": "Reextrair livro",
|
||||
"book_detail_less": "Menos",
|
||||
"book_detail_more": "Mais",
|
||||
|
||||
"chapters_search_placeholder": "Pesquisar capítulos…",
|
||||
"chapters_jump_to": "Ir para Cap.{n}",
|
||||
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
|
||||
"chapters_none_available": "Nenhum capítulo disponível ainda.",
|
||||
"chapters_reading_indicator": "lendo",
|
||||
"chapters_result_count": "{n} resultados",
|
||||
|
||||
"reader_fetching_chapter": "Buscando capítulo…",
|
||||
"reader_words": "{n} palavras",
|
||||
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
|
||||
|
||||
"profile_click_to_change": "Clique no avatar para mudar a foto",
|
||||
"profile_tts_voice": "Voz TTS",
|
||||
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
|
||||
"profile_saving": "Salvando…",
|
||||
"profile_saved": "Salvo!",
|
||||
"profile_session_this": "Esta sessão",
|
||||
"profile_session_signed_in": "Entrou em {date}",
|
||||
"profile_session_last_seen": "· Visto por último em {date}",
|
||||
"profile_session_sign_out": "Sair",
|
||||
"profile_session_end": "Encerrar",
|
||||
"profile_session_unrecognised": "Estes são todos os dispositivos conectados à sua conta. Encerre qualquer sessão que não reconhecer.",
|
||||
"profile_no_sessions": "Nenhum registro de sessão encontrado. As sessões são rastreadas a partir do próximo login.",
|
||||
"profile_change_password_heading": "Mudar senha",
|
||||
"profile_update_password": "Atualizar senha",
|
||||
"profile_updating": "Atualizando…",
|
||||
"profile_password_changed_ok": "Senha alterada com sucesso.",
|
||||
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
|
||||
|
||||
"user_currently_reading": "Lendo Agora",
|
||||
"user_library_count": "Biblioteca ({n})",
|
||||
"user_joined": "Entrou em {date}",
|
||||
"user_followers_label": "seguidores",
|
||||
"user_following_label": "seguindo",
|
||||
"user_no_books": "Nenhum livro na biblioteca ainda.",
|
||||
|
||||
"admin_pages_label": "Páginas",
|
||||
"admin_tools_label": "Ferramentas",
|
||||
|
||||
"admin_scrape_status_idle": "Ocioso",
|
||||
"admin_scrape_full_catalogue": "Catálogo completo",
|
||||
"admin_scrape_single_book": "Livro único",
|
||||
"admin_scrape_quick_genres": "Gêneros rápidos",
|
||||
"admin_scrape_task_history": "Histórico de tarefas",
|
||||
"admin_scrape_filter_placeholder": "Filtrar por tipo, status ou URL…",
|
||||
"admin_scrape_no_matching": "Nenhuma tarefa correspondente.",
|
||||
"admin_scrape_start": "Iniciar extração",
|
||||
"admin_scrape_queuing": "Na fila…",
|
||||
"admin_scrape_running": "Executando…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
|
||||
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
|
||||
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
|
||||
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
|
||||
"admin_audio_cache_empty": "Cache de áudio vazio.",
|
||||
"admin_audio_no_cache_results": "Sem resultados.",
|
||||
|
||||
"admin_changelog_gitea": "Releases do Gitea",
|
||||
"admin_changelog_no_releases": "Nenhum release encontrado.",
|
||||
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
|
||||
|
||||
"comments_top": "Mais votados",
|
||||
"comments_new": "Novos",
|
||||
"comments_posting": "Publicando…",
|
||||
"comments_login_link": "Entre",
|
||||
"comments_login_suffix": "para deixar um comentário.",
|
||||
"comments_anonymous": "Anônimo",
|
||||
|
||||
"reader_audio_narration": "Narração em Áudio",
|
||||
"reader_playing": "Reproduzindo — controles abaixo",
|
||||
"reader_paused": "Pausado — controles abaixo",
|
||||
"reader_ch_ready": "Cap.{n} pronto",
|
||||
"reader_ch_preparing": "Preparando Cap.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Cap.{n} será gerado ao navegar",
|
||||
"reader_now_playing": "Reproduzindo: {title}",
|
||||
"reader_load_this_chapter": "Carregar este capítulo",
|
||||
"reader_generate_samples": "Gerar amostras ausentes",
|
||||
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
|
||||
"reader_choose_voice": "Escolher Voz",
|
||||
"reader_generating_narration": "Gerando narração…"
|
||||
}
|
||||
383
ui/messages/ru.json
Normal file
383
ui/messages/ru.json
Normal file
@@ -0,0 +1,383 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Библиотека",
|
||||
"nav_catalogue": "Каталог",
|
||||
"nav_feedback": "Обратная связь",
|
||||
"nav_admin": "Админ",
|
||||
"nav_profile": "Профиль",
|
||||
"nav_sign_in": "Войти",
|
||||
"nav_sign_out": "Выйти",
|
||||
"nav_toggle_menu": "Меню",
|
||||
"nav_admin_panel": "Панель администратора",
|
||||
|
||||
"footer_library": "Библиотека",
|
||||
"footer_catalogue": "Каталог",
|
||||
"footer_feedback": "Обратная связь",
|
||||
"footer_disclaimer": "Отказ от ответственности",
|
||||
"footer_privacy": "Конфиденциальность",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Книги",
|
||||
"home_stat_chapters": "Главы",
|
||||
"home_stat_in_progress": "В процессе",
|
||||
"home_continue_reading": "Продолжить чтение",
|
||||
"home_view_all": "Смотреть все",
|
||||
"home_recently_updated": "Недавно обновлённые",
|
||||
"home_from_following": "От авторов, на которых вы подписаны",
|
||||
"home_empty_title": "Ваша библиотека пуста",
|
||||
"home_empty_body": "Откройте для себя новеллы и добавьте их в библиотеку.",
|
||||
"home_discover_novels": "Открыть новеллы",
|
||||
"home_via_reader": "от {username}",
|
||||
"home_chapter_badge": "гл.{n}",
|
||||
|
||||
"player_generating": "Генерация… {percent}%",
|
||||
"player_loading": "Загрузка…",
|
||||
"player_chapters": "Главы",
|
||||
"player_chapter_n": "Глава {n}",
|
||||
"player_toggle_chapter_list": "Список глав",
|
||||
"player_chapter_list_label": "Список глав",
|
||||
"player_close_chapter_list": "Закрыть список глав",
|
||||
"player_rewind_15": "Назад 15 секунд",
|
||||
"player_skip_30": "Вперёд 30 секунд",
|
||||
"player_back_15": "−15 сек",
|
||||
"player_forward_30": "+30 сек",
|
||||
"player_play": "Воспроизвести",
|
||||
"player_pause": "Пауза",
|
||||
"player_speed_label": "Скорость {speed}x",
|
||||
"player_change_speed": "Изменить скорость",
|
||||
"player_auto_next_on": "Автопереход вкл.",
|
||||
"player_auto_next_off": "Автопереход выкл.",
|
||||
"player_auto_next_ready": "Автопереход — гл.{n} готова",
|
||||
"player_auto_next_preparing": "Автопереход — подготовка гл.{n}…",
|
||||
"player_auto_next_aria": "Автопереход {state}",
|
||||
"player_go_to_chapter": "Перейти к главе",
|
||||
"player_close": "Закрыть плеер",
|
||||
|
||||
"login_page_title": "Вход — libnovel",
|
||||
"login_heading": "Войти в libnovel",
|
||||
"login_subheading": "Выберите провайдера для входа",
|
||||
"login_continue_google": "Продолжить с Google",
|
||||
"login_continue_github": "Продолжить с GitHub",
|
||||
"login_terms_notice": "Входя, вы принимаете наши условия использования.",
|
||||
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
|
||||
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
|
||||
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
|
||||
|
||||
"books_page_title": "Библиотека — libnovel",
|
||||
"books_heading": "Ваша библиотека",
|
||||
"books_empty_title": "Книг пока нет",
|
||||
"books_empty_body": "Добавляйте книги в библиотеку, посещая страницы книг.",
|
||||
"books_browse_catalogue": "Обзор каталога",
|
||||
"books_chapter_count": "{n} глав",
|
||||
"books_last_read": "Последнее: гл.{n}",
|
||||
"books_reading_progress": "Гл.{current} / {total}",
|
||||
"books_remove": "Удалить",
|
||||
|
||||
"catalogue_page_title": "Каталог — libnovel",
|
||||
"catalogue_heading": "Каталог",
|
||||
"catalogue_search_placeholder": "Поиск новелл…",
|
||||
"catalogue_filter_genre": "Жанр",
|
||||
"catalogue_filter_status": "Статус",
|
||||
"catalogue_filter_sort": "Сортировка",
|
||||
"catalogue_sort_popular": "Популярные",
|
||||
"catalogue_sort_new": "Новые",
|
||||
"catalogue_sort_top_rated": "Топ по рейтингу",
|
||||
"catalogue_sort_rank": "По рангу",
|
||||
"catalogue_status_all": "Все",
|
||||
"catalogue_status_ongoing": "Продолжаются",
|
||||
"catalogue_status_completed": "Завершены",
|
||||
"catalogue_genre_all": "Все жанры",
|
||||
"catalogue_clear_filters": "Сбросить",
|
||||
"catalogue_reset": "Сброс",
|
||||
"catalogue_no_results": "Новеллы не найдены.",
|
||||
"catalogue_loading": "Загрузка…",
|
||||
"catalogue_load_more": "Загрузить ещё",
|
||||
"catalogue_results_count": "{n} результатов",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_add_to_library": "В библиотеку",
|
||||
"book_detail_remove_from_library": "Удалить из библиотеки",
|
||||
"book_detail_read_now": "Читать",
|
||||
"book_detail_continue_reading": "Продолжить чтение",
|
||||
"book_detail_start_reading": "Начать чтение",
|
||||
"book_detail_chapters": "{n} глав",
|
||||
"book_detail_status": "Статус",
|
||||
"book_detail_author": "Автор",
|
||||
"book_detail_genres": "Жанры",
|
||||
"book_detail_description": "Описание",
|
||||
"book_detail_source": "Источник",
|
||||
"book_detail_rescrape": "Обновить",
|
||||
"book_detail_scraping": "Обновление…",
|
||||
"book_detail_in_library": "В библиотеке",
|
||||
|
||||
"chapters_page_title": "Главы — {title}",
|
||||
"chapters_heading": "Главы",
|
||||
"chapters_back_to_book": "К книге",
|
||||
"chapters_reading_now": "Читается",
|
||||
"chapters_empty": "Главы ещё не загружены.",
|
||||
|
||||
"reader_page_title": "{title} — Гл.{n} — libnovel",
|
||||
"reader_play_narration": "Воспроизвести озвучку",
|
||||
"reader_generating_audio": "Генерация аудио…",
|
||||
"reader_audio_error": "Ошибка генерации аудио.",
|
||||
"reader_prev_chapter": "Предыдущая глава",
|
||||
"reader_next_chapter": "Следующая глава",
|
||||
"reader_back_to_chapters": "К главам",
|
||||
"reader_chapter_n": "Глава {n}",
|
||||
"reader_change_voice": "Сменить голос",
|
||||
"reader_voice_panel_title": "Выбрать голос",
|
||||
"reader_voice_kokoro": "Голоса Kokoro",
|
||||
"reader_voice_pocket": "Голоса Pocket-TTS",
|
||||
"reader_voice_play_sample": "Прослушать образец",
|
||||
"reader_voice_stop_sample": "Остановить образец",
|
||||
"reader_voice_selected": "Выбран",
|
||||
"reader_close_voice_panel": "Закрыть панель голоса",
|
||||
"reader_auto_next": "Автопереход",
|
||||
"reader_speed": "Скорость",
|
||||
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
|
||||
|
||||
"profile_page_title": "Профиль — libnovel",
|
||||
"profile_heading": "Профиль",
|
||||
"profile_avatar_label": "Аватар",
|
||||
"profile_change_avatar": "Изменить аватар",
|
||||
"profile_username": "Имя пользователя",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Изменить пароль",
|
||||
"profile_current_password": "Текущий пароль",
|
||||
"profile_new_password": "Новый пароль",
|
||||
"profile_confirm_password": "Подтвердить пароль",
|
||||
"profile_save_password": "Сохранить пароль",
|
||||
"profile_appearance_heading": "Внешний вид",
|
||||
"profile_theme_label": "Тема",
|
||||
"profile_theme_amber": "Янтарь",
|
||||
"profile_theme_slate": "Сланец",
|
||||
"profile_theme_rose": "Роза",
|
||||
"profile_reading_heading": "Настройки чтения",
|
||||
"profile_voice_label": "Голос по умолчанию",
|
||||
"profile_speed_label": "Скорость воспроизведения",
|
||||
"profile_auto_next_label": "Автопереход к следующей главе",
|
||||
"profile_save_settings": "Сохранить настройки",
|
||||
"profile_settings_saved": "Настройки сохранены.",
|
||||
"profile_settings_error": "Не удалось сохранить настройки.",
|
||||
"profile_password_saved": "Пароль изменён.",
|
||||
"profile_password_error": "Не удалось изменить пароль.",
|
||||
"profile_sessions_heading": "Активные сессии",
|
||||
"profile_sign_out_all": "Выйти на всех других устройствах",
|
||||
"profile_joined": "Зарегистрирован {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Библиотека {username}",
|
||||
"user_follow": "Подписаться",
|
||||
"user_unfollow": "Отписаться",
|
||||
"user_followers": "{n} подписчиков",
|
||||
"user_following": "{n} подписок",
|
||||
"user_library_empty": "В библиотеке нет книг.",
|
||||
|
||||
"error_not_found_title": "Страница не найдена",
|
||||
"error_not_found_body": "Запрошенная страница не существует.",
|
||||
"error_generic_title": "Что-то пошло не так",
|
||||
"error_go_home": "На главную",
|
||||
"error_status": "Ошибка {status}",
|
||||
|
||||
"admin_scrape_page_title": "Парсинг — Админ",
|
||||
"admin_scrape_heading": "Парсинг",
|
||||
"admin_scrape_catalogue": "Парсинг каталога",
|
||||
"admin_scrape_book": "Парсинг книги",
|
||||
"admin_scrape_url_placeholder": "URL книги на novelfire.net",
|
||||
"admin_scrape_range": "Диапазон глав",
|
||||
"admin_scrape_from": "От",
|
||||
"admin_scrape_to": "До",
|
||||
"admin_scrape_submit": "Парсить",
|
||||
"admin_scrape_cancel": "Отмена",
|
||||
"admin_scrape_status_pending": "Ожидание",
|
||||
"admin_scrape_status_running": "Выполняется",
|
||||
"admin_scrape_status_done": "Готово",
|
||||
"admin_scrape_status_failed": "Ошибка",
|
||||
"admin_scrape_status_cancelled": "Отменено",
|
||||
"admin_tasks_heading": "Последние задачи",
|
||||
"admin_tasks_empty": "Задач пока нет.",
|
||||
|
||||
"admin_audio_page_title": "Аудио — Админ",
|
||||
"admin_audio_heading": "Аудио задачи",
|
||||
"admin_audio_empty": "Аудио задач нет.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Админ",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Комментарии",
|
||||
"comments_empty": "Комментариев пока нет. Будьте первым!",
|
||||
"comments_placeholder": "Написать комментарий…",
|
||||
"comments_submit": "Отправить",
|
||||
"comments_login_prompt": "Войдите, чтобы комментировать.",
|
||||
"comments_vote_up": "Плюс",
|
||||
"comments_vote_down": "Минус",
|
||||
"comments_delete": "Удалить",
|
||||
"comments_reply": "Ответить",
|
||||
"comments_show_replies": "Показать {n} ответов",
|
||||
"comments_hide_replies": "Скрыть ответы",
|
||||
"comments_edited": "изменено",
|
||||
"comments_deleted": "[удалено]",
|
||||
|
||||
"disclaimer_page_title": "Отказ от ответственности — libnovel",
|
||||
"privacy_page_title": "Политика конфиденциальности — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Условия использования — libnovel",
|
||||
|
||||
"common_loading": "Загрузка…",
|
||||
"common_error": "Ошибка",
|
||||
"common_save": "Сохранить",
|
||||
"common_cancel": "Отмена",
|
||||
"common_close": "Закрыть",
|
||||
"common_search": "Поиск",
|
||||
"common_back": "Назад",
|
||||
"common_next": "Далее",
|
||||
"common_previous": "Назад",
|
||||
"common_yes": "Да",
|
||||
"common_no": "Нет",
|
||||
"common_on": "вкл.",
|
||||
"common_off": "выкл.",
|
||||
|
||||
"locale_switcher_label": "Язык",
|
||||
|
||||
"books_empty_library": "Ваша библиотека пуста.",
|
||||
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
|
||||
"books_empty_discover_link": "Каталога",
|
||||
"books_empty_discover_suffix": "появятся здесь.",
|
||||
"books_count": "{n} книг{s}",
|
||||
|
||||
"catalogue_sort_updated": "По дате обновления",
|
||||
"catalogue_search_button": "Поиск",
|
||||
"catalogue_refresh": "Обновить",
|
||||
"catalogue_refreshing": "В очереди…",
|
||||
"catalogue_refresh_mobile": "Обновить каталог",
|
||||
"catalogue_all_loaded": "Все новеллы загружены",
|
||||
"catalogue_scroll_top": "Вверх",
|
||||
"catalogue_view_grid": "Сетка",
|
||||
"catalogue_view_list": "Список",
|
||||
"catalogue_browse_source": "Смотреть новеллы с novelfire.net",
|
||||
"catalogue_search_results": "{n} результат{s} по запросу «{q}»",
|
||||
"catalogue_search_local_count": "({local} локальных, {remote} с novelfire)",
|
||||
"catalogue_rank_ranked": "{n} новелл отсортированы по последнему парсингу каталога",
|
||||
"catalogue_rank_no_data": "Нет данных рейтинга.",
|
||||
"catalogue_rank_no_data_body": "Нет данных рейтинга — запустите полный парсинг каталога для заполнения",
|
||||
"catalogue_rank_run_scrape_admin": "Нажмите «Обновить каталог» выше, чтобы запустить полный парсинг.",
|
||||
"catalogue_rank_run_scrape_user": "Попросите администратора запустить парсинг каталога.",
|
||||
"catalogue_scrape_queued_flash": "Полный парсинг каталога поставлен в очередь. Библиотека и рейтинг обновятся по мере обработки.",
|
||||
"catalogue_scrape_busy_flash": "Парсинг уже запущен. Проверьте позже.",
|
||||
"catalogue_scrape_error_flash": "Не удалось поставить парсинг в очередь. Проверьте доступность сервиса.",
|
||||
"catalogue_filters_label": "Фильтры",
|
||||
"catalogue_apply": "Применить",
|
||||
"catalogue_filter_rank_note": "Фильтры по жанру и статусу применяются только к разделу «Обзор»",
|
||||
"catalogue_no_results_search": "Ничего не найдено.",
|
||||
"catalogue_no_results_try": "Попробуйте другой запрос.",
|
||||
"catalogue_no_results_filters": "Попробуйте другие фильтры или проверьте позже.",
|
||||
"catalogue_scrape_queued_badge": "В очереди",
|
||||
"catalogue_scrape_busy_badge": "Парсер занят",
|
||||
"catalogue_scrape_busy_list": "Занят",
|
||||
"catalogue_scrape_forbidden_badge": "Запрещено",
|
||||
"catalogue_scrape_novel_button": "Парсить",
|
||||
"catalogue_scraping_novel": "Парсинг…",
|
||||
|
||||
"book_detail_not_in_library": "не в библиотеке",
|
||||
"book_detail_continue_ch": "Продолжить гл.{n}",
|
||||
"book_detail_start_ch1": "Начать с гл.1",
|
||||
"book_detail_preview_ch1": "Предпросмотр гл.1",
|
||||
"book_detail_reading_ch": "Читается гл.{n} из {total}",
|
||||
"book_detail_n_chapters": "{n} глав",
|
||||
"book_detail_rescraping": "В очереди…",
|
||||
"book_detail_from_chapter": "С главы",
|
||||
"book_detail_to_chapter": "До главы (необязательно)",
|
||||
"book_detail_range_queuing": "В очереди…",
|
||||
"book_detail_scrape_range": "Диапазон глав",
|
||||
"book_detail_admin": "Администрирование",
|
||||
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
|
||||
"book_detail_scraping_home": "← На главную",
|
||||
"book_detail_rescrape_book": "Перепарсить книгу",
|
||||
"book_detail_less": "Скрыть",
|
||||
"book_detail_more": "Ещё",
|
||||
|
||||
"chapters_search_placeholder": "Поиск глав…",
|
||||
"chapters_jump_to": "Перейти к гл.{n}",
|
||||
"chapters_no_match": "Главы по запросу «{q}» не найдены",
|
||||
"chapters_none_available": "Глав пока нет.",
|
||||
"chapters_reading_indicator": "читается",
|
||||
"chapters_result_count": "{n} результатов",
|
||||
|
||||
"reader_fetching_chapter": "Загрузка главы…",
|
||||
"reader_words": "{n} слов",
|
||||
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
|
||||
|
||||
"profile_click_to_change": "Нажмите на аватар для смены фото",
|
||||
"profile_tts_voice": "Голос TTS",
|
||||
"profile_auto_advance": "Автопереход к следующей главе",
|
||||
"profile_saving": "Сохранение…",
|
||||
"profile_saved": "Сохранено!",
|
||||
"profile_session_this": "Текущая сессия",
|
||||
"profile_session_signed_in": "Вход {date}",
|
||||
"profile_session_last_seen": "· Последний визит {date}",
|
||||
"profile_session_sign_out": "Выйти",
|
||||
"profile_session_end": "Завершить",
|
||||
"profile_session_unrecognised": "Это все устройства, авторизованные в вашем аккаунте. Завершите любую сессию, которую не узнаёте.",
|
||||
"profile_no_sessions": "Записей сессий нет. Отслеживание начнётся со следующего входа.",
|
||||
"profile_change_password_heading": "Изменить пароль",
|
||||
"profile_update_password": "Обновить пароль",
|
||||
"profile_updating": "Обновление…",
|
||||
"profile_password_changed_ok": "Пароль успешно изменён.",
|
||||
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
|
||||
|
||||
"user_currently_reading": "Сейчас читает",
|
||||
"user_library_count": "Библиотека ({n})",
|
||||
"user_joined": "Зарегистрирован {date}",
|
||||
"user_followers_label": "подписчиков",
|
||||
"user_following_label": "подписок",
|
||||
"user_no_books": "Книг в библиотеке пока нет.",
|
||||
|
||||
"admin_pages_label": "Страницы",
|
||||
"admin_tools_label": "Инструменты",
|
||||
|
||||
"admin_scrape_status_idle": "Ожидание",
|
||||
"admin_scrape_full_catalogue": "Полный каталог",
|
||||
"admin_scrape_single_book": "Одна книга",
|
||||
"admin_scrape_quick_genres": "Быстрые жанры",
|
||||
"admin_scrape_task_history": "История задач",
|
||||
"admin_scrape_filter_placeholder": "Фильтр по типу, статусу или URL…",
|
||||
"admin_scrape_no_matching": "Задач не найдено.",
|
||||
"admin_scrape_start": "Начать парсинг",
|
||||
"admin_scrape_queuing": "В очереди…",
|
||||
"admin_scrape_running": "Выполняется…",
|
||||
|
||||
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
|
||||
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
|
||||
"admin_audio_no_matching_jobs": "Заданий не найдено.",
|
||||
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
|
||||
"admin_audio_cache_empty": "Аудиокэш пуст.",
|
||||
"admin_audio_no_cache_results": "Результатов нет.",
|
||||
|
||||
"admin_changelog_gitea": "Релизы Gitea",
|
||||
"admin_changelog_no_releases": "Релизов не найдено.",
|
||||
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
|
||||
|
||||
"comments_top": "Лучшие",
|
||||
"comments_new": "Новые",
|
||||
"comments_posting": "Отправка…",
|
||||
"comments_login_link": "Войдите",
|
||||
"comments_login_suffix": "чтобы оставить комментарий.",
|
||||
"comments_anonymous": "Аноним",
|
||||
|
||||
"reader_audio_narration": "Аудионарратив",
|
||||
"reader_playing": "Воспроизводится — управление ниже",
|
||||
"reader_paused": "Пауза — управление ниже",
|
||||
"reader_ch_ready": "Гл.{n} готова",
|
||||
"reader_ch_preparing": "Подготовка гл.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Гл.{n} сгенерируется при переходе",
|
||||
"reader_now_playing": "Сейчас играет: {title}",
|
||||
"reader_load_this_chapter": "Загрузить эту главу",
|
||||
"reader_generate_samples": "Сгенерировать недостающие образцы",
|
||||
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
|
||||
"reader_choose_voice": "Выбрать голос",
|
||||
"reader_generating_narration": "Генерация озвучки…"
|
||||
}
|
||||
222
ui/package-lock.json
generated
222
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
@@ -1719,6 +1720,49 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/paraglide-js": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.15.1.tgz",
|
||||
"integrity": "sha512-7wWKbLWwLx1dkkYz55TnVp+39atKXf7rnlHnL8adSmM73UaAdB9fXDzo24GHSY/6FPGFKSkgHdT2qyJv2whWsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inlang/recommend-sherlock": "^0.2.1",
|
||||
"@inlang/sdk": "^2.9.1",
|
||||
"commander": "11.1.0",
|
||||
"consola": "3.4.0",
|
||||
"json5": "2.2.3",
|
||||
"unplugin": "^2.1.2",
|
||||
"urlpattern-polyfill": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"paraglide-js": "bin/run.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/recommend-sherlock": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
|
||||
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"comment-json": "^4.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/sdk": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.1.tgz",
|
||||
"integrity": "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lix-js/sdk": "0.4.9",
|
||||
"@sinclair/typebox": "^0.31.17",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
@@ -1780,6 +1824,43 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/sdk": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.9.tgz",
|
||||
"integrity": "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lix-js/server-protocol-schema": "0.1.1",
|
||||
"dedent": "1.5.1",
|
||||
"human-id": "^4.1.1",
|
||||
"js-sha256": "^0.11.0",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/sdk/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/server-protocol-schema": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
|
||||
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -4135,6 +4216,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.31.28",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
|
||||
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@smithy/abort-controller": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz",
|
||||
@@ -4867,6 +4954,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sqlite.org/sqlite-wasm": {
|
||||
"version": "3.48.0-build4",
|
||||
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
|
||||
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"sqlite-wasm": "bin/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -5405,6 +5501,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/array-timsort": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
|
||||
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -5590,6 +5692,28 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/comment-json": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
|
||||
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-timsort": "^1.0.3",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
@@ -5597,6 +5721,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
|
||||
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -5635,6 +5768,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"babel-plugin-macros": "^3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -5966,6 +6113,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-id": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz",
|
||||
"integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"human-id": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz",
|
||||
@@ -6062,6 +6218,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha256": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
|
||||
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6101,6 +6263,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kysely": {
|
||||
"version": "0.28.14",
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz",
|
||||
"integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -6988,6 +7159,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlite-wasm-kysely": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
|
||||
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
|
||||
"dependencies": {
|
||||
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"kysely": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
@@ -7201,6 +7383,21 @@
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -7231,6 +7428,25 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/urlpattern-polyfill": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
|
||||
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -7330,6 +7546,12 @@
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
|
||||
11
ui/project.inlang/settings.json
Normal file
11
ui/project.inlang/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "ru", "id", "pt-BR", "fr"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%" dir="%dir%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { handleErrorWithSentry } from '@sentry/sveltekit';
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { randomBytes, createHmac } from 'node:crypto';
|
||||
@@ -13,6 +14,7 @@ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
|
||||
// ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
|
||||
// No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev).
|
||||
@@ -138,7 +140,21 @@ export function parseAuthToken(token: string): { id: string; username: string; r
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
function getTextDirection(locale: string): string {
|
||||
// All supported locales (en, ru, id, pt-BR, fr) are LTR
|
||||
return 'ltr';
|
||||
}
|
||||
|
||||
const paraglideHandle: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
|
||||
event.request = localizedRequest;
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale))
|
||||
});
|
||||
});
|
||||
|
||||
const appHandle: Handle = async ({ event, resolve }) => {
|
||||
// During graceful shutdown, reject new requests immediately so the load
|
||||
// balancer / Docker health-check can drain existing connections.
|
||||
if (shuttingDown) {
|
||||
@@ -197,3 +213,5 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle = sequence(paraglideHandle, appHandle);
|
||||
|
||||
|
||||
4
ui/src/hooks.ts
Normal file
4
ui/src/hooks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Reroute } from '@sveltejs/kit';
|
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
|
||||
export const reroute: Reroute = ({ url }) => deLocalizeUrl(url).pathname;
|
||||
@@ -52,6 +52,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -702,7 +703,7 @@
|
||||
size="icon"
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
||||
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||
title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
|
||||
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
|
||||
>
|
||||
{#if samplePlayingVoice === v.id}
|
||||
@@ -724,7 +725,7 @@
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-(--color-text) font-medium">Audio Narration</span>
|
||||
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
|
||||
</div>
|
||||
|
||||
<!-- Voice selector button -->
|
||||
@@ -734,7 +735,7 @@
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title="Change voice"
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
@@ -751,13 +752,13 @@
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Choose Voice</span>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
aria-label="Close voice selector"
|
||||
aria-label={m.reader_close_voice_panel()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
@@ -787,7 +788,7 @@
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
New voice applies on next "Play narration".
|
||||
{m.reader_voice_applies_next()}
|
||||
{#if voices.length > 0}
|
||||
<a
|
||||
href="/api/audio/voice-samples"
|
||||
@@ -796,7 +797,7 @@
|
||||
e.preventDefault();
|
||||
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||
}}
|
||||
>Generate missing samples</a>
|
||||
>{m.reader_generate_samples()}</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -810,12 +811,12 @@
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<Button variant="default" size="sm" disabled>
|
||||
@@ -823,12 +824,12 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading…
|
||||
{m.player_loading()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-(--color-muted)">Generating narration…</p>
|
||||
<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
|
||||
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-(--color-brand) rounded-full transition-none"
|
||||
@@ -842,17 +843,17 @@
|
||||
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>Playing — controls below</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>Paused — controls below</span>
|
||||
{/if}
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>{m.reader_playing()}</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>{m.reader_paused()}</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
@@ -864,39 +865,39 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
</Button>
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
|
||||
<div class="mt-2">
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>Preparing Ch.{nextChapter}… {Math.round(audioStore.nextProgress)}%</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
Ch.{nextChapter} ready
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-(--color-muted) opacity-60">Ch.{nextChapter} will generate on navigate</p>
|
||||
{/if}
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{m.reader_ch_ready({ n: String(nextChapter) })}
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -905,10 +906,10 @@
|
||||
<!-- ── A different chapter is currently playing ── -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
||||
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
Load this chapter
|
||||
{m.reader_load_this_chapter()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -918,7 +919,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { BookComment } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false,
|
||||
@@ -244,7 +245,7 @@
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-(--color-text)">
|
||||
Comments
|
||||
{m.comments_heading()}
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
@@ -258,13 +259,13 @@
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => (sort = 'top')}
|
||||
>Top</Button>
|
||||
>{m.comments_top()}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => (sort = 'new')}
|
||||
>New</Button>
|
||||
>{m.comments_new()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -275,7 +276,7 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<Textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
placeholder={m.comments_placeholder()}
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -292,15 +293,15 @@
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
onclick={postComment}
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
{posting ? m.comments_posting() : m.comments_submit()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
|
||||
{m.comments_login_suffix()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -319,7 +320,7 @@
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-(--color-danger)">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-(--color-muted)">No comments yet. Be the first!</p>
|
||||
<p class="text-sm text-(--color-muted)">{m.comments_empty()}</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
@@ -341,7 +342,7 @@
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-(--color-muted)">Anonymous</span>
|
||||
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
|
||||
@@ -359,7 +360,7 @@
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
title="Upvote"
|
||||
title={m.comments_vote_up()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
@@ -374,7 +375,7 @@
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
title="Downvote"
|
||||
title={m.comments_vote_down()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
@@ -403,7 +404,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
{m.comments_reply()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -420,7 +421,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
{m.comments_delete()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -430,7 +431,7 @@
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
|
||||
<Textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
placeholder={m.comments_placeholder()}
|
||||
rows={2}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@@ -446,14 +447,14 @@
|
||||
size="sm"
|
||||
class="text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
>Cancel</Button>
|
||||
>{m.common_cancel()}</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
onclick={() => postReply(comment.id)}
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
{replyPosting ? m.comments_posting() : m.comments_reply()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,7 +483,7 @@
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-(--color-muted)">Anonymous</span>
|
||||
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
|
||||
@@ -493,28 +494,28 @@
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title="Upvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title={m.comments_vote_up()}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
title="Downvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
title={m.comments_vote_down()}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
@@ -526,15 +527,15 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</Button>
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
{m.comments_delete()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const status = $derived(page.status);
|
||||
const message = $derived(page.error?.message ?? 'Something went wrong.');
|
||||
|
||||
const title = $derived(
|
||||
status === 404
|
||||
? 'Page not found'
|
||||
: status === 403
|
||||
? 'Access denied'
|
||||
: status === 429
|
||||
? 'Too many requests'
|
||||
: status >= 500
|
||||
? 'Server error'
|
||||
: 'Error'
|
||||
? m.error_not_found_title()
|
||||
: m.error_generic_title()
|
||||
);
|
||||
|
||||
const description = $derived(
|
||||
status === 404
|
||||
? "The page you're looking for doesn't exist or has been moved."
|
||||
: status === 403
|
||||
? "You don't have permission to access this page."
|
||||
: status === 429
|
||||
? 'You are sending too many requests. Please slow down and try again shortly.'
|
||||
: status >= 500
|
||||
? 'An unexpected error occurred on our end. Try refreshing, or come back in a moment.'
|
||||
: message
|
||||
? m.error_not_found_body()
|
||||
: page.error?.message ?? m.error_generic_title()
|
||||
);
|
||||
|
||||
const code = $derived(String(status));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} — {title} · libnovel</title>
|
||||
<title>{m.error_status({ status: code })} · libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
|
||||
@@ -56,13 +44,13 @@
|
||||
href="/"
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Go home
|
||||
{m.error_go_home()}
|
||||
</a>
|
||||
<button
|
||||
onclick={() => history.back()}
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-surface-2) border border-(--color-border) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
Go back
|
||||
{m.common_back()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.js';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -260,16 +262,16 @@
|
||||
{#if data.user}
|
||||
<!-- Desktop nav links (hidden on mobile) -->
|
||||
<a
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
@@ -277,7 +279,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
@@ -287,7 +289,7 @@
|
||||
href="/admin/scrape"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Admin
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
@@ -298,7 +300,7 @@
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="hidden sm:block">
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-(--color-muted) hover:text-(--color-text)">
|
||||
Sign out
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -307,7 +309,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={m.nav_toggle_menu()}
|
||||
aria-expanded={menuOpen}
|
||||
class="sm:hidden -mr-1"
|
||||
>
|
||||
@@ -330,7 +332,7 @@
|
||||
href="/login"
|
||||
class="text-sm px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Sign in
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,14 +346,14 @@
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
@@ -360,24 +362,24 @@
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback ↗
|
||||
{m.nav_feedback()} ↗
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Profile <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
|
||||
{m.nav_profile()} <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">Admin</p>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">{m.nav_admin()}</p>
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Admin panel
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
@@ -387,7 +389,7 @@
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-(--color-danger) hover:bg-(--color-surface-2) hover:text-(--color-danger)"
|
||||
>
|
||||
Sign out
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -404,15 +406,15 @@
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
|
||||
<!-- Top row: site links -->
|
||||
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
<a href="/books" class="hover:text-(--color-text) transition-colors">Library</a>
|
||||
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">Catalogue</a>
|
||||
<a href="/books" class="hover:text-(--color-text) transition-colors">{m.footer_library()}</a>
|
||||
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">{m.footer_catalogue()}</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
Feedback
|
||||
{m.footer_feedback()}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
@@ -430,13 +432,29 @@
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Locale switcher (always visible) -->
|
||||
<div class="hidden sm:flex items-center gap-1 ml-2">
|
||||
{#each locales as locale}
|
||||
<a
|
||||
href={localizeHref(page.url.pathname, { locale })}
|
||||
data-sveltekit-reload
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale
|
||||
? 'text-(--color-brand) bg-(--color-brand)/10'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
aria-label="{m.locale_switcher_label()}: {locale}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<!-- Bottom row: legal links + copyright -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
|
||||
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">Disclaimer</a>
|
||||
<a href="/privacy" class="hover:text-(--color-text) transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-(--color-text) transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">{m.footer_disclaimer()}</a>
|
||||
<a href="/privacy" class="hover:text-(--color-text) transition-colors">{m.footer_privacy()}</a>
|
||||
<a href="/dmca" class="hover:text-(--color-text) transition-colors">{m.footer_dmca()}</a>
|
||||
<span>{m.footer_copyright({ year: String(new Date().getFullYear()) })}</span>
|
||||
</div>
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
@@ -473,12 +491,12 @@
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</span>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label="Close chapter list"
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -497,7 +515,7 @@
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
@@ -538,8 +556,8 @@
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
|
||||
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
{#if audioStore.chapterTitle}
|
||||
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
|
||||
@@ -549,14 +567,14 @@
|
||||
{/if}
|
||||
{#if audioStore.status === 'generating'}
|
||||
<p class="text-xs text-(--color-brand) leading-tight">
|
||||
Generating… {Math.round(audioStore.progress)}%
|
||||
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-(--color-muted) leading-tight">Loading…</p>
|
||||
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -566,8 +584,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipBack}
|
||||
title="Back 15s"
|
||||
aria-label="Rewind 15 seconds"
|
||||
title={m.player_back_15()}
|
||||
aria-label={m.player_rewind_15()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
@@ -579,7 +597,7 @@
|
||||
<button
|
||||
onclick={togglePlay}
|
||||
class="w-10 h-10 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={audioStore.isPlaying ? m.player_pause() : m.player_play()}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -597,8 +615,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipForward}
|
||||
title="Forward 30s"
|
||||
aria-label="Skip 30 seconds"
|
||||
title={m.player_forward_30()}
|
||||
aria-label={m.player_skip_30()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
@@ -610,8 +628,8 @@
|
||||
<button
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title="Change playback speed"
|
||||
aria-label="Playback speed {audioStore.speed}x"
|
||||
title={m.player_change_speed()}
|
||||
aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
@@ -627,12 +645,12 @@
|
||||
)}
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||
? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||
: 'Auto-next on'
|
||||
: 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
|
||||
: m.player_auto_next_on()
|
||||
: m.player_auto_next_off()}
|
||||
aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
@@ -659,8 +677,8 @@
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
|
||||
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
|
||||
title="Go to chapter"
|
||||
aria-label="Go to chapter"
|
||||
title={m.player_go_to_chapter()}
|
||||
aria-label={m.player_go_to_chapter()}
|
||||
>
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
@@ -684,8 +702,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={dismiss}
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
title={m.player_close()}
|
||||
aria-label={m.player_close()}
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,22 +17,22 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>libnovel</title>
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Books</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Chapters</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">In progress</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +40,8 @@
|
||||
{#if data.continueReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Continue Reading</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.continueReading as { book, chapter }}
|
||||
@@ -66,7 +67,7 @@
|
||||
{/if}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
@@ -85,8 +86,8 @@
|
||||
{#if data.recentlyUpdated.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Recently Updated</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.recentlyUpdated as book}
|
||||
@@ -137,13 +138,13 @@
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">Your library is empty</p>
|
||||
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Discover Novels
|
||||
{m.home_discover_novels()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -152,7 +153,7 @@
|
||||
{#if data.subscriptionFeed.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">From People You Follow</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
@@ -185,7 +186,7 @@
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">
|
||||
via <span class="text-amber-500/70">{readerUsername}</span>
|
||||
{m.home_via_reader({ username: readerUsername })}
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
@@ -27,7 +28,7 @@
|
||||
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
|
||||
<!-- Internal pages -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Pages</p>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
<a
|
||||
@@ -45,7 +46,7 @@
|
||||
|
||||
<!-- External tools -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Tools</p>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each externalLinks as link}
|
||||
<a
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -94,13 +95,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio — libnovel admin</title>
|
||||
<title>{m.admin_audio_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Audio</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_audio_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
@@ -142,13 +143,13 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, voice or status…"
|
||||
placeholder={m.admin_audio_filter_jobs()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
|
||||
{jobsQ.trim() ? m.admin_audio_no_matching_jobs() : m.admin_audio_no_jobs()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
@@ -218,13 +219,13 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={cacheQ}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
placeholder={m.admin_audio_filter_cache()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredCache.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
{cacheQ.trim() ? m.admin_audio_no_cache_results() : m.admin_audio_cache_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -11,19 +12,19 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Changelog — libnovel admin</title>
|
||||
<title>{m.admin_changelog_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Changelog</h1>
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_changelog_heading()}</h1>
|
||||
<a
|
||||
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
Gitea releases
|
||||
{m.admin_changelog_gitea()}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
@@ -32,9 +33,9 @@
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-(--color-danger)">Could not load releases: {data.error}</p>
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">No releases found.</p>
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { ScrapingTask } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -228,15 +229,15 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Scrape tasks — libnovel admin</title>
|
||||
<title>{m.admin_scrape_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Scrape</h1>
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
|
||||
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
|
||||
● {running ? 'Running' : 'Idle'}
|
||||
● {running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -244,20 +245,20 @@
|
||||
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<!-- Full catalogue -->
|
||||
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Full catalogue</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
|
||||
<button
|
||||
onclick={triggerCatalogueScrape}
|
||||
disabled={running || cataloguing}
|
||||
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
|
||||
{cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
|
||||
</button>
|
||||
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Single book -->
|
||||
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Single book</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
@@ -269,14 +270,14 @@
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
{scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
|
||||
</button>
|
||||
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Chapter range</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={rangeUrl}
|
||||
@@ -302,14 +303,14 @@
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
{ranging ? m.admin_scrape_queuing() : 'Go'}
|
||||
</button>
|
||||
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick genre chips -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Quick genres</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
@@ -334,18 +335,18 @@
|
||||
<!-- Tasks table -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">Task history</h2>
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by kind, status or URL…"
|
||||
placeholder={m.admin_scrape_filter_placeholder()}
|
||||
class="w-full max-w-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
|
||||
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
@@ -393,7 +394,7 @@
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
@@ -461,7 +462,7 @@
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,23 +17,23 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — libnovel</title>
|
||||
<title>{m.books_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Library</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
|
||||
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.books?.length}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">Your library is empty.</p>
|
||||
<p class="text-lg">{m.books_empty_library()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
Books you start reading or save from
|
||||
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Discover</a>
|
||||
will appear here.
|
||||
{m.books_empty_discover()}
|
||||
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.books_empty_discover_link()}</a>
|
||||
{m.books_empty_discover_suffix()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -38,8 +39,6 @@
|
||||
}
|
||||
|
||||
const genres = $derived(parseGenres(data.book?.genres ?? []));
|
||||
|
||||
// Use chapters from loaded data (both library and preview paths return chapters now)
|
||||
const chapterList = $derived(data.chapters ?? []);
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
@@ -102,9 +101,6 @@
|
||||
let adminOpen = $state(false);
|
||||
|
||||
// ── Auto-poll when scrape task is in flight ───────────────────────────────
|
||||
// When the backend enqueues a scrape for an unseen book, the page shows a
|
||||
// spinner. Poll every 3 s until the task reaches "done" or "failed", then
|
||||
// reload the full page data so chapters appear automatically.
|
||||
$effect(() => {
|
||||
if (!data.scraping || !data.taskId) return;
|
||||
|
||||
@@ -130,7 +126,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.scraping ? 'Scraping…' : data.book?.title ?? 'Book'} — libnovel</title>
|
||||
<title>{data.scraping ? m.book_detail_scraping() : (data.book?.title ?? 'Book')} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.scraping}
|
||||
@@ -141,15 +137,15 @@
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-(--color-text) font-semibold text-lg">Scraping in progress…</p>
|
||||
<p class="text-(--color-text) font-semibold text-lg">{m.book_detail_scraping()}</p>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
Fetching the first 20 chapters. This page will refresh automatically.
|
||||
{m.book_detail_scraping_progress()}
|
||||
</p>
|
||||
{#if data.taskId}
|
||||
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">← Home</a>
|
||||
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.book_detail_scraping_home()}</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
@@ -189,7 +185,7 @@
|
||||
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) shrink-0"
|
||||
title="This book was fetched live from the source and is not yet in your library"
|
||||
>
|
||||
not in library
|
||||
{m.book_detail_not_in_library()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -220,20 +216,20 @@
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
|
||||
>
|
||||
{summaryExpanded ? 'Less' : 'More'}
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
|
||||
<!-- CTA buttons — desktop only -->
|
||||
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
||||
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -244,14 +240,14 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
@@ -277,14 +273,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
|
||||
<!-- CTA buttons — mobile only -->
|
||||
<div class="flex sm:hidden gap-2 items-center">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
||||
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -295,14 +291,14 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
|
||||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
@@ -330,7 +326,6 @@
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
||||
<!-- Chapters row: links to the full chapter list page -->
|
||||
<a
|
||||
href="/books/{book.slug}/chapters"
|
||||
class="flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-surface-2)/60 transition-colors group"
|
||||
@@ -339,13 +334,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-(--color-text)">Chapters</span>
|
||||
<span class="text-sm font-semibold text-(--color-text)">{m.chapters_heading()}</span>
|
||||
{#if chapterList.length > 0}
|
||||
<span class="text-xs text-(--color-muted)">
|
||||
{#if data.lastChapter && data.lastChapter > 0}
|
||||
Reading ch.{data.lastChapter} of {chapterList.length}
|
||||
{m.book_detail_reading_ch({ n: String(data.lastChapter), total: String(chapterList.length) })}
|
||||
{:else}
|
||||
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
|
||||
{m.book_detail_n_chapters({ n: String(chapterList.length) })}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -366,7 +361,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Admin
|
||||
{m.book_detail_admin()}
|
||||
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
@@ -387,17 +382,17 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Queuing…
|
||||
{m.book_detail_rescraping()}
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Rescrape book
|
||||
{m.book_detail_rescrape_book()}
|
||||
{/if}
|
||||
</button>
|
||||
{#if scrapeResult}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
|
||||
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -405,7 +400,7 @@
|
||||
<!-- Range scrape -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">From chapter</label>
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
@@ -416,7 +411,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">To chapter (optional)</label>
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<input
|
||||
id="range-to"
|
||||
type="number"
|
||||
@@ -434,11 +429,11 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
|
||||
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{rangeScraping ? 'Queuing…' : 'Scrape range'}
|
||||
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
|
||||
</button>
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
|
||||
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — Chapters — libnovel</title>
|
||||
<title>{m.chapters_page_title({ title: data.book.title })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
|
||||
@@ -69,7 +70,7 @@
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
{m.common_back()}
|
||||
</a>
|
||||
<span class="text-(--color-border)">/</span>
|
||||
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
||||
@@ -82,7 +83,7 @@
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
placeholder={m.chapters_search_placeholder()}
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder-zinc-500 text-sm focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
@@ -126,21 +127,21 @@
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
Jump to Ch.{data.lastChapter}
|
||||
{m.chapters_jump_to({ n: String(data.lastChapter) })}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
{#if visibleChapters.length === 0}
|
||||
{#if searchQuery}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">{m.chapters_no_match({ q: searchQuery })}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-sm">No chapters available yet.</p>
|
||||
<p class="text-(--color-muted) text-sm">{m.chapters_none_available()}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Result count while searching -->
|
||||
{#if searchQuery}
|
||||
<p class="text-xs text-(--color-muted) mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
|
||||
<p class="text-xs text-(--color-muted) mb-2">{m.chapters_result_count({ n: String(visibleChapters.length) })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -165,7 +166,7 @@
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||
>
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
@@ -177,7 +178,7 @@
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">reading</span>
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -9,15 +10,6 @@
|
||||
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
|
||||
let fetchError = $state('');
|
||||
|
||||
// ── Word count ────────────────────────────────────────────────────────────
|
||||
function countWords(htmlStr: string | null): number {
|
||||
if (!htmlStr) return 0;
|
||||
// Strip HTML tags, collapse whitespace, split on whitespace
|
||||
return htmlStr.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
const wordCount = $derived(countWords(html));
|
||||
|
||||
onMount(async () => {
|
||||
// Umami analytics: track chapter reads
|
||||
window.umami?.track('chapter_read', {
|
||||
@@ -50,31 +42,35 @@
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
fetchError = m.reader_audio_error();
|
||||
}
|
||||
} catch (e) {
|
||||
fetchError = 'Could not fetch chapter content.';
|
||||
fetchError = m.reader_fetching_chapter();
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const wordCount = $derived(
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Chapters
|
||||
{m.reader_back_to_chapters()}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@@ -83,7 +79,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
← Ch.{data.prev}
|
||||
← {m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
@@ -91,7 +87,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Ch.{data.next} →
|
||||
{m.reader_chapter_n({ n: String(data.next) })} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -100,10 +96,10 @@
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-bold text-(--color-text)">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
{#if wordCount > 0}
|
||||
<p class="text-(--color-muted) text-xs mt-1">{wordCount.toLocaleString()} words</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -112,7 +108,7 @@
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
@@ -121,7 +117,7 @@
|
||||
/>
|
||||
{:else}
|
||||
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
||||
Preview chapter — audio not available for books outside the library.
|
||||
{m.reader_preview_audio_notice()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -132,11 +128,11 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Fetching chapter…
|
||||
{m.reader_fetching_chapter()}
|
||||
</div>
|
||||
{:else if !html}
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || 'Chapter content not available.'}</p>
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
@@ -151,7 +147,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
← Previous chapter
|
||||
← {m.reader_prev_chapter()}
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
@@ -161,7 +157,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Next chapter →
|
||||
{m.reader_next_chapter()} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,15 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { NovelListing } from './+page.server';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Local filter state (mirrors URL params) ──────────────────────────────
|
||||
// These are separate from data.* so we can bind them to selects and keep
|
||||
// the DOM in sync. They sync back from data whenever a navigation completes.
|
||||
let filterSort = $state(untrack(() => data.sort));
|
||||
let filterGenre = $state(untrack(() => data.genre));
|
||||
let filterStatus = $state(untrack(() => data.status));
|
||||
|
||||
// Keep local state in sync whenever SvelteKit re-runs the load (URL changed).
|
||||
$effect(() => {
|
||||
filterSort = data.sort;
|
||||
filterGenre = data.genre;
|
||||
@@ -31,10 +29,8 @@
|
||||
goto(`/catalogue?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Track which novel card is currently being navigated to
|
||||
let loadingSlug = $state<string | null>(null);
|
||||
|
||||
// Clear loading state when navigation ends (success or failure)
|
||||
$effect(() => {
|
||||
if (!navigating) loadingSlug = null;
|
||||
});
|
||||
@@ -44,15 +40,11 @@
|
||||
}
|
||||
|
||||
// ── Infinite scroll state ────────────────────────────────────────────────
|
||||
// novels is the accumulated list across all fetched pages.
|
||||
// Seeded from SSR page 1; new pages are appended client-side.
|
||||
let novels = $state<NovelListing[]>(untrack(() => data.novels));
|
||||
let currentPage = $state(untrack(() => data.page));
|
||||
let hasNext = $state(untrack(() => data.hasNext));
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// A key derived from the active filters — when it changes, reset the list
|
||||
// to the fresh SSR data (SvelteKit already re-ran the server load).
|
||||
let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
|
||||
let lastFilterKey = '';
|
||||
$effect(() => {
|
||||
@@ -66,7 +58,6 @@
|
||||
|
||||
async function loadNextPage() {
|
||||
if (loadingMore || !hasNext) return;
|
||||
// Infinite scroll only applies in browse mode (not rank, not search)
|
||||
if (data.sort === 'rank' || data.searchQuery) return;
|
||||
|
||||
loadingMore = true;
|
||||
@@ -106,44 +97,38 @@
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Filter options — built from Meilisearch facet distribution when available,
|
||||
// with a hardcoded fallback list for when Meilisearch is not yet populated.
|
||||
const FALLBACK_GENRES = [
|
||||
'action', 'adventure', 'comedy', 'drama', 'fantasy', 'harem',
|
||||
'historical', 'horror', 'isekai', 'martial-arts', 'mystery',
|
||||
'psychological', 'romance', 'sci-fi', 'system', 'xianxia'
|
||||
];
|
||||
const genres = $derived([
|
||||
{ value: 'all', label: 'All Genres' },
|
||||
{ value: 'all', label: m.catalogue_genre_all() },
|
||||
...((data.genres?.length ? data.genres : FALLBACK_GENRES).map((g: string) => ({
|
||||
value: g,
|
||||
label: g.charAt(0).toUpperCase() + g.slice(1).replace(/-/g, ' ')
|
||||
})))
|
||||
]);
|
||||
const sorts = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'update', label: 'Updated' },
|
||||
{ value: 'top-rated', label: 'Top Rated' },
|
||||
{ value: 'rank', label: 'Ranking' }
|
||||
];
|
||||
const sorts = $derived([
|
||||
{ value: 'popular', label: m.catalogue_sort_popular() },
|
||||
{ value: 'new', label: m.catalogue_sort_new() },
|
||||
{ value: 'update', label: m.catalogue_sort_updated() },
|
||||
{ value: 'top-rated', label: m.catalogue_sort_top_rated() },
|
||||
{ value: 'rank', label: m.catalogue_sort_rank() }
|
||||
]);
|
||||
const FALLBACK_STATUSES = ['ongoing', 'completed'];
|
||||
const statuses = $derived([
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'all', label: m.catalogue_status_all() },
|
||||
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1)
|
||||
})))
|
||||
]);
|
||||
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
const isSearchView = $derived(!!data.searchQuery);
|
||||
|
||||
|
||||
// View toggle: 'grid' | 'list'. Persisted in localStorage.
|
||||
// Rank view always uses list; otherwise restore saved preference (default: grid).
|
||||
const VIEW_KEY = 'libnovel:browse:view';
|
||||
function savedView(): 'grid' | 'list' {
|
||||
if (data.sort === 'rank') return 'list';
|
||||
@@ -154,7 +139,6 @@
|
||||
return 'grid';
|
||||
}
|
||||
let view = $state<'grid' | 'list'>(savedView());
|
||||
// Keep view in sync when sort changes via filter form, and persist changes.
|
||||
$effect(() => {
|
||||
if (data.sort === 'rank' && view === 'grid') view = 'list';
|
||||
});
|
||||
@@ -194,7 +178,6 @@
|
||||
// ── Collapsible filters panel ────────────────────────────────────────────
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
// Human-readable summary of active filters shown on the toggle button
|
||||
const filterSummary = $derived((() => {
|
||||
const parts: string[] = [];
|
||||
const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
|
||||
@@ -210,7 +193,6 @@
|
||||
return parts.join(' · ');
|
||||
})());
|
||||
|
||||
// Whether any non-default filter is active (used to show a dot indicator)
|
||||
const hasActiveFilters = $derived(
|
||||
(data.genre && data.genre !== 'all') ||
|
||||
(data.status && data.status !== 'all') ||
|
||||
@@ -229,26 +211,26 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Catalogue — libnovel</title>
|
||||
<title>{m.catalogue_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.catalogue_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{#if isSearchView}
|
||||
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-(--color-text)">{data.searchQuery}</span>"
|
||||
{m.catalogue_search_results({ n: String(novels.length), s: novels.length !== 1 ? 's' : '', q: data.searchQuery })}
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-(--color-muted) text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
<span class="text-(--color-muted) text-xs ml-1">{m.catalogue_search_local_count({ local: String(data.searchLocalCount), remote: String(data.searchRemoteCount) })}</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if novels.length > 0}
|
||||
{novels.length} novels ranked from last catalogue scrape
|
||||
{m.catalogue_rank_ranked({ n: String(novels.length) })}
|
||||
{:else}
|
||||
No ranking data — run a full catalogue scrape to populate
|
||||
{m.catalogue_rank_no_data_body()}
|
||||
{/if}
|
||||
{:else}
|
||||
Browse novels from novelfire.net
|
||||
{m.catalogue_browse_source()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -257,15 +239,15 @@
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
Full catalogue scrape queued. Library and ranking will update as books are processed.
|
||||
{m.catalogue_scrape_queued_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
A scrape job is already running. Check back once it finishes.
|
||||
{m.catalogue_scrape_busy_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-danger)/10 border border-(--color-danger) text-(--color-danger) text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
{m.catalogue_scrape_error_flash()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -278,21 +260,21 @@
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchQuery}
|
||||
placeholder="Search…"
|
||||
placeholder={m.catalogue_search_placeholder()}
|
||||
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) placeholder-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
{m.catalogue_search_button()}
|
||||
</button>
|
||||
{#if data.searchQuery}
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="px-3 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Clear
|
||||
{m.catalogue_clear_filters()}
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -311,8 +293,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 4h18M7 8h10M11 12h2M9 16h6" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Filters</span>
|
||||
<!-- Active indicator dot -->
|
||||
<span class="hidden sm:inline">{m.catalogue_filters_label()}</span>
|
||||
{#if hasActiveFilters}
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-(--color-brand)"></span>
|
||||
{/if}
|
||||
@@ -322,7 +303,7 @@
|
||||
<div class="flex items-center bg-(--color-surface-2) border border-(--color-border) rounded overflow-hidden shrink-0">
|
||||
<button
|
||||
onclick={() => (view = 'grid')}
|
||||
title="Grid view"
|
||||
title={m.catalogue_view_grid()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'grid'
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
@@ -334,7 +315,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (view = 'list')}
|
||||
title="List view"
|
||||
title={m.catalogue_view_list()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'list'
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
@@ -364,7 +345,7 @@
|
||||
disabled={refreshing}
|
||||
class="hidden sm:block px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -374,13 +355,13 @@
|
||||
{#if !filtersOpen && hasActiveFilters}
|
||||
<p class="text-xs text-(--color-muted) mb-3">
|
||||
<span class="text-(--color-muted)">{filterSummary}</span>
|
||||
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">clear</a>
|
||||
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">{m.catalogue_clear_filters().toLowerCase()}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Collapsible filter panel -->
|
||||
{#if filtersOpen}
|
||||
<!-- Admin refresh (mobile only — outside filter form to avoid nested <form>) -->
|
||||
<!-- Admin refresh (mobile only) -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
@@ -399,7 +380,7 @@
|
||||
disabled={refreshing}
|
||||
class="w-full px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh_mobile()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -409,7 +390,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">Sort</label>
|
||||
<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_sort()}</label>
|
||||
<select
|
||||
id="filter-sort"
|
||||
name="sort"
|
||||
@@ -423,7 +404,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">Genre</label>
|
||||
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_genre()}</label>
|
||||
<select
|
||||
id="filter-genre"
|
||||
name="genre"
|
||||
@@ -438,7 +419,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">Status</label>
|
||||
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_status()}</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
name="status"
|
||||
@@ -454,19 +435,19 @@
|
||||
</div>
|
||||
|
||||
{#if isRankView}
|
||||
<p class="text-xs text-(--color-muted) italic">Genre & status filters apply to Browse only</p>
|
||||
<p class="text-xs text-(--color-muted) italic">{m.catalogue_filter_rank_note()}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a href="/catalogue" class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors">
|
||||
Reset
|
||||
{m.catalogue_reset()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={applyFilters}
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Apply
|
||||
{m.catalogue_apply()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -475,18 +456,18 @@
|
||||
<!-- Content -->
|
||||
{#if novels.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isSearchView}
|
||||
Try a different search term.
|
||||
{m.catalogue_no_results_try()}
|
||||
{:else if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-(--color-brand)">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_admin()}
|
||||
{:else}
|
||||
Ask an admin to run a catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_user()}
|
||||
{/if}
|
||||
{:else}
|
||||
Try different filters or check back later.
|
||||
{m.catalogue_no_results_filters()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -553,20 +534,20 @@
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="mt-auto pt-1">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Forbidden</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.catalogue_scrape_forbidden_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -650,18 +631,18 @@
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="shrink-0">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => scrapeNovel(novel)}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -690,11 +671,9 @@
|
||||
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
|
||||
{#if !isRankView && !isSearchView}
|
||||
{#if hasNext}
|
||||
<!-- Invisible div watched by IntersectionObserver -->
|
||||
<div bind:this={sentinel} class="h-px mt-8"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading spinner while fetching next page -->
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
@@ -703,7 +682,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
{:else if !hasNext && novels.length > 0}
|
||||
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">All novels loaded</p>
|
||||
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">{m.catalogue_all_loaded()}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -712,8 +691,8 @@
|
||||
<button
|
||||
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-text) shadow-lg hover:bg-(--color-surface-3) hover:text-(--color-text) transition-colors"
|
||||
title="Back to top"
|
||||
aria-label="Scroll to top"
|
||||
title={m.catalogue_scroll_top()}
|
||||
aria-label={m.catalogue_scroll_top()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Disclaimer — libnovel</title>
|
||||
<title>{m.disclaimer_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DMCA — libnovel</title>
|
||||
<title>{m.dmca_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { PageServerLoad } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: { error?: string } } = $props();
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
oauth_state: 'Sign-in was cancelled or expired. Please try again.',
|
||||
oauth_failed: 'Could not connect to the provider. Please try again.',
|
||||
oauth_no_email: 'Your account has no verified email address. Please add one and retry.'
|
||||
};
|
||||
const errorMessages = $derived<Record<string, string>>({
|
||||
oauth_state: m.login_error_oauth_state(),
|
||||
oauth_failed: m.login_error_oauth_failed(),
|
||||
oauth_no_email: m.login_error_oauth_no_email()
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in — libnovel</title>
|
||||
<title>{m.login_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-2">Sign in to libnovel</h1>
|
||||
<p class="text-sm text-(--color-muted)">Choose a provider to continue</p>
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-2">{m.login_heading()}</h1>
|
||||
<p class="text-sm text-(--color-muted)">{m.login_subheading()}</p>
|
||||
</div>
|
||||
|
||||
{#if data.error && errorMessages[data.error]}
|
||||
@@ -54,7 +54,7 @@
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
{m.login_continue_google()}
|
||||
</a>
|
||||
|
||||
<!-- GitHub -->
|
||||
@@ -76,12 +76,12 @@
|
||||
2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
{m.login_continue_github()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-center text-xs text-(--color-muted)">
|
||||
By signing in you agree to our terms of service.
|
||||
{m.login_terms_notice()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy — libnovel</title>
|
||||
<title>{m.privacy_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -93,10 +94,10 @@
|
||||
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
|
||||
|
||||
const THEMES: { id: string; label: string; swatch: string }[] = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
|
||||
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
|
||||
const THEMES: { id: string; label: () => string; swatch: string }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||||
];
|
||||
|
||||
let settingsSaving = $state(false);
|
||||
@@ -204,7 +205,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile — libnovel</title>
|
||||
<title>{m.profile_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if cropFile && browser}
|
||||
@@ -227,7 +228,7 @@
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
|
||||
title="Change profile picture"
|
||||
title={m.profile_change_avatar()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{#if avatarUrl}
|
||||
@@ -269,17 +270,17 @@
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1">Click avatar to change photo</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Appearance</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-(--color-text)">Theme</p>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
{#each THEMES as t}
|
||||
<button
|
||||
@@ -292,7 +293,7 @@
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
{t.label()}
|
||||
{#if selectedTheme === t.id}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
@@ -306,16 +307,16 @@
|
||||
|
||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Reading settings</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
|
||||
|
||||
<!-- Voice -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">TTS voice</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
||||
{#if !voicesLoaded}
|
||||
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
|
||||
{:else if voices.length === 0}
|
||||
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
||||
<option>No voices available</option>
|
||||
<option>{m.common_loading()}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
@@ -344,7 +345,7 @@
|
||||
<!-- Speed -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
|
||||
Playback speed — <span class="text-(--color-brand) font-mono">{speed.toFixed(1)}x</span>
|
||||
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
id="speed-range"
|
||||
@@ -370,7 +371,7 @@
|
||||
style="accent-color: var(--color-brand);"
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-(--color-text)">Auto-advance to next chapter</span>
|
||||
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
@@ -379,18 +380,18 @@
|
||||
disabled={settingsSaving}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{settingsSaving ? 'Saving…' : 'Save settings'}
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-green-400">Saved!</span>
|
||||
<span class="text-sm text-green-400">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Active sessions</h2>
|
||||
<p class="text-sm text-(--color-muted)">These are all devices currently signed into your account. End any session you don't recognise.</p>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
||||
|
||||
{#if revokeError}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
@@ -399,7 +400,7 @@
|
||||
{/if}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-(--color-muted) italic">No session records found. Sessions are tracked from the next login.</p>
|
||||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sessions as session (session.id)}
|
||||
@@ -408,16 +409,16 @@
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) truncate">{parseUA(session.user_agent)}</span>
|
||||
{#if session.is_current}
|
||||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">This session</span>
|
||||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">{m.profile_session_this()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if session.ip}
|
||||
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Signed in {formatDate(session.created_at)}
|
||||
{m.profile_session_signed_in({ date: formatDate(session.created_at) })}
|
||||
{#if session.last_seen && session.last_seen !== session.created_at}
|
||||
· Last seen {formatDate(session.last_seen)}
|
||||
{m.profile_session_last_seen({ date: formatDate(session.last_seen) })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -429,7 +430,7 @@
|
||||
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
|
||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -439,7 +440,7 @@
|
||||
|
||||
<!-- ── Change password ──────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Change password</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_change_password_heading()}</h2>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
@@ -449,7 +450,7 @@
|
||||
|
||||
{#if pwSuccess}
|
||||
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
|
||||
Password changed successfully.
|
||||
{m.profile_password_changed_ok()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -466,7 +467,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">Current password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
|
||||
<input
|
||||
id="current"
|
||||
name="current"
|
||||
@@ -477,7 +478,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">New password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
|
||||
<input
|
||||
id="next"
|
||||
name="next"
|
||||
@@ -488,7 +489,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">Confirm new password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
|
||||
<input
|
||||
id="confirm"
|
||||
name="confirm"
|
||||
@@ -503,7 +504,7 @@
|
||||
disabled={pwSubmitting}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{pwSubmitting ? 'Updating…' : 'Update password'}
|
||||
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Terms of Service — libnovel</title>
|
||||
<title>{m.terms_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.username} — libnovel</title>
|
||||
<title>{m.user_page_title({ username: data.profile.username })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Header ────────────────────────────────────────────────────────────── -->
|
||||
@@ -73,17 +74,17 @@
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-(--color-text) mb-0.5">{data.profile.username}</h1>
|
||||
<p class="text-xs text-(--color-muted) mb-3">Joined {joinDate(data.profile.created)}</p>
|
||||
<p class="text-xs text-(--color-muted) mb-3">{m.user_joined({ date: joinDate(data.profile.created) })}</p>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex gap-5 text-sm mb-4">
|
||||
<span>
|
||||
<span class="font-semibold text-(--color-text)">{followerCount}</span>
|
||||
<span class="text-(--color-muted) ml-1">followers</span>
|
||||
<span class="text-(--color-muted) ml-1">{m.user_followers_label()}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-semibold text-(--color-text)">{data.profile.followingCount}</span>
|
||||
<span class="text-(--color-muted) ml-1">following</span>
|
||||
<span class="text-(--color-muted) ml-1">{m.user_following_label()}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +101,9 @@
|
||||
{#if subLoading}
|
||||
…
|
||||
{:else if subscribed}
|
||||
Following
|
||||
{m.user_unfollow()}
|
||||
{:else}
|
||||
Follow
|
||||
{m.user_follow()}
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !data.isLoggedIn}
|
||||
@@ -110,7 +111,7 @@
|
||||
href="/login"
|
||||
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Follow
|
||||
{m.user_follow()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -119,7 +120,7 @@
|
||||
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
|
||||
{#if data.currentlyReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">Currently Reading</h2>
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">{m.user_currently_reading()}</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.currentlyReading as { book, chapter }}
|
||||
<a
|
||||
@@ -161,8 +162,7 @@
|
||||
{#if data.library.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">
|
||||
Library
|
||||
<span class="text-(--color-muted) font-normal text-sm ml-1">({data.library.length})</span>
|
||||
{m.user_library_count({ n: String(data.library.length) })}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.library as { book, chapter, saved }}
|
||||
@@ -220,6 +220,6 @@
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<p class="text-sm">No books in library yet.</p>
|
||||
<p class="text-sm">{m.user_no_books()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { paraglideVitePlugin as paraglide } from '@inlang/paraglide-js';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Source maps are always generated so that the CI pipeline can upload them to
|
||||
@@ -8,7 +9,15 @@ export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
paraglide({
|
||||
project: './project.inlang',
|
||||
outdir: './src/lib/paraglide',
|
||||
strategy: ['url', 'cookie', 'baseLocale']
|
||||
}),
|
||||
sveltekit()
|
||||
],
|
||||
ssr: {
|
||||
// Force these packages to be bundled into the server output rather than
|
||||
// treated as external requires. The production Docker image has no
|
||||
|
||||
Reference in New Issue
Block a user