mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
chore: remove unused files and keep staged settings updates
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
@layer components {
|
||||
/* ── Reader viewer chrome ────────────────────────────────── */
|
||||
.viewer-btn {
|
||||
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
}
|
||||
.viewer-sep {
|
||||
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
|
||||
}
|
||||
|
||||
/* ── Settings typography scale ──────────────────────────────
|
||||
Change these once to update every settings panel at once.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
.settings-title {
|
||||
@apply font-serif font-semibold text-foreground text-2xl tracking-tight;
|
||||
}
|
||||
.settings-subtitle {
|
||||
@apply text-sm text-muted-foreground mt-1;
|
||||
}
|
||||
.settings-group-label {
|
||||
@apply text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3;
|
||||
}
|
||||
.settings-label {
|
||||
@apply text-sm font-medium text-foreground;
|
||||
}
|
||||
.settings-hint {
|
||||
@apply text-xs text-muted-foreground mt-0.5;
|
||||
}
|
||||
.settings-value {
|
||||
@apply text-sm font-medium tabular-nums text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
.pattern-preview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern-preview[class*='pattern-']::before {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Fundamental ───────────────────────────────────────────── */
|
||||
|
||||
.pattern-dots {
|
||||
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
.pattern-cross {
|
||||
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px), radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position:
|
||||
0 0,
|
||||
12px 12px;
|
||||
}
|
||||
|
||||
.pattern-terminal::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
opacity: 0.05;
|
||||
background-image: repeating-linear-gradient(90deg, var(--primary), var(--primary) 1px, transparent 1px, transparent 40px);
|
||||
}
|
||||
|
||||
.pattern-millimeter::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--primary) 5%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 5%, transparent) 1px, transparent 1px);
|
||||
background-size: 2px 2px;
|
||||
}
|
||||
|
||||
/* ── Structural ────────────────────────────────────────────── */
|
||||
|
||||
.pattern-blueprint::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--primary) 4%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 4%, transparent) 1px, transparent 1px);
|
||||
background-size: 4px 4px;
|
||||
}
|
||||
|
||||
.pattern-brushed {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
color-mix(in oklch, var(--primary) 6%, transparent) 1px,
|
||||
color-mix(in oklch, var(--primary) 6%, transparent) 2px
|
||||
);
|
||||
}
|
||||
|
||||
.pattern-scanlines::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, transparent 50%, color-mix(in oklch, var(--primary) 5%, transparent) 50%);
|
||||
background-size: 100% 4px;
|
||||
}
|
||||
|
||||
.pattern-carbon::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, color-mix(in oklch, var(--primary) 5%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, color-mix(in oklch, var(--primary) 5%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, color-mix(in oklch, var(--primary) 5%, transparent) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, color-mix(in oklch, var(--primary) 5%, transparent) 75%);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
|
||||
.pattern-vinyl::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: repeating-radial-gradient(circle at 50% -25%, transparent, transparent 3px, color-mix(in oklch, var(--primary) 7%, transparent) 3.75px, transparent 4.5px);
|
||||
}
|
||||
|
||||
.pattern-perforated::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle at center, color-mix(in oklch, var(--primary) 8%, transparent) 1.5px, transparent 2px);
|
||||
background-size: 12px 12px;
|
||||
}
|
||||
|
||||
/* ── Ambient ───────────────────────────────────────────────── */
|
||||
|
||||
.pattern-aurora {
|
||||
background-image:
|
||||
radial-gradient(ellipse 60% 45% at 0% 50%, color-mix(in oklch, var(--primary) 15%, transparent), transparent 100%),
|
||||
radial-gradient(ellipse 50% 40% at 100% 50%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 100%),
|
||||
radial-gradient(ellipse 40% 35% at 50% 0%, color-mix(in oklch, var(--primary) 8%, transparent), transparent 100%);
|
||||
}
|
||||
|
||||
.pattern-horizon {
|
||||
background-image: linear-gradient(180deg, transparent 0%, color-mix(in oklch, var(--primary) 8%, transparent) 50%, transparent 100%);
|
||||
}
|
||||
|
||||
.pattern-glow {
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklch, var(--primary) 20%, transparent) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.pattern-mesh {
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, color-mix(in oklch, var(--primary) 20%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, color-mix(in oklch, var(--primary) 15%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 100%, color-mix(in oklch, var(--primary) 20%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 100%, color-mix(in oklch, var(--primary) 15%, transparent) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 50%, color-mix(in oklch, var(--primary) 10%, transparent) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
.pattern-elevation::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 5%, transparent), transparent 40%),
|
||||
linear-gradient(to top, color-mix(in oklch, var(--primary) 3%, transparent), transparent 30%);
|
||||
}
|
||||
|
||||
/* ── Refractive ────────────────────────────────────────────── */
|
||||
|
||||
.pattern-prism::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(135deg, rgba(255, 0, 0, 0.03) 0%, transparent 50%), linear-gradient(225deg, rgba(0, 0, 255, 0.03) 0%, transparent 50%),
|
||||
linear-gradient(45deg, rgba(0, 255, 0, 0.03) 0%, transparent 50%);
|
||||
filter: contrast(150%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-spectrum::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(135deg, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(225deg, oklch(0.7 0.15 calc(var(--tint-h) + 120) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(45deg, oklch(0.7 0.15 calc(var(--tint-h) + 240) / 0.08) 0%, transparent 50%);
|
||||
filter: contrast(160%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-spectrum-x::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(135deg, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(225deg, oklch(0.7 0.15 calc(var(--tint-h) + 90) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(315deg, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(45deg, oklch(0.7 0.15 calc(var(--tint-h) + 270) / 0.08) 0%, transparent 50%);
|
||||
filter: contrast(160%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-spectrum-plus::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to bottom, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(to left, oklch(0.7 0.15 calc(var(--tint-h) + 90) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(to top, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.08) 0%, transparent 50%),
|
||||
linear-gradient(to right, oklch(0.7 0.15 calc(var(--tint-h) + 270) / 0.08) 0%, transparent 50%);
|
||||
filter: contrast(160%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-eclipse::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, oklch(0.7 0.15 var(--tint-h) / 0.12) 0%, transparent 65%),
|
||||
radial-gradient(at 100% 100%, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.12) 0%, transparent 65%);
|
||||
filter: contrast(150%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-nova::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(at 50% 50%, oklch(0.7 0.15 var(--tint-h) / 0.12) 0%, transparent 60%),
|
||||
linear-gradient(to bottom, oklch(0.7 0.15 calc(var(--tint-h) + 150) / 0.08) 0%, transparent 40%),
|
||||
linear-gradient(to top, oklch(0.7 0.15 calc(var(--tint-h) + 210) / 0.08) 0%, transparent 40%);
|
||||
filter: contrast(150%) brightness(110%);
|
||||
}
|
||||
|
||||
.pattern-golden-ratio::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(at 38.2% 38.2%, oklch(0.7 0.15 var(--tint-h) / 0.1) 0%, transparent 55%),
|
||||
radial-gradient(at 61.8% 61.8%, oklch(0.7 0.15 calc(var(--tint-h) + 137.5) / 0.1) 0%, transparent 55%),
|
||||
radial-gradient(at 80% 20%, oklch(0.7 0.15 calc(var(--tint-h) + 275) / 0.08) 0%, transparent 50%);
|
||||
filter: contrast(150%) brightness(110%);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { bookCoverStyle } from '../lib/book-cover'
|
||||
|
||||
export { bookCoverStyle }
|
||||
|
||||
export interface BookFile {
|
||||
id: number
|
||||
format: string | null
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: number
|
||||
status: string
|
||||
title: string | null
|
||||
authors: string[]
|
||||
seriesName: string | null
|
||||
seriesIndex: number | null
|
||||
files: BookFile[]
|
||||
}
|
||||
|
||||
interface BooksPage {
|
||||
items: Book[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useBooks(libraryId: Ref<number | null>) {
|
||||
const books = ref<Book[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
|
||||
const hasMore = computed(() => books.value.length < total.value)
|
||||
|
||||
async function load(reset = false) {
|
||||
if (loading.value || libraryId.value === null) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const currentPage = reset ? 0 : page.value
|
||||
const params = new URLSearchParams({
|
||||
libraryId: String(libraryId.value),
|
||||
page: String(currentPage),
|
||||
size: String(PAGE_SIZE),
|
||||
})
|
||||
if (search.value.trim()) params.set('search', search.value.trim())
|
||||
|
||||
const res = await api(`/api/v1/books?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const data: BooksPage = await res.json()
|
||||
|
||||
if (reset) {
|
||||
books.value = data.items
|
||||
page.value = 1
|
||||
} else {
|
||||
books.value.push(...data.items)
|
||||
page.value = currentPage + 1
|
||||
}
|
||||
total.value = data.total
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load books'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
books.value = []
|
||||
total.value = 0
|
||||
page.value = 0
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
load(true)
|
||||
}
|
||||
|
||||
return { books, total, loading, error, search, hasMore, load, onSearch, clear }
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { LayoutGrid, RotateCw, X, ZoomIn } from 'lucide-vue-next'
|
||||
import { ZOOM_PRESETS } from '../composables/usePdfZoom'
|
||||
|
||||
defineProps<{
|
||||
zoomMode: string
|
||||
customScale: number
|
||||
zoomLabel: string
|
||||
spread: 'none' | 'odd' | 'even'
|
||||
scrollMode: 'vertical' | 'page'
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
applyZoomPreset: [value: string]
|
||||
'update:spread': [v: 'none' | 'odd' | 'even']
|
||||
'update:scrollMode': [v: 'vertical' | 'page']
|
||||
rotate: []
|
||||
}>()
|
||||
|
||||
type Tab = 'zoom' | 'layout' | 'display'
|
||||
const activeTab = ref<Tab>('zoom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
|
||||
@click.stop
|
||||
>
|
||||
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in [
|
||||
{ id: 'zoom', icon: ZoomIn, label: 'Zoom' },
|
||||
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
|
||||
{ id: 'display', icon: RotateCw, label: 'Display' },
|
||||
] as const"
|
||||
:key="tab.id"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="activeTab === tab.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
<component :is="tab.icon" :size="14" />
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 space-y-6">
|
||||
<template v-if="activeTab === 'zoom'">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Preset</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
zoomMode === 'fit-width'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('applyZoomPreset', 'fit-width')"
|
||||
>
|
||||
Fit Width
|
||||
</button>
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
zoomMode === 'fit-page'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('applyZoomPreset', 'fit-page')"
|
||||
>
|
||||
Fit Page
|
||||
</button>
|
||||
<button
|
||||
v-for="preset in ZOOM_PRESETS"
|
||||
:key="preset.label"
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
zoomMode === 'custom' && customScale === parseFloat(preset.value)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('applyZoomPreset', preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="activeTab === 'layout'">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Scroll Mode</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
scrollMode === 'vertical'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('update:scrollMode', 'vertical')"
|
||||
>
|
||||
Scrolled
|
||||
</button>
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
scrollMode === 'page'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('update:scrollMode', 'page')"
|
||||
>
|
||||
Page by Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Page Spread</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
spread === 'none'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('update:spread', 'none')"
|
||||
>
|
||||
Single Page
|
||||
</button>
|
||||
<button
|
||||
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
|
||||
:class="
|
||||
spread !== 'none'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
|
||||
"
|
||||
@click="emit('update:spread', 'odd')"
|
||||
>
|
||||
Two-Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="activeTab === 'display'">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Rotation</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium">Rotate Clockwise</p>
|
||||
<p class="text-xs text-muted-foreground">Current: {{ rotation }}°</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border-2 border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground transition-colors"
|
||||
@click="emit('rotate')"
|
||||
>
|
||||
<RotateCw :size="16" />
|
||||
Rotate 90°
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type * as pdfjsLib from 'pdfjs-dist'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
|
||||
export function usePdfThumbnails(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | null>, totalPages: Ref<number>) {
|
||||
const thumbnails = ref(new Map<number, string>())
|
||||
const pending = new Set<number>()
|
||||
|
||||
async function loadThumbnail(pageNum: number): Promise<void> {
|
||||
const doc = pdfDoc.value
|
||||
if (!doc || thumbnails.value.has(pageNum) || pending.has(pageNum)) return
|
||||
if (pageNum < 1 || pageNum > totalPages.value) return
|
||||
|
||||
pending.add(pageNum)
|
||||
try {
|
||||
const page = await doc.getPage(pageNum)
|
||||
const vp = page.getViewport({ scale: 0.25 })
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.round(vp.width)
|
||||
canvas.height = Math.round(vp.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
await page.render({ canvasContext: ctx, viewport: vp }).promise
|
||||
page.cleanup()
|
||||
thumbnails.value = new Map(thumbnails.value).set(pageNum, canvas.toDataURL('image/jpeg', 0.7))
|
||||
} catch {
|
||||
// silently skip failed thumbnails
|
||||
} finally {
|
||||
pending.delete(pageNum)
|
||||
}
|
||||
}
|
||||
|
||||
function loadRange(start: number, end: number) {
|
||||
const cap = totalPages.value
|
||||
for (let i = start; i <= Math.min(end, cap); i++) {
|
||||
loadThumbnail(i)
|
||||
}
|
||||
}
|
||||
|
||||
return { thumbnails, loadThumbnail, loadRange }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { type ReaderFormatGroup, type ReaderSettings, READER_GROUP_DEFAULTS, getFormatGroup } from '@projectx/types'
|
||||
@@ -191,6 +192,7 @@ export function useReaderDefaultSettings<T extends ReaderSettings>(format: strin
|
||||
if (syncEnabled.value) {
|
||||
api(`/api/v1/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {})
|
||||
}
|
||||
toast.success('Settings reset to defaults')
|
||||
}
|
||||
|
||||
return { effective, load, update, reset }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { useKoboDevices } from '@/features/kobo/composables/useKoboDevices'
|
||||
@@ -22,7 +23,6 @@ const createError = ref<string | null>(null)
|
||||
// New device token display
|
||||
const newDeviceToken = ref<string | null>(null)
|
||||
const newDeviceSyncUrl = ref<string | null>(null)
|
||||
const tokenCopied = ref(false)
|
||||
|
||||
// Rename
|
||||
const renamingId = ref<number | null>(null)
|
||||
@@ -79,8 +79,10 @@ async function submitCreate() {
|
||||
newDeviceSyncUrl.value = `${window.location.origin}/api/v1/kobo/${device.token}`
|
||||
showCreateForm.value = false
|
||||
newDeviceName.value = ''
|
||||
toast.success(`Device "${device.name}" registered`)
|
||||
} catch (e) {
|
||||
createError.value = e instanceof Error ? e.message : 'Failed to create device'
|
||||
toast.error(createError.value ?? 'Failed to create device')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
@@ -95,14 +97,12 @@ function cancelCreate() {
|
||||
function dismissToken() {
|
||||
newDeviceToken.value = null
|
||||
newDeviceSyncUrl.value = null
|
||||
tokenCopied.value = false
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!newDeviceSyncUrl.value) return
|
||||
await navigator.clipboard.writeText(newDeviceSyncUrl.value)
|
||||
tokenCopied.value = true
|
||||
setTimeout(() => (tokenCopied.value = false), 2000)
|
||||
toast.success('Sync URL copied to clipboard')
|
||||
}
|
||||
|
||||
function startRename(device: KoboDevice) {
|
||||
@@ -120,7 +120,10 @@ async function submitRename(device: KoboDevice) {
|
||||
renaming.value = true
|
||||
try {
|
||||
await renameDevice(device.id, renameValue.value.trim())
|
||||
toast.success('Device renamed')
|
||||
renamingId.value = null
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to rename device')
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
@@ -128,12 +131,18 @@ async function submitRename(device: KoboDevice) {
|
||||
|
||||
async function revoke(device: KoboDevice) {
|
||||
if (!confirm(`Revoke access for "${device.name}"? The device will not be able to sync until re-paired.`)) return
|
||||
await revokeDevice(device.id)
|
||||
try {
|
||||
await revokeDevice(device.id)
|
||||
toast.success(`Access revoked for "${device.name}"`)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to revoke access')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (readingThreshold.value >= finishedThreshold.value) {
|
||||
settingsError.value = 'Reading threshold must be less than finished threshold'
|
||||
toast.error(settingsError.value ?? 'Failed to save settings')
|
||||
return
|
||||
}
|
||||
savingSettings.value = true
|
||||
@@ -147,8 +156,10 @@ async function saveSettings() {
|
||||
forceEnableHyphenation: forceEnableHyphenation.value,
|
||||
kepubConversionLimitMb: kepubConversionLimitMb.value,
|
||||
})
|
||||
toast.success('Kobo sync settings saved')
|
||||
} catch (e) {
|
||||
settingsError.value = e instanceof Error ? e.message : 'Failed to save'
|
||||
toast.error(settingsError.value ?? 'Failed to save settings')
|
||||
} finally {
|
||||
savingSettings.value = false
|
||||
}
|
||||
@@ -188,8 +199,8 @@ async function saveSettings() {
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
@click="copyToken()"
|
||||
>
|
||||
<component :is="tokenCopied ? Check : Copy" :size="12" />
|
||||
{{ tokenCopied ? 'Copied' : 'Copy' }}
|
||||
<Copy :size="12" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground leading-relaxed">
|
||||
|
||||
@@ -168,7 +168,12 @@ async function onSaved(library: LibraryType) {
|
||||
creatorOpen.value = false
|
||||
editingLibrary.value = null
|
||||
subscribeLibrary(library.id)
|
||||
if (isNew) pendingNavigateLibraryId.value = library.id
|
||||
if (isNew) {
|
||||
pendingNavigateLibraryId.value = library.id
|
||||
toast.success(`Library "${library.name}" created`)
|
||||
} else {
|
||||
toast.success(`Library "${library.name}" updated`)
|
||||
}
|
||||
await refreshLibraries()
|
||||
loadAllStats()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Check, RefreshCw, Sparkles, FileEdit } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import type { GlobalFileWriteSettings } from '@projectx/types'
|
||||
@@ -21,8 +22,10 @@ async function rebuildEmbeddings() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: { queued: number } = await res.json()
|
||||
queued.value = data.queued
|
||||
toast.success(`${data.queued} books queued for embedding`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed'
|
||||
toast.error(`Failed to rebuild embeddings: ${error.value ?? 'Unknown error'}`)
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
@@ -30,7 +33,6 @@ async function rebuildEmbeddings() {
|
||||
|
||||
const writeSettings = ref<GlobalFileWriteSettings>(structuredClone(DEFAULT_FILE_WRITE_SETTINGS))
|
||||
const writeSaving = ref(false)
|
||||
const writeSaved = ref(false)
|
||||
|
||||
const epubMaxMb = computed({
|
||||
get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)),
|
||||
@@ -61,7 +63,6 @@ onMounted(async () => {
|
||||
async function saveWriteSettings() {
|
||||
if (writeSaving.value) return
|
||||
writeSaving.value = true
|
||||
writeSaved.value = false
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/file-write-settings', {
|
||||
method: 'PUT',
|
||||
@@ -70,11 +71,12 @@ async function saveWriteSettings() {
|
||||
})
|
||||
if (res.ok) {
|
||||
writeSettings.value = await res.json()
|
||||
writeSaved.value = true
|
||||
setTimeout(() => {
|
||||
writeSaved.value = false
|
||||
}, 2000)
|
||||
toast.success('Metadata sync settings saved')
|
||||
} else {
|
||||
toast.error('Failed to save metadata sync settings')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to save metadata sync settings')
|
||||
} finally {
|
||||
writeSaving.value = false
|
||||
}
|
||||
@@ -294,11 +296,6 @@ function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="writeSaved" class="px-5 py-3 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
|
||||
<Check :size="12" />
|
||||
Settings saved
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
@@ -19,7 +20,6 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const saveSuccess = ref(false)
|
||||
const testResult = ref<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
const form = reactive<OidcConfig>({
|
||||
@@ -48,7 +48,6 @@ onMounted(async () => {
|
||||
|
||||
async function save() {
|
||||
saveError.value = null
|
||||
saveSuccess.value = false
|
||||
saving.value = true
|
||||
try {
|
||||
const body: Partial<OidcConfig> = { ...form }
|
||||
@@ -63,12 +62,10 @@ async function save() {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
|
||||
}
|
||||
saveSuccess.value = true
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = false
|
||||
}, 3000)
|
||||
toast.success('OIDC settings saved')
|
||||
} catch (err) {
|
||||
saveError.value = err instanceof Error ? err.message : 'Failed to save settings'
|
||||
toast.error(saveError.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -222,8 +219,7 @@ async function testConnection() {
|
||||
<button type="submit" :disabled="saving" class="settings-btn-primary">
|
||||
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||
</button>
|
||||
<p v-if="saveSuccess" class="text-sm text-green-600">Saved.</p>
|
||||
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { Plus, Trash2, Copy, Check, Rss } from 'lucide-vue-next'
|
||||
import { Plus, Trash2, Copy, Rss } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -14,7 +15,6 @@ const opdsEnabled = ref(true)
|
||||
const opdsUsers = ref<OpdsUser[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
const showCreateForm = ref(false)
|
||||
const createUsername = ref('')
|
||||
@@ -59,18 +59,26 @@ onMounted(async () => {
|
||||
|
||||
async function toggleOpds() {
|
||||
const newVal = !opdsEnabled.value
|
||||
const res = await api('/api/v1/app-settings/opds_enabled', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
})
|
||||
if (res.ok) opdsEnabled.value = newVal
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/opds_enabled', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
})
|
||||
if (res.ok) {
|
||||
opdsEnabled.value = newVal
|
||||
toast.success(`OPDS server ${newVal ? 'enabled' : 'disabled'}`)
|
||||
} else {
|
||||
toast.error('Failed to update OPDS settings')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to update OPDS settings')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyUrl() {
|
||||
await navigator.clipboard.writeText(opdsUrl.value)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 2000)
|
||||
toast.success('OPDS URL copied to clipboard')
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
@@ -85,6 +93,7 @@ async function createUser() {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
createError.value = data.message ?? 'Failed to create OPDS user'
|
||||
toast.error(createError.value ?? 'Failed to create OPDS user')
|
||||
return
|
||||
}
|
||||
const user = await res.json()
|
||||
@@ -93,21 +102,31 @@ async function createUser() {
|
||||
createUsername.value = ''
|
||||
createPassword.value = ''
|
||||
createSortOrder.value = 'recent'
|
||||
toast.success(`OPDS user "${user.username}" created`)
|
||||
} catch {
|
||||
toast.error('Failed to create OPDS user')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSortOrder(user: OpdsUser, sortOrder: OpdsSortOrder) {
|
||||
const res = await api(`/api/v1/opds-users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sortOrder }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
const idx = opdsUsers.value.findIndex((u) => u.id === user.id)
|
||||
if (idx >= 0) opdsUsers.value[idx] = updated
|
||||
try {
|
||||
const res = await api(`/api/v1/opds-users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sortOrder }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
const idx = opdsUsers.value.findIndex((u) => u.id === user.id)
|
||||
if (idx >= 0) opdsUsers.value[idx] = updated
|
||||
toast.success(`Sort order updated for "${user.username}"`)
|
||||
} else {
|
||||
toast.error('Failed to update sort order')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to update sort order')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,9 +137,16 @@ function cancelCreate() {
|
||||
|
||||
async function deleteUser(user: OpdsUser) {
|
||||
if (!confirm(`Delete OPDS user "${user.username}"? This cannot be undone.`)) return
|
||||
const res = await api(`/api/v1/opds-users/${user.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
opdsUsers.value = opdsUsers.value.filter((u) => u.id !== user.id)
|
||||
try {
|
||||
const res = await api(`/api/v1/opds-users/${user.id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
opdsUsers.value = opdsUsers.value.filter((u) => u.id !== user.id)
|
||||
toast.success(`OPDS user "${user.username}" deleted`)
|
||||
} else {
|
||||
toast.error('Failed to delete OPDS user')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete OPDS user')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -156,8 +182,8 @@ async function deleteUser(user: OpdsUser) {
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
|
||||
@click="copyUrl()"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" :size="12" />
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
<Copy :size="12" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Monitor, Cloud } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
@@ -11,13 +12,20 @@ const syncEnabled = computed(() => user.value?.settings?.syncReaderPreferences ?
|
||||
|
||||
async function setStorageMode(sync: boolean) {
|
||||
if (!user.value || syncEnabled.value === sync) return
|
||||
const res = await api('/api/v1/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { syncReaderPreferences: sync } }),
|
||||
})
|
||||
if (res.ok) {
|
||||
user.value = { ...user.value, settings: { ...user.value.settings, syncReaderPreferences: sync } }
|
||||
try {
|
||||
const res = await api('/api/v1/users/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: { syncReaderPreferences: sync } }),
|
||||
})
|
||||
if (res.ok) {
|
||||
user.value = { ...user.value, settings: { ...user.value.settings, syncReaderPreferences: sync } }
|
||||
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
|
||||
} else {
|
||||
toast.error('Failed to update storage mode')
|
||||
}
|
||||
} catch {
|
||||
toast.error('An error occurred while updating storage mode')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
|
||||
const autoScan = ref(true)
|
||||
const scanOnStartup = ref(true)
|
||||
const watchForChanges = ref(false)
|
||||
const scanInterval = ref('hourly')
|
||||
|
||||
const fileTypes = ref([
|
||||
{ ext: 'epub', label: 'EPUB', enabled: true },
|
||||
{ ext: 'pdf', label: 'PDF', enabled: true },
|
||||
{ ext: 'cbz', label: 'CBZ', enabled: true },
|
||||
{ ext: 'cbr', label: 'CBR', enabled: true },
|
||||
{ ext: 'cb7', label: 'CB7', enabled: true },
|
||||
{ ext: 'mobi', label: 'MOBI', enabled: true },
|
||||
{ ext: 'azw3', label: 'AZW3', enabled: true },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageHeader title="Scanner" subtitle="Configure when and how your libraries are scanned for new content." />
|
||||
|
||||
<!-- Schedule -->
|
||||
<div class="mb-6">
|
||||
<p class="settings-group-label">Schedule</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Automatic scanning</p>
|
||||
<p class="settings-hint">Periodically scan libraries for new or changed files</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="autoScan" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card" :class="!autoScan ? 'opacity-40 pointer-events-none' : ''">
|
||||
<div>
|
||||
<p class="settings-label">Scan interval</p>
|
||||
<p class="settings-hint">How often to check all library folders</p>
|
||||
</div>
|
||||
<select
|
||||
v-model="scanInterval"
|
||||
class="text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="15min">Every 15 minutes</option>
|
||||
<option value="hourly">Every hour</option>
|
||||
<option value="6h">Every 6 hours</option>
|
||||
<option value="daily">Once a day</option>
|
||||
<option value="weekly">Once a week</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Scan on startup</p>
|
||||
<p class="settings-hint">Run a scan automatically when the server starts</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="scanOnStartup" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-5 py-4 bg-card">
|
||||
<div>
|
||||
<p class="settings-label">Watch for changes</p>
|
||||
<p class="settings-hint">Detect new files instantly using filesystem events</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="watchForChanges" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File types -->
|
||||
<div>
|
||||
<p class="settings-group-label">Supported file types</p>
|
||||
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
|
||||
<div v-for="type in fileTypes" :key="type.ext" class="flex items-center justify-between px-5 py-3.5 bg-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded uppercase"> .{{ type.ext }} </span>
|
||||
<p class="text-sm text-foreground">{{ type.label }}</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="type.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-xs text-muted-foreground">Scanner configuration will be persisted in a future update.</p>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { api } from '@/lib/api'
|
||||
@@ -39,11 +40,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function saveSetting(key: string, value: string) {
|
||||
await api(`/api/v1/app-settings/${key}`, {
|
||||
const res = await api(`/api/v1/app-settings/${key}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to save setting')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
@@ -56,7 +60,12 @@ async function toggle() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
})
|
||||
if (res.ok) autoFetch.value = newVal
|
||||
if (res.ok) {
|
||||
autoFetch.value = newVal
|
||||
toast.success(newVal ? 'Auto-fetch enabled' : 'Auto-fetch disabled')
|
||||
} else {
|
||||
toast.error('Failed to update setting')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -72,7 +81,12 @@ async function toggleAutoFinalize() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
})
|
||||
if (res.ok) autoFinalizeEnabled.value = newVal
|
||||
if (res.ok) {
|
||||
autoFinalizeEnabled.value = newVal
|
||||
toast.success(newVal ? 'Auto-finalize enabled' : 'Auto-finalize disabled')
|
||||
} else {
|
||||
toast.error('Failed to update setting')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -87,15 +101,18 @@ async function onLibraryChange(event: Event) {
|
||||
saveSetting('staging_auto_finalize_library_id', String(id)),
|
||||
saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
|
||||
])
|
||||
toast.success('Destination library updated')
|
||||
}
|
||||
|
||||
async function onFolderChange(event: Event) {
|
||||
autoFinalizeFolderId.value = Number((event.target as HTMLSelectElement).value)
|
||||
await saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
|
||||
toast.success('Destination folder updated')
|
||||
}
|
||||
|
||||
async function onThresholdChange() {
|
||||
await saveSetting('staging_auto_finalize_threshold', String(autoFinalizeThreshold.value))
|
||||
toast.success('Confidence threshold updated')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PackageOpen, ArrowRight, Clock, CheckCircle, AlertCircle } from 'lucide-vue-next'
|
||||
import { useStagingSummary } from '../composables/useStagingSummary'
|
||||
|
||||
const router = useRouter()
|
||||
const { summary, fetchSummary } = useStagingSummary()
|
||||
|
||||
onMounted(() => {
|
||||
fetchSummary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="summary.total > 0"
|
||||
class="rounded-xl border border-border bg-card/50 p-4 space-y-3 hover:border-primary/30 transition-colors cursor-pointer"
|
||||
@click="router.push({ name: 'staging' })"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center justify-center size-8 rounded-lg bg-primary/10">
|
||||
<PackageOpen class="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-foreground">Staging</p>
|
||||
<p class="text-xs text-muted-foreground">{{ summary.total }} file{{ summary.total === 1 ? '' : 's' }} awaiting review</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div v-if="summary.pending > 0" class="flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||
<Clock class="size-3" />
|
||||
{{ summary.pending }} pending
|
||||
</div>
|
||||
<div v-if="summary.ready > 0" class="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle class="size-3" />
|
||||
{{ summary.ready }} ready
|
||||
</div>
|
||||
<div v-if="summary.error > 0" class="flex items-center gap-1 text-red-500">
|
||||
<AlertCircle class="size-3" />
|
||||
{{ summary.error }} error
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user