Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd283bf6c6 | ||
|
|
3154a22500 | ||
|
|
61e0d98057 | ||
|
|
601c26d436 | ||
|
|
4a267d8fd8 |
@@ -3,49 +3,137 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 — Page Not Found</title>
|
||||
<title>404 — Page Not Found — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #71717a;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="/">Go home</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">404</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Page not found</span>
|
||||
</div>
|
||||
|
||||
<h1>Nothing here</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
|
||||
<a class="btn" href="/">Go home</a>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,49 +3,160 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>502 — Service Unavailable</title>
|
||||
<title>502 — Service Unavailable — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">502</div>
|
||||
<h1>Service Unavailable</h1>
|
||||
<p>The server is temporarily unreachable. Please try again in a moment.</p>
|
||||
<a href="/">Go home</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">502</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Service unavailable</span>
|
||||
</div>
|
||||
|
||||
<h1>Something went wrong</h1>
|
||||
<p>The server is temporarily unreachable. This usually resolves itself quickly.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,49 +3,163 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>503 — Maintenance</title>
|
||||
<title>Under Maintenance — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
/* ── Main ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">503</div>
|
||||
<h1>Under Maintenance</h1>
|
||||
<p>LibNovel is briefly offline for maintenance. We’ll be back shortly.</p>
|
||||
<a href="/">Try again</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">503</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Maintenance in progress</span>
|
||||
</div>
|
||||
|
||||
<h1>We'll be right back</h1>
|
||||
<p>LibNovel is briefly offline for scheduled maintenance. No data is being changed — hang tight.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">30</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 30;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,49 +3,160 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>504 — Gateway Timeout</title>
|
||||
<title>504 — Gateway Timeout — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">504</div>
|
||||
<h1>Gateway Timeout</h1>
|
||||
<p>The request took too long to complete. Please refresh and try again.</p>
|
||||
<a href="/">Go home</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">504</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Gateway timeout</span>
|
||||
</div>
|
||||
|
||||
<h1>Request timed out</h1>
|
||||
<p>The server took too long to respond. Please refresh and try again.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"player_play": "Play",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Playback speed {speed}x",
|
||||
"player_seek_label": "Chapter progress",
|
||||
"player_change_speed": "Change playback speed",
|
||||
"player_auto_next_on": "Auto-next on",
|
||||
"player_auto_next_off": "Auto-next off",
|
||||
@@ -99,6 +100,7 @@
|
||||
"catalogue_results_count": "{n} results",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Sign in to save",
|
||||
"book_detail_add_to_library": "Add to Library",
|
||||
"book_detail_remove_from_library": "Remove from Library",
|
||||
"book_detail_read_now": "Read Now",
|
||||
@@ -123,6 +125,8 @@
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Play narration",
|
||||
"reader_generating_audio": "Generating audio…",
|
||||
"reader_signin_for_audio": "Audio narration available",
|
||||
"reader_signin_audio_desc": "Sign in to listen to this chapter narrated by AI.",
|
||||
"reader_audio_error": "Audio generation failed.",
|
||||
"reader_prev_chapter": "Previous chapter",
|
||||
"reader_next_chapter": "Next chapter",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"player_play": "Lecture",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Vitesse {speed}x",
|
||||
"player_seek_label": "Progression du chapitre",
|
||||
"player_change_speed": "Changer la vitesse",
|
||||
"player_auto_next_on": "Suivant auto activé",
|
||||
"player_auto_next_off": "Suivant auto désactivé",
|
||||
@@ -99,6 +100,7 @@
|
||||
"catalogue_results_count": "{n} résultats",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
|
||||
"book_detail_add_to_library": "Ajouter à la bibliothèque",
|
||||
"book_detail_remove_from_library": "Retirer de la bibliothèque",
|
||||
"book_detail_read_now": "Lire maintenant",
|
||||
@@ -123,6 +125,8 @@
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Lire la narration",
|
||||
"reader_generating_audio": "Génération audio…",
|
||||
"reader_signin_for_audio": "Narration audio disponible",
|
||||
"reader_signin_audio_desc": "Connectez-vous pour écouter ce chapitre narré par l'IA.",
|
||||
"reader_audio_error": "Échec de la génération audio.",
|
||||
"reader_prev_chapter": "Chapitre précédent",
|
||||
"reader_next_chapter": "Chapitre suivant",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"player_play": "Putar",
|
||||
"player_pause": "Jeda",
|
||||
"player_speed_label": "Kecepatan {speed}x",
|
||||
"player_seek_label": "Kemajuan bab",
|
||||
"player_change_speed": "Ubah kecepatan",
|
||||
"player_auto_next_on": "Auto-lanjut aktif",
|
||||
"player_auto_next_off": "Auto-lanjut nonaktif",
|
||||
@@ -99,6 +100,7 @@
|
||||
"catalogue_results_count": "{n} hasil",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Masuk untuk menyimpan",
|
||||
"book_detail_add_to_library": "Tambah ke Perpustakaan",
|
||||
"book_detail_remove_from_library": "Hapus dari Perpustakaan",
|
||||
"book_detail_read_now": "Baca Sekarang",
|
||||
@@ -123,6 +125,8 @@
|
||||
"reader_page_title": "{title} — Bab.{n} — libnovel",
|
||||
"reader_play_narration": "Putar narasi",
|
||||
"reader_generating_audio": "Membuat audio…",
|
||||
"reader_signin_for_audio": "Narasi audio tersedia",
|
||||
"reader_signin_audio_desc": "Masuk untuk mendengarkan bab ini yang dinarasikan oleh AI.",
|
||||
"reader_audio_error": "Pembuatan audio gagal.",
|
||||
"reader_prev_chapter": "Bab sebelumnya",
|
||||
"reader_next_chapter": "Bab berikutnya",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"player_play": "Reproduzir",
|
||||
"player_pause": "Pausar",
|
||||
"player_speed_label": "Velocidade {speed}x",
|
||||
"player_seek_label": "Progresso do capítulo",
|
||||
"player_change_speed": "Mudar velocidade",
|
||||
"player_auto_next_on": "Próximo automático ativado",
|
||||
"player_auto_next_off": "Próximo automático desativado",
|
||||
@@ -99,6 +100,7 @@
|
||||
"catalogue_results_count": "{n} resultados",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Entre para salvar",
|
||||
"book_detail_add_to_library": "Adicionar à Biblioteca",
|
||||
"book_detail_remove_from_library": "Remover da Biblioteca",
|
||||
"book_detail_read_now": "Ler Agora",
|
||||
@@ -123,6 +125,8 @@
|
||||
"reader_page_title": "{title} — Cap.{n} — libnovel",
|
||||
"reader_play_narration": "Reproduzir narração",
|
||||
"reader_generating_audio": "Gerando áudio…",
|
||||
"reader_signin_for_audio": "Narração de áudio disponível",
|
||||
"reader_signin_audio_desc": "Entre para ouvir este capítulo narrado por IA.",
|
||||
"reader_audio_error": "Falha na geração de áudio.",
|
||||
"reader_prev_chapter": "Capítulo anterior",
|
||||
"reader_next_chapter": "Próximo capítulo",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"player_play": "Воспроизвести",
|
||||
"player_pause": "Пауза",
|
||||
"player_speed_label": "Скорость {speed}x",
|
||||
"player_seek_label": "Прогресс главы",
|
||||
"player_change_speed": "Изменить скорость",
|
||||
"player_auto_next_on": "Автопереход вкл.",
|
||||
"player_auto_next_off": "Автопереход выкл.",
|
||||
@@ -99,6 +100,7 @@
|
||||
"catalogue_results_count": "{n} результатов",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
|
||||
"book_detail_add_to_library": "В библиотеку",
|
||||
"book_detail_remove_from_library": "Удалить из библиотеки",
|
||||
"book_detail_read_now": "Читать",
|
||||
@@ -123,6 +125,8 @@
|
||||
"reader_page_title": "{title} — Гл.{n} — libnovel",
|
||||
"reader_play_narration": "Воспроизвести озвучку",
|
||||
"reader_generating_audio": "Генерация аудио…",
|
||||
"reader_signin_for_audio": "Доступна аудионарративация",
|
||||
"reader_signin_audio_desc": "Войдите, чтобы слушать эту главу в озвучке ИИ.",
|
||||
"reader_audio_error": "Ошибка генерации аудио.",
|
||||
"reader_prev_chapter": "Предыдущая глава",
|
||||
"reader_next_chapter": "Следующая глава",
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
--color-muted: #a1a1aa; /* zinc-400 */
|
||||
--color-text: #f4f4f5; /* zinc-100 */
|
||||
--color-border: #3f3f46; /* zinc-700 */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
|
||||
@@ -23,6 +24,7 @@
|
||||
--color-text: #f4f4f5;
|
||||
--color-border: #3f3f46;
|
||||
--color-danger: #f87171;
|
||||
--color-success: #4ade80;
|
||||
}
|
||||
|
||||
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
|
||||
@@ -36,6 +38,7 @@
|
||||
--color-text: #f1f5f9; /* slate-100 */
|
||||
--color-border: #334155; /* slate-700 */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
|
||||
@@ -49,6 +52,7 @@
|
||||
--color-text: #f4f4f5; /* zinc-100 */
|
||||
--color-border: #3f2d36; /* custom rose border */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -1012,6 +1012,8 @@ export async function createUserSession(
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return rec.id;
|
||||
}
|
||||
|
||||
@@ -1048,6 +1050,28 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* Called on login so the list self-cleans without a separate cron job.
|
||||
*/
|
||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const stale = await listAll<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
||||
);
|
||||
if (stale.length === 0) return;
|
||||
const token = await getToken();
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a specific session by its PocketBase record ID.
|
||||
* Only allows deletion if the session belongs to the given userId.
|
||||
|
||||
@@ -4,11 +4,15 @@ import { getSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']);
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms', '/catalogue']);
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
// Allow /auth/* (OAuth initiation + callbacks) without login
|
||||
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
|
||||
// Allow public routes, /auth/*, and all book-browsing URLs (/books/[slug] and deeper)
|
||||
// Note: /books (the personal library) is intentionally NOT public
|
||||
const isPublic =
|
||||
PUBLIC_ROUTES.has(url.pathname) ||
|
||||
url.pathname.startsWith('/auth/') ||
|
||||
url.pathname.startsWith('/books/');
|
||||
if (!isPublic && !locals.user) {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
// Mobile nav drawer state
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// Desktop dropdown menus
|
||||
let userMenuOpen = $state(false);
|
||||
let langMenuOpen = $state(false);
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'amber', color: '#f59e0b' },
|
||||
{ id: 'slate', color: '#818cf8' },
|
||||
{ id: 'rose', color: '#fb7185' },
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
|
||||
@@ -303,61 +313,101 @@
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<!-- Theme quick picker -->
|
||||
<div class="hidden sm:flex items-center gap-1">
|
||||
{#each [{ id: 'amber', color: '#f59e0b' }, { id: 'slate', color: '#818cf8' }, { id: 'rose', color: '#fb7185' }] as t}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Theme dots (desktop) -->
|
||||
<div class="hidden sm:flex items-center gap-1 mr-1">
|
||||
{#each THEMES as t}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-4 h-4 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-60 hover:opacity-100'}"
|
||||
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Language quick picker -->
|
||||
<div class="hidden sm:flex items-center gap-0.5">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Language dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { langMenuOpen = !langMenuOpen; userMenuOpen = false; }}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded text-xs font-mono transition-colors {langMenuOpen ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
{getLocale().toUpperCase()}
|
||||
<svg class="w-3 h-3 shrink-0 transition-transform {langMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if langMenuOpen}
|
||||
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
langMenuOpen = false;
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="w-full text-left px-3 py-1.5 text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/profile"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{data.user.username}
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="hidden sm:block">
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-(--color-muted) hover:text-(--color-text)">
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
<!-- User menu dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { userMenuOpen = !userMenuOpen; langMenuOpen = false; }}
|
||||
class="flex items-center gap-1.5 pl-1.5 pr-2 py-1 rounded transition-colors {userMenuOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-full bg-(--color-brand)/20 text-(--color-brand) text-xs font-bold flex items-center justify-center shrink-0">
|
||||
{data.user.username[0].toUpperCase()}
|
||||
</span>
|
||||
<svg class="w-3 h-3 text-(--color-muted) transition-transform {userMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if userMenuOpen}
|
||||
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[170px]">
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
<span class="text-xs opacity-40 truncate max-w-[80px]">{data.user.username}</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
|
||||
{m.nav_sign_out()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: hamburger button -->
|
||||
<Button
|
||||
@@ -369,18 +419,25 @@
|
||||
class="sm:hidden -mr-1"
|
||||
>
|
||||
{#if menuOpen}
|
||||
<!-- X icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Hamburger icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -437,6 +494,48 @@
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Theme switcher -->
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each THEMES as t}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language switcher -->
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.locale_switcher_label()}</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
menuOpen = false;
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<Button
|
||||
@@ -487,28 +586,6 @@
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Locale switcher (footer) -->
|
||||
<div class="hidden sm:flex items-center gap-1 ml-2">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
aria-label="{m.locale_switcher_label()}: {locale}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<!-- Bottom row: legal links + copyright -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
|
||||
@@ -601,6 +678,7 @@
|
||||
<div class="px-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label={m.player_seek_label()}
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{m.book_detail_scraping_progress()}
|
||||
</p>
|
||||
{#if data.taskId}
|
||||
{#if data.taskId && data.user?.role === 'admin'}
|
||||
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -243,7 +243,17 @@
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
{#if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
title={m.book_detail_signin_to_save()}
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
@@ -294,7 +304,17 @@
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
{#if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
title={m.book_detail_signin_to_save()}
|
||||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
← {m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
@@ -170,7 +170,7 @@
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
{m.reader_chapter_n({ n: String(data.next) })} →
|
||||
</a>
|
||||
@@ -184,7 +184,11 @@
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
{#if wordCount > 0}
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">
|
||||
{m.reader_words({ n: wordCount.toLocaleString() })}
|
||||
<span class="opacity-50 mx-1">·</span>
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min read
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -247,7 +251,21 @@
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
{#if audioProRequired}
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated: sign-in prompt -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{:else if audioProRequired}
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
||||
@@ -303,17 +321,17 @@
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
← {m.reader_prev_chapter()}
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
{m.reader_next_chapter()} →
|
||||
</a>
|
||||
|
||||
@@ -242,11 +242,11 @@
|
||||
<!-- Admin flash messages -->
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-success)/10 border border-(--color-success)/40 text-(--color-success) text-sm">
|
||||
{m.catalogue_scrape_queued_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-brand)/10 border border-(--color-brand)/40 text-(--color-brand) text-sm">
|
||||
{m.catalogue_scrape_busy_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
href="/auth/google"
|
||||
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
|
||||
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
|
||||
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
|
||||
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
@@ -62,7 +62,7 @@
|
||||
href="/auth/github"
|
||||
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
|
||||
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
|
||||
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
|
||||
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0 fill-(--color-text)" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
|
||||
{#if data.isPro}
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-400/15 text-amber-400 border border-amber-400/30 tracking-wide uppercase">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 tracking-wide uppercase">
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
@@ -339,7 +339,7 @@
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors"
|
||||
>
|
||||
{m.profile_upgrade_annual()}
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-amber-400/15 text-amber-400 border border-amber-400/30">–33%</span>
|
||||
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">–33%</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,7 +416,7 @@
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-green-400">{m.profile_saved()}</span>
|
||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -502,7 +502,7 @@
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-green-400">{m.profile_saved()}</span>
|
||||
<span class="text-sm text-(--color-success)">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
37
ui/src/routes/sitemap.xml/+server.ts
Normal file
37
ui/src/routes/sitemap.xml/+server.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const SITE = 'https://libnovel.cc';
|
||||
|
||||
// Only static public pages are listed here. Book/catalogue pages are
|
||||
// discoverable via the catalogue link and don't need individual entries
|
||||
// (the catalogue itself serves as an index for crawlers).
|
||||
const PUBLIC_PAGES = [
|
||||
{ path: '/catalogue', changefreq: 'daily', priority: '0.9' },
|
||||
{ path: '/login', changefreq: 'monthly', priority: '0.5' },
|
||||
{ path: '/disclaimer', changefreq: 'yearly', priority: '0.2' },
|
||||
{ path: '/privacy', changefreq: 'yearly', priority: '0.2' },
|
||||
{ path: '/dmca', changefreq: 'yearly', priority: '0.2' },
|
||||
];
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const urls = PUBLIC_PAGES.map(
|
||||
({ path, changefreq, priority }) => `
|
||||
<url>
|
||||
<loc>${SITE}${path}</loc>
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>
|
||||
</url>`
|
||||
).join('');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,14 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Disallow: /books
|
||||
Disallow: /profile
|
||||
Disallow: /admin/
|
||||
Disallow: /api/
|
||||
Disallow: /auth/
|
||||
Allow: /books/
|
||||
Allow: /catalogue
|
||||
Allow: /login
|
||||
Allow: /disclaimer
|
||||
Allow: /privacy
|
||||
Allow: /dmca
|
||||
|
||||
Sitemap: https://libnovel.cc/sitemap.xml
|
||||
|
||||
Reference in New Issue
Block a user