spark-server/fullstorynovel-web/assets/js/site.js
DESKTOP-L8OUL9F\gfl0034 f941bf4454 feat(web): 补齐官网协议页,并按包名控制会员价格下发
商店审核需要独立的隐私、用户协议和注销页;运营后台也要按应用包名启用或停用价格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-11 21:32:53 +08:00

1085 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Full Story Novel — public site: i18n + theme + SPA */
const LANG_KEY = "fsn_lang";
const THEME_KEY = "fsn_theme";
const DEFAULT_LANG = "en";
const RTL_LANGS = new Set(["ar", "ur"]);
const LANGS = [
{ code: "en", label: "English" },
{ code: "zh-CN", label: "简体中文" },
{ code: "zh-TW", label: "繁體中文" },
{ code: "hi", label: "हिन्दी" },
{ code: "es", label: "Español" },
{ code: "ar", label: "العربية" },
{ code: "bn", label: "বাংলা" },
{ code: "pt", label: "Português" },
{ code: "ru", label: "Русский" },
{ code: "ur", label: "اردو" },
{ code: "ja", label: "日本語" },
{ code: "ko", label: "한국어" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" },
{ code: "it", label: "Italiano" },
];
const I18N = {
en: {
"nav.home": "Home", "nav.novels": "Novels", "nav.genres": "Genres", "nav.download": "Download App",
"search.placeholder": "Search novels...", "search.button": "Search",
"prefs.language": "Language", "prefs.theme": "Theme", "prefs.themeLight": "Light", "prefs.themeDark": "Dark",
"footer.tagline": "Serialized fantasy reading inspired by classic web-novel platforms.",
"footer.browse": "Browse", "footer.admin": "Admin",
"common.loading": "Loading…", "common.unknown": "Unknown", "common.any": "Any",
"common.viewAll": "View all", "common.chapters": "chapters", "common.views": "views",
"common.failed": "Failed to load", "common.previous": "Previous", "common.next": "Next",
"common.by": "by", "common.total": "total", "common.series": "series",
"home.featured": "Featured Series", "home.readNow": "Read Now", "home.browse": "Browse Library",
"home.welcome": "Welcome to Full Story Novel", "home.emptyHint": "Add novels from the admin panel to get started.",
"home.popularGenres": "Popular Genres", "home.noGenres": "No genres yet",
"home.popular": "Popular This Week", "home.new": "New Releases", "home.updated": "Recently Updated",
"home.nothing": "Nothing here yet.",
"novels.title": "Novels", "novels.search": "Search", "novels.genre": "Category",
"novels.tag": "Tag", "novels.allTags": "All tags",
"novels.status": "Status", "novels.language": "Language", "novels.sort": "Sort",
"novels.placeholder": "Title or author", "novels.empty": "No novels matched.",
"status.ONGOING": "Ongoing", "status.COMPLETED": "Completed", "status.HIATUS": "Hiatus",
"lang.ENGLISH": "English", "lang.CHINESE": "Chinese", "lang.KOREAN": "Korean",
"sort.popular": "Popular", "sort.rating": "Rating", "sort.newest": "New",
"sort.updated": "Updated", "sort.name": "Name", "sort.chapters": "Chapters",
"genres.title": "Genres", "genres.explore": "Explore this genre.",
"genres.tags": "Tags",
"novel.start": "Start Reading", "novel.noChapters": "No chapters yet",
"novel.back": "Back to Library", "novel.chapters": "Chapters", "novel.chapter": "Chapter",
"novel.nonePublished": "No chapters published.",
"read.prev": "← Previous", "read.next": "Next →", "read.chapters": "Chapters",
"download.title": "Read anywhere",
"download.lead": "Offline bookshelf, PDF/TXT import, and cloud picks — crafted for Android.",
"download.android": "Get Android APK",
"download.resolving": "Preparing the latest build…",
"download.ready": "Build ready — tap to install.",
"download.unavailable": "Package temporarily unavailable. Please try again shortly.",
"download.hint1": "Android only for now. Allow installs from this browser if prompted.",
"download.hint2": "Package name: wf.wyrs.com",
"download.hint3": "Signed release build mirrored on this site for a direct install.",
"download.feature1": "Offline bookshelf",
"download.feature2": "PDF / TXT import",
"download.feature3": "Cloud recommendations",
"download.platform": "Android",
},
"zh-CN": {
"nav.home": "首页", "nav.novels": "书库", "nav.genres": "类型", "nav.download": "App下载",
"search.placeholder": "搜索小说…", "search.button": "搜索",
"prefs.language": "语言", "prefs.theme": "主题", "prefs.themeLight": "日间", "prefs.themeDark": "暗黑",
"footer.tagline": "连载奇幻阅读,致敬经典网文平台。",
"footer.browse": "浏览", "footer.admin": "管理",
"common.loading": "加载中…", "common.unknown": "未知", "common.any": "全部",
"common.viewAll": "查看全部", "common.chapters": "章", "common.views": "阅读",
"common.failed": "加载失败", "common.previous": "上一页", "common.next": "下一页",
"common.by": "作者", "common.total": "共", "common.series": "部作品",
"home.featured": "精选推荐", "home.readNow": "立即阅读", "home.browse": "浏览书库",
"home.welcome": "欢迎来到 Full Story Novel", "home.emptyHint": "请先在管理后台添加小说。",
"home.popularGenres": "热门类型", "home.noGenres": "暂无类型",
"home.popular": "本周热门", "home.new": "新书上架", "home.updated": "最近更新",
"home.nothing": "暂无内容。",
"novels.title": "书库", "novels.search": "搜索", "novels.genre": "一级分类",
"novels.tag": "二级标签", "novels.allTags": "全部标签",
"novels.status": "状态", "novels.language": "语言", "novels.sort": "排序",
"novels.placeholder": "书名或作者", "novels.empty": "没有匹配的小说。",
"status.ONGOING": "连载中", "status.COMPLETED": "已完结", "status.HIATUS": "暂停",
"lang.ENGLISH": "英语", "lang.CHINESE": "中文", "lang.KOREAN": "韩语",
"sort.popular": "热门", "sort.rating": "评分", "sort.newest": "最新",
"sort.updated": "更新", "sort.name": "名称", "sort.chapters": "章节数",
"genres.title": "类型", "genres.explore": "探索该类型。",
"genres.tags": "二级标签",
"novel.start": "开始阅读", "novel.noChapters": "暂无章节",
"novel.back": "返回书库", "novel.chapters": "章节列表", "novel.chapter": "第",
"novel.nonePublished": "暂无已发布章节。",
"read.prev": "← 上一章", "read.next": "下一章 →", "read.chapters": "目录",
"download.title": "随时开读",
"download.lead": "本地书架、PDF/TXT 导入与云端推荐 — 专为 Android 打造。",
"download.android": "获取 Android 安装包",
"download.resolving": "正在准备最新构建…",
"download.ready": "构建已就绪,点击即可安装。",
"download.unavailable": "安装包暂不可用,请稍后再试。",
"download.hint1": "目前仅提供 Android。若系统提示,请允许从此浏览器安装。",
"download.hint2": "包名:wf.wyrs.com",
"download.hint3": "本站提供已签名正式版,可直接安装。",
"download.feature1": "离线书架",
"download.feature2": "PDF / TXT 导入",
"download.feature3": "云端推荐",
"download.platform": "Android",
},
"zh-TW": {
"nav.home": "首頁", "nav.novels": "書庫", "nav.genres": "類型", "nav.download": "App下載",
"search.placeholder": "搜尋小說…", "search.button": "搜尋",
"prefs.language": "語言", "prefs.theme": "主題", "prefs.themeLight": "日間", "prefs.themeDark": "暗黑",
"footer.tagline": "連載奇幻閱讀,致敬經典網文平台。",
"footer.browse": "瀏覽", "footer.admin": "管理",
"common.loading": "載入中…", "common.unknown": "未知", "common.any": "全部",
"common.viewAll": "查看全部", "common.chapters": "章", "common.views": "閱讀",
"common.failed": "載入失敗", "common.previous": "上一頁", "common.next": "下一頁",
"common.by": "作者", "common.total": "共", "common.series": "部作品",
"home.featured": "精選推薦", "home.readNow": "立即閱讀", "home.browse": "瀏覽書庫",
"home.welcome": "歡迎來到 Full Story Novel", "home.emptyHint": "請先在管理後台新增小說。",
"home.popularGenres": "熱門類型", "home.noGenres": "暫無類型",
"home.popular": "本週熱門", "home.new": "新書上架", "home.updated": "最近更新",
"home.nothing": "暫無內容。",
"novels.title": "書庫", "novels.search": "搜尋", "novels.genre": "一級分類",
"novels.tag": "二級標籤", "novels.allTags": "全部標籤",
"novels.status": "狀態", "novels.language": "語言", "novels.sort": "排序",
"novels.placeholder": "書名或作者", "novels.empty": "沒有符合的小說。",
"status.ONGOING": "連載中", "status.COMPLETED": "已完結", "status.HIATUS": "暫停",
"lang.ENGLISH": "英語", "lang.CHINESE": "中文", "lang.KOREAN": "韓語",
"sort.popular": "熱門", "sort.rating": "評分", "sort.newest": "最新",
"sort.updated": "更新", "sort.name": "名稱", "sort.chapters": "章節數",
"genres.title": "類型", "genres.explore": "探索此類型。",
"genres.tags": "二級標籤",
"novel.start": "開始閱讀", "novel.noChapters": "暫無章節",
"novel.back": "返回書庫", "novel.chapters": "章節列表", "novel.chapter": "第",
"novel.nonePublished": "暫無已發佈章節。",
"read.prev": "← 上一章", "read.next": "下一章 →", "read.chapters": "目錄",
"download.title": "隨時開讀",
"download.lead": "本地書架、PDF/TXT 匯入與雲端推薦 — 專為 Android 打造。",
"download.android": "取得 Android 安裝包",
"download.resolving": "正在準備最新建置…",
"download.ready": "建置已就緒,點擊即可安裝。",
"download.unavailable": "安裝包暫不可用,請稍後再試。",
"download.hint1": "目前僅提供 Android。若系統提示,請允許從此瀏覽器安裝。",
"download.hint2": "包名:wf.wyrs.com",
"download.hint3": "本站提供已簽名正式版,可直接安裝。",
"download.feature1": "離線書架",
"download.feature2": "PDF / TXT 匯入",
"download.feature3": "雲端推薦",
"download.platform": "Android",
},
hi: {
"nav.home": "होम", "nav.novels": "उपन्यास", "nav.genres": "विधाएँ",
"search.placeholder": "उपन्यास खोजें...", "search.button": "खोजें",
"prefs.language": "भाषा", "prefs.theme": "थीम", "prefs.themeLight": "दिन", "prefs.themeDark": "रात",
"footer.tagline": "क्लासिक वेब-उपन्यास शैली में धारावाहिक फंतासी पठन।",
"footer.browse": "ब्राउज़", "footer.admin": "एडमिन",
"common.loading": "लोड हो रहा है…", "common.unknown": "अज्ञात", "common.any": "कोई भी",
"common.viewAll": "सभी देखें", "common.chapters": "अध्याय", "common.views": "दृश्य",
"common.failed": "लोड विफल", "common.previous": "पिछला", "common.next": "अगला",
"common.by": "द्वारा", "common.total": "कुल", "common.series": "श्रृंखला",
"home.featured": "विशेष श्रृंखला", "home.readNow": "अभी पढ़ें", "home.browse": "लाइब्रेरी देखें",
"home.welcome": "Full Story Novel में आपका स्वागत है", "home.emptyHint": "शुरू करने के लिए एडमिन से उपन्यास जोड़ें।",
"home.popularGenres": "लोकप्रिय विधाएँ", "home.noGenres": "अभी कोई विधा नहीं",
"home.popular": "इस सप्ताह लोकप्रिय", "home.new": "नई रिलीज़", "home.updated": "हाल ही में अपडेट",
"home.nothing": "अभी कुछ नहीं।",
"novels.title": "उपन्यास", "novels.search": "खोज", "novels.genre": "विधा",
"novels.status": "स्थिति", "novels.language": "भाषा", "novels.sort": "क्रम",
"novels.placeholder": "शीर्षक या लेखक", "novels.empty": "कोई उपन्यास नहीं मिला।",
"status.ONGOING": "चालू", "status.COMPLETED": "पूर्ण", "status.HIATUS": "विराम",
"lang.ENGLISH": "अंग्रेज़ी", "lang.CHINESE": "चीनी", "lang.KOREAN": "कोरियाई",
"sort.popular": "लोकप्रिय", "sort.rating": "रेटिंग", "sort.newest": "नया",
"sort.updated": "अपडेट", "sort.name": "नाम", "sort.chapters": "अध्याय",
"genres.title": "विधाएँ", "genres.explore": "इस विधा को देखें।",
"novel.start": "पढ़ना शुरू करें", "novel.noChapters": "अभी अध्याय नहीं",
"novel.back": "लाइब्रेरी पर वापस", "novel.chapters": "अध्याय", "novel.chapter": "अध्याय",
"novel.nonePublished": "कोई अध्याय प्रकाशित नहीं।",
"read.prev": "← पिछला", "read.next": "अगला →", "read.chapters": "अध्याय",
},
es: {
"nav.home": "Inicio", "nav.novels": "Novelas", "nav.genres": "Géneros",
"search.placeholder": "Buscar novelas...", "search.button": "Buscar",
"prefs.language": "Idioma", "prefs.theme": "Tema", "prefs.themeLight": "Claro", "prefs.themeDark": "Oscuro",
"footer.tagline": "Lectura fantástica por entregas inspirada en novelas web clásicas.",
"footer.browse": "Explorar", "footer.admin": "Admin",
"common.loading": "Cargando…", "common.unknown": "Desconocido", "common.any": "Cualquiera",
"common.viewAll": "Ver todo", "common.chapters": "capítulos", "common.views": "vistas",
"common.failed": "Error al cargar", "common.previous": "Anterior", "common.next": "Siguiente",
"common.by": "por", "common.total": "total", "common.series": "series",
"home.featured": "Serie destacada", "home.readNow": "Leer ahora", "home.browse": "Explorar biblioteca",
"home.welcome": "Bienvenido a Full Story Novel", "home.emptyHint": "Añade novelas desde el panel de administración.",
"home.popularGenres": "Géneros populares", "home.noGenres": "Sin géneros aún",
"home.popular": "Popular esta semana", "home.new": "Novedades", "home.updated": "Actualizado recientemente",
"home.nothing": "Nada aquí todavía.",
"novels.title": "Novelas", "novels.search": "Buscar", "novels.genre": "Género",
"novels.status": "Estado", "novels.language": "Idioma", "novels.sort": "Orden",
"novels.placeholder": "Título o autor", "novels.empty": "Ninguna novela coincide.",
"status.ONGOING": "En curso", "status.COMPLETED": "Completada", "status.HIATUS": "Pausa",
"lang.ENGLISH": "Inglés", "lang.CHINESE": "Chino", "lang.KOREAN": "Coreano",
"sort.popular": "Popular", "sort.rating": "Valoración", "sort.newest": "Nuevo",
"sort.updated": "Actualizado", "sort.name": "Nombre", "sort.chapters": "Capítulos",
"genres.title": "Géneros", "genres.explore": "Explora este género.",
"novel.start": "Empezar a leer", "novel.noChapters": "Sin capítulos aún",
"novel.back": "Volver a la biblioteca", "novel.chapters": "Capítulos", "novel.chapter": "Capítulo",
"novel.nonePublished": "No hay capítulos publicados.",
"read.prev": "← Anterior", "read.next": "Siguiente →", "read.chapters": "Capítulos",
},
ar: {
"nav.home": "الرئيسية", "nav.novels": "الروايات", "nav.genres": "الأنواع",
"search.placeholder": "ابحث عن روايات...", "search.button": "بحث",
"prefs.language": "اللغة", "prefs.theme": "المظهر", "prefs.themeLight": "نهاري", "prefs.themeDark": "داكن",
"footer.tagline": "قراءة خيالية متسلسلة مستوحاة من منصات الروايات الكلاسيكية.",
"footer.browse": "تصفح", "footer.admin": "الإدارة",
"common.loading": "جارٍ التحميل…", "common.unknown": "غير معروف", "common.any": "الكل",
"common.viewAll": "عرض الكل", "common.chapters": "فصول", "common.views": "مشاهدات",
"common.failed": "فشل التحميل", "common.previous": "السابق", "common.next": "التالي",
"common.by": "بقلم", "common.total": "الإجمالي", "common.series": "سلسلة",
"home.featured": "سلسلة مميزة", "home.readNow": "اقرأ الآن", "home.browse": "تصفح المكتبة",
"home.welcome": "مرحبًا بك في Full Story Novel", "home.emptyHint": "أضف روايات من لوحة الإدارة للبدء.",
"home.popularGenres": "أنواع شائعة", "home.noGenres": "لا أنواع بعد",
"home.popular": "الأكثر شعبية هذا الأسبوع", "home.new": "إصدارات جديدة", "home.updated": "حديث التحديث",
"home.nothing": "لا شيء هنا بعد.",
"novels.title": "الروايات", "novels.search": "بحث", "novels.genre": "النوع",
"novels.status": "الحالة", "novels.language": "اللغة", "novels.sort": "ترتيب",
"novels.placeholder": "العنوان أو المؤلف", "novels.empty": "لا روايات مطابقة.",
"status.ONGOING": "مستمرة", "status.COMPLETED": "مكتملة", "status.HIATUS": "متوقفة",
"lang.ENGLISH": "الإنجليزية", "lang.CHINESE": "الصينية", "lang.KOREAN": "الكورية",
"sort.popular": "شائع", "sort.rating": "التقييم", "sort.newest": "الأحدث",
"sort.updated": "محدث", "sort.name": "الاسم", "sort.chapters": "الفصول",
"genres.title": "الأنواع", "genres.explore": "استكشف هذا النوع.",
"novel.start": "ابدأ القراءة", "novel.noChapters": "لا فصول بعد",
"novel.back": "العودة للمكتبة", "novel.chapters": "الفصول", "novel.chapter": "فصل",
"novel.nonePublished": "لا فصول منشورة.",
"read.prev": "→ السابق", "read.next": "التالي ←", "read.chapters": "الفصول",
},
bn: {
"nav.home": "হোম", "nav.novels": "উপন্যাস", "nav.genres": "ধরন",
"search.placeholder": "উপন্যাস খুঁজুন...", "search.button": "অনুসন্ধান",
"prefs.language": "ভাষা", "prefs.theme": "থিম", "prefs.themeLight": "দিন", "prefs.themeDark": "রাত",
"footer.tagline": "ক্লাসিক ওয়েব-উপন্যাস প্ল্যাটফর্ম থেকে অনুপ্রাণিত ধারাবাহিক ফ্যান্টাসি পাঠ।",
"footer.browse": "ব্রাউজ", "footer.admin": "অ্যাডমিন",
"common.loading": "লোড হচ্ছে…", "common.unknown": "অজানা", "common.any": "যেকোনো",
"common.viewAll": "সব দেখুন", "common.chapters": "অধ্যায়", "common.views": "দেখা",
"common.failed": "লোড ব্যর্থ", "common.previous": "পূর্ববর্তী", "common.next": "পরবর্তী",
"common.by": "লেখক", "common.total": "মোট", "common.series": "সিরিজ",
"home.featured": "বিশেষ সিরিজ", "home.readNow": "এখনই পড়ুন", "home.browse": "লাইব্রেরি দেখুন",
"home.welcome": "Full Story Novel-এ স্বাগতম", "home.emptyHint": "শুরু করতে অ্যাডমিন থেকে উপন্যাস যোগ করুন।",
"home.popularGenres": "জনপ্রিয় ধরন", "home.noGenres": "এখনো কোনো ধরন নেই",
"home.popular": "এই সপ্তাহের জনপ্রিয়", "home.new": "নতুন প্রকাশ", "home.updated": "সম্প্রতি আপডেট",
"home.nothing": "এখনো কিছু নেই।",
"novels.title": "উপন্যাস", "novels.search": "অনুসন্ধান", "novels.genre": "ধরন",
"novels.status": "অবস্থা", "novels.language": "ভাষা", "novels.sort": "সাজানো",
"novels.placeholder": "শিরোনাম বা লেখক", "novels.empty": "কোনো উপন্যাস মেলেনি।",
"status.ONGOING": "চলমান", "status.COMPLETED": "সম্পূর্ণ", "status.HIATUS": "বিরতি",
"lang.ENGLISH": "ইংরেজি", "lang.CHINESE": "চীনা", "lang.KOREAN": "কোরিয়ান",
"sort.popular": "জনপ্রিয়", "sort.rating": "রেটিং", "sort.newest": "নতুন",
"sort.updated": "আপডেট", "sort.name": "নাম", "sort.chapters": "অধ্যায়",
"genres.title": "ধরন", "genres.explore": "এই ধরন অন্বেষণ করুন।",
"novel.start": "পড়া শুরু করুন", "novel.noChapters": "এখনো অধ্যায় নেই",
"novel.back": "লাইব্রেরিতে ফিরুন", "novel.chapters": "অধ্যায়", "novel.chapter": "অধ্যায়",
"novel.nonePublished": "কোনো অধ্যায় প্রকাশিত হয়নি।",
"read.prev": "← পূর্ববর্তী", "read.next": "পরবর্তী →", "read.chapters": "অধ্যায়",
},
pt: {
"nav.home": "Início", "nav.novels": "Romances", "nav.genres": "Gêneros",
"search.placeholder": "Pesquisar romances...", "search.button": "Pesquisar",
"prefs.language": "Idioma", "prefs.theme": "Tema", "prefs.themeLight": "Claro", "prefs.themeDark": "Escuro",
"footer.tagline": "Leitura fantástica em série inspirada em plataformas clássicas de web novels.",
"footer.browse": "Explorar", "footer.admin": "Admin",
"common.loading": "Carregando…", "common.unknown": "Desconhecido", "common.any": "Qualquer",
"common.viewAll": "Ver tudo", "common.chapters": "capítulos", "common.views": "visualizações",
"common.failed": "Falha ao carregar", "common.previous": "Anterior", "common.next": "Próximo",
"common.by": "por", "common.total": "total", "common.series": "séries",
"home.featured": "Série em destaque", "home.readNow": "Ler agora", "home.browse": "Explorar biblioteca",
"home.welcome": "Bem-vindo ao Full Story Novel", "home.emptyHint": "Adicione romances no painel admin para começar.",
"home.popularGenres": "Gêneros populares", "home.noGenres": "Sem gêneros ainda",
"home.popular": "Popular esta semana", "home.new": "Lançamentos", "home.updated": "Atualizados recentemente",
"home.nothing": "Nada aqui ainda.",
"novels.title": "Romances", "novels.search": "Pesquisar", "novels.genre": "Gênero",
"novels.status": "Status", "novels.language": "Idioma", "novels.sort": "Ordenar",
"novels.placeholder": "Título ou autor", "novels.empty": "Nenhum romance encontrado.",
"status.ONGOING": "Em andamento", "status.COMPLETED": "Concluído", "status.HIATUS": "Pausa",
"lang.ENGLISH": "Inglês", "lang.CHINESE": "Chinês", "lang.KOREAN": "Coreano",
"sort.popular": "Popular", "sort.rating": "Avaliação", "sort.newest": "Novo",
"sort.updated": "Atualizado", "sort.name": "Nome", "sort.chapters": "Capítulos",
"genres.title": "Gêneros", "genres.explore": "Explore este gênero.",
"novel.start": "Começar a ler", "novel.noChapters": "Sem capítulos ainda",
"novel.back": "Voltar à biblioteca", "novel.chapters": "Capítulos", "novel.chapter": "Capítulo",
"novel.nonePublished": "Nenhum capítulo publicado.",
"read.prev": "← Anterior", "read.next": "Próximo →", "read.chapters": "Capítulos",
},
ru: {
"nav.home": "Главная", "nav.novels": "Новеллы", "nav.genres": "Жанры",
"search.placeholder": "Поиск новелл...", "search.button": "Найти",
"prefs.language": "Язык", "prefs.theme": "Тема", "prefs.themeLight": "Светлая", "prefs.themeDark": "Тёмная",
"footer.tagline": "Серийное фэнтези в духе классических веб-новелл.",
"footer.browse": "Каталог", "footer.admin": "Админ",
"common.loading": "Загрузка…", "common.unknown": "Неизвестно", "common.any": "Любой",
"common.viewAll": "Смотреть все", "common.chapters": "глав", "common.views": "просмотров",
"common.failed": "Ошибка загрузки", "common.previous": "Назад", "common.next": "Далее",
"common.by": "автор", "common.total": "всего", "common.series": "серий",
"home.featured": "Избранная серия", "home.readNow": "Читать", "home.browse": "В библиотеку",
"home.welcome": "Добро пожаловать в Full Story Novel", "home.emptyHint": "Добавьте новеллы в админ-панели.",
"home.popularGenres": "Популярные жанры", "home.noGenres": "Пока нет жанров",
"home.popular": "Популярное за неделю", "home.new": "Новинки", "home.updated": "Недавно обновлено",
"home.nothing": "Пока пусто.",
"novels.title": "Новеллы", "novels.search": "Поиск", "novels.genre": "Жанр",
"novels.status": "Статус", "novels.language": "Язык", "novels.sort": "Сортировка",
"novels.placeholder": "Название или автор", "novels.empty": "Ничего не найдено.",
"status.ONGOING": "В процессе", "status.COMPLETED": "Завершено", "status.HIATUS": "Пауза",
"lang.ENGLISH": "Английский", "lang.CHINESE": "Китайский", "lang.KOREAN": "Корейский",
"sort.popular": "Популярные", "sort.rating": "Рейтинг", "sort.newest": "Новые",
"sort.updated": "Обновления", "sort.name": "Имя", "sort.chapters": "Главы",
"genres.title": "Жанры", "genres.explore": "Исследуйте этот жанр.",
"novel.start": "Начать чтение", "novel.noChapters": "Пока нет глав",
"novel.back": "К библиотеке", "novel.chapters": "Главы", "novel.chapter": "Глава",
"novel.nonePublished": "Нет опубликованных глав.",
"read.prev": "← Назад", "read.next": "Далее →", "read.chapters": "Оглавление",
},
ur: {
"nav.home": "ہوم", "nav.novels": "ناول", "nav.genres": "اقسام",
"search.placeholder": "ناول تلاش کریں...", "search.button": "تلاش",
"prefs.language": "زبان", "prefs.theme": "تھیم", "prefs.themeLight": "دن", "prefs.themeDark": "رات",
"footer.tagline": "کلاسک ویب ناول پلیٹ فارمز سے متاثر سلسلہ وار فنتاسی مطالعہ۔",
"footer.browse": "براؤز", "footer.admin": "ایڈمن",
"common.loading": "لوڈ ہو رہا ہے…", "common.unknown": "نامعلوم", "common.any": "کوئی بھی",
"common.viewAll": "سب دیکھیں", "common.chapters": "ابواب", "common.views": "ملاحظات",
"common.failed": "لوڈ ناکام", "common.previous": "پچھلا", "common.next": "اگلا",
"common.by": "از", "common.total": "کل", "common.series": "سیریز",
"home.featured": "نمایاں سیریز", "home.readNow": "ابھی پڑھیں", "home.browse": "لائبریری دیکھیں",
"home.welcome": "Full Story Novel میں خوش آمدید", "home.emptyHint": "شروع کرنے کے لیے ایڈمن سے ناول شامل کریں۔",
"home.popularGenres": "مقبول اقسام", "home.noGenres": "ابھی کوئی قسم نہیں",
"home.popular": "اس ہفتے مقبول", "home.new": "نئی ریلیز", "home.updated": "حال ہی میں اپڈیٹ",
"home.nothing": "ابھی کچھ نہیں۔",
"novels.title": "ناول", "novels.search": "تلاش", "novels.genre": "قسم",
"novels.status": "حیثیت", "novels.language": "زبان", "novels.sort": "ترتیب",
"novels.placeholder": "عنوان یا مصنف", "novels.empty": "کوئی ناول نہیں ملا۔",
"status.ONGOING": "جاری", "status.COMPLETED": "مکمل", "status.HIATUS": "وقفہ",
"lang.ENGLISH": "انگریزی", "lang.CHINESE": "چینی", "lang.KOREAN": "کوریائی",
"sort.popular": "مقبول", "sort.rating": "ریٹنگ", "sort.newest": "نیا",
"sort.updated": "اپڈیٹ", "sort.name": "نام", "sort.chapters": "ابواب",
"genres.title": "اقسام", "genres.explore": "اس قسم کو دیکھیں۔",
"novel.start": "پڑھنا شروع کریں", "novel.noChapters": "ابھی ابواب نہیں",
"novel.back": "لائبریری پر واپس", "novel.chapters": "ابواب", "novel.chapter": "باب",
"novel.nonePublished": "کوئی باب شائع نہیں۔",
"read.prev": "→ پچھلا", "read.next": "اگلا ←", "read.chapters": "ابواب",
},
ja: {
"nav.home": "ホーム", "nav.novels": "小説", "nav.genres": "ジャンル",
"search.placeholder": "小説を検索...", "search.button": "検索",
"prefs.language": "言語", "prefs.theme": "テーマ", "prefs.themeLight": "ライト", "prefs.themeDark": "ダーク",
"footer.tagline": "古典的なウェブ小説プラットフォームに着想を得た連載ファンタジー。",
"footer.browse": "閲覧", "footer.admin": "管理",
"common.loading": "読み込み中…", "common.unknown": "不明", "common.any": "すべて",
"common.viewAll": "すべて見る", "common.chapters": "章", "common.views": "閲覧",
"common.failed": "読み込み失敗", "common.previous": "前へ", "common.next": "次へ",
"common.by": "著者", "common.total": "合計", "common.series": "作品",
"home.featured": "注目作品", "home.readNow": "今すぐ読む", "home.browse": "ライブラリを見る",
"home.welcome": "Full Story Novel へようこそ", "home.emptyHint": "管理画面から小説を追加してください。",
"home.popularGenres": "人気ジャンル", "home.noGenres": "ジャンルはまだありません",
"home.popular": "今週の人気", "home.new": "新着", "home.updated": "最近更新",
"home.nothing": "まだありません。",
"novels.title": "小説", "novels.search": "検索", "novels.genre": "ジャンル",
"novels.status": "状態", "novels.language": "言語", "novels.sort": "並び替え",
"novels.placeholder": "タイトルまたは著者", "novels.empty": "一致する小説がありません。",
"status.ONGOING": "連載中", "status.COMPLETED": "完結", "status.HIATUS": "休載",
"lang.ENGLISH": "英語", "lang.CHINESE": "中国語", "lang.KOREAN": "韓国語",
"sort.popular": "人気", "sort.rating": "評価", "sort.newest": "新着",
"sort.updated": "更新", "sort.name": "名前", "sort.chapters": "章数",
"genres.title": "ジャンル", "genres.explore": "このジャンルを見る。",
"novel.start": "読み始める", "novel.noChapters": "章はまだありません",
"novel.back": "ライブラリに戻る", "novel.chapters": "目次", "novel.chapter": "第",
"novel.nonePublished": "公開された章はありません。",
"read.prev": "← 前の章", "read.next": "次の章 →", "read.chapters": "目次",
},
ko: {
"nav.home": "홈", "nav.novels": "소설", "nav.genres": "장르",
"search.placeholder": "소설 검색...", "search.button": "검색",
"prefs.language": "언어", "prefs.theme": "테마", "prefs.themeLight": "라이트", "prefs.themeDark": "다크",
"footer.tagline": "클래식 웹소설 플랫폼에서 영감을 받은 연재 판타지 독서.",
"footer.browse": "둘러보기", "footer.admin": "관리",
"common.loading": "로딩 중…", "common.unknown": "알 수 없음", "common.any": "전체",
"common.viewAll": "전체 보기", "common.chapters": "화", "common.views": "조회",
"common.failed": "불러오기 실패", "common.previous": "이전", "common.next": "다음",
"common.by": "작가", "common.total": "총", "common.series": "작품",
"home.featured": "추천 작품", "home.readNow": "바로 읽기", "home.browse": "서재 보기",
"home.welcome": "Full Story Novel에 오신 것을 환영합니다", "home.emptyHint": "관리자에서 소설을 추가하세요.",
"home.popularGenres": "인기 장르", "home.noGenres": "장르가 없습니다",
"home.popular": "이번 주 인기", "home.new": "신작", "home.updated": "최근 업데이트",
"home.nothing": "아직 없습니다.",
"novels.title": "소설", "novels.search": "검색", "novels.genre": "장르",
"novels.status": "상태", "novels.language": "언어", "novels.sort": "정렬",
"novels.placeholder": "제목 또는 작가", "novels.empty": "일치하는 소설이 없습니다.",
"status.ONGOING": "연재중", "status.COMPLETED": "완결", "status.HIATUS": "휴재",
"lang.ENGLISH": "영어", "lang.CHINESE": "중국어", "lang.KOREAN": "한국어",
"sort.popular": "인기", "sort.rating": "평점", "sort.newest": "최신",
"sort.updated": "업데이트", "sort.name": "이름", "sort.chapters": "화수",
"genres.title": "장르", "genres.explore": "이 장르를 탐색하세요.",
"novel.start": "읽기 시작", "novel.noChapters": "아직 회차가 없습니다",
"novel.back": "서재로 돌아가기", "novel.chapters": "목차", "novel.chapter": "제",
"novel.nonePublished": "게시된 회차가 없습니다.",
"read.prev": "← 이전", "read.next": "다음 →", "read.chapters": "목차",
},
fr: {
"nav.home": "Accueil", "nav.novels": "Romans", "nav.genres": "Genres",
"search.placeholder": "Rechercher des romans...", "search.button": "Rechercher",
"prefs.language": "Langue", "prefs.theme": "Thème", "prefs.themeLight": "Clair", "prefs.themeDark": "Sombre",
"footer.tagline": "Lecture fantasy en série inspirée des plateformes de web novels classiques.",
"footer.browse": "Parcourir", "footer.admin": "Admin",
"common.loading": "Chargement…", "common.unknown": "Inconnu", "common.any": "Tous",
"common.viewAll": "Tout voir", "common.chapters": "chapitres", "common.views": "vues",
"common.failed": "Échec du chargement", "common.previous": "Précédent", "common.next": "Suivant",
"common.by": "par", "common.total": "total", "common.series": "séries",
"home.featured": "Série à la une", "home.readNow": "Lire maintenant", "home.browse": "Parcourir la bibliothèque",
"home.welcome": "Bienvenue sur Full Story Novel", "home.emptyHint": "Ajoutez des romans depuis l’admin.",
"home.popularGenres": "Genres populaires", "home.noGenres": "Pas encore de genres",
"home.popular": "Populaire cette semaine", "home.new": "Nouveautés", "home.updated": "Récemment mis à jour",
"home.nothing": "Rien ici pour l’instant.",
"novels.title": "Romans", "novels.search": "Recherche", "novels.genre": "Genre",
"novels.status": "Statut", "novels.language": "Langue", "novels.sort": "Tri",
"novels.placeholder": "Titre ou auteur", "novels.empty": "Aucun roman trouvé.",
"status.ONGOING": "En cours", "status.COMPLETED": "Terminé", "status.HIATUS": "Pause",
"lang.ENGLISH": "Anglais", "lang.CHINESE": "Chinois", "lang.KOREAN": "Coréen",
"sort.popular": "Populaire", "sort.rating": "Note", "sort.newest": "Récent",
"sort.updated": "Mis à jour", "sort.name": "Nom", "sort.chapters": "Chapitres",
"genres.title": "Genres", "genres.explore": "Explorez ce genre.",
"novel.start": "Commencer", "novel.noChapters": "Pas encore de chapitres",
"novel.back": "Retour à la bibliothèque", "novel.chapters": "Chapitres", "novel.chapter": "Chapitre",
"novel.nonePublished": "Aucun chapitre publié.",
"read.prev": "← Précédent", "read.next": "Suivant →", "read.chapters": "Chapitres",
},
de: {
"nav.home": "Start", "nav.novels": "Romane", "nav.genres": "Genres",
"search.placeholder": "Romane suchen...", "search.button": "Suchen",
"prefs.language": "Sprache", "prefs.theme": "Thema", "prefs.themeLight": "Hell", "prefs.themeDark": "Dunkel",
"footer.tagline": "Serielle Fantasy-Lektüre inspiriert von klassischen Webnovel-Plattformen.",
"footer.browse": "Stöbern", "footer.admin": "Admin",
"common.loading": "Laden…", "common.unknown": "Unbekannt", "common.any": "Alle",
"common.viewAll": "Alle anzeigen", "common.chapters": "Kapitel", "common.views": "Aufrufe",
"common.failed": "Laden fehlgeschlagen", "common.previous": "Zurück", "common.next": "Weiter",
"common.by": "von", "common.total": "gesamt", "common.series": "Serien",
"home.featured": "Empfohlene Serie", "home.readNow": "Jetzt lesen", "home.browse": "Bibliothek öffnen",
"home.welcome": "Willkommen bei Full Story Novel", "home.emptyHint": "Füge Romane im Admin-Bereich hinzu.",
"home.popularGenres": "Beliebte Genres", "home.noGenres": "Noch keine Genres",
"home.popular": "Beliebt diese Woche", "home.new": "Neuerscheinungen", "home.updated": "Kürzlich aktualisiert",
"home.nothing": "Noch nichts hier.",
"novels.title": "Romane", "novels.search": "Suche", "novels.genre": "Genre",
"novels.status": "Status", "novels.language": "Sprache", "novels.sort": "Sortierung",
"novels.placeholder": "Titel oder Autor", "novels.empty": "Keine Treffer.",
"status.ONGOING": "Laufend", "status.COMPLETED": "Abgeschlossen", "status.HIATUS": "Pause",
"lang.ENGLISH": "Englisch", "lang.CHINESE": "Chinesisch", "lang.KOREAN": "Koreanisch",
"sort.popular": "Beliebt", "sort.rating": "Bewertung", "sort.newest": "Neu",
"sort.updated": "Aktualisiert", "sort.name": "Name", "sort.chapters": "Kapitel",
"genres.title": "Genres", "genres.explore": "Dieses Genre entdecken.",
"novel.start": "Lesen starten", "novel.noChapters": "Noch keine Kapitel",
"novel.back": "Zur Bibliothek", "novel.chapters": "Kapitel", "novel.chapter": "Kapitel",
"novel.nonePublished": "Keine veröffentlichten Kapitel.",
"read.prev": "← Zurück", "read.next": "Weiter →", "read.chapters": "Kapitel",
},
it: {
"nav.home": "Home", "nav.novels": "Romanzi", "nav.genres": "Generi",
"search.placeholder": "Cerca romanzi...", "search.button": "Cerca",
"prefs.language": "Lingua", "prefs.theme": "Tema", "prefs.themeLight": "Chiaro", "prefs.themeDark": "Scuro",
"footer.tagline": "Lettura fantasy a puntate ispirata alle piattaforme di web novel classiche.",
"footer.browse": "Sfoglia", "footer.admin": "Admin",
"common.loading": "Caricamento…", "common.unknown": "Sconosciuto", "common.any": "Qualsiasi",
"common.viewAll": "Vedi tutto", "common.chapters": "capitoli", "common.views": "visualizzazioni",
"common.failed": "Caricamento non riuscito", "common.previous": "Precedente", "common.next": "Successivo",
"common.by": "di", "common.total": "totale", "common.series": "serie",
"home.featured": "Serie in evidenza", "home.readNow": "Leggi ora", "home.browse": "Sfoglia la libreria",
"home.welcome": "Benvenuto su Full Story Novel", "home.emptyHint": "Aggiungi romanzi dal pannello admin.",
"home.popularGenres": "Generi popolari", "home.noGenres": "Nessun genere ancora",
"home.popular": "Popolari questa settimana", "home.new": "Nuove uscite", "home.updated": "Aggiornati di recente",
"home.nothing": "Ancora niente qui.",
"novels.title": "Romanzi", "novels.search": "Cerca", "novels.genre": "Genere",
"novels.status": "Stato", "novels.language": "Lingua", "novels.sort": "Ordina",
"novels.placeholder": "Titolo o autore", "novels.empty": "Nessun romanzo trovato.",
"status.ONGOING": "In corso", "status.COMPLETED": "Completato", "status.HIATUS": "Pausa",
"lang.ENGLISH": "Inglese", "lang.CHINESE": "Cinese", "lang.KOREAN": "Coreano",
"sort.popular": "Popolari", "sort.rating": "Valutazione", "sort.newest": "Nuovi",
"sort.updated": "Aggiornati", "sort.name": "Nome", "sort.chapters": "Capitoli",
"genres.title": "Generi", "genres.explore": "Esplora questo genere.",
"novel.start": "Inizia a leggere", "novel.noChapters": "Ancora nessun capitolo",
"novel.back": "Torna alla libreria", "novel.chapters": "Capitoli", "novel.chapter": "Capitolo",
"novel.nonePublished": "Nessun capitolo pubblicato.",
"read.prev": "← Precedente", "read.next": "Successivo →", "read.chapters": "Capitoli",
},
};
let lang = DEFAULT_LANG;
let theme = "dark";
function t(key, fallback) {
const dict = I18N[lang] || I18N.en;
return dict[key] || I18N.en[key] || fallback || key;
}
function applyDocumentLocale() {
document.documentElement.lang = lang;
document.documentElement.dir = RTL_LANGS.has(lang) ? "rtl" : "ltr";
}
function applyTheme() {
document.documentElement.setAttribute("data-theme", theme);
const btn = document.getElementById("theme-toggle");
if (btn) {
btn.textContent = theme === "light" ? "☾" : "☀";
btn.title = theme === "light" ? t("prefs.themeDark") : t("prefs.themeLight");
btn.setAttribute("aria-label", btn.title);
}
}
function applyStaticI18n() {
document.querySelectorAll("[data-i18n]").forEach((el) => {
el.textContent = t(el.getAttribute("data-i18n"));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
el.setAttribute("placeholder", t(el.getAttribute("data-i18n-placeholder")));
});
const sel = document.getElementById("lang-select");
if (sel) {
sel.setAttribute("aria-label", t("prefs.language"));
sel.title = t("prefs.language");
}
applyTheme();
}
function initPrefs() {
try {
const savedLang = localStorage.getItem(LANG_KEY);
lang = savedLang && I18N[savedLang] ? savedLang : DEFAULT_LANG;
const savedTheme = localStorage.getItem(THEME_KEY);
theme = savedTheme === "light" || savedTheme === "dark" ? savedTheme : "dark";
} catch (_) {
lang = DEFAULT_LANG;
theme = "dark";
}
applyDocumentLocale();
applyTheme();
const sel = document.getElementById("lang-select");
if (sel) {
sel.innerHTML = LANGS.map(
(l) => `<option value="${l.code}" ${l.code === lang ? "selected" : ""}>${l.label}</option>`
).join("");
sel.addEventListener("change", () => {
lang = sel.value;
try {
localStorage.setItem(LANG_KEY, lang);
} catch (_) {}
applyDocumentLocale();
applyStaticI18n();
render();
});
}
const themeBtn = document.getElementById("theme-toggle");
if (themeBtn) {
themeBtn.addEventListener("click", () => {
theme = theme === "dark" ? "light" : "dark";
try {
localStorage.setItem(THEME_KEY, theme);
} catch (_) {}
applyTheme();
});
}
applyStaticI18n();
}
const app = document.getElementById("app");
const state = { genres: [], genreTree: [] };
function asGenreTree(list) {
const arr = Array.isArray(list) ? list : [];
if (arr.some((g) => Array.isArray(g.children) && g.children.length)) {
return arr.filter((g) => !g.parentId);
}
const parents = arr.filter((g) => !g.parentId);
const children = arr.filter((g) => g.parentId);
if (!parents.length) return arr;
return parents.map((p) => ({
...p,
children: children.filter((c) => c.parentId === p.id || c.parentSlug === p.slug),
}));
}
function findGenreNode(tree, slug) {
const key = String(slug || "").toLowerCase();
for (const p of tree) {
if (String(p.slug).toLowerCase() === key) return { parent: p, child: null };
for (const c of p.children || []) {
if (String(c.slug).toLowerCase() === key) return { parent: p, child: c };
}
}
return { parent: null, child: null };
}
async function api(path) {
const res = await fetch(path);
if (!res.ok) throw new Error((await res.text()) || res.statusText);
return res.json();
}
function escapeHtml(s) {
return String(s ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function cover(url, title) {
if (url) return url;
const initial = (title || "?").slice(0, 1).toUpperCase();
const svg = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#4c1d95"/><stop offset="1" stop-color="#0f172a"/></linearGradient></defs><rect width="100%" height="100%" fill="url(#g)"/><text x="50%" y="52%" fill="#e9d5ff" font-size="140" font-family="Segoe UI,sans-serif" text-anchor="middle" dominant-baseline="middle">${initial}</text></svg>`
);
return `data:image/svg+xml,${svg}`;
}
function formatViews(v) {
const n = Number(v || 0);
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M " + t("common.views");
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K " + t("common.views");
return n + " " + t("common.views");
}
function statusLabel(s) {
return t("status." + s, s);
}
function languageLabel(s) {
return t("lang." + s, s);
}
function badges(novel) {
const items = [
`<span class="badge green">${escapeHtml(statusLabel(novel.status))}</span>`,
`<span class="badge">${escapeHtml(languageLabel(novel.language))}</span>`,
...((novel.genres || []).slice(0, 3).map((g) => `<span class="badge">${escapeHtml(g)}</span>`)),
];
return `<div class="badge-row">${items.join("")}</div>`;
}
function novelCard(n) {
return `
<a class="novel-card" href="/novel/${encodeURIComponent(n.slug)}">
<img class="cover" src="${cover(n.coverUrl, n.title)}" alt="${escapeHtml(n.title)}" />
<div class="body">
<h3>${escapeHtml(n.title)}</h3>
<div class="meta">${escapeHtml(n.author || t("common.unknown"))} · ★ ${Number(n.rating || 0).toFixed(1)}</div>
<div class="meta">${n.chapterCount || 0} ${t("common.chapters")} · ${formatViews(n.views)}</div>
</div>
</a>`;
}
function section(title, items) {
return `
<div class="section-head"><h2>${title}</h2><a class="muted" href="/novels">${t("common.viewAll")}</a></div>
<div class="grid">${(items || []).map(novelCard).join("") || `<div class="empty">${t("home.nothing")}</div>`}</div>`;
}
function parseRoute() {
const path = location.pathname.replace(/\/+$/, "") || "/";
const parts = path.split("/").filter(Boolean);
if (!parts.length) return { name: "home" };
if (parts[0] === "novels") return { name: "novels" };
if (parts[0] === "genres") return { name: "genres" };
if (parts[0] === "download" || parts[0] === "app") return { name: "download" };
if (parts[0] === "novel" && parts[1]) return { name: "novel", slug: decodeURIComponent(parts[1]) };
if (parts[0] === "read" && parts[1] && parts[2]) {
return {
name: "read",
novelSlug: decodeURIComponent(parts[1]),
chapterSlug: decodeURIComponent(parts[2]),
};
}
return { name: "home" };
}
async function renderHome() {
const data = await api("/api/home");
state.genreTree = asGenreTree(data.genres || []);
state.genres = state.genreTree;
const featured = (data.featured || [])[0];
app.innerHTML = `
<section class="hero">
<div class="hero-card">
${
featured
? `<img class="cover" src="${cover(featured.coverUrl, featured.title)}" alt="" />
<div>
<div class="muted">${t("home.featured")}</div>
<h1>${escapeHtml(featured.title)}</h1>
${badges(featured)}
<p>${escapeHtml((featured.synopsis || "").slice(0, 280))}${(featured.synopsis || "").length > 280 ? "…" : ""}</p>
<div class="actions">
<a class="btn" href="/novel/${encodeURIComponent(featured.slug)}">${t("home.readNow")}</a>
<a class="btn secondary" href="/novels">${t("home.browse")}</a>
</div>
</div>`
: `<div><h1>${t("home.welcome")}</h1><p class="muted">${t("home.emptyHint")}</p></div>`
}
</div>
<div class="panel" style="padding:1.2rem">
<h2 style="margin-top:0">${t("home.popularGenres")}</h2>
<div class="badge-row">
${state.genreTree
.map((g) => `<a class="badge" href="/novels?genre=${encodeURIComponent(g.slug)}">${escapeHtml(g.name)}</a>`)
.join("") || `<span class="muted">${t("home.noGenres")}</span>`}
</div>
</div>
</section>
${section(t("home.popular"), data.popular)}
${section(t("home.new"), data.newReleases)}
${section(t("home.updated"), data.recentlyUpdated)}
`;
}
async function renderNovels() {
const params = new URLSearchParams(location.search);
const q = params.get("q") || "";
const genre = params.get("genre") || "";
const status = params.get("status") || "";
const language = params.get("language") || "";
const sort = params.get("sort") || "popular";
const page = Number(params.get("page") || 0);
if (!state.genreTree.length) {
state.genreTree = asGenreTree(await api("/api/genres?tree=true"));
state.genres = state.genreTree;
}
const located = findGenreNode(state.genreTree, genre);
const parentSlug = located.parent ? located.parent.slug : "";
const childSlug = located.child ? located.child.slug : "";
const childOptions = located.parent ? located.parent.children || [] : [];
const filterSlug = childSlug || parentSlug || genre;
const qs = new URLSearchParams({ page: String(page), size: "24", sort });
if (q) qs.set("q", q);
if (filterSlug) qs.set("genre", filterSlug);
if (status) qs.set("status", status);
if (language) qs.set("language", language);
const data = await api(`/api/novels?${qs}`);
app.innerHTML = `
<div class="section-head"><h2>${t("novels.title")}</h2><div class="muted">${data.total} ${t("common.series")}</div></div>
<form class="filters panel" id="filter-form">
<label>${t("novels.search")}<input name="q" value="${escapeHtml(q)}" placeholder="${escapeHtml(t("novels.placeholder"))}" /></label>
<label>${t("novels.genre")}<select name="parent"><option value="">${t("common.any")}</option>${state.genreTree
.map((g) => `<option value="${escapeHtml(g.slug)}" ${g.slug === parentSlug ? "selected" : ""}>${escapeHtml(g.name)}</option>`)
.join("")}</select></label>
<label>${t("novels.tag")}<select name="tag" ${childOptions.length ? "" : "disabled"}><option value="">${t("novels.allTags")}</option>${childOptions
.map((g) => `<option value="${escapeHtml(g.slug)}" ${g.slug === childSlug ? "selected" : ""}>${escapeHtml(g.name)}</option>`)
.join("")}</select></label>
<label>${t("novels.status")}<select name="status">${[
["", t("common.any")],
["ONGOING", t("status.ONGOING")],
["COMPLETED", t("status.COMPLETED")],
["HIATUS", t("status.HIATUS")],
]
.map(([v, l]) => `<option value="${v}" ${status === v ? "selected" : ""}>${l}</option>`)
.join("")}</select></label>
<label>${t("novels.language")}<select name="language">${[
["", t("common.any")],
["ENGLISH", t("lang.ENGLISH")],
["CHINESE", t("lang.CHINESE")],
["KOREAN", t("lang.KOREAN")],
]
.map(([v, l]) => `<option value="${v}" ${language === v ? "selected" : ""}>${l}</option>`)
.join("")}</select></label>
<label>${t("novels.sort")}<select name="sort">${[
["popular", t("sort.popular")],
["rating", t("sort.rating")],
["newest", t("sort.newest")],
["updated", t("sort.updated")],
["name", t("sort.name")],
["chapters", t("sort.chapters")],
]
.map(([v, l]) => `<option value="${v}" ${sort === v ? "selected" : ""}>${l}</option>`)
.join("")}</select></label>
</form>
<div class="grid">${(data.items || []).map(novelCard).join("") || `<div class="empty">${t("novels.empty")}</div>`}</div>
<div class="actions" style="margin-top:1.25rem">
${page > 0 ? `<a class="btn secondary" href="${pageLink(page - 1)}">${t("common.previous")}</a>` : ""}
${page + 1 < (data.totalPages || 0) ? `<a class="btn secondary" href="${pageLink(page + 1)}">${t("common.next")}</a>` : ""}
</div>`;
document.getElementById("filter-form").addEventListener("change", (ev) => {
const fd = new FormData(document.getElementById("filter-form"));
const next = new URLSearchParams();
const parent = String(fd.get("parent") || "").trim();
const tag = String(fd.get("tag") || "").trim();
if (ev.target && ev.target.name === "parent") {
if (parent) next.set("genre", parent);
} else if (tag) {
next.set("genre", tag);
} else if (parent) {
next.set("genre", parent);
}
for (const [k, v] of fd.entries()) {
if (k === "parent" || k === "tag") continue;
if (String(v).trim()) next.set(k, String(v).trim());
}
history.pushState({}, "", `/novels?${next}`);
render();
});
function pageLink(p) {
const next = new URLSearchParams(params);
next.set("page", String(p));
return `/novels?${next}`;
}
}
async function renderGenres() {
const genres = asGenreTree(await api("/api/genres?tree=true"));
state.genreTree = genres;
app.innerHTML = `
<div class="section-head"><h2>${t("genres.title")}</h2></div>
<div class="genre-grid">
${genres
.map(
(g) => `<article class="genre-card">
<a href="/novels?genre=${encodeURIComponent(g.slug)}"><h3>${escapeHtml(g.name)}</h3></a>
<p class="muted">${escapeHtml(g.description || t("genres.explore"))}</p>
<div class="child-tags">
${(g.children || [])
.map((c) => `<a class="badge" href="/novels?genre=${encodeURIComponent(c.slug)}">${escapeHtml(c.name)}</a>`)
.join("")}
</div>
</article>`
)
.join("")}
</div>`;
}
async function renderNovel(slug) {
const [novel, chapters] = await Promise.all([
api(`/api/novels/${encodeURIComponent(slug)}`),
api(`/api/novels/${encodeURIComponent(slug)}/chapters`),
]);
const first = chapters[0];
app.innerHTML = `
<article class="novel-detail panel">
<img class="cover cover-lg" src="${cover(novel.coverUrl, novel.title)}" alt="" />
<div>
<h1>${escapeHtml(novel.title)}</h1>
<div class="meta">${t("common.by")} ${escapeHtml(novel.author || t("common.unknown"))} · ★ ${Number(novel.rating || 0).toFixed(1)} · ${formatViews(novel.views)}</div>
${badges(novel)}
<p>${escapeHtml(novel.synopsis || "")}</p>
<div class="actions">
${first ? `<a class="btn" href="/read/${encodeURIComponent(slug)}/${encodeURIComponent(first.slug)}">${t("novel.start")}</a>` : `<span class="muted">${t("novel.noChapters")}</span>`}
<a class="btn secondary" href="/novels">${t("novel.back")}</a>
</div>
</div>
</article>
<section class="chapter-list">
<div class="section-head"><h2>${t("novel.chapters")}</h2><div class="muted">${chapters.length} ${t("common.total")}</div></div>
${
chapters
.map(
(c) => `<a class="chapter-row" href="/read/${encodeURIComponent(slug)}/${encodeURIComponent(c.slug)}">
<div><strong>${t("novel.chapter")} ${c.chapterNumber}</strong> · ${escapeHtml(c.title)}</div>
<div class="muted">${c.publishedAt ? new Date(c.publishedAt).toLocaleDateString(lang) : ""}</div>
</a>`
)
.join("") || `<div class="empty">${t("novel.nonePublished")}</div>`
}
</section>`;
}
async function renderRead(novelSlug, chapterSlug) {
const chapter = await api(
`/api/novels/${encodeURIComponent(novelSlug)}/chapters/${encodeURIComponent(chapterSlug)}`
);
document.title = `${chapter.title} · ${chapter.novelTitle} · Full Story Novel`;
app.innerHTML = `
<article class="reader">
<div class="muted"><a href="/novel/${encodeURIComponent(novelSlug)}">${escapeHtml(chapter.novelTitle)}</a></div>
<h1>${t("novel.chapter")} ${chapter.chapterNumber}: ${escapeHtml(chapter.title)}</h1>
<div class="reader-content">${escapeHtml(chapter.content)}</div>
<div class="reader-nav">
${chapter.prev ? `<a class="btn secondary" href="/read/${encodeURIComponent(novelSlug)}/${encodeURIComponent(chapter.prev.slug)}">${t("read.prev")}</a>` : `<span></span>`}
<a class="btn secondary" href="/novel/${encodeURIComponent(novelSlug)}">${t("read.chapters")}</a>
${chapter.next ? `<a class="btn" href="/read/${encodeURIComponent(novelSlug)}/${encodeURIComponent(chapter.next.slug)}">${t("read.next")}</a>` : `<span></span>`}
</div>
</article>`;
window.scrollTo({ top: 0, behavior: "smooth" });
}
/** Public download-info;未上架时回退站内 APK 镜像(不暴露内部仓库)。 */
const APP_DOWNLOAD = {
infoUrl: "https://admin.wyrs365.com/api/public/novel-reader/download-info",
localApk: "/downloads/FullStoryNovel-release.apk",
packageName: "wf.wyrs.com",
};
async function resolveAndroidApkUrl() {
try {
const res = await fetch(APP_DOWNLOAD.infoUrl, { credentials: "omit", cache: "no-store" });
if (res.ok) {
const json = await res.json();
const data = json && json.data ? json.data : json;
if (data && data.releaseApkOnShelf && data.releaseApkUrl) {
return { url: data.releaseApkUrl, source: "admin", name: data.releaseApkFileName || "FullStoryNovel.apk" };
}
if (data && data.appStoreOnShelf && data.appStoreUrl) {
return { url: data.appStoreUrl, source: "store", name: "App Store" };
}
}
} catch (_) {
/* fall through */
}
try {
const local = await fetch(APP_DOWNLOAD.localApk, { method: "HEAD", cache: "no-store" });
if (local.ok) {
return { url: APP_DOWNLOAD.localApk, source: "site", name: "FullStoryNovel-release.apk" };
}
} catch (_) {
/* fall through */
}
return {
url: APP_DOWNLOAD.localApk,
source: "site",
name: "FullStoryNovel-release.apk",
};
}
function ensureDownloadFonts() {
if (document.getElementById("fsn-dl-fonts")) return;
const link = document.createElement("link");
link.id = "fsn-dl-fonts";
link.rel = "stylesheet";
link.href =
"https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Syne:wght@600;700;800&display=swap";
document.head.appendChild(link);
}
async function renderDownload() {
ensureDownloadFonts();
document.body.classList.add("page-download");
document.title = `${t("download.title")} · Full Story Novel`;
app.innerHTML = `
<section class="dl-stage" aria-label="Download">
<div class="dl-atmosphere" aria-hidden="true">
<div class="dl-grid"></div>
<div class="dl-orb dl-orb-a"></div>
<div class="dl-orb dl-orb-b"></div>
<div class="dl-scan"></div>
</div>
<article class="dl-hero">
<div class="dl-brand">
<img class="dl-logo" src="/assets/logo.png?v=20260923-dl" width="72" height="72" alt="" />
<p class="dl-brand-name">Full Story Novel</p>
</div>
<p class="dl-kicker">${t("download.platform")}</p>
<h1 class="dl-title">${t("download.title")}</h1>
<p class="dl-lead">${t("download.lead")}</p>
<div class="dl-actions">
<a class="dl-cta" id="dl-primary" href="${APP_DOWNLOAD.localApk}" rel="noopener">
<span class="dl-cta-glow" aria-hidden="true"></span>
<span class="dl-cta-label">${t("download.android")}</span>
</a>
</div>
<p class="dl-status" id="dl-status">${t("download.resolving")}</p>
<p class="dl-meta" id="dl-meta" hidden></p>
<ul class="dl-features">
<li>${t("download.feature1")}</li>
<li>${t("download.feature2")}</li>
<li>${t("download.feature3")}</li>
</ul>
<ol class="dl-notes">
<li>${t("download.hint1")}</li>
<li>${t("download.hint2")}</li>
<li>${t("download.hint3")}</li>
</ol>
</article>
</section>`;
const primary = document.getElementById("dl-primary");
const status = document.getElementById("dl-status");
const meta = document.getElementById("dl-meta");
const resolved = await resolveAndroidApkUrl();
if (primary) {
primary.href = resolved.url;
if (resolved.source === "store") primary.setAttribute("target", "_blank");
}
if (status) status.textContent = t("download.ready");
if (meta) {
meta.hidden = true;
meta.textContent = resolved.name || "";
}
}
async function render() {
const route = parseRoute();
document.title = "Full Story Novel";
document.body.classList.toggle("page-download", route.name === "download");
syncNavActive();
app.innerHTML = `<div class="loading">${t("common.loading")}</div>`;
try {
if (route.name === "home") await renderHome();
else if (route.name === "novels") await renderNovels();
else if (route.name === "genres") await renderGenres();
else if (route.name === "download") await renderDownload();
else if (route.name === "novel") await renderNovel(route.slug);
else if (route.name === "read") await renderRead(route.novelSlug, route.chapterSlug);
else await renderHome();
} catch (err) {
app.innerHTML = `<div class="empty">${t("common.failed")}: ${escapeHtml(err.message)}</div>`;
}
syncNavActive();
}
document.getElementById("search-form").addEventListener("submit", (e) => {
e.preventDefault();
const q = document.getElementById("search-input").value.trim();
history.pushState({}, "", q ? `/novels?q=${encodeURIComponent(q)}` : "/novels");
render();
});
document.addEventListener("click", (e) => {
const a = e.target.closest("a");
if (!a) return;
const href = a.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("#") || a.target === "_blank") return;
if (href === "/privacy" || href === "/terms" || href === "/delete") return;
e.preventDefault();
history.pushState({}, "", href);
render();
});
window.addEventListener("popstate", render);
initPrefs();
initMobileNav();
render();
function initMobileNav() {
const topbar = document.querySelector(".topbar");
const toggle = document.getElementById("nav-toggle");
const nav = document.getElementById("site-nav");
if (!topbar || !toggle || !nav) return;
const setOpen = (open) => {
topbar.classList.toggle("is-nav-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
};
toggle.addEventListener("click", () => {
setOpen(!topbar.classList.contains("is-nav-open"));
});
nav.addEventListener("click", (e) => {
if (e.target.closest("a")) setOpen(false);
});
const appDl = document.getElementById("app-dl-link");
if (appDl) {
appDl.addEventListener("click", () => setOpen(false));
}
document.addEventListener("click", (e) => {
if (!topbar.contains(e.target)) setOpen(false);
});
window.addEventListener("resize", () => {
if (window.matchMedia("(min-width: 901px)").matches) setOpen(false);
});
}
function syncNavActive() {
const path = location.pathname.replace(/\/+$/, "") || "/";
document.querySelectorAll("#site-nav a, #app-dl-link").forEach((a) => {
const href = (a.getAttribute("href") || "").replace(/\/+$/, "") || "/";
let active = false;
if (href === "/") active = path === "/" || path === "";
else if (href === "/novels") active = path === "/novels" || path.startsWith("/novel/");
else if (href === "/genres") active = path === "/genres";
else if (href === "/download") active = path === "/download" || path === "/app";
a.classList.toggle("active", active);
});
}