商店审核需要独立的隐私、用户协议和注销页;运营后台也要按应用包名启用或停用价格。 Co-authored-by: Cursor <cursoragent@cursor.com>
1085 lines
63 KiB
JavaScript
1085 lines
63 KiB
JavaScript
/* 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("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """);
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|