Compare commits
5 Commits
b8961b85c5
...
e6fe5ac27f
| Author | SHA1 | Date | |
|---|---|---|---|
| e6fe5ac27f | |||
| c704e24a53 | |||
| 5a3b0ace61 | |||
| 844cd3f58e | |||
| 6a1f208e66 |
52
AGENTS.md
Normal file
52
AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `src/app/`: Next.js App Router pages, layouts, API routes, and server actions.
|
||||||
|
- `src/components/`: UI and feature components (`home/`, `reader/`, `layout/`, `ui/`).
|
||||||
|
- `src/lib/`: shared services (Komga/API access), auth, logger, utilities.
|
||||||
|
- `src/hooks/`, `src/contexts/`, `src/types/`, `src/constants/`: reusable runtime logic and typing.
|
||||||
|
- `src/i18n/messages/{en,fr}/`: translation dictionaries.
|
||||||
|
- `prisma/`: database schema and Prisma artifacts.
|
||||||
|
- `public/`: static files and PWA assets.
|
||||||
|
- `scripts/`: maintenance scripts (DB init, admin password reset, icon generation).
|
||||||
|
- `docs/` and `devbook.md`: implementation notes and architecture decisions.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
Use `pnpm` (lockfile and `packageManager` are configured for it).
|
||||||
|
- `pnpm dev`: start local dev server.
|
||||||
|
- `pnpm build`: create production build.
|
||||||
|
- `pnpm start`: run production server.
|
||||||
|
- `pnpm lint`: run ESLint across the repo.
|
||||||
|
- `pnpm typecheck` or `pnpm -s tsc --noEmit`: strict TypeScript checks.
|
||||||
|
- `pnpm init-db`: initialize database data.
|
||||||
|
- `pnpm reset-admin-password`: reset admin credentials.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Language: TypeScript (`.ts/.tsx`) with React function components.
|
||||||
|
- Architecture priority: **server-first**. Default to React Server Components (RSC) for pages and feature composition.
|
||||||
|
- Data mutations: prefer **Server Actions** (`src/app/actions/`) over client-side fetch patterns when possible.
|
||||||
|
- Client components (`"use client"`): use only for browser-only concerns (event handlers, local UI state, effects, DOM APIs).
|
||||||
|
- Data fetching: do it on the server first (`page.tsx`, server components, services in `src/lib/services`), then pass serialized props down.
|
||||||
|
- Indentation: 2 spaces; keep imports grouped and sorted logically.
|
||||||
|
- Components/hooks/services: `PascalCase` for components, `camelCase` for hooks/functions, `*.service.ts` for service modules.
|
||||||
|
- Styling: Tailwind utility classes; prefer existing `src/components/ui` primitives before creating new ones.
|
||||||
|
- Quality gates: ESLint (`eslint.config.mjs`) + TypeScript must pass before merge.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- No dedicated unit test framework is currently configured.
|
||||||
|
- Minimum validation for each change: `pnpm lint` and `pnpm typecheck`.
|
||||||
|
- For UI changes, perform a quick manual smoke test on affected routes (home, libraries, series, reader) and both themes.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Follow Conventional Commit style seen in history: `fix: ...`, `refactor: ...`, `feat: ...`.
|
||||||
|
- Keep subjects imperative and specific (e.g., `fix: reduce header/home spacing overlap`).
|
||||||
|
- PRs should include:
|
||||||
|
- short problem/solution summary,
|
||||||
|
- linked issue (if any),
|
||||||
|
- screenshots or short video for UI updates,
|
||||||
|
- verification steps/commands run.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Never commit secrets; use `.env` based on `.env.example`.
|
||||||
|
- Validate Komga and auth-related config through settings flows before merging.
|
||||||
|
- Prefer server-side data fetching/services for sensitive operations.
|
||||||
@@ -5,75 +5,392 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Hors ligne - StripStream</title>
|
<title>Hors ligne - StripStream</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #020817;
|
||||||
|
--panel: rgba(2, 8, 23, 0.66);
|
||||||
|
--panel-strong: rgba(2, 8, 23, 0.82);
|
||||||
|
--line: rgba(99, 102, 241, 0.3);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--muted: #cbd5e1;
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--primary-2: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family:
|
font-family: "Segoe UI", "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
system-ui,
|
background: var(--bg);
|
||||||
-apple-system,
|
color: var(--text);
|
||||||
sans-serif;
|
|
||||||
background-color: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(2, 8, 23, 0.99) 0%, rgba(2, 8, 23, 0.94) 42%, #020817 100%),
|
||||||
|
radial-gradient(70% 45% at 12% 0%, rgba(79, 70, 229, 0.16), transparent 62%),
|
||||||
|
radial-gradient(58% 38% at 88% 8%, rgba(6, 182, 212, 0.14), transparent 65%),
|
||||||
|
radial-gradient(50% 34% at 50% 100%, rgba(236, 72, 153, 0.1), transparent 70%),
|
||||||
|
repeating-linear-gradient(0deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 24px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.015) 0 1px, transparent 1px 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
height: 64px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(2, 8, 23, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(112deg, rgba(79, 70, 229, 0.24) 0%, rgba(6, 182, 212, 0.2) 30%, transparent 56%),
|
||||||
|
linear-gradient(248deg, rgba(244, 114, 182, 0.16) 0%, transparent 46%),
|
||||||
|
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.03) 0 1px, transparent 1px 11px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--primary-2), #d946ef);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
color: rgba(226, 232, 240, 0.75);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(2, 8, 23, 0.55);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
position: relative;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-open .sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 64px 0 0 0;
|
||||||
|
background: rgba(2, 6, 23, 0.48);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-open .sidebar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, rgba(79, 70, 229, 0.12) 0%, rgba(6, 182, 212, 0.08) 32%, transparent 58%),
|
||||||
|
linear-gradient(332deg, rgba(244, 114, 182, 0.06) 0%, transparent 42%),
|
||||||
|
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 11px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: rgba(2, 8, 23, 0.45);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0.25rem 0.45rem 0.6rem;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.62rem 0.78rem;
|
||||||
|
margin: 0.14rem 0;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
border-color: rgba(79, 70, 229, 0.45);
|
||||||
|
background: rgba(79, 70, 229, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
padding: 2rem;
|
||||||
padding: 1rem;
|
min-height: calc(100vh - 64px);
|
||||||
}
|
}
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
.card {
|
||||||
margin: 0 auto;
|
width: min(720px, 100%);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
box-shadow: 0 25px 60px -35px rgba(2, 6, 23, 0.92);
|
||||||
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
h1 {
|
|
||||||
font-size: 2rem;
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(127, 29, 29, 0.3);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #4f46e5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
font-size: clamp(1.35rem, 2.4vw, 1.95rem);
|
||||||
|
line-height: 1.24;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 1.1rem;
|
margin: 0;
|
||||||
line-height: 1.5;
|
color: var(--muted);
|
||||||
color: #94a3b8;
|
line-height: 1.6;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
.buttons {
|
|
||||||
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 0.7rem;
|
||||||
justify-content: center;
|
margin-top: 1.35rem;
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
background-color: #4f46e5;
|
.btn {
|
||||||
color: white;
|
appearance: none;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
padding: 0.75rem 1.5rem;
|
border-radius: 0.65rem;
|
||||||
border-radius: 0.5rem;
|
padding: 0.7rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
button:hover {
|
|
||||||
background-color: #4338ca;
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
button.secondary {
|
|
||||||
background-color: #475569;
|
.btn-secondary {
|
||||||
|
background: rgba(2, 8, 23, 0.45);
|
||||||
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
button.secondary:hover {
|
|
||||||
background-color: #334155;
|
.btn-secondary:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px dashed rgba(148, 163, 184, 0.28);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(148, 163, 184, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.main {
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<header class="header">
|
||||||
<h1>Vous êtes hors ligne</h1>
|
<div class="header-inner">
|
||||||
<p>
|
<div class="brand">
|
||||||
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de
|
<button class="menu-btn" id="sidebar-toggle" type="button" aria-label="Menu">☰</button>
|
||||||
StripStream peuvent ne pas être disponibles en mode hors ligne.
|
<div>
|
||||||
</p>
|
<div class="brand-title">STRIPSTREAM</div>
|
||||||
<div class="buttons">
|
<div class="brand-subtitle">comic reader</div>
|
||||||
<button class="secondary" onclick="window.history.back()">Retour</button>
|
</div>
|
||||||
<button onclick="window.location.reload()">Réessayer</button>
|
</div>
|
||||||
|
<span class="pill">Mode hors ligne</span>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<button class="sidebar-overlay" id="sidebar-overlay" aria-label="Fermer le menu"></button>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Navigation</h2>
|
||||||
|
<button class="nav-link active" type="button">Accueil</button>
|
||||||
|
<button class="nav-link" type="button">Telechargements</button>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Compte</h2>
|
||||||
|
<button class="nav-link" type="button">Mon compte</button>
|
||||||
|
<button class="nav-link" type="button">Preferences</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">● Hors ligne</div>
|
||||||
|
<h1>Cette page n'est pas encore disponible hors ligne.</h1>
|
||||||
|
<p>
|
||||||
|
Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera
|
||||||
|
disponible hors ligne apres une visite en ligne.
|
||||||
|
</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary" onclick="window.history.back()">Retour</button>
|
||||||
|
<button class="btn btn-primary" onclick="window.location.reload()">Reessayer</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es
|
||||||
|
en ligne.
|
||||||
|
</div>
|
||||||
|
<div class="footer">StripStream - interface hors ligne</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const toggle = document.getElementById("sidebar-toggle");
|
||||||
|
const overlay = document.getElementById("sidebar-overlay");
|
||||||
|
|
||||||
|
if (toggle && overlay) {
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
document.body.classList.toggle("sidebar-open");
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener("click", () => {
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
232
public/sw.js
232
public/sw.js
File diff suppressed because one or more lines are too long
@@ -44,7 +44,7 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
|||||||
isHiding={pullToRefresh.isHiding}
|
isHiding={pullToRefresh.isHiding}
|
||||||
/>
|
/>
|
||||||
<main className="relative isolate overflow-hidden">
|
<main className="relative isolate overflow-hidden">
|
||||||
<div className="container mx-auto space-y-12 px-4 py-8">
|
<div className="container mx-auto space-y-6 px-4 pb-8 pt-3">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +36,7 @@ interface CacheStats {
|
|||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
total: number;
|
total: number;
|
||||||
|
visitablePages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
@@ -197,11 +200,13 @@ export function CacheSettings() {
|
|||||||
const {
|
const {
|
||||||
isSupported,
|
isSupported,
|
||||||
isReady,
|
isReady,
|
||||||
|
isDevModeEnabled,
|
||||||
version,
|
version,
|
||||||
getCacheStats,
|
getCacheStats,
|
||||||
getCacheEntries,
|
getCacheEntries,
|
||||||
clearCache,
|
clearCache,
|
||||||
reinstallServiceWorker,
|
reinstallServiceWorker,
|
||||||
|
setDevModeEnabled,
|
||||||
} = useServiceWorker();
|
} = useServiceWorker();
|
||||||
|
|
||||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||||
@@ -276,6 +281,25 @@ export function CacheSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleServiceWorkerDevToggle = async (checked: boolean) => {
|
||||||
|
try {
|
||||||
|
const success = await setDevModeEnabled(checked);
|
||||||
|
if (!success) {
|
||||||
|
throw new Error("Failed to toggle service worker in development");
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: t("settings.title"),
|
||||||
|
description: t("settings.cache.devServiceWorker.saved"),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("settings.error.title"),
|
||||||
|
description: t("settings.cache.devServiceWorker.error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
// Calculer le pourcentage du cache utilisé (basé sur 100MB limite images)
|
||||||
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
const maxCacheSize = 100 * 1024 * 1024; // 100MB
|
||||||
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
const usagePercent = stats ? Math.min((stats.images.size / maxCacheSize) * 100, 100) : 0;
|
||||||
@@ -328,6 +352,20 @@ export function CacheSettings() {
|
|||||||
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="dev-sw-toggle">{t("settings.cache.devServiceWorker.label")}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.cache.devServiceWorker.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="dev-sw-toggle"
|
||||||
|
checked={isDevModeEnabled}
|
||||||
|
onCheckedChange={handleServiceWorkerDevToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Barre de progression globale */}
|
{/* Barre de progression globale */}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -339,6 +377,9 @@ export function CacheSettings() {
|
|||||||
<p className="text-xs text-muted-foreground text-right">
|
<p className="text-xs text-muted-foreground text-right">
|
||||||
{t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })}
|
{t("settings.cache.imagesQuota", { used: Math.round(usagePercent) })}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.cache.visitablePages", { count: stats.visitablePages })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { KomgaConfig } from "@/types/komga";
|
import type { KomgaConfig } from "@/types/komga";
|
||||||
import type { KomgaLibrary } from "@/types/komga";
|
import type { KomgaLibrary } from "@/types/komga";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -16,15 +17,35 @@ interface ClientSettingsProps {
|
|||||||
initialLibraries: KomgaLibrary[];
|
initialLibraries: KomgaLibrary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SETTINGS_TAB_STORAGE_KEY = "stripstream:settings-active-tab";
|
||||||
|
|
||||||
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
|
export function ClientSettings({ initialConfig, initialLibraries }: ClientSettingsProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const [activeTab, setActiveTab] = useState<"display" | "connection">("display");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTab = window.sessionStorage.getItem(SETTINGS_TAB_STORAGE_KEY);
|
||||||
|
if (savedTab === "display" || savedTab === "connection") {
|
||||||
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
|
setActiveTab(savedTab);
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTabChange = (tab: string) => {
|
||||||
|
if (tab === "display" || tab === "connection") {
|
||||||
|
setActiveTab(tab);
|
||||||
|
window.sessionStorage.setItem(SETTINGS_TAB_STORAGE_KEY, tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="display" className="w-full">
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="display" className="flex items-center gap-2">
|
<TabsTrigger value="display" className="flex items-center gap-2">
|
||||||
<Monitor className="h-4 w-4" />
|
<Monitor className="h-4 w-4" />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function NetworkStatus() {
|
|||||||
if (isOnline) return null;
|
if (isOnline) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-[100] flex items-center gap-2 rounded-lg bg-destructive/90 backdrop-blur-md px-4 py-2 text-sm text-destructive-foreground shadow-lg">
|
<div className="fixed right-4 top-[calc(4.5rem+env(safe-area-inset-top,0px))] z-[110] flex items-center gap-2 rounded-full border border-destructive/40 bg-destructive/90 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-destructive-foreground shadow-lg backdrop-blur-md animate-in fade-in slide-in-from-top-1">
|
||||||
<WifiOff className="h-4 w-4" />
|
<WifiOff className="h-4 w-4" />
|
||||||
<span>Hors ligne</span>
|
<span>Hors ligne</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
import { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { registerServiceWorker, unregisterServiceWorker } from "@/lib/registerSW";
|
import {
|
||||||
|
registerServiceWorker,
|
||||||
|
unregisterServiceWorker,
|
||||||
|
isServiceWorkerEnabledInDev,
|
||||||
|
setServiceWorkerEnabledInDev,
|
||||||
|
} from "@/lib/registerSW";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface CacheStats {
|
interface CacheStats {
|
||||||
@@ -12,6 +17,7 @@ interface CacheStats {
|
|||||||
images: { size: number; entries: number };
|
images: { size: number; entries: number };
|
||||||
books: { size: number; entries: number };
|
books: { size: number; entries: number };
|
||||||
total: number;
|
total: number;
|
||||||
|
visitablePages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
@@ -29,6 +35,7 @@ type CacheType = "all" | "static" | "pages" | "api" | "images" | "books";
|
|||||||
interface ServiceWorkerContextValue {
|
interface ServiceWorkerContextValue {
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
|
isDevModeEnabled: boolean;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
hasNewVersion: boolean;
|
hasNewVersion: boolean;
|
||||||
cacheUpdates: CacheUpdate[];
|
cacheUpdates: CacheUpdate[];
|
||||||
@@ -40,6 +47,7 @@ interface ServiceWorkerContextValue {
|
|||||||
skipWaiting: () => void;
|
skipWaiting: () => void;
|
||||||
reloadForUpdate: () => void;
|
reloadForUpdate: () => void;
|
||||||
reinstallServiceWorker: () => Promise<boolean>;
|
reinstallServiceWorker: () => Promise<boolean>;
|
||||||
|
setDevModeEnabled: (enabled: boolean) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(null);
|
||||||
@@ -47,6 +55,7 @@ const ServiceWorkerContext = createContext<ServiceWorkerContextValue | null>(nul
|
|||||||
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
||||||
const [isSupported, setIsSupported] = useState(false);
|
const [isSupported, setIsSupported] = useState(false);
|
||||||
const [isReady, setIsReady] = useState(false);
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isDevModeEnabled, setIsDevModeEnabled] = useState(process.env.NODE_ENV !== "development");
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||||
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
|
const [cacheUpdates, setCacheUpdates] = useState<CacheUpdate[]>([]);
|
||||||
@@ -104,7 +113,12 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
case "CACHE_STATS":
|
case "CACHE_STATS":
|
||||||
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
const statsResolver = pendingRequests.current.get("CACHE_STATS");
|
||||||
if (statsResolver) {
|
if (statsResolver) {
|
||||||
statsResolver(payload);
|
const normalizedPayload = {
|
||||||
|
...payload,
|
||||||
|
visitablePages:
|
||||||
|
typeof payload?.visitablePages === "number" ? payload.visitablePages : 0,
|
||||||
|
};
|
||||||
|
statsResolver(normalizedPayload);
|
||||||
pendingRequests.current.delete("CACHE_STATS");
|
pendingRequests.current.delete("CACHE_STATS");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -159,7 +173,6 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
// Silently ignore message handling errors to prevent app crashes
|
// Silently ignore message handling errors to prevent app crashes
|
||||||
// This can happen with malformed messages or during SW reinstall
|
// This can happen with malformed messages or during SW reinstall
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
|
||||||
console.warn("[SW Context] Error handling message:", error, event.data);
|
console.warn("[SW Context] Error handling message:", error, event.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,8 +185,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development" && !isServiceWorkerEnabledInDev()) {
|
||||||
setIsSupported(false);
|
setIsDevModeEnabled(false);
|
||||||
|
// Browser still supports SW, it is only disabled by preference in dev
|
||||||
|
setIsSupported(true);
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setVersion(null);
|
setVersion(null);
|
||||||
|
|
||||||
@@ -184,6 +199,10 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
setIsDevModeEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
setIsSupported(true);
|
setIsSupported(true);
|
||||||
|
|
||||||
// Register service worker
|
// Register service worker
|
||||||
@@ -348,11 +367,37 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setDevModeEnabled = useCallback(async (enabled: boolean): Promise<boolean> => {
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setServiceWorkerEnabledInDev(enabled);
|
||||||
|
setIsDevModeEnabled(enabled);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
await unregisterServiceWorker();
|
||||||
|
setIsSupported("serviceWorker" in navigator);
|
||||||
|
setIsReady(false);
|
||||||
|
setVersion(null);
|
||||||
|
setHasNewVersion(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Failed to toggle service worker in development");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServiceWorkerContext.Provider
|
<ServiceWorkerContext.Provider
|
||||||
value={{
|
value={{
|
||||||
isSupported,
|
isSupported,
|
||||||
isReady,
|
isReady,
|
||||||
|
isDevModeEnabled,
|
||||||
version,
|
version,
|
||||||
hasNewVersion,
|
hasNewVersion,
|
||||||
cacheUpdates,
|
cacheUpdates,
|
||||||
@@ -364,6 +409,7 @@ export function ServiceWorkerProvider({ children }: { children: ReactNode }) {
|
|||||||
skipWaiting,
|
skipWaiting,
|
||||||
reloadForUpdate,
|
reloadForUpdate,
|
||||||
reinstallServiceWorker,
|
reinstallServiceWorker,
|
||||||
|
setDevModeEnabled,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ if (!i18n.isInitialized) {
|
|||||||
transKeepBasicHtmlNodesFor: ["br", "strong", "i", "p", "span"], // Liste des balises autorisées
|
transKeepBasicHtmlNodesFor: ["br", "strong", "i", "p", "span"], // Liste des balises autorisées
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Keep translation resources in sync during HMR/dev without full re-init.
|
||||||
|
i18n.addResourceBundle("fr", "common", frCommon, true, true);
|
||||||
|
i18n.addResourceBundle("en", "common", enCommon, true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -144,10 +144,11 @@
|
|||||||
"initializing": "Initializing...",
|
"initializing": "Initializing...",
|
||||||
"totalStorage": "Total storage",
|
"totalStorage": "Total storage",
|
||||||
"imagesQuota": "{used}% of images quota used",
|
"imagesQuota": "{used}% of images quota used",
|
||||||
|
"visitablePages": "{count} pages available offline",
|
||||||
"static": "Static resources",
|
"static": "Static resources",
|
||||||
"staticDesc": "Next.js scripts, styles and assets",
|
"staticDesc": "Next.js scripts, styles and assets",
|
||||||
"pages": "Visited pages",
|
"pages": "Visited pages",
|
||||||
"pagesDesc": "Home, libraries, series and details",
|
"pagesDesc": "Pages and navigation data available offline",
|
||||||
"api": "API data",
|
"api": "API data",
|
||||||
"apiDesc": "Series, books and library metadata",
|
"apiDesc": "Series, books and library metadata",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -161,6 +162,12 @@
|
|||||||
"unavailable": "Cache statistics unavailable",
|
"unavailable": "Cache statistics unavailable",
|
||||||
"reinstall": "Reinstall Service Worker",
|
"reinstall": "Reinstall Service Worker",
|
||||||
"reinstallError": "Error reinstalling Service Worker",
|
"reinstallError": "Error reinstalling Service Worker",
|
||||||
|
"devServiceWorker": {
|
||||||
|
"label": "Service Worker in development",
|
||||||
|
"description": "Enable Service Worker in dev mode to test cache/offline behavior. A reload is applied.",
|
||||||
|
"saved": "Dev Service Worker preference updated",
|
||||||
|
"error": "Failed to update dev Service Worker preference"
|
||||||
|
},
|
||||||
"entry": "entry",
|
"entry": "entry",
|
||||||
"entries": "entries",
|
"entries": "entries",
|
||||||
"loadingEntries": "Loading entries...",
|
"loadingEntries": "Loading entries...",
|
||||||
|
|||||||
@@ -144,10 +144,11 @@
|
|||||||
"initializing": "Initialisation...",
|
"initializing": "Initialisation...",
|
||||||
"totalStorage": "Stockage total",
|
"totalStorage": "Stockage total",
|
||||||
"imagesQuota": "{used}% du quota images utilisé",
|
"imagesQuota": "{used}% du quota images utilisé",
|
||||||
|
"visitablePages": "{count} pages visitables hors ligne",
|
||||||
"static": "Ressources statiques",
|
"static": "Ressources statiques",
|
||||||
"staticDesc": "Scripts, styles et assets Next.js",
|
"staticDesc": "Scripts, styles et assets Next.js",
|
||||||
"pages": "Pages visitées",
|
"pages": "Pages visitées",
|
||||||
"pagesDesc": "Home, bibliothèques, séries et détails",
|
"pagesDesc": "Pages et données de navigation disponibles hors ligne",
|
||||||
"api": "Données API",
|
"api": "Données API",
|
||||||
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
"apiDesc": "Métadonnées des séries, livres et bibliothèques",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -161,6 +162,12 @@
|
|||||||
"unavailable": "Statistiques du cache non disponibles",
|
"unavailable": "Statistiques du cache non disponibles",
|
||||||
"reinstall": "Réinstaller le Service Worker",
|
"reinstall": "Réinstaller le Service Worker",
|
||||||
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
|
"reinstallError": "Erreur lors de la réinstallation du Service Worker",
|
||||||
|
"devServiceWorker": {
|
||||||
|
"label": "Service Worker en développement",
|
||||||
|
"description": "Active le Service Worker en mode dev pour tester le cache/hors-ligne. Un rechargement est appliqué.",
|
||||||
|
"saved": "Préférence Service Worker dev mise à jour",
|
||||||
|
"error": "Impossible de mettre à jour la préférence Service Worker dev"
|
||||||
|
},
|
||||||
"entry": "entrée",
|
"entry": "entrée",
|
||||||
"entries": "entrées",
|
"entries": "entrées",
|
||||||
"loadingEntries": "Chargement des entrées...",
|
"loadingEntries": "Chargement des entrées...",
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ interface ServiceWorkerRegistrationOptions {
|
|||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEV_SW_ENABLED_STORAGE_KEY = "stripstream:sw-dev-enabled";
|
||||||
|
|
||||||
|
export const isServiceWorkerEnabledInDev = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
if (process.env.NODE_ENV !== "development") return true;
|
||||||
|
return window.localStorage.getItem(DEV_SW_ENABLED_STORAGE_KEY) === "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setServiceWorkerEnabledInDev = (enabled: boolean): void => {
|
||||||
|
if (typeof window === "undefined" || process.env.NODE_ENV !== "development") return;
|
||||||
|
window.localStorage.setItem(DEV_SW_ENABLED_STORAGE_KEY, enabled ? "true" : "false");
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the service worker with optional callbacks for update and success events
|
* Register the service worker with optional callbacks for update and success events
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user