Compare commits

...

6 Commits

Author SHA1 Message Date
Admin
6559a8c015 fix: split long text into chunks before sending to Cloudflare AI TTS
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.

GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
2026-04-04 20:45:22 +05:00
Admin
05bfd110b8 refactor: replace floating settings panel with bottom sheet + Reading/Listening tabs
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
The old vertical dropdown was too tall on mobile — 14 stacked groups
required heavy scrolling and had no visual hierarchy.

New design:
- Full-width bottom sheet slides from screen edge (natural mobile gesture)
- Drag handle + dimmed backdrop for clarity
- Two tabs split the settings: Reading (typography + layout) and Listening
  (player style, speed, auto-next, sleep timer)
- Each row uses label-on-left + pill-group-on-right layout — saves one line
  per setting and makes the list scannable at a glance
- Settings are grouped into titled cards (Typography, Layout, Player)
  with dividers between rows instead of floating individual blocks
- Gear button moved to bottom-[4.5rem] to clear the mini-player bar
2026-04-04 20:35:15 +05:00
Admin
bfd0ad8fb7 fix: chapter content vanishes — replace stale untrack snapshots with $derived
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / ui (push) Successful in 4m58s
Release / Gitea Release (push) Has been cancelled
html and fetchingContent were captured with untrack() at mount time.
When SvelteKit re-ran the page load (triggered by the layout's settings PUT),
data.html updated but html stayed stale. The {#key} block in the layout then
destroyed and recreated the component, and on remount data.html was momentarily
empty so html became '' and the live-scrape fallback ran unnecessarily.

Fix:
- html is now $derived(scrapedHtml || data.html || '') — always tracks load
- scrapedHtml is a separate $state only set by the live-scrape fallback
- fetchingContent starts false; the fallback sets it true only when actually fetching
- translationStatus/translatingLang: dropped untrack() so they also react to re-runs
- Removed unused untrack import
2026-04-04 20:26:43 +05:00
Admin
4b7fcf432b fix: pass POLAR_API_TOKEN and POLAR_WEBHOOK_SECRET to ui container
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 43s
Both vars were present in Doppler but never injected into the ui service
environment, causing all checkout requests to fail with 500 and webhooks
to be silently rejected.
2026-04-04 20:18:19 +05:00
Admin
c4a0256f6e feat: /subscribe pricing page + Pro nav link + fix checkout token scope
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 41s
- Add /subscribe route with hero, benefits list, and pricing cards
  (annual featured with Save 33% badge, monthly secondary)
- Add 'Pro' link in nav for non-Pro users
- Add 'See plans' link in profile subscription section
- i18n keys across en/fr/id/pt/ru for all subscribe strings

Note: checkout still requires POLAR_API_TOKEN with checkouts:write scope.
Regenerate the token at polar.sh to fix the 502 error on subscribe buttons.
2026-04-04 20:12:24 +05:00
Admin
18f490f790 feat: admin controls on book detail page (cover/desc/chapter-names/audio TTS)
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m22s
Release / Docker / runner (push) Successful in 2m51s
Release / Docker / ui (push) Successful in 3m17s
Release / Gitea Release (push) Successful in 1m12s
Add 5 admin sections to /books/[slug] for admins:
- Book cover generation (CF AI image-gen with preview + save)
- Chapter cover generation (chapter number input + preview)
- Description regeneration (preview + apply/discard)
- Chapter names generation (preview table + apply/discard)
- Audio TTS bulk enqueue (voice selector, chapter range, cancel)

Also adds /api/admin/audio/bulk and /api/admin/audio/cancel-bulk proxy routes,
and all i18n keys across en/fr/id/pt/ru.
2026-04-04 19:57:16 +05:00
33 changed files with 1550 additions and 229 deletions

View File

@@ -17,6 +17,10 @@
// response. There is no 100-second Cloudflare proxy timeout because we are
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
// homelab tunnel.
//
// The aura-2-en model enforces a hard 2 000-character limit per request.
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
// and concatenates the resulting MP3 frames.
package cfai
import (
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
}
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("cfai: empty text")
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
speaker = "luna"
}
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
var combined []byte
for _, chunk := range chunks {
part, err := c.generateChunk(ctx, chunk, speaker)
if err != nil {
return nil, err
}
combined = append(combined, part...)
}
return combined, nil
}
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
body, err := json.Marshal(map[string]any{
"text": text,
"speaker": speaker,
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
return mp3, nil
}
// splitText splits src into chunks of at most maxChars characters each.
// It tries to break at paragraph boundaries first, then at sentence-ending
// punctuation (. ! ?), and falls back to the nearest space.
func splitText(src string, maxChars int) []string {
if len(src) <= maxChars {
return []string{src}
}
var chunks []string
remaining := src
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Search window: the first maxChars bytes of remaining.
// Use byte length here because the API limit is in bytes/chars for ASCII;
// for safety we operate on rune-aware slices.
window := remaining
if len(window) > maxChars {
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
window = runeSlice(remaining, maxChars)
}
cut := -1
// 1. Prefer paragraph break (\n\n or \n).
if i := strings.LastIndex(window, "\n\n"); i > 0 {
cut = i + 2
} else if i := strings.LastIndex(window, "\n"); i > 0 {
cut = i + 1
}
// 2. Fall back to sentence-ending punctuation followed by a space.
if cut < 0 {
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
if i := strings.LastIndex(window, punct); i > 0 {
candidate := i + len(punct)
if cut < 0 || candidate > cut {
cut = candidate
}
}
}
}
// 3. Last resort: nearest space.
if cut < 0 {
if i := strings.LastIndex(window, " "); i > 0 {
cut = i + 1
}
}
// 4. Hard cut at maxChars runes if no boundary found.
if cut < 0 {
cut = len(window)
}
chunk := strings.TrimSpace(remaining[:cut])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = remaining[cut:]
}
return chunks
}
// runeSlice returns the first n runes of s as a string.
func runeSlice(s string, n int) string {
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
mp3, err := c.GenerateAudio(ctx, text, voice)

View File

@@ -310,6 +310,9 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
# Polar (subscriptions)
POLAR_API_TOKEN: "${POLAR_API_TOKEN}"
POLAR_WEBHOOK_SECRET: "${POLAR_WEBHOOK_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
"subscribe_page_title": "Go Pro \u2014 libnovel",
"subscribe_heading": "Read more. Listen more.",
"subscribe_subheading": "Upgrade to Pro and unlock the full libnovel experience.",
"subscribe_monthly_label": "Monthly",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per month",
"subscribe_annual_label": "Annual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per year",
"subscribe_annual_save": "Save 33%",
"subscribe_cta_monthly": "Start monthly plan",
"subscribe_cta_annual": "Start annual plan",
"subscribe_already_pro": "You already have a Pro subscription.",
"subscribe_manage": "Manage subscription",
"subscribe_benefit_audio": "Unlimited audio chapters per day",
"subscribe_benefit_voices": "Voice selection across all TTS engines",
"subscribe_benefit_translation": "Read in French, Indonesian, Portuguese, and Russian",
"subscribe_benefit_downloads": "Download chapters for offline listening",
"subscribe_login_prompt": "Sign in to subscribe",
"subscribe_login_cta": "Sign in",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
"subscribe_monthly_price": "6 $",
"subscribe_monthly_period": "par mois",
"subscribe_annual_label": "Annuel",
"subscribe_annual_price": "48 $",
"subscribe_annual_period": "par an",
"subscribe_annual_save": "Économisez 33 %",
"subscribe_cta_monthly": "Commencer le plan mensuel",
"subscribe_cta_annual": "Commencer le plan annuel",
"subscribe_already_pro": "Vous avez déjà un abonnement Pro.",
"subscribe_manage": "Gérer l'abonnement",
"subscribe_benefit_audio": "Chapitres audio illimités par jour",
"subscribe_benefit_voices": "Sélection de voix pour tous les moteurs TTS",
"subscribe_benefit_translation": "Lire en français, indonésien, portugais et russe",
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per bulan",
"subscribe_annual_label": "Tahunan",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per tahun",
"subscribe_annual_save": "Hemat 33%",
"subscribe_cta_monthly": "Mulai paket bulanan",
"subscribe_cta_annual": "Mulai paket tahunan",
"subscribe_already_pro": "Anda sudah berlangganan Pro.",
"subscribe_manage": "Kelola langganan",
"subscribe_benefit_audio": "Bab audio tak terbatas per hari",
"subscribe_benefit_voices": "Pilihan suara untuk semua mesin TTS",
"subscribe_benefit_translation": "Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia",
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "por mês",
"subscribe_annual_label": "Anual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "por ano",
"subscribe_annual_save": "Economize 33%",
"subscribe_cta_monthly": "Começar plano mensal",
"subscribe_cta_annual": "Começar plano anual",
"subscribe_already_pro": "Você já tem uma assinatura Pro.",
"subscribe_manage": "Gerenciar assinatura",
"subscribe_benefit_audio": "Capítulos de áudio ilimitados por dia",
"subscribe_benefit_voices": "Seleção de voz para todos os mecanismos TTS",
"subscribe_benefit_translation": "Leia em francês, indonésio, português e russo",
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",

View File

@@ -366,6 +366,26 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "в месяц",
"subscribe_annual_label": "Ежегодно",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "в год",
"subscribe_annual_save": "Сэкономьте 33%",
"subscribe_cta_monthly": "Начать месячный план",
"subscribe_cta_annual": "Начать годовой план",
"subscribe_already_pro": "У вас уже есть подписка Pro.",
"subscribe_manage": "Управление подпиской",
"subscribe_benefit_audio": "Неограниченные аудиоглавы в день",
"subscribe_benefit_voices": "Выбор голоса для всех TTS-движков",
"subscribe_benefit_translation": "Читайте на французском, индонезийском, португальском и русском",
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",

View File

@@ -339,6 +339,26 @@ export * from './profile_upgrade_desc.js'
export * from './profile_upgrade_monthly.js'
export * from './profile_upgrade_annual.js'
export * from './profile_free_limits.js'
export * from './subscribe_page_title.js'
export * from './subscribe_heading.js'
export * from './subscribe_subheading.js'
export * from './subscribe_monthly_label.js'
export * from './subscribe_monthly_price.js'
export * from './subscribe_monthly_period.js'
export * from './subscribe_annual_label.js'
export * from './subscribe_annual_price.js'
export * from './subscribe_annual_period.js'
export * from './subscribe_annual_save.js'
export * from './subscribe_cta_monthly.js'
export * from './subscribe_cta_annual.js'
export * from './subscribe_already_pro.js'
export * from './subscribe_manage.js'
export * from './subscribe_benefit_audio.js'
export * from './subscribe_benefit_voices.js'
export * from './subscribe_benefit_translation.js'
export * from './subscribe_benefit_downloads.js'
export * from './subscribe_login_prompt.js'
export * from './subscribe_login_cta.js'
export * from './user_currently_reading.js'
export * from './user_library_count.js'
export * from './user_joined.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Already_ProInputs */
const en_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`You already have a Pro subscription.`)
};
const ru_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`У вас уже есть подписка Pro.`)
};
const id_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anda sudah berlangganan Pro.`)
};
const pt_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Você já tem uma assinatura Pro.`)
};
const fr_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Vous avez déjà un abonnement Pro.`)
};
/**
* | output |
* | --- |
* | "You already have a Pro subscription." |
*
* @param {Subscribe_Already_ProInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_already_pro = /** @type {((inputs?: Subscribe_Already_ProInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Already_ProInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_already_pro(inputs)
if (locale === "ru") return ru_subscribe_already_pro(inputs)
if (locale === "id") return id_subscribe_already_pro(inputs)
if (locale === "pt") return pt_subscribe_already_pro(inputs)
return fr_subscribe_already_pro(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_LabelInputs */
const en_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annual`)
};
const ru_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежегодно`)
};
const id_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tahunan`)
};
const pt_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anual`)
};
const fr_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annuel`)
};
/**
* | output |
* | --- |
* | "Annual" |
*
* @param {Subscribe_Annual_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_label = /** @type {((inputs?: Subscribe_Annual_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_label(inputs)
if (locale === "ru") return ru_subscribe_annual_label(inputs)
if (locale === "id") return id_subscribe_annual_label(inputs)
if (locale === "pt") return pt_subscribe_annual_label(inputs)
return fr_subscribe_annual_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PeriodInputs */
const en_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per year`)
};
const ru_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в год`)
};
const id_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per tahun`)
};
const pt_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por ano`)
};
const fr_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par an`)
};
/**
* | output |
* | --- |
* | "per year" |
*
* @param {Subscribe_Annual_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_period = /** @type {((inputs?: Subscribe_Annual_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_period(inputs)
if (locale === "ru") return ru_subscribe_annual_period(inputs)
if (locale === "id") return id_subscribe_annual_period(inputs)
if (locale === "pt") return pt_subscribe_annual_period(inputs)
return fr_subscribe_annual_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PriceInputs */
const en_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const ru_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const id_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const pt_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const fr_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`48 $`)
};
/**
* | output |
* | --- |
* | "$48" |
*
* @param {Subscribe_Annual_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_price = /** @type {((inputs?: Subscribe_Annual_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_price(inputs)
if (locale === "ru") return ru_subscribe_annual_price(inputs)
if (locale === "id") return id_subscribe_annual_price(inputs)
if (locale === "pt") return pt_subscribe_annual_price(inputs)
return fr_subscribe_annual_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_SaveInputs */
const en_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Save 33%`)
};
const ru_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сэкономьте 33%`)
};
const id_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Hemat 33%`)
};
const pt_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Economize 33%`)
};
const fr_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Économisez 33 %`)
};
/**
* | output |
* | --- |
* | "Save 33%" |
*
* @param {Subscribe_Annual_SaveInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_save = /** @type {((inputs?: Subscribe_Annual_SaveInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_SaveInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_save(inputs)
if (locale === "ru") return ru_subscribe_annual_save(inputs)
if (locale === "id") return id_subscribe_annual_save(inputs)
if (locale === "pt") return pt_subscribe_annual_save(inputs)
return fr_subscribe_annual_save(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_AudioInputs */
const en_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unlimited audio chapters per day`)
};
const ru_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Неограниченные аудиоглавы в день`)
};
const id_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bab audio tak terbatas per hari`)
};
const pt_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capítulos de áudio ilimitados por dia`)
};
const fr_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapitres audio illimités par jour`)
};
/**
* | output |
* | --- |
* | "Unlimited audio chapters per day" |
*
* @param {Subscribe_Benefit_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_audio = /** @type {((inputs?: Subscribe_Benefit_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_audio(inputs)
if (locale === "ru") return ru_subscribe_benefit_audio(inputs)
if (locale === "id") return id_subscribe_benefit_audio(inputs)
if (locale === "pt") return pt_subscribe_benefit_audio(inputs)
return fr_subscribe_benefit_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_DownloadsInputs */
const en_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Download chapters for offline listening`)
};
const ru_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Скачивайте главы для прослушивания офлайн`)
};
const id_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unduh bab untuk didengarkan secara offline`)
};
const pt_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baixe capítulos para ouvir offline`)
};
const fr_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Télécharger des chapitres pour une écoute hors ligne`)
};
/**
* | output |
* | --- |
* | "Download chapters for offline listening" |
*
* @param {Subscribe_Benefit_DownloadsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_downloads = /** @type {((inputs?: Subscribe_Benefit_DownloadsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_DownloadsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_downloads(inputs)
if (locale === "ru") return ru_subscribe_benefit_downloads(inputs)
if (locale === "id") return id_subscribe_benefit_downloads(inputs)
if (locale === "pt") return pt_subscribe_benefit_downloads(inputs)
return fr_subscribe_benefit_downloads(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_TranslationInputs */
const en_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read in French, Indonesian, Portuguese, and Russian`)
};
const ru_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте на французском, индонезийском, португальском и русском`)
};
const id_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia`)
};
const pt_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia em francês, indonésio, português e russo`)
};
const fr_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lire en français, indonésien, portugais et russe`)
};
/**
* | output |
* | --- |
* | "Read in French, Indonesian, Portuguese, and Russian" |
*
* @param {Subscribe_Benefit_TranslationInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_translation = /** @type {((inputs?: Subscribe_Benefit_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_translation(inputs)
if (locale === "ru") return ru_subscribe_benefit_translation(inputs)
if (locale === "id") return id_subscribe_benefit_translation(inputs)
if (locale === "pt") return pt_subscribe_benefit_translation(inputs)
return fr_subscribe_benefit_translation(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_VoicesInputs */
const en_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voice selection across all TTS engines`)
};
const ru_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Выбор голоса для всех TTS-движков`)
};
const id_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Pilihan suara untuk semua mesin TTS`)
};
const pt_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seleção de voz para todos os mecanismos TTS`)
};
const fr_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sélection de voix pour tous les moteurs TTS`)
};
/**
* | output |
* | --- |
* | "Voice selection across all TTS engines" |
*
* @param {Subscribe_Benefit_VoicesInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_voices = /** @type {((inputs?: Subscribe_Benefit_VoicesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_VoicesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_voices(inputs)
if (locale === "ru") return ru_subscribe_benefit_voices(inputs)
if (locale === "id") return id_subscribe_benefit_voices(inputs)
if (locale === "pt") return pt_subscribe_benefit_voices(inputs)
return fr_subscribe_benefit_voices(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_AnnualInputs */
const en_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start annual plan`)
};
const ru_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать годовой план`)
};
const id_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket tahunan`)
};
const pt_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano anual`)
};
const fr_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan annuel`)
};
/**
* | output |
* | --- |
* | "Start annual plan" |
*
* @param {Subscribe_Cta_AnnualInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_annual = /** @type {((inputs?: Subscribe_Cta_AnnualInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_AnnualInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_annual(inputs)
if (locale === "ru") return ru_subscribe_cta_annual(inputs)
if (locale === "id") return id_subscribe_cta_annual(inputs)
if (locale === "pt") return pt_subscribe_cta_annual(inputs)
return fr_subscribe_cta_annual(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_MonthlyInputs */
const en_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start monthly plan`)
};
const ru_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать месячный план`)
};
const id_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket bulanan`)
};
const pt_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano mensal`)
};
const fr_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan mensuel`)
};
/**
* | output |
* | --- |
* | "Start monthly plan" |
*
* @param {Subscribe_Cta_MonthlyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_monthly = /** @type {((inputs?: Subscribe_Cta_MonthlyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_MonthlyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_monthly(inputs)
if (locale === "ru") return ru_subscribe_cta_monthly(inputs)
if (locale === "id") return id_subscribe_cta_monthly(inputs)
if (locale === "pt") return pt_subscribe_cta_monthly(inputs)
return fr_subscribe_cta_monthly(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_HeadingInputs */
const en_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read more. Listen more.`)
};
const ru_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте больше. Слушайте больше.`)
};
const id_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca lebih. Dengarkan lebih.`)
};
const pt_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia mais. Ouça mais.`)
};
const fr_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lisez plus. Écoutez plus.`)
};
/**
* | output |
* | --- |
* | "Read more. Listen more." |
*
* @param {Subscribe_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_heading = /** @type {((inputs?: Subscribe_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_heading(inputs)
if (locale === "ru") return ru_subscribe_heading(inputs)
if (locale === "id") return id_subscribe_heading(inputs)
if (locale === "pt") return pt_subscribe_heading(inputs)
return fr_subscribe_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_CtaInputs */
const en_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in`)
};
const ru_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войти`)
};
const id_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk`)
};
const pt_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entrar`)
};
const fr_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Se connecter`)
};
/**
* | output |
* | --- |
* | "Sign in" |
*
* @param {Subscribe_Login_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_cta = /** @type {((inputs?: Subscribe_Login_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_cta(inputs)
if (locale === "ru") return ru_subscribe_login_cta(inputs)
if (locale === "id") return id_subscribe_login_cta(inputs)
if (locale === "pt") return pt_subscribe_login_cta(inputs)
return fr_subscribe_login_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_PromptInputs */
const en_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in to subscribe`)
};
const ru_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войдите, чтобы оформить подписку`)
};
const id_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk untuk berlangganan`)
};
const pt_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entre para assinar`)
};
const fr_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Connectez-vous pour vous abonner`)
};
/**
* | output |
* | --- |
* | "Sign in to subscribe" |
*
* @param {Subscribe_Login_PromptInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_prompt = /** @type {((inputs?: Subscribe_Login_PromptInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_PromptInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_prompt(inputs)
if (locale === "ru") return ru_subscribe_login_prompt(inputs)
if (locale === "id") return id_subscribe_login_prompt(inputs)
if (locale === "pt") return pt_subscribe_login_prompt(inputs)
return fr_subscribe_login_prompt(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_ManageInputs */
const en_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Manage subscription`)
};
const ru_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Управление подпиской`)
};
const id_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kelola langganan`)
};
const pt_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gerenciar assinatura`)
};
const fr_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gérer l'abonnement`)
};
/**
* | output |
* | --- |
* | "Manage subscription" |
*
* @param {Subscribe_ManageInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_manage = /** @type {((inputs?: Subscribe_ManageInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_ManageInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_manage(inputs)
if (locale === "ru") return ru_subscribe_manage(inputs)
if (locale === "id") return id_subscribe_manage(inputs)
if (locale === "pt") return pt_subscribe_manage(inputs)
return fr_subscribe_manage(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_LabelInputs */
const en_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Monthly`)
};
const ru_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежемесячно`)
};
const id_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bulanan`)
};
const pt_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensal`)
};
const fr_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensuel`)
};
/**
* | output |
* | --- |
* | "Monthly" |
*
* @param {Subscribe_Monthly_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_label = /** @type {((inputs?: Subscribe_Monthly_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_label(inputs)
if (locale === "ru") return ru_subscribe_monthly_label(inputs)
if (locale === "id") return id_subscribe_monthly_label(inputs)
if (locale === "pt") return pt_subscribe_monthly_label(inputs)
return fr_subscribe_monthly_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PeriodInputs */
const en_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per month`)
};
const ru_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в месяц`)
};
const id_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per bulan`)
};
const pt_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por mês`)
};
const fr_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par mois`)
};
/**
* | output |
* | --- |
* | "per month" |
*
* @param {Subscribe_Monthly_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_period = /** @type {((inputs?: Subscribe_Monthly_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_period(inputs)
if (locale === "ru") return ru_subscribe_monthly_period(inputs)
if (locale === "id") return id_subscribe_monthly_period(inputs)
if (locale === "pt") return pt_subscribe_monthly_period(inputs)
return fr_subscribe_monthly_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PriceInputs */
const en_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const ru_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const id_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const pt_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const fr_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`6 $`)
};
/**
* | output |
* | --- |
* | "$6" |
*
* @param {Subscribe_Monthly_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_price = /** @type {((inputs?: Subscribe_Monthly_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_price(inputs)
if (locale === "ru") return ru_subscribe_monthly_price(inputs)
if (locale === "id") return id_subscribe_monthly_price(inputs)
if (locale === "pt") return pt_subscribe_monthly_price(inputs)
return fr_subscribe_monthly_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Page_TitleInputs */
const en_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Go Pro — libnovel`)
};
const ru_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейти на Pro — libnovel`)
};
const id_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jadi Pro — libnovel`)
};
const pt_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seja Pro — libnovel`)
};
const fr_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passer Pro — libnovel`)
};
/**
* | output |
* | --- |
* | "Go Pro — libnovel" |
*
* @param {Subscribe_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_page_title = /** @type {((inputs?: Subscribe_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_page_title(inputs)
if (locale === "ru") return ru_subscribe_page_title(inputs)
if (locale === "id") return id_subscribe_page_title(inputs)
if (locale === "pt") return pt_subscribe_page_title(inputs)
return fr_subscribe_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_SubheadingInputs */
const en_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Upgrade to Pro and unlock the full libnovel experience.`)
};
const ru_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейдите на Pro и откройте полный опыт libnovel.`)
};
const id_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.`)
};
const pt_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Torne-se Pro e desbloqueie a experiência completa do libnovel.`)
};
const fr_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passez Pro et débloquez l'expérience libnovel complète.`)
};
/**
* | output |
* | --- |
* | "Upgrade to Pro and unlock the full libnovel experience." |
*
* @param {Subscribe_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_subheading = /** @type {((inputs?: Subscribe_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_subheading(inputs)
if (locale === "ru") return ru_subscribe_subheading(inputs)
if (locale === "id") return id_subscribe_subheading(inputs)
if (locale === "pt") return pt_subscribe_subheading(inputs)
return fr_subscribe_subheading(inputs)
});

View File

@@ -407,6 +407,15 @@
>
{m.nav_catalogue()}
</a>
{#if !data.isPro}
<a
href="/subscribe"
class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold transition-colors {page.url.pathname.startsWith('/subscribe') ? 'text-(--color-brand)' : 'text-(--color-brand) opacity-70 hover:opacity-100'}"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
Pro
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, untrack, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
@@ -11,14 +11,16 @@
let { data }: { data: PageData } = $props();
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let scrapedHtml = $state(''); // only set by the live-preview fallback
let html = $derived(scrapedHtml || data.html || '');
let fetchingContent = $state(false);
let fetchError = $state('');
let audioProRequired = $state(false);
// ── Reader settings panel ────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let settingsPanelOpen = $state(false);
let settingsTab = $state<'reading' | 'listening'>('reading');
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
@@ -202,8 +204,8 @@
{ code: 'pt', label: 'PT' },
{ code: 'fr', label: 'FR' }
];
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
let translatingLang = $state(untrack(() => data.lang ?? ''));
let translationStatus = $state(data.translationStatus ?? 'idle');
let translatingLang = $state(data.lang ?? '');
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
function currentLang() {
@@ -293,6 +295,7 @@
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
fetchingContent = true;
(async () => {
try {
const res = await fetch(
@@ -302,7 +305,7 @@
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
scrapedHtml = await marked(d.text, { async: true });
} else {
fetchError = m.reader_audio_error();
}
@@ -589,22 +592,14 @@
</div>
{/if}
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
{#if settingsCtx}
<!-- Backdrop -->
{#if settingsPanelOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40"
onclick={() => (settingsPanelOpen = false)}
></div>
{/if}
<!-- Gear button -->
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
<button
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
aria-label="Reader settings"
class="fixed bottom-20 right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
>
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
@@ -612,231 +607,277 @@
</svg>
</button>
<!-- Settings drawer -->
<!-- Bottom sheet -->
{#if settingsPanelOpen}
<div
class="fixed bottom-36 right-4 z-50 w-72 bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-2xl p-4 flex flex-col gap-4"
>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Reader Settings</p>
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
<!-- Theme -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Theme</p>
<div class="flex flex-wrap gap-1.5">
{#each READER_THEMES as t}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
<!-- Drag handle -->
<div class="flex justify-center pt-3 pb-1 shrink-0">
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
</div>
<!-- Tab bar -->
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
<button
type="button"
onclick={() => (settingsTab = 'reading')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'reading'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Reading</button>
<button
type="button"
onclick={() => (settingsTab = 'listening')}
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
{settingsTab === 'listening'
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>Listening</button>
</div>
<!-- Scrollable content -->
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
{#if settingsTab === 'reading'}
<!-- ── Typography group ──────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Theme -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
<div class="flex flex-wrap gap-1.5 flex-1">
{#each READER_THEMES as t}
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
>
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Font -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
<div class="flex gap-1.5 flex-1">
{#each READER_FONTS as f}
<button
onclick={() => applyFont(f.id)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelFont === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelFont === f.id}
>{f.label}</button>
{/each}
</div>
</div>
<!-- Size -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
<div class="flex gap-1.5 flex-1">
{#each READER_SIZES as s}
<button
onclick={() => applySize(s.value)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelSize === s.value}
>{s.label}</button>
{/each}
</div>
</div>
</div>
</div>
<!-- ── Layout group ──────────────────────────────────────────── -->
<div class="mt-4 mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Read mode -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
<div class="flex gap-1.5 flex-1">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, lbl]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
<div class="flex gap-1.5 flex-1">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Width -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
<div class="flex gap-1.5 flex-1">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, lbl]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Paragraphs -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
<div class="flex gap-1.5 flex-1">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{lbl}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{layout.focusMode ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={layout.focusMode}
>
<span class="w-2.5 h-2.5 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
<span>Focus mode</span>
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
{/each}
</div>
</div>
<!-- Font family -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Font</p>
<div class="flex gap-1.5">
{#each READER_FONTS as f}
</div>
</div>
{:else}
<!-- ── Listening tab ──────────────────────────────────────────── -->
<div class="mb-1">
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
<!-- Player style -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
<div class="flex gap-1.5 flex-1">
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, lbl]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.playerStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.playerStyle === s}
>{lbl}</button>
{/each}
</div>
</div>
{#if page.data.user}
<!-- Speed -->
<div class="flex items-center gap-3 px-3 py-2.5">
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
<div class="flex gap-1 flex-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
onclick={() => applyFont(f.id)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelFont === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelFont === f.id}
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
aria-pressed={audioStore.autoNext}
>
{f.label}
<span>Auto-next chapter</span>
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
{/each}
</div>
</div>
<!-- Text size -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Text size</p>
<div class="flex gap-1.5">
{#each READER_SIZES as s}
<!-- Sleep timer -->
<button
onclick={() => applySize(s.value)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelSize === s.value}
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
>
{s.label}
<span>Sleep timer</span>
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
</button>
{/each}
{/if}
</div>
</div>
{/if}
<p class="text-[11px] text-(--color-muted)/50 text-center mt-3">Changes save automatically</p>
</div>
<!-- Divider -->
<div class="border-t border-(--color-border)"></div>
<!-- Read mode -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Read mode</p>
<div class="flex gap-1.5">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{label}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Line spacing</p>
<div class="flex gap-1.5">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Reading width -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Width</p>
<div class="flex gap-1.5">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{label}</button>
{/each}
</div>
</div>
<!-- Paragraph style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Paragraphs</p>
<div class="flex gap-1.5">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{layout.focusMode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.focusMode}
>
<span>Focus mode</span>
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
<!-- ── Listening section ─────────────────────────────────────────── -->
<div class="border-t border-(--color-border)"></div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
<!-- Player style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Player style</p>
<div class="flex gap-1.5">
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.playerStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.playerStyle === s}
>{label}</button>
{/each}
</div>
</div>
{#if page.data.user}
<!-- Playback speed -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Speed</p>
<div class="flex gap-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.autoNext}
>
<span>Auto-next chapter</span>
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
>
<span>Sleep timer</span>
<span class="opacity-60">{sleepSettingsLabel}</span>
</button>
{/if}
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
</div>
{/if}
{/if}

View File

@@ -375,7 +375,7 @@
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()} <a href="/subscribe" class="text-(--color-brand) hover:underline">See plans →</a></p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}

View File

@@ -0,0 +1,5 @@
// Data is inherited from the root layout (user, isPro).
// This file exists only to ensure the route is treated as a page by SvelteKit.
export const load = async () => {
return {};
};

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { LayoutData } from '../$types';
import * as m from '$lib/paraglide/messages.js';
// Data flows from root layout (user, isPro)
let { data }: { data: LayoutData } = $props();
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
let checkoutError = $state('');
async function startCheckout(product: 'monthly' | 'annual') {
checkoutLoading = product;
checkoutError = '';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product })
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
return;
}
const { url } = await res.json() as { url: string };
window.location.href = url;
} catch {
checkoutError = 'Network error. Please try again.';
} finally {
checkoutLoading = null;
}
}
const benefits = [
{ icon: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3', label: () => m.subscribe_benefit_audio() },
{ icon: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z', label: () => m.subscribe_benefit_voices() },
{ icon: 'M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129', label: () => m.subscribe_benefit_translation() },
{ icon: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', label: () => m.subscribe_benefit_downloads() },
];
</script>
<svelte:head>
<title>{m.subscribe_page_title()}</title>
</svelte:head>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col items-center justify-center px-4 py-16">
<!-- Hero -->
<div class="text-center mb-12 max-w-xl">
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-(--color-brand)/15 border border-(--color-brand)/30 text-(--color-brand) text-xs font-semibold uppercase tracking-wider mb-5">
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
Pro
</div>
<h1 class="text-4xl sm:text-5xl font-bold text-(--color-text) tracking-tight mb-4">
{m.subscribe_heading()}
</h1>
<p class="text-lg text-(--color-muted)">
{m.subscribe_subheading()}
</p>
</div>
<!-- Benefits list -->
<ul class="mb-12 space-y-3 w-full max-w-sm">
{#each benefits as b}
<li class="flex items-center gap-3 text-sm text-(--color-text)">
<span class="shrink-0 w-8 h-8 rounded-full bg-(--color-brand)/15 text-(--color-brand) flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d={b.icon}/>
</svg>
</span>
{b.label()}
</li>
{/each}
</ul>
{#if data.isPro}
<!-- Already Pro -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-brand)/40 rounded-xl p-6 text-center">
<p class="text-base font-semibold text-(--color-text) mb-1">{m.subscribe_already_pro()}</p>
<a
href="https://polar.sh/libnovel/portal"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-(--color-brand) hover:underline"
>
{m.subscribe_manage()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
{:else if !data.user}
<!-- Not logged in -->
<div class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6 text-center">
<p class="text-sm text-(--color-muted) mb-4">{m.subscribe_login_prompt()}</p>
<a
href="/login?next=/subscribe"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
>
{m.subscribe_login_cta()}
</a>
</div>
{:else}
<!-- Pricing cards -->
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-4 text-center">{checkoutError}</p>
{/if}
<div class="w-full max-w-sm grid gap-4">
<!-- Annual card (featured) -->
<div class="relative bg-(--color-surface-2) border-2 border-(--color-brand) rounded-xl p-6">
<div class="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 bg-(--color-brand) text-(--color-surface) text-xs font-bold rounded-full tracking-wide uppercase">
{m.subscribe_annual_save()}
</div>
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_annual_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_annual_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_annual_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{#if checkoutLoading === 'annual'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{/if}
{m.subscribe_cta_annual()}
</button>
</div>
<!-- Monthly card -->
<div class="bg-(--color-surface-2) border border-(--color-border) rounded-xl p-6">
<div class="flex items-baseline justify-between mb-5">
<span class="text-base font-semibold text-(--color-text)">{m.subscribe_monthly_label()}</span>
<div class="text-right">
<span class="text-3xl font-bold text-(--color-text)">{m.subscribe_monthly_price()}</span>
<span class="text-sm text-(--color-muted) ml-1">{m.subscribe_monthly_period()}</span>
</div>
</div>
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait"
>
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{/if}
{m.subscribe_cta_monthly()}
</button>
</div>
</div>
{/if}
<p class="mt-8 text-xs text-(--color-muted) text-center max-w-xs">
Payments processed securely by <a href="https://polar.sh" target="_blank" rel="noopener noreferrer" class="underline hover:text-(--color-text)">Polar</a>. Cancel anytime.
</p>
</div>