mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(client): add the Nostalgia appearance style
Add a Style setting beside mode, accent, radius and background. Nostalgia restyles the app through tokens only (neutral surface palettes, system type, flat shell, top glow) and pins background, opacity and brightness. Add a top glow reach slider for both styles. Tidy the theme layer first: charts read live tokens, bare `rounded` follows the radius scale, one shared sign-in toolbar. Also carries the settings page header redesign.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="BookOrbit" />
|
||||
<meta name="theme-color" content="#fafaf8" />
|
||||
<title>BookOrbit</title>
|
||||
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
|
||||
<script src="/theme-init.js"></script>
|
||||
|
||||
@@ -2,19 +2,42 @@
|
||||
function readStoredValue(key, fallback) {
|
||||
var stored = localStorage.getItem(key)
|
||||
if (stored === null) return fallback
|
||||
var value = stored
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
value = JSON.parse(stored)
|
||||
} catch {
|
||||
return stored
|
||||
value = stored
|
||||
}
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
// Mirrors STANDALONE_ROUTE_PREFIXES in src/lib/theme-styles.ts; theme-init.spec.ts keeps them equal.
|
||||
var STANDALONE_ROUTE_PREFIXES = ['/read/', '/login', '/register', '/setup', '/forgot-password', '/reset-password', '/oauth2-callback', '/magic']
|
||||
|
||||
function isAppShellPath(path) {
|
||||
for (var i = 0; i < STANDALONE_ROUTE_PREFIXES.length; i++) {
|
||||
var prefix = STANDALONE_ROUTE_PREFIXES[i]
|
||||
if (path === prefix || path.indexOf(prefix.charAt(prefix.length - 1) === '/' ? prefix : prefix + '/') === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var root = document.documentElement
|
||||
var theme = readStoredValue('theme', 'system')
|
||||
var accent = readStoredValue('accent', 'neutral')
|
||||
var accent = readStoredValue('accent', 'blue')
|
||||
var radius = readStoredValue('radius', 'default')
|
||||
var style = readStoredValue('style', 'bookorbit')
|
||||
var surfacePalette = readStoredValue('surfacePalette', 'steel')
|
||||
var rawReach = localStorage.getItem('glowReach')
|
||||
var storedReach = rawReach === null ? 80 : Number(rawReach)
|
||||
var glowReach = isFinite(storedReach) ? Math.min(200, Math.max(0, storedReach)) : 80
|
||||
var systemDark = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
var isDark = theme === 'dark' || (theme !== 'light' && systemDark)
|
||||
if (isDark) document.documentElement.classList.add('dark')
|
||||
if (accent !== 'neutral') document.documentElement.classList.add('accent-' + accent)
|
||||
if (radius !== 'default') document.documentElement.classList.add('radius-' + radius)
|
||||
if (isDark) root.classList.add('dark')
|
||||
root.classList.add('accent-' + accent)
|
||||
if (radius !== 'default') root.classList.add('radius-' + radius)
|
||||
root.setAttribute('data-style', isAppShellPath(window.location.pathname) ? style : 'bookorbit')
|
||||
root.setAttribute('data-surface', surfacePalette)
|
||||
root.style.setProperty('--glow-reach', String(glowReach / 100))
|
||||
root.setAttribute('data-glow', glowReach > 0 ? 'on' : 'off')
|
||||
})()
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+4
-6
@@ -4,8 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
|
||||
import { APP_FEATURES } from '@bookorbit/types'
|
||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts'
|
||||
import { useChartTokens } from '@/composables/useChartTokens'
|
||||
import { buildChartTheme } from '@/lib/echarts'
|
||||
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
||||
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
|
||||
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||
@@ -40,7 +40,7 @@ const TOASTER_MOBILE_OFFSET = {
|
||||
}
|
||||
|
||||
const { isOpen } = useChangePasswordDialog()
|
||||
const themeStore = useThemeStore()
|
||||
const chartTokens = useChartTokens()
|
||||
|
||||
const route = useRoute()
|
||||
const { user } = useAuth()
|
||||
@@ -79,12 +79,10 @@ watch(
|
||||
(name) => syncPopup(name as string | undefined),
|
||||
)
|
||||
|
||||
initChartThemes()
|
||||
|
||||
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
|
||||
provide(
|
||||
THEME_KEY,
|
||||
computed(() => getBookorbitThemeName(themeStore.resolvedTheme, themeStore.accent)),
|
||||
computed(() => buildChartTheme(chartTokens.value)),
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
@import './theme/tokens.css';
|
||||
@import './theme/accents.css';
|
||||
@import './theme/radius.css';
|
||||
@import './theme/surfaces.css';
|
||||
@import './theme/styles/nostalgia.css';
|
||||
@import './theme/bridge.css';
|
||||
@import './theme/cover-effects.css';
|
||||
|
||||
@@ -252,11 +254,34 @@
|
||||
}
|
||||
|
||||
.glow-wrapper {
|
||||
background-image: radial-gradient(50% 50% at center -25px, color-mix(in oklch, var(--primary) 35%, transparent) 0%, transparent 100%);
|
||||
background-blend-mode: hard-light, color-dodge;
|
||||
background-image: var(--glow-image);
|
||||
background-position: top;
|
||||
background-repeat: no-repeat;
|
||||
background-size: auto 12rem;
|
||||
background-size: auto calc(var(--glow-band) * var(--glow-reach));
|
||||
}
|
||||
|
||||
/* The texture sits on its own layer so its tiles keep their size; the reach only moves the fade. Negative
|
||||
z-index keeps it under the shell's content and the glow, like the background patterns. */
|
||||
.glow-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: -1;
|
||||
height: calc(var(--glow-band) * var(--glow-reach));
|
||||
pointer-events: none;
|
||||
background-image: var(--glow-texture);
|
||||
background-position: top center;
|
||||
background-size: var(--glow-texture-size);
|
||||
-webkit-mask-image: radial-gradient(ellipse 62% 100% at 50% 0%, #000 0%, transparent 100%);
|
||||
mask-image: radial-gradient(ellipse 62% 100% at 50% 0%, #000 0%, transparent 100%);
|
||||
}
|
||||
|
||||
html[data-glow='off'] .glow-wrapper {
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
html[data-glow='off'] .glow-wrapper::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -358,7 +383,7 @@
|
||||
|
||||
@layer components {
|
||||
.viewer-btn {
|
||||
@apply flex items-center justify-center w-8 h-8 rounded text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
@apply flex items-center justify-center w-8 h-8 rounded-xs text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
|
||||
}
|
||||
.viewer-sep {
|
||||
@apply w-px h-5 mx-1 bg-border shrink-0;
|
||||
@@ -385,6 +410,44 @@
|
||||
.settings-value {
|
||||
@apply text-sm font-medium tabular-nums text-foreground;
|
||||
}
|
||||
/* ── App shell ─────────────────────────────────────────────
|
||||
The glass behind the sidebar and header cards. A style swaps
|
||||
--shell-backdrop to change it.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
.shell-backdrop {
|
||||
backdrop-filter: var(--shell-backdrop);
|
||||
-webkit-backdrop-filter: var(--shell-backdrop);
|
||||
}
|
||||
.sidebar-section-label {
|
||||
font-size: var(--sidebar-heading-size);
|
||||
font-weight: var(--sidebar-heading-weight);
|
||||
letter-spacing: var(--sidebar-heading-tracking);
|
||||
color: var(--sidebar-heading-foreground);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Count chips beside sidebar rows and collapsed sections. Colour utilities on the element still win. */
|
||||
.sidebar-count {
|
||||
background-color: var(--sidebar-count-bg);
|
||||
font-size: var(--sidebar-count-size);
|
||||
font-weight: var(--sidebar-count-weight);
|
||||
color: var(--sidebar-count-foreground);
|
||||
}
|
||||
|
||||
/* ── Book metadata chips ───────────────────────────────────
|
||||
Genres and tags on the book detail page. The ring is inset so
|
||||
a style can add one without moving the genre fold measurement.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
.chip-genre {
|
||||
background-color: var(--chip-genre-bg);
|
||||
color: var(--chip-genre-fg);
|
||||
box-shadow: inset 0 0 0 1px var(--chip-genre-border);
|
||||
}
|
||||
.chip-tag {
|
||||
background-color: var(--chip-tag-bg);
|
||||
color: var(--chip-tag-fg);
|
||||
border-color: var(--chip-tag-border);
|
||||
}
|
||||
|
||||
/* ── Provider chips ────────────────────────────────────────
|
||||
Callers set --provider-color; the ink/fill/ring mixes come from the theme so
|
||||
light mode darkens the brand hue enough to pass contrast. See tokens.css.
|
||||
|
||||
+130
-129
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
Maps CSS variables → Tailwind utility classes.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
@theme inline {
|
||||
--radius-xs: calc(var(--radius) - 6px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -9,9 +10,9 @@
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-serif: 'Fraunces Variable', ui-serif, Georgia, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-sans: var(--app-font-sans);
|
||||
--font-serif: var(--app-font-serif);
|
||||
--font-mono: var(--app-font-mono);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* ── Nostalgia style ─────────────────────────────────────────
|
||||
Neutral grounds from the surface palette, system type, a flat
|
||||
shell and a tall accent glow over a faint grid of squares.
|
||||
Only reassigns tokens, so components never ask which style is
|
||||
active. Accent, radius and background stay the user's: the
|
||||
accent is only re-shaded (from --accent-primary), never replaced.
|
||||
|
||||
Same specificity as the accent classes and imported after them,
|
||||
so where both set a token (--sidebar) the style wins.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
html[data-style='nostalgia'] {
|
||||
--app-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--app-font-serif: var(--app-font-sans);
|
||||
|
||||
--background: var(--surface-ground);
|
||||
--foreground: oklch(0.2 calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--card: var(--surface-card);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--secondary: oklch(calc(0.94 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--secondary-foreground: var(--foreground);
|
||||
--muted: oklch(calc(0.94 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--muted-foreground: oklch(0.5 calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--accent: oklch(calc(0.94 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--accent-foreground: var(--foreground);
|
||||
--border: var(--surface-edge);
|
||||
--input: oklch(calc(0.85 + var(--surface-l) * 0.4) calc(var(--surface-c) * 1.2) var(--surface-h));
|
||||
|
||||
--surface-1: var(--card);
|
||||
--surface-2: oklch(calc(0.965 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--surface-3: var(--muted);
|
||||
--surface-4: oklch(calc(0.915 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--volume-unread: oklch(calc(0.86 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.8) var(--surface-h));
|
||||
--dot-color: oklch(calc(0.83 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.8) var(--surface-h));
|
||||
--scrollbar-thumb: oklch(0.8 calc(var(--surface-c) * 0.8) var(--surface-h));
|
||||
--scrollbar-thumb-hover: oklch(0.7 calc(var(--surface-c) * 0.8) var(--surface-h));
|
||||
|
||||
--sidebar: var(--card);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-accent: var(--muted);
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: var(--border);
|
||||
/* Book counts wear the accent hue, held dark enough to clear AA on the sidebar card. */
|
||||
--sidebar-count-foreground: oklch(from var(--primary) min(l, 0.5) c h);
|
||||
--sidebar-heading-foreground: var(--foreground);
|
||||
--sidebar-heading-size: 12px;
|
||||
--sidebar-heading-weight: 700;
|
||||
--sidebar-heading-tracking: 0.06em;
|
||||
--sidebar-count-bg: transparent;
|
||||
--sidebar-count-size: 14px;
|
||||
--sidebar-count-weight: 400;
|
||||
/* The classic active row is a faint wash, so the accent text and count on it keep their full contrast. */
|
||||
--sidebar-active-bg: color-mix(in oklch, var(--primary) 7%, transparent);
|
||||
--sidebar-active-weight: 700;
|
||||
|
||||
--shell-border: var(--border);
|
||||
--shell-backdrop: none;
|
||||
--shell-shadow: 0 3px 5px oklch(0 0 0 / 0.02), 0 0 2px oklch(0 0 0 / 0.05), 0 1px 4px oklch(0 0 0 / 0.08);
|
||||
--shell-accent-wash: color-mix(in oklch, var(--primary) 8%, transparent);
|
||||
--shell-accent-tint: color-mix(in oklch, var(--primary) 16%, transparent);
|
||||
|
||||
--glow-image: radial-gradient(50% 50% at center -25px, color-mix(in oklch, var(--primary) 38%, transparent) 0%, transparent 100%);
|
||||
--glow-band: 20rem;
|
||||
--glow-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 84' fill='%23000'%3E%3Crect x='1' y='1' width='19' height='19' fill-opacity='0.044'/%3E%3Crect x='22' y='1' width='19' height='19' fill-opacity='0.058'/%3E%3Crect x='43' y='1' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='64' y='1' width='19' height='19' fill-opacity='0.035'/%3E%3Crect x='1' y='22' width='19' height='19' fill-opacity='0.059'/%3E%3Crect x='22' y='22' width='19' height='19' fill-opacity='0.02'/%3E%3Crect x='43' y='22' width='19' height='19' fill-opacity='0.054'/%3E%3Crect x='64' y='22' width='19' height='19' fill-opacity='0.03'/%3E%3Crect x='1' y='43' width='19' height='19' fill-opacity='0.024'/%3E%3Crect x='22' y='43' width='19' height='19' fill-opacity='0.023'/%3E%3Crect x='43' y='43' width='19' height='19' fill-opacity='0.031'/%3E%3Crect x='64' y='43' width='19' height='19' fill-opacity='0.052'/%3E%3Crect x='1' y='64' width='19' height='19' fill-opacity='0.026'/%3E%3Crect x='22' y='64' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='43' y='64' width='19' height='19' fill-opacity='0.045'/%3E%3Crect x='64' y='64' width='19' height='19' fill-opacity='0.034'/%3E%3C/svg%3E");
|
||||
--glow-texture-size: 107px 107px;
|
||||
|
||||
--cover-hover-transform: translateY(-3px);
|
||||
|
||||
--format-chip-fill: 18%;
|
||||
--format-chip-border: 55%;
|
||||
|
||||
--chip-genre-bg: oklch(0.6 0.2 25 / 0.12);
|
||||
--chip-genre-fg: oklch(0.5 0.17 25);
|
||||
--chip-genre-border: oklch(0.6 0.2 25 / 0.35);
|
||||
--chip-tag-bg: oklch(0.65 0.17 150 / 0.12);
|
||||
--chip-tag-fg: oklch(0.47 0.13 150);
|
||||
--chip-tag-border: oklch(0.65 0.17 150 / 0.35);
|
||||
}
|
||||
|
||||
/* The classic ash ladder, shifted by the palette's --surface-l. Ground, card and edge come from surfaces.css. The style pins surface brightness to 0, so
|
||||
--bg-lift adds nothing here. Light mode takes 40% of the shift: enough to tell greys apart, not to dim a page. */
|
||||
html[data-style='nostalgia'].dark {
|
||||
/* Accents at their classic 400-shade brightness: the darker ones (greens, lime) lift, the rest already sit there. */
|
||||
--primary: oklch(from var(--accent-primary) max(l, 0.78) c h);
|
||||
|
||||
--foreground: oklch(1 0 0);
|
||||
--secondary: oklch(calc(0.307 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--muted: oklch(calc(0.307 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--muted-foreground: oklch(0.76 calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--accent: oklch(calc(0.307 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--input: oklch(calc(0.432 + var(--bg-lift) + var(--surface-l)) calc(var(--surface-c) * 1.4) var(--surface-h));
|
||||
|
||||
--surface-2: oklch(calc(0.262 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--surface-4: oklch(calc(0.362 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--volume-unread: oklch(calc(0.362 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--dot-color: oklch(calc(0.302 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--scrollbar-thumb: oklch(0.38 var(--surface-c) var(--surface-h));
|
||||
--scrollbar-thumb-hover: oklch(0.48 var(--surface-c) var(--surface-h));
|
||||
|
||||
--sidebar-count-foreground: oklch(from var(--primary) max(l, 0.72) c h);
|
||||
|
||||
--glow-image: radial-gradient(50% 50% at center -25px, color-mix(in oklch, var(--primary) 60%, transparent) 0%, transparent 100%);
|
||||
--glow-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 84' fill='%23fff'%3E%3Crect x='1' y='1' width='19' height='19' fill-opacity='0.056'/%3E%3Crect x='22' y='1' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='43' y='1' width='19' height='19' fill-opacity='0.082'/%3E%3Crect x='64' y='1' width='19' height='19' fill-opacity='0.036'/%3E%3Crect x='1' y='22' width='19' height='19' fill-opacity='0.073'/%3E%3Crect x='22' y='22' width='19' height='19' fill-opacity='0.059'/%3E%3Crect x='43' y='22' width='19' height='19' fill-opacity='0.035'/%3E%3Crect x='64' y='22' width='19' height='19' fill-opacity='0.071'/%3E%3Crect x='1' y='43' width='19' height='19' fill-opacity='0.033'/%3E%3Crect x='22' y='43' width='19' height='19' fill-opacity='0.065'/%3E%3Crect x='43' y='43' width='19' height='19' fill-opacity='0.036'/%3E%3Crect x='64' y='43' width='19' height='19' fill-opacity='0.037'/%3E%3Crect x='1' y='64' width='19' height='19' fill-opacity='0.064'/%3E%3Crect x='22' y='64' width='19' height='19' fill-opacity='0.096'/%3E%3Crect x='43' y='64' width='19' height='19' fill-opacity='0.04'/%3E%3Crect x='64' y='64' width='19' height='19' fill-opacity='0.048'/%3E%3C/svg%3E");
|
||||
|
||||
/* Solid chips in a deep shade of each format's hue with white ink, as the classic look wore them; at this
|
||||
lightness every format clears 5.4:1. */
|
||||
--format-chip-fill: 100%;
|
||||
--format-chip-border: 100%;
|
||||
--format-chip-fill-l: 0.5;
|
||||
--format-chip-ink: oklch(1 0 0);
|
||||
|
||||
--chip-genre-bg: oklch(0.64 0.2 25 / 0.18);
|
||||
--chip-genre-fg: oklch(0.82 0.1 25);
|
||||
--chip-genre-border: oklch(0.64 0.2 25 / 0.35);
|
||||
--chip-tag-bg: oklch(0.72 0.17 150 / 0.18);
|
||||
--chip-tag-fg: oklch(0.84 0.12 150);
|
||||
--chip-tag-border: oklch(0.72 0.17 150 / 0.35);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* ── Surface palettes ────────────────────────────────────────
|
||||
Neutral grounds for styles that declare a surface palette
|
||||
control. A palette is a hue, a chroma and a small lightness
|
||||
offset on the style's ladder (darker for onyx, lighter for
|
||||
charcoal), bounded so every palette keeps the ladder's AA
|
||||
contrast. Applied as data-surface on <html>, or on a swatch
|
||||
to preview a palette in place.
|
||||
────────────────────────────────────────────────────────────── */
|
||||
[data-surface='onyx'] {
|
||||
--surface-h: 0;
|
||||
--surface-c: 0;
|
||||
--surface-l: -0.06;
|
||||
}
|
||||
[data-surface='carbon'] {
|
||||
--surface-h: 248;
|
||||
--surface-c: 0.005;
|
||||
--surface-l: -0.035;
|
||||
}
|
||||
[data-surface='neutral'] {
|
||||
--surface-h: 0;
|
||||
--surface-c: 0;
|
||||
--surface-l: -0.015;
|
||||
}
|
||||
[data-surface='zinc'] {
|
||||
--surface-h: 286;
|
||||
--surface-c: 0.006;
|
||||
--surface-l: -0.025;
|
||||
}
|
||||
[data-surface='iron'] {
|
||||
--surface-h: 248;
|
||||
--surface-c: 0.007;
|
||||
--surface-l: 0;
|
||||
}
|
||||
[data-surface='steel'] {
|
||||
--surface-h: 258;
|
||||
--surface-c: 0.01;
|
||||
--surface-l: -0.005;
|
||||
}
|
||||
[data-surface='ash'] {
|
||||
--surface-h: 245;
|
||||
--surface-c: 0.012;
|
||||
--surface-l: 0;
|
||||
}
|
||||
[data-surface='smoke'] {
|
||||
--surface-h: 270;
|
||||
--surface-c: 0.006;
|
||||
--surface-l: 0.005;
|
||||
}
|
||||
[data-surface='charcoal'] {
|
||||
--surface-h: 0;
|
||||
--surface-c: 0;
|
||||
--surface-l: 0.025;
|
||||
}
|
||||
[data-surface='slate'] {
|
||||
--surface-h: 262;
|
||||
--surface-c: 0.03;
|
||||
--surface-l: -0.038;
|
||||
}
|
||||
[data-surface='midnight-blue'] {
|
||||
--surface-h: 252;
|
||||
--surface-c: 0.022;
|
||||
--surface-l: 0.027;
|
||||
}
|
||||
[data-surface='ocean'] {
|
||||
--surface-h: 228;
|
||||
--surface-c: 0.034;
|
||||
--surface-l: 0.038;
|
||||
}
|
||||
[data-surface='pine'] {
|
||||
--surface-h: 165;
|
||||
--surface-c: 0.022;
|
||||
--surface-l: 0;
|
||||
}
|
||||
[data-surface='olive'] {
|
||||
--surface-h: 135;
|
||||
--surface-c: 0.018;
|
||||
--surface-l: 0.03;
|
||||
}
|
||||
[data-surface='stone'] {
|
||||
--surface-h: 58;
|
||||
--surface-c: 0.009;
|
||||
--surface-l: -0.02;
|
||||
}
|
||||
[data-surface='sepia'] {
|
||||
--surface-h: 70;
|
||||
--surface-c: 0.024;
|
||||
--surface-l: 0;
|
||||
}
|
||||
[data-surface='crimson'] {
|
||||
--surface-h: 18;
|
||||
--surface-c: 0.016;
|
||||
--surface-l: 0.018;
|
||||
}
|
||||
[data-surface='plum'] {
|
||||
--surface-h: 330;
|
||||
--surface-c: 0.02;
|
||||
--surface-l: 0;
|
||||
}
|
||||
|
||||
/* The ground, card and edge of the ladder, in the active mode, for whichever palette the element carries. The
|
||||
Nostalgia style paints its surfaces from these, and a picker tile previews a palette with the very same values. */
|
||||
[data-surface] {
|
||||
--surface-ground: oklch(calc(0.955 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.6) var(--surface-h));
|
||||
--surface-card: oklch(calc(0.99 + var(--surface-l) * 0.4) calc(var(--surface-c) * 0.3) var(--surface-h));
|
||||
--surface-edge: oklch(calc(0.87 + var(--surface-l) * 0.4) calc(var(--surface-c) * 1.2) var(--surface-h));
|
||||
}
|
||||
.dark[data-surface],
|
||||
.dark [data-surface] {
|
||||
--surface-ground: oklch(calc(0.167 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--surface-card: oklch(calc(0.232 + var(--bg-lift) + var(--surface-l)) var(--surface-c) var(--surface-h));
|
||||
--surface-edge: oklch(calc(0.402 + var(--bg-lift) + var(--surface-l)) calc(var(--surface-c) * 1.4) var(--surface-h));
|
||||
}
|
||||
|
||||
/* Picker tile: a small window, ground behind a card panel. */
|
||||
.surface-tile {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--surface-ground);
|
||||
border: 1px solid var(--surface-edge);
|
||||
}
|
||||
.surface-tile::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 34% -1px -1px 26%;
|
||||
border: 1px solid var(--surface-edge);
|
||||
border-top-left-radius: 4px;
|
||||
background-color: var(--surface-card);
|
||||
}
|
||||
@@ -25,7 +25,9 @@
|
||||
--popover: oklch(0.975 var(--tint-c-surface) var(--tint-h));
|
||||
--popover-foreground: oklch(0.145 var(--tint-c-surface) var(--tint-h));
|
||||
|
||||
--primary: oklch(0.21 var(--tint-c-surface) var(--tint-h));
|
||||
/* The accent class sets --accent-primary; a style may derive --primary from it instead. */
|
||||
--accent-primary: oklch(0.21 var(--tint-c-surface) var(--tint-h));
|
||||
--primary: var(--accent-primary);
|
||||
--primary-foreground: oklch(0.985 0.004 var(--tint-h));
|
||||
|
||||
--secondary: oklch(0.955 var(--tint-c-surface) var(--tint-h));
|
||||
@@ -39,12 +41,6 @@
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.52 0.16 150);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
--warning: oklch(0.57 0.15 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--info: oklch(0.52 0.17 245);
|
||||
--info-foreground: oklch(0.985 0 0);
|
||||
|
||||
--success: oklch(0.52 0.14 148);
|
||||
--success-foreground: oklch(0.985 0 0);
|
||||
@@ -56,6 +52,7 @@
|
||||
|
||||
/* In flight: neither finished nor broken. Tuned per theme for the same reason. */
|
||||
--info: oklch(0.55 0.12 255);
|
||||
--info-foreground: oklch(0.985 0 0);
|
||||
|
||||
--star-highlight: oklch(0.76 0.18 90);
|
||||
|
||||
@@ -129,6 +126,16 @@
|
||||
/* Shape */
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* The default surface palette (steel, as in surfaces.css), so a ladder that reads these is valid before data-surface lands. */
|
||||
--surface-h: 258;
|
||||
--surface-c: 0.01;
|
||||
--surface-l: -0.005;
|
||||
|
||||
/* Type. The bridge maps the Tailwind font utilities onto these, so a style can swap them at runtime. */
|
||||
--app-font-sans: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--app-font-serif: 'Fraunces Variable', ui-serif, Georgia, serif;
|
||||
--app-font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* App shell (sidebar + header): one card gap, one material.
|
||||
--shell-surface-opacity is user-controlled and overwritten on <html>. */
|
||||
--shell-gap: 0.75rem;
|
||||
@@ -140,6 +147,43 @@
|
||||
--shell-accent-tint: color-mix(in oklch, var(--primary) 12%, transparent);
|
||||
--shell-accent-line: color-mix(in oklch, var(--primary) 30%, transparent);
|
||||
--shell-border: var(--shell-accent-line);
|
||||
--shell-backdrop: blur(24px) saturate(150%);
|
||||
--shell-shadow: var(--elevation-lg);
|
||||
|
||||
/* Accent glow behind the top of the shell (.glow-wrapper). --glow-band is the style's own reach and
|
||||
--glow-reach the user's multiplier on it, written on <html>. A texture keeps its tile size at any reach;
|
||||
only the fade that reveals it follows the band. */
|
||||
--glow-reach: 0.8;
|
||||
--glow-band: 12rem;
|
||||
--glow-image: radial-gradient(50% 50% at center -25px, color-mix(in oklch, var(--primary) 35%, transparent) 0%, transparent 100%);
|
||||
--glow-texture: none;
|
||||
--glow-texture-size: auto;
|
||||
|
||||
/* How a grid cover answers the pointer. */
|
||||
--cover-hover-transform: scale(1.02);
|
||||
|
||||
/* Sidebar section labels, counts and the active navigation row. */
|
||||
--sidebar-heading-foreground: var(--muted-foreground);
|
||||
--sidebar-heading-size: 11px;
|
||||
--sidebar-heading-weight: 500;
|
||||
--sidebar-heading-tracking: 0.14em;
|
||||
--sidebar-count-bg: var(--shell-accent-tint);
|
||||
--sidebar-count-size: 11px;
|
||||
--sidebar-count-weight: 600;
|
||||
--sidebar-active-bg: var(--shell-accent-tint);
|
||||
--sidebar-active-weight: 500;
|
||||
|
||||
/* How strongly a format chip wears its format colour (see formatChipStyle). */
|
||||
--format-chip-fill: 10%;
|
||||
--format-chip-border: 40%;
|
||||
|
||||
/* Book detail metadata chips. Tag ink is tuned like --warning so it clears AA on a light card. */
|
||||
--chip-genre-bg: var(--muted);
|
||||
--chip-genre-fg: var(--foreground);
|
||||
--chip-genre-border: transparent;
|
||||
--chip-tag-bg: oklch(0.769 0.188 70.08 / 0.1);
|
||||
--chip-tag-fg: oklch(0.52 0.13 68);
|
||||
--chip-tag-border: oklch(0.769 0.188 70.08 / 0.3);
|
||||
|
||||
--dot-color: oklch(0.82 var(--tint-c-surface) var(--tint-h));
|
||||
|
||||
@@ -250,31 +294,6 @@
|
||||
--audit-collections: oklch(0.55 0.09 255);
|
||||
--audit-integrations: oklch(0.58 0.15 150);
|
||||
--audit-settings: oklch(0.55 0.15 25);
|
||||
|
||||
/* File format, worn as a chip whose own text carries the colour. Tuned per theme for the same
|
||||
reason every other text colour is: one set of hex values for both themes left the brightest
|
||||
(yellow on white, around 1.9:1) unreadable in light mode and the darkest invisible in dark. */
|
||||
--pill-format-default: oklch(0.55 0.02 260);
|
||||
--pill-format-epub: oklch(0.55 0.14 150);
|
||||
--pill-format-kepub: oklch(0.55 0.12 178);
|
||||
--pill-format-mobi: oklch(0.55 0.18 275);
|
||||
--pill-format-azw3: oklch(0.53 0.11 185);
|
||||
--pill-format-azw: oklch(0.55 0.14 235);
|
||||
--pill-format-fb2: oklch(0.55 0.17 355);
|
||||
--pill-format-djvu: oklch(0.55 0.04 255);
|
||||
--pill-format-txt: oklch(0.55 0.03 60);
|
||||
--pill-format-pdf: oklch(0.55 0.17 25);
|
||||
--pill-format-cbz: oklch(0.55 0.16 260);
|
||||
--pill-format-cbr: oklch(0.58 0.15 55);
|
||||
--pill-format-cb7: oklch(0.55 0.19 300);
|
||||
--pill-format-cbx: oklch(0.55 0.17 325);
|
||||
--pill-format-m4b: oklch(0.58 0.13 75);
|
||||
--pill-format-m4a: oklch(0.58 0.13 95);
|
||||
--pill-format-mp3: oklch(0.56 0.15 145);
|
||||
--pill-format-opus: oklch(0.55 0.11 205);
|
||||
--pill-format-ogg: oklch(0.56 0.15 130);
|
||||
--pill-format-flac: oklch(0.55 0.13 165);
|
||||
--pill-format-readalong: oklch(0.52 0.1 186);
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
@@ -304,7 +323,7 @@
|
||||
--popover: oklch(calc(0.18 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h));
|
||||
--popover-foreground: oklch(0.985 0.001 var(--tint-h));
|
||||
|
||||
--primary: oklch(0.91 var(--tint-c-surface) var(--tint-h));
|
||||
--accent-primary: oklch(0.91 var(--tint-c-surface) var(--tint-h));
|
||||
--primary-foreground: oklch(0.18 var(--tint-c-surface) var(--tint-h));
|
||||
|
||||
--secondary: oklch(calc(0.245 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h));
|
||||
@@ -318,12 +337,6 @@
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.74 0.15 150);
|
||||
--success-foreground: oklch(0.145 0 0);
|
||||
--warning: oklch(0.78 0.14 75);
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
--info: oklch(0.75 0.14 245);
|
||||
--info-foreground: oklch(0.145 0 0);
|
||||
|
||||
--success: oklch(0.72 0.15 148);
|
||||
--success-foreground: oklch(0.145 0 0);
|
||||
@@ -332,6 +345,7 @@
|
||||
--warning-foreground: oklch(0.145 0 0);
|
||||
|
||||
--info: oklch(0.72 0.12 255);
|
||||
--info-foreground: oklch(0.145 0 0);
|
||||
|
||||
--star-highlight: oklch(0.84 0.17 95);
|
||||
|
||||
@@ -390,6 +404,8 @@
|
||||
--scrollbar-thumb: oklch(0.35 var(--tint-c) var(--tint-h));
|
||||
--scrollbar-thumb-hover: oklch(0.45 var(--tint-c) var(--tint-h));
|
||||
|
||||
--chip-tag-fg: oklch(0.828 0.189 84.429);
|
||||
|
||||
/* Lifted lightness so pills read on dark surfaces */
|
||||
--pill-web: oklch(0.72 0.14 245);
|
||||
--pill-ios: oklch(0.75 0.14 150);
|
||||
@@ -474,30 +490,4 @@
|
||||
--audit-collections: oklch(0.72 0.09 255);
|
||||
--audit-integrations: oklch(0.75 0.13 150);
|
||||
--audit-settings: oklch(0.72 0.15 25);
|
||||
|
||||
/* File format, worn as a chip whose own text carries the colour. Tuned per theme for the same
|
||||
reason every other text colour is: one set of hex values for both themes left the brightest
|
||||
(yellow on white, around 1.9:1) unreadable in light mode and the darkest invisible in dark. */
|
||||
|
||||
--pill-format-default: oklch(0.75 0.02 260);
|
||||
--pill-format-epub: oklch(0.75 0.13 150);
|
||||
--pill-format-kepub: oklch(0.75 0.11 178);
|
||||
--pill-format-mobi: oklch(0.72 0.16 275);
|
||||
--pill-format-azw3: oklch(0.76 0.11 185);
|
||||
--pill-format-azw: oklch(0.72 0.13 235);
|
||||
--pill-format-fb2: oklch(0.73 0.15 355);
|
||||
--pill-format-djvu: oklch(0.75 0.04 255);
|
||||
--pill-format-txt: oklch(0.76 0.03 60);
|
||||
--pill-format-pdf: oklch(0.72 0.15 25);
|
||||
--pill-format-cbz: oklch(0.72 0.14 260);
|
||||
--pill-format-cbr: oklch(0.76 0.13 55);
|
||||
--pill-format-cb7: oklch(0.72 0.17 300);
|
||||
--pill-format-cbx: oklch(0.73 0.15 325);
|
||||
--pill-format-m4b: oklch(0.79 0.13 75);
|
||||
--pill-format-m4a: oklch(0.8 0.13 95);
|
||||
--pill-format-mp3: oklch(0.78 0.15 145);
|
||||
--pill-format-opus: oklch(0.76 0.11 205);
|
||||
--pill-format-ogg: oklch(0.78 0.15 130);
|
||||
--pill-format-flac: oklch(0.75 0.13 165);
|
||||
--pill-format-readalong: oklch(0.78 0.11 186);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ import RadiusPicker from '@/components/RadiusPicker.vue'
|
||||
import BackgroundPicker from '@/components/BackgroundPicker.vue'
|
||||
import ThemePicker from '@/components/ThemePicker.vue'
|
||||
import SurfaceBrightnessPicker from '@/components/SurfaceBrightnessPicker.vue'
|
||||
import SurfacePicker from '@/components/SurfacePicker.vue'
|
||||
import SurfaceOpacityPicker from '@/components/SurfaceOpacityPicker.vue'
|
||||
import StylePicker from '@/components/StylePicker.vue'
|
||||
import SurfacePalettePicker from '@/components/SurfacePalettePicker.vue'
|
||||
import GlowReachPicker from '@/components/GlowReachPicker.vue'
|
||||
import { useGlobalSearch, type GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
|
||||
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
@@ -71,6 +74,8 @@ const { onLibraryUploadCompleted } = useLibraryUploadEvents()
|
||||
const { subscribe: subscribeNotifications } = useNotifications()
|
||||
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
|
||||
const themeStore = useThemeStore()
|
||||
const showSurfaceOpacity = computed(() => themeStore.appearanceControls.surfaceOpacity)
|
||||
const showSurfaceBrightness = computed(() => themeStore.resolvedTheme === 'dark' && themeStore.appearanceControls.brightness)
|
||||
const localeStore = useLocaleStore()
|
||||
const { openLegalNotices } = useLegalNotices()
|
||||
const currentLanguageLabel = computed(() => LOCALE_LABELS[localeStore.locale])
|
||||
@@ -343,7 +348,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="relative z-30 mx-(--shell-gap) mt-(--shell-gap) flex h-12 flex-none shrink-0 items-center gap-2 rounded-(--shell-radius) border border-(--shell-border) bg-(--shell-surface) px-3 shadow-lg backdrop-blur-xl backdrop-saturate-150"
|
||||
class="relative z-30 mx-(--shell-gap) mt-(--shell-gap) flex h-12 flex-none shrink-0 items-center gap-2 rounded-(--shell-radius) border border-(--shell-border) bg-(--shell-surface) px-3 shadow-(--shell-shadow) shell-backdrop"
|
||||
>
|
||||
<!-- Mobile: search active overlay -->
|
||||
<template v-if="mobileSearchOpen">
|
||||
@@ -397,7 +402,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
:book-id="row.result.id"
|
||||
type="thumbnail"
|
||||
:version="row.result.coverVersion"
|
||||
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
|
||||
class="h-16 w-12 object-cover rounded-xs shrink-0 bg-muted"
|
||||
:alt="row.result.title ?? ''"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -427,7 +432,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
v-for="entry in resultFormatEntries(row.result)"
|
||||
:key="entry.key"
|
||||
:format-key="entry.key"
|
||||
class="gap-0.5 text-[11px] px-1 py-0.5 rounded"
|
||||
class="gap-0.5 text-[11px] px-1 py-0.5 rounded-xs"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -481,7 +486,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
</button>
|
||||
<kbd
|
||||
v-else
|
||||
class="hidden h-5 select-none items-center gap-1 rounded border border-(--shell-accent-line) px-1.5 font-mono text-[11px] font-semibold text-muted-foreground lg:inline-flex"
|
||||
class="hidden h-5 select-none items-center gap-1 rounded-xs border border-(--shell-accent-line) px-1.5 font-mono text-[11px] font-semibold text-muted-foreground lg:inline-flex"
|
||||
>
|
||||
<span class="leading-none">⌘</span>
|
||||
<span class="leading-none">K</span>
|
||||
@@ -522,7 +527,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
:book-id="row.result.id"
|
||||
type="thumbnail"
|
||||
:version="row.result.coverVersion"
|
||||
class="h-16 w-12 object-cover rounded shrink-0 bg-muted"
|
||||
class="h-16 w-12 object-cover rounded-xs shrink-0 bg-muted"
|
||||
:alt="row.result.title ?? ''"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -552,7 +557,7 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
v-for="entry in resultFormatEntries(row.result)"
|
||||
:key="entry.key"
|
||||
:format-key="entry.key"
|
||||
class="gap-0.5 text-[11px] px-1 py-0.5 rounded"
|
||||
class="gap-0.5 text-[11px] px-1 py-0.5 rounded-xs"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -773,6 +778,18 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
<PopoverContent class="w-72 p-4" align="end">
|
||||
<div class="space-y-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground">{{ t('components.appHeader.appearance') }}</p>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.style') }}</span>
|
||||
<StylePicker />
|
||||
</div>
|
||||
<div v-if="themeStore.appearanceControls.surfacePalette" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfacePalette') }}</span>
|
||||
<SurfacePalettePicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.topGlow') }}</span>
|
||||
<GlowReachPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.theme') }}</span>
|
||||
<ThemePicker />
|
||||
@@ -785,15 +802,15 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.radius') }}</span>
|
||||
<RadiusPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="showSurfaceOpacity" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfaceOpacity') }}</span>
|
||||
<SurfacePicker />
|
||||
<SurfaceOpacityPicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-1.5">
|
||||
<div v-if="showSurfaceBrightness" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</span>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="themeStore.appearanceControls.background" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.background') }}</span>
|
||||
<BackgroundPicker />
|
||||
</div>
|
||||
@@ -892,6 +909,21 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
<SheetDescription class="sr-only">{{ t('components.appHeader.appearanceDescription') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card p-3">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.style') }}</h3>
|
||||
<StylePicker touch />
|
||||
</div>
|
||||
<div v-if="themeStore.appearanceControls.surfacePalette" class="space-y-2 border-t border-border pt-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.surfacePalette') }}</h3>
|
||||
<SurfacePalettePicker touch />
|
||||
</div>
|
||||
<div class="space-y-2 border-t border-border pt-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.topGlow') }}</h3>
|
||||
<GlowReachPicker />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.theme') }}</h3>
|
||||
<ThemePicker touch />
|
||||
@@ -907,18 +939,18 @@ function resultFormatEntries(result: GlobalSearchResult) {
|
||||
<RadiusPicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card p-3">
|
||||
<div class="space-y-2">
|
||||
<section v-if="showSurfaceOpacity || showSurfaceBrightness" class="space-y-3 rounded-lg border border-border bg-card p-3">
|
||||
<div v-if="showSurfaceOpacity" class="space-y-2">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.surfaceOpacity') }}</h3>
|
||||
<SurfacePicker />
|
||||
<SurfaceOpacityPicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-2 border-t border-border pt-3">
|
||||
<div v-if="showSurfaceBrightness" class="space-y-2" :class="showSurfaceOpacity ? 'border-t border-border pt-3' : ''">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</h3>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<section v-if="themeStore.appearanceControls.background" class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.background') }}</h3>
|
||||
<BackgroundPicker touch />
|
||||
</section>
|
||||
|
||||
@@ -5,12 +5,12 @@ import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import BookMetadataFetchWidget from '@/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue'
|
||||
import { useThemeStore, BACKGROUND_OPTIONS } from '@/stores/theme'
|
||||
import { useAppStyleScope } from '@/composables/useAppStyleScope'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
|
||||
useAppStyleScope()
|
||||
|
||||
const BOOK_ROUTE_NAMES = new Set(['book-detail'])
|
||||
|
||||
@@ -50,7 +50,7 @@ const viewKey = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider class="app-shell glow-wrapper min-h-svh" :class="backgroundClass">
|
||||
<SidebarProvider class="app-shell glow-wrapper min-h-svh" :class="themeStore.appliedBackgroundClass">
|
||||
<AppSidebar />
|
||||
<SidebarInset class="app-shell-inset flex flex-col h-svh overflow-hidden relative bg-transparent pb-(--shell-gap)">
|
||||
<!-- 1. Global App Header: Fixed at the top, independent of views -->
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
@@ -15,13 +17,14 @@ const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="opt.label"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:aria-pressed="themeStore.background === opt.id"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div
|
||||
class="w-full overflow-hidden ring-2 transition-all"
|
||||
:class="[
|
||||
props.touch ? 'h-10 rounded-md' : 'h-8 rounded',
|
||||
props.touch ? 'h-10 rounded-md' : 'h-8 rounded-xs',
|
||||
themeStore.background === opt.id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40',
|
||||
]"
|
||||
>
|
||||
@@ -29,7 +32,7 @@ const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { GLOW_REACH_MAX, GLOW_REACH_MIN } from '@bookorbit/types'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const valueLabel = computed(() =>
|
||||
themeStore.glowReach === 0
|
||||
? t('settings.appearance.theme.topGlow.off')
|
||||
: t('settings.appearance.theme.topGlow.value', { value: formatNumber(themeStore.glowReach) }),
|
||||
)
|
||||
|
||||
function handleGlowReachInput(event: Event) {
|
||||
themeStore.setGlowReach(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="themeStore.glowReach"
|
||||
type="range"
|
||||
:min="GLOW_REACH_MIN"
|
||||
:max="GLOW_REACH_MAX"
|
||||
step="10"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:aria-label="t('settings.appearance.theme.topGlow.label')"
|
||||
:aria-valuetext="valueLabel"
|
||||
@input="handleGlowReachInput"
|
||||
/>
|
||||
<span class="w-11 shrink-0 text-right text-[13px] tabular-nums text-muted-foreground">{{ valueLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -305,7 +305,7 @@ onUnmounted(() => {
|
||||
<button
|
||||
v-if="modelValue && !hideText"
|
||||
type="button"
|
||||
class="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors shrink-0 focus-visible:outline-none"
|
||||
class="h-5 w-5 flex items-center justify-center rounded-xs text-muted-foreground hover:text-foreground transition-colors shrink-0 focus-visible:outline-none"
|
||||
:aria-label="t('components.iconPicker.clearSelectionAria')"
|
||||
@click="clearValue"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { RADIUS_OPTIONS, useThemeStore, type RadiusOption } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
@@ -9,47 +9,43 @@ const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
const shapes = [
|
||||
{ id: 'sharp' as const, rx: '0', labelKey: 'components.radiusPicker.sharp' },
|
||||
{ id: 'default' as const, rx: '3', labelKey: 'components.radiusPicker.default' },
|
||||
{ id: 'rounded' as const, rx: '8', labelKey: 'components.radiusPicker.rounded' },
|
||||
{ id: 'pill' as const, rx: '99', labelKey: 'components.radiusPicker.pill' },
|
||||
]
|
||||
function selectRadius(option: RadiusOption) {
|
||||
themeStore.setRadius(option.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.touch" class="grid grid-cols-4 gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<Tooltip v-for="option in RADIUS_OPTIONS" :key="option.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.radius === s.id ? 'border-primary bg-primary/8 text-primary' : 'border-border bg-muted/30 text-muted-foreground'"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
:class="themeStore.radius === option.id ? 'border-primary bg-primary/8 text-primary' : 'border-border bg-muted/30 text-muted-foreground'"
|
||||
:aria-label="t(option.descriptionKey)"
|
||||
:aria-pressed="themeStore.radius === option.id"
|
||||
@click="selectRadius(option)"
|
||||
>
|
||||
<span class="block h-4 w-8 border-2 border-current" :style="{ borderRadius: `${s.rx}px` }" />
|
||||
<span class="block h-4 w-8 border-2 border-current" :style="{ borderRadius: option.previewRadius }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
<TooltipContent>{{ t(option.descriptionKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<Tooltip v-for="option in RADIUS_OPTIONS" :key="option.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-5 border-2 transition-colors focus:outline-none"
|
||||
:style="{
|
||||
borderRadius: `${s.rx}px`,
|
||||
borderColor: themeStore.radius === s.id ? 'var(--primary)' : 'var(--muted-foreground)',
|
||||
opacity: themeStore.radius === s.id ? '1' : '0.5',
|
||||
}"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
class="w-8 h-5 border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.radius === option.id ? 'border-primary' : 'border-muted-foreground opacity-50 hover:opacity-100'"
|
||||
:style="{ borderRadius: option.previewRadius }"
|
||||
:aria-label="t(option.descriptionKey)"
|
||||
:aria-pressed="themeStore.radius === option.id"
|
||||
@click="selectRadius(option)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
<TooltipContent>{{ t(option.descriptionKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -565,7 +565,7 @@ watch(
|
||||
<template v-if="count > 50">
|
||||
<input
|
||||
v-model="deleteInput"
|
||||
class="h-7 w-24 rounded border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||
class="h-7 w-24 rounded-xs border border-border bg-background px-2 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||
:placeholder="t('components.selectionActionBar.typeDelete')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { THEME_STYLES, type ThemeStyleDefinition } from '@/lib/theme-styles'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
function selectStyle(style: ThemeStyleDefinition) {
|
||||
themeStore.setStyle(style.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center rounded-md border border-border bg-muted/50 p-1" :class="props.touch ? 'w-full gap-0.5' : 'w-fit gap-1'">
|
||||
<button
|
||||
v-for="style in THEME_STYLES"
|
||||
:key="style.id"
|
||||
type="button"
|
||||
class="rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.style === style.id ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
:aria-pressed="themeStore.style === style.id"
|
||||
:title="t(style.descriptionKey)"
|
||||
@click="selectStyle(style)"
|
||||
>
|
||||
{{ t(style.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SURFACE_PALETTES, type SurfacePaletteOption } from '@/lib/theme-styles'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = SURFACE_PALETTES.find((palette) => palette.id === themeStore.surfacePalette)
|
||||
return selected ? t(selected.labelKey) : ''
|
||||
})
|
||||
|
||||
function selectPalette(palette: SurfacePaletteOption) {
|
||||
themeStore.setSurfacePalette(palette.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-2">
|
||||
<!-- Darkest greys first, tinted grounds last; each tile is the palette's real ground, card and edge. -->
|
||||
<div class="grid grid-cols-6" :class="props.touch ? 'gap-2.5' : 'gap-2'">
|
||||
<Tooltip v-for="palette in SURFACE_PALETTES" :key="palette.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:data-surface="palette.id"
|
||||
class="surface-tile aspect-[4/3] w-full rounded-md outline-offset-2 transition-[outline-color] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.surfacePalette === palette.id ? 'outline-2 outline-primary' : 'outline-2 outline-transparent hover:outline-border'"
|
||||
:aria-label="t(palette.labelKey)"
|
||||
:aria-pressed="themeStore.surfacePalette === palette.id"
|
||||
@click="selectPalette(palette)"
|
||||
>
|
||||
<span
|
||||
v-if="themeStore.surfacePalette === palette.id"
|
||||
class="absolute top-0.5 left-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-primary text-primary-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Check :size="10" :stroke-width="3" />
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(palette.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground" aria-live="polite">{{ selectedLabel }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,7 +26,7 @@ function handleSystemTheme() {
|
||||
<div class="flex items-center rounded-md border border-border bg-muted/50 p-1" :class="props.touch ? 'w-full gap-0.5' : 'w-fit gap-1'">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
class="flex items-center gap-1.5 rounded-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
@@ -37,7 +37,7 @@ function handleSystemTheme() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
class="flex items-center gap-1.5 rounded-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
@@ -48,7 +48,7 @@ function handleSystemTheme() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
class="flex items-center gap-1.5 rounded-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'system' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
|
||||
@@ -78,7 +78,12 @@ vi.mock('@/features/whats-new/composables/useWhatsNew', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => ({ radius: 'rounded' }),
|
||||
useThemeStore: () => ({
|
||||
radius: 'rounded',
|
||||
resolvedTheme: 'light',
|
||||
glowReach: 100,
|
||||
appearanceControls: { surfacePalette: false, background: true, surfaceOpacity: true, brightness: true },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/locale', () => ({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlowReachPicker from '../GlowReachPicker.vue'
|
||||
|
||||
const themeStore = reactive({
|
||||
glowReach: 100,
|
||||
setGlowReach: vi.fn<(reach: number) => void>((reach: number) => {
|
||||
themeStore.glowReach = reach
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
describe('GlowReachPicker', () => {
|
||||
beforeEach(() => {
|
||||
themeStore.glowReach = 100
|
||||
themeStore.setGlowReach.mockClear()
|
||||
})
|
||||
|
||||
it('shows the reach as a percentage', () => {
|
||||
const wrapper = mount(GlowReachPicker)
|
||||
|
||||
expect(wrapper.get('input').attributes('aria-valuetext')).toBe('100%')
|
||||
expect(wrapper.text()).toContain('100%')
|
||||
})
|
||||
|
||||
it('sets the reach from the slider and reads Off at zero', async () => {
|
||||
const wrapper = mount(GlowReachPicker)
|
||||
const input = wrapper.get('input')
|
||||
|
||||
await input.setValue('0')
|
||||
|
||||
expect(themeStore.setGlowReach).toHaveBeenCalledWith(0)
|
||||
expect(wrapper.text()).toContain('Off')
|
||||
expect(input.attributes('aria-valuetext')).toBe('Off')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import StylePicker from '../StylePicker.vue'
|
||||
|
||||
const themeStore = reactive({
|
||||
style: 'bookorbit',
|
||||
setStyle: vi.fn<(style: string) => void>((style: string) => {
|
||||
themeStore.style = style
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
describe('StylePicker', () => {
|
||||
beforeEach(() => {
|
||||
themeStore.style = 'bookorbit'
|
||||
themeStore.setStyle.mockClear()
|
||||
})
|
||||
|
||||
it('offers every style with the current one pressed', () => {
|
||||
const wrapper = mount(StylePicker)
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons.map((button) => button.text())).toEqual(['BookOrbit', 'Nostalgia'])
|
||||
expect(buttons.map((button) => button.attributes('aria-pressed'))).toEqual(['true', 'false'])
|
||||
})
|
||||
|
||||
it('selects a style', async () => {
|
||||
const wrapper = mount(StylePicker)
|
||||
|
||||
await wrapper.findAll('button')[1]!.trigger('click')
|
||||
|
||||
expect(themeStore.setStyle).toHaveBeenCalledWith('nostalgia')
|
||||
expect(wrapper.findAll('button')[1]!.attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { h, reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SURFACE_PALETTE_IDS } from '@bookorbit/types'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import SurfacePalettePicker from '../SurfacePalettePicker.vue'
|
||||
|
||||
const themeStore = reactive({
|
||||
surfacePalette: 'ash',
|
||||
setSurfacePalette: vi.fn<(palette: string) => void>((palette: string) => {
|
||||
themeStore.surfacePalette = palette
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
function mountPicker() {
|
||||
return mount(TooltipProvider, { slots: { default: () => h(SurfacePalettePicker) } })
|
||||
}
|
||||
|
||||
describe('SurfacePalettePicker', () => {
|
||||
beforeEach(() => {
|
||||
themeStore.surfacePalette = 'ash'
|
||||
themeStore.setSurfacePalette.mockClear()
|
||||
})
|
||||
|
||||
it('renders one labelled swatch per palette, each previewing its own palette', () => {
|
||||
const swatches = mountPicker().findAll('button')
|
||||
|
||||
expect(swatches.map((swatch) => swatch.attributes('data-surface'))).toEqual([...SURFACE_PALETTE_IDS])
|
||||
const ash = swatches.find((swatch) => swatch.attributes('data-surface') === 'ash')!
|
||||
expect(ash.attributes('aria-label')).toBe('Ash')
|
||||
expect(ash.attributes('aria-pressed')).toBe('true')
|
||||
expect(ash.classes()).toContain('outline-primary')
|
||||
expect(ash.find('svg').exists()).toBe(true)
|
||||
expect(swatches.filter((swatch) => swatch.classes().includes('outline-primary'))).toHaveLength(1)
|
||||
expect(swatches.filter((swatch) => swatch.find('svg').exists())).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('selects a palette', async () => {
|
||||
const wrapper = mountPicker()
|
||||
|
||||
await wrapper.get('button[data-surface="sepia"]').trigger('click')
|
||||
|
||||
expect(themeStore.setSurfacePalette).toHaveBeenCalledWith('sepia')
|
||||
expect(wrapper.get('button[data-surface="sepia"]').classes()).toContain('outline-primary')
|
||||
expect(wrapper.get('button[data-surface="ash"]').classes()).not.toContain('outline-primary')
|
||||
expect(wrapper.text()).toContain('Sepia')
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ const props = withDefaults(
|
||||
const inkClass = computed(() => {
|
||||
if (props.tone === 'accent') return 'font-bold text-primary'
|
||||
if (props.variant === 'progress') return 'font-semibold text-primary'
|
||||
return 'font-semibold text-sidebar-count-foreground group-data-[active=true]/item:text-primary'
|
||||
return 'group-data-[active=true]/item:text-primary'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@ const inkClass = computed(() => {
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="ml-auto shrink-0 rounded-md bg-(--shell-accent-tint) px-1.5 py-0.5 text-[11px] tabular-nums transition-colors duration-150 group-data-[collapsible=icon]:hidden"
|
||||
class="sidebar-count ml-auto shrink-0 rounded-md px-1.5 py-0.5 tabular-nums transition-colors duration-150 group-data-[collapsible=icon]:hidden"
|
||||
:class="inkClass"
|
||||
:aria-label="label"
|
||||
:title="label"
|
||||
|
||||
@@ -36,7 +36,7 @@ const buttonClass = [
|
||||
'group/item h-8 w-full min-w-0 gap-2 rounded-md px-2 font-normal',
|
||||
'before:absolute before:left-0 before:top-1/2 before:h-4 before:w-[2px] before:-translate-y-1/2 before:rounded-full before:bg-primary',
|
||||
'before:opacity-0 before:transition-opacity before:duration-150',
|
||||
'data-[active=true]:bg-(--shell-accent-tint) data-[active=true]:font-medium data-[active=true]:before:opacity-100',
|
||||
'data-[active=true]:bg-(--sidebar-active-bg) data-[active=true]:[font-weight:var(--sidebar-active-weight)] data-[active=true]:before:opacity-100',
|
||||
].join(' ')
|
||||
|
||||
// The grip sits out of flow, so a row only gives up right-edge space while one is rendered.
|
||||
|
||||
@@ -73,11 +73,8 @@ const showCount = computed(() => !props.isOpen && typeof props.count === 'number
|
||||
:aria-controls="contentId"
|
||||
@click="handleToggle"
|
||||
>
|
||||
<span class="min-w-0 truncate text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{{ label }}</span>
|
||||
<span
|
||||
v-if="showCount"
|
||||
class="shrink-0 rounded-md bg-(--shell-accent-tint) px-1.5 text-[11px] font-semibold tabular-nums text-sidebar-count-foreground"
|
||||
>
|
||||
<span class="sidebar-section-label min-w-0 truncate">{{ label }}</span>
|
||||
<span v-if="showCount" class="sidebar-count shrink-0 rounded-md px-1.5 tabular-nums">
|
||||
{{ formatCompactNumber(count ?? 0) }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
@@ -88,7 +85,7 @@ const showCount = computed(() => !props.isOpen && typeof props.count === 'number
|
||||
:class="isOpen ? 'rotate-0' : '-rotate-90'"
|
||||
/>
|
||||
</button>
|
||||
<p v-else class="min-w-0 flex-1 truncate px-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{{ label }}</p>
|
||||
<p v-else class="sidebar-section-label min-w-0 flex-1 truncate px-2">{{ label }}</p>
|
||||
|
||||
<button
|
||||
v-if="canAdd"
|
||||
|
||||
@@ -26,7 +26,7 @@ defineProps<{
|
||||
{{ formatCompactNumber(count) }}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" align="start" :side-offset="8" class="w-72 border-(--shell-border) bg-(--shell-surface) p-1 backdrop-blur-xl">
|
||||
<PopoverContent side="right" align="start" :side-offset="8" class="w-72 border-(--shell-border) bg-(--shell-surface) p-1 shell-backdrop">
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -35,10 +35,7 @@ function handleToggle() {
|
||||
:collapsible="!alwaysOpen"
|
||||
@toggle="handleToggle"
|
||||
/>
|
||||
<p
|
||||
v-else-if="label"
|
||||
class="mb-0.5 px-2 pt-1.5 pb-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<p v-else-if="label" class="mb-0.5 px-2 pt-1.5 pb-1 sidebar-section-label group-data-[collapsible=icon]:hidden">
|
||||
{{ label }}
|
||||
</p>
|
||||
<SidebarSectionBody :open="isOpen" :content-id="sectionId ? contentId : undefined">
|
||||
|
||||
@@ -170,7 +170,7 @@ function onBlur() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none motion-reduce:transition-none"
|
||||
class="shrink-0 rounded-xs p-0.5 text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none motion-reduce:transition-none"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('components.ui.tokenSelect.showOptions')"
|
||||
@mousedown.prevent="toggleList"
|
||||
|
||||
@@ -82,7 +82,7 @@ const { t } = useI18n()
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="relative z-0 flex h-full w-full flex-col border-r border-(--shell-border) bg-(--shell-surface) backdrop-blur-xl backdrop-saturate-150 group-data-[variant=floating]:rounded-(--shell-radius) group-data-[variant=floating]:border group-data-[variant=floating]:shadow-lg"
|
||||
class="relative z-0 flex h-full w-full flex-col border-r border-(--shell-border) bg-(--shell-surface) shell-backdrop group-data-[variant=floating]:rounded-(--shell-radius) group-data-[variant=floating]:border group-data-[variant=floating]:shadow-(--shell-shadow)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ function handleShowJumpRailsChange(event: Event) {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="coverFallback"
|
||||
class="size-4 shrink-0 cursor-pointer rounded border-input accent-primary"
|
||||
class="size-4 shrink-0 cursor-pointer rounded-xs border-input accent-primary"
|
||||
@change="handleCoverFallbackChange"
|
||||
/>
|
||||
</label>
|
||||
@@ -145,7 +145,7 @@ function handleShowJumpRailsChange(event: Event) {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="showJumpRails"
|
||||
class="size-4 shrink-0 cursor-pointer rounded border-input accent-primary"
|
||||
class="size-4 shrink-0 cursor-pointer rounded-xs border-input accent-primary"
|
||||
@change="handleShowJumpRailsChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -14,6 +14,9 @@ function createThemeStore() {
|
||||
background: 'vinyl' as ThemePreferences['background'],
|
||||
brightness: 35,
|
||||
surfaceOpacity: 92,
|
||||
style: 'bookorbit' as NonNullable<ThemePreferences['style']>,
|
||||
surfacePalette: 'ash' as NonNullable<ThemePreferences['surfacePalette']>,
|
||||
glowReach: 100,
|
||||
setTheme(nextTheme: ThemePreferences['theme']) {
|
||||
this.theme = nextTheme
|
||||
},
|
||||
@@ -32,6 +35,15 @@ function createThemeStore() {
|
||||
setSurfaceOpacity(nextSurfaceOpacity: number) {
|
||||
this.surfaceOpacity = nextSurfaceOpacity
|
||||
},
|
||||
setStyle(nextStyle: NonNullable<ThemePreferences['style']>) {
|
||||
this.style = nextStyle
|
||||
},
|
||||
setSurfacePalette(nextPalette: NonNullable<ThemePreferences['surfacePalette']>) {
|
||||
this.surfacePalette = nextPalette
|
||||
},
|
||||
setGlowReach(reach: number) {
|
||||
this.glowReach = reach
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,12 +293,83 @@ describe('useThemeSync', () => {
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
surfaceOpacity: 92,
|
||||
style: 'bookorbit',
|
||||
surfacePalette: 'ash',
|
||||
glowReach: 100,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('saves a style or surface palette change like any other appearance setting', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
||||
|
||||
const { initThemeSync } = await import('../useThemeSync')
|
||||
initThemeSync()
|
||||
|
||||
themeStore.setStyle('nostalgia')
|
||||
themeStore.setSurfacePalette('sepia')
|
||||
await vi.advanceTimersByTimeAsync(1600)
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
const body = JSON.parse((apiMock.mock.calls[0]![1] as { body: string }).body) as { settings: ThemePreferences }
|
||||
expect(body.settings).toMatchObject({ style: 'nostalgia', surfacePalette: 'sepia' })
|
||||
})
|
||||
|
||||
it('loadFromServer applies a synchronized style and surface palette', async () => {
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
settings: {
|
||||
theme: 'dark',
|
||||
accent: 'blue',
|
||||
radius: 'rounded',
|
||||
background: 'vinyl',
|
||||
brightness: 35,
|
||||
style: 'nostalgia',
|
||||
surfacePalette: 'olive',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
await loadFromServer()
|
||||
|
||||
expect(themeStore.style).toBe('nostalgia')
|
||||
expect(themeStore.surfacePalette).toBe('olive')
|
||||
})
|
||||
|
||||
it('loadFromServer applies a synchronized glow reach and ignores one out of range', async () => {
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, glowReach: 40 } }),
|
||||
)
|
||||
await loadFromServer()
|
||||
expect(themeStore.glowReach).toBe(40)
|
||||
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, glowReach: 900 } }),
|
||||
)
|
||||
await loadFromServer()
|
||||
expect(themeStore.glowReach).toBe(40)
|
||||
})
|
||||
|
||||
it('loadFromServer keeps the local style when the server value is unknown or absent', async () => {
|
||||
themeStore.style = 'nostalgia'
|
||||
themeStore.surfacePalette = 'pine'
|
||||
apiMock.mockResolvedValueOnce(
|
||||
mockJsonResponse({ settings: { theme: 'dark', accent: 'blue', radius: 'rounded', background: 'vinyl', brightness: 35, style: 'retro' } }),
|
||||
)
|
||||
|
||||
const { loadFromServer } = await import('../useThemeSync')
|
||||
await loadFromServer()
|
||||
|
||||
expect(themeStore.style).toBe('nostalgia')
|
||||
expect(themeStore.surfacePalette).toBe('pine')
|
||||
})
|
||||
|
||||
it('cancelPendingThemeSync prevents a scheduled save from firing', async () => {
|
||||
user.value = { settings: { syncThemePreferences: true } }
|
||||
apiMock.mockResolvedValue(mockJsonResponse({}, true))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
/** Lets the chosen style dress the app shell for as long as the calling component lives. */
|
||||
export function useAppStyleScope(): void {
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.setStyleScopeActive(true)
|
||||
onScopeDispose(() => themeStore.setStyleScopeActive(false))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import { createChartTokens, type ChartTokens } from '@/lib/echarts'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
/** Chart colours read from the live tokens, rebuilt whenever an appearance setting changes them. */
|
||||
export function useChartTokens(): ComputedRef<ChartTokens> {
|
||||
const themeStore = useThemeStore()
|
||||
return computed(() => createChartTokens(themeStore.appearanceKey, themeStore.resolvedTheme === 'dark'))
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, SURFACE_OPACITY_MAX, SURFACE_OPACITY_MIN, THEME_IDS, type ThemePreferences } from '@bookorbit/types'
|
||||
import {
|
||||
ACCENT_IDS,
|
||||
BACKGROUND_IDS,
|
||||
GLOW_REACH_MAX,
|
||||
GLOW_REACH_MIN,
|
||||
RADIUS_IDS,
|
||||
STYLE_IDS,
|
||||
SURFACE_OPACITY_MAX,
|
||||
SURFACE_OPACITY_MIN,
|
||||
SURFACE_PALETTE_IDS,
|
||||
THEME_IDS,
|
||||
type ThemePreferences,
|
||||
} from '@bookorbit/types'
|
||||
import { watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { i18n } from '@/i18n'
|
||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||
import { api, getAccessToken } from '@/lib/api'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
@@ -20,6 +33,9 @@ function getCurrentPrefs(): ThemePreferences {
|
||||
background: store.background,
|
||||
brightness: store.brightness,
|
||||
surfaceOpacity: store.surfaceOpacity,
|
||||
style: store.style,
|
||||
surfacePalette: store.surfacePalette,
|
||||
glowReach: store.glowReach,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +65,13 @@ function sanitizeServerPrefs(raw: unknown): Partial<ThemePreferences> {
|
||||
) {
|
||||
out.surfaceOpacity = obj.surfaceOpacity
|
||||
}
|
||||
if (STYLE_IDS.includes(obj.style as NonNullable<ThemePreferences['style']>)) out.style = obj.style as ThemePreferences['style']
|
||||
if (SURFACE_PALETTE_IDS.includes(obj.surfacePalette as NonNullable<ThemePreferences['surfacePalette']>)) {
|
||||
out.surfacePalette = obj.surfacePalette as ThemePreferences['surfacePalette']
|
||||
}
|
||||
if (typeof obj.glowReach === 'number' && Number.isInteger(obj.glowReach) && obj.glowReach >= GLOW_REACH_MIN && obj.glowReach <= GLOW_REACH_MAX) {
|
||||
out.glowReach = obj.glowReach
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -93,6 +116,9 @@ export async function loadFromServer(): Promise<void> {
|
||||
if (prefs.background !== undefined) store.setBackground(prefs.background)
|
||||
if (prefs.brightness !== undefined) store.setBrightness(prefs.brightness)
|
||||
if (prefs.surfaceOpacity !== undefined) store.setSurfaceOpacity(prefs.surfaceOpacity)
|
||||
if (prefs.style !== undefined) store.setStyle(prefs.style)
|
||||
if (prefs.surfacePalette !== undefined) store.setSurfacePalette(prefs.surfacePalette)
|
||||
if (prefs.glowReach !== undefined) store.setGlowReach(prefs.glowReach)
|
||||
} finally {
|
||||
isApplyingServerPrefs = false
|
||||
}
|
||||
@@ -123,11 +149,9 @@ export async function saveToServer(prefs: ThemePreferences): Promise<void> {
|
||||
body: JSON.stringify({ settings: prefs }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error('Failed to save theme preferences')
|
||||
}
|
||||
if (!res.ok) toast.error(i18n.global.t('settings.appearance.theme.syncSaveFailed'))
|
||||
} catch {
|
||||
toast.error('Failed to save theme preferences')
|
||||
toast.error(i18n.global.t('settings.appearance.theme.syncSaveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +169,18 @@ export function initThemeSync(): void {
|
||||
const store = useThemeStore()
|
||||
|
||||
watch(
|
||||
() => [store.theme, store.accent, store.radius, store.background, store.brightness, store.surfaceOpacity] as const,
|
||||
() =>
|
||||
[
|
||||
store.theme,
|
||||
store.accent,
|
||||
store.radius,
|
||||
store.background,
|
||||
store.brightness,
|
||||
store.surfaceOpacity,
|
||||
store.style,
|
||||
store.surfacePalette,
|
||||
store.glowReach,
|
||||
] as const,
|
||||
() => {
|
||||
if (isApplyingServerPrefs || !isSyncEnabled()) return
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ async function handleSubmit() {
|
||||
v-if="!isEdit && passwordLoginEnabled"
|
||||
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5"
|
||||
>
|
||||
<input v-model="isSharedAccount" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary" />
|
||||
<input v-model="isSharedAccount" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary" />
|
||||
<span>
|
||||
<span class="block text-sm font-medium text-foreground">{{ t('adminFeature.userForm.sharedAccount') }}</span>
|
||||
<span class="settings-hint block">{{ t('adminFeature.userForm.sharedAccountHint') }}</span>
|
||||
@@ -473,7 +473,7 @@ async function handleSubmit() {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedLibraryIds.has(library.id)"
|
||||
class="size-4 shrink-0 rounded border-input accent-primary"
|
||||
class="size-4 shrink-0 rounded-xs border-input accent-primary"
|
||||
@change="toggleLibrary(library.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-foreground">{{ library.name }}</span>
|
||||
|
||||
@@ -424,7 +424,7 @@ async function saveDefaultLibraryAccess() {
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span
|
||||
class="rounded px-1 text-xs tabular-nums"
|
||||
class="rounded-xs px-1 text-xs tabular-nums"
|
||||
:class="state === filter.value ? 'bg-primary/15 text-primary' : 'bg-foreground/8 text-muted-foreground'"
|
||||
>
|
||||
{{ formatNumber(filter.count) }}
|
||||
|
||||
@@ -115,7 +115,7 @@ function saveLibraryDefaults() {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedLibraryIds.has(library.id)"
|
||||
class="size-3.5 rounded border-input accent-primary"
|
||||
class="size-3.5 rounded-xs border-input accent-primary"
|
||||
@change="toggleLibrary(library.id)"
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ library.name }}</span>
|
||||
|
||||
@@ -200,7 +200,7 @@ function clearFilters() {
|
||||
<span
|
||||
v-if="group.manageOnly"
|
||||
aria-hidden="true"
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
class="rounded-xs bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ t('adminFeature.userForm.permissionLevels.manage') }}
|
||||
</span>
|
||||
@@ -212,18 +212,18 @@ function clearFilters() {
|
||||
<label
|
||||
v-for="row in group.rows"
|
||||
:key="row.name"
|
||||
class="flex cursor-pointer items-start gap-2 rounded py-1 focus-within:ring-2 focus-within:ring-ring"
|
||||
class="flex cursor-pointer items-start gap-2 rounded-xs py-1 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="row.checked"
|
||||
class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary"
|
||||
@change="togglePermission(row.name)"
|
||||
/>
|
||||
<span class="text-sm leading-tight text-foreground">{{ row.label }}</span>
|
||||
<span
|
||||
v-if="row.manage && !group.manageOnly"
|
||||
class="ms-auto mt-0.5 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
class="ms-auto mt-0.5 shrink-0 rounded-xs bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ t('adminFeature.userForm.permissionLevels.manage') }}
|
||||
</span>
|
||||
|
||||
@@ -113,7 +113,7 @@ function toggleContentFilters(enabled: boolean) {
|
||||
<p class="settings-hint">{{ t('adminFeature.userForm.excludeGenresHint') }}</p>
|
||||
</div>
|
||||
<label class="flex cursor-pointer items-start gap-2 focus-within:ring-2 focus-within:ring-ring @lg:col-span-2">
|
||||
<input v-model="seeOwnRequestedBooks" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary" />
|
||||
<input v-model="seeOwnRequestedBooks" type="checkbox" class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary" />
|
||||
<span>
|
||||
<span class="block text-sm leading-tight text-foreground">{{ t('adminFeature.userForm.seeOwnRequestedBooks') }}</span>
|
||||
<span class="settings-hint block">{{ t('adminFeature.userForm.seeOwnRequestedBooksHint') }}</span>
|
||||
@@ -137,7 +137,7 @@ function toggleContentFilters(enabled: boolean) {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="restriction.checked"
|
||||
class="mt-0.5 size-4 shrink-0 rounded border-input accent-primary"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary"
|
||||
@change="toggleRestriction(restriction.name)"
|
||||
/>
|
||||
<span>
|
||||
|
||||
@@ -126,7 +126,7 @@ onUnmounted(() => {
|
||||
v-if="hasSelection"
|
||||
type="button"
|
||||
:aria-label="t('annotations.combobox.clearBookFilter')"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@mousedown.prevent="clear"
|
||||
>
|
||||
<X :size="14" />
|
||||
|
||||
@@ -22,7 +22,7 @@ function handleError() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="relative flex shrink-0 items-center justify-center overflow-hidden rounded border border-border bg-muted">
|
||||
<span class="relative flex shrink-0 items-center justify-center overflow-hidden rounded-xs border border-border bg-muted">
|
||||
<img v-if="!failed" :src="src" :alt="title ?? ''" loading="lazy" class="h-full w-full object-cover" @error="handleError" />
|
||||
<BookOpen v-else :size="14" class="text-muted-foreground" />
|
||||
</span>
|
||||
|
||||
@@ -253,7 +253,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<Tooltip v-else>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded border border-border bg-muted"
|
||||
class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-xs border border-border bg-muted"
|
||||
:style="{ color: annotation.color }"
|
||||
>
|
||||
<component :is="styleIcon" :size="16" />
|
||||
@@ -308,7 +308,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded border text-xs transition-colors"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs border text-xs transition-colors"
|
||||
:class="
|
||||
annotation.style === s.value
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
@@ -378,7 +378,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<RouterLink
|
||||
:to="{ name: 'book-detail', params: { bookId: annotation.bookId }, query: { tab: 'details' } }"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
>
|
||||
<Info :size="15" />
|
||||
</RouterLink>
|
||||
@@ -390,7 +390,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
@click="handleJump"
|
||||
>
|
||||
<BookOpen :size="15" />
|
||||
@@ -404,7 +404,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
@click="handleRestore"
|
||||
>
|
||||
<ArchiveRestore :size="15" />
|
||||
@@ -416,7 +416,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
@click="handlePurge"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
@@ -431,7 +431,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="toggleNoteEditor"
|
||||
>
|
||||
<FileEdit :size="14" />
|
||||
@@ -444,7 +444,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs transition-colors hover:bg-muted hover:text-foreground"
|
||||
:class="showStylePanel ? 'bg-muted text-foreground' : 'text-muted-foreground'"
|
||||
@click="toggleStylePanel"
|
||||
>
|
||||
@@ -458,7 +458,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Check v-if="copied" :size="14" class="text-primary" />
|
||||
@@ -471,7 +471,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<template v-if="confirmTrash && mode === 'book'">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:title="t('common.cancel')"
|
||||
@click="handleCancelTrash"
|
||||
>
|
||||
@@ -479,7 +479,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded bg-destructive/15 px-2 text-[10px] font-medium uppercase text-destructive ring-1 ring-destructive/40"
|
||||
class="h-7 rounded-xs bg-destructive/15 px-2 text-[10px] font-medium uppercase text-destructive ring-1 ring-destructive/40"
|
||||
@click="handleTrash"
|
||||
>
|
||||
{{ t('annotations.listItem.trash') }}
|
||||
@@ -489,7 +489,7 @@ function shouldSeparateMetadataItem(index: number): boolean {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
@click="handleTrash"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
|
||||
@@ -78,7 +78,7 @@ onMounted(() => {
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li v-for="position in positions" :key="position.format" class="flex flex-wrap items-center gap-2">
|
||||
<span class="w-24 shrink-0 text-muted-foreground">{{ formatLabel(position.format) }}</span>
|
||||
<span class="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium" :class="statusClass(position.status)">
|
||||
<span class="inline-flex items-center rounded-xs border px-1.5 py-0.5 text-[10px] font-medium" :class="statusClass(position.status)">
|
||||
{{ statusLabel(position.status) }}
|
||||
</span>
|
||||
<span v-if="position.reason" class="truncate text-muted-foreground">{{ position.reason }}</span>
|
||||
@@ -86,7 +86,7 @@ onMounted(() => {
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:disabled="retrying === position.format"
|
||||
@click="() => handleRetry(position.format)"
|
||||
>
|
||||
@@ -109,7 +109,7 @@ onMounted(() => {
|
||||
{{ deviceLabel(device) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
class="inline-flex items-center rounded-xs border px-1.5 py-0.5 text-[10px] font-medium"
|
||||
:class="device.upToDate ? 'border-primary/30 bg-primary/10 text-primary' : 'border-border bg-muted text-muted-foreground'"
|
||||
>
|
||||
{{ device.upToDate ? t('annotations.sync.upToDate') : t('annotations.sync.pendingVersion', { version: detail.version }) }}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('AnnotationListItem', () => {
|
||||
const wrapper = mountItem({ annotation: makeHubAnnotation(), mode: 'hub' })
|
||||
const pill = wrapper.findAll('span').find((span) => span.text() === 'MOBI')
|
||||
|
||||
expect(pill?.attributes('style')).toContain('color: var(--pill-format-mobi)')
|
||||
expect(pill?.attributes('style')).toContain('color: var(--format-chip-ink, var(--pill-format-mobi))')
|
||||
})
|
||||
|
||||
it('renders the quote and the note', () => {
|
||||
|
||||
@@ -130,7 +130,7 @@ function handleOpenTrash() {
|
||||
v-for="entry in overview.originBreakdown"
|
||||
:key="entry.origin"
|
||||
type="button"
|
||||
class="-mx-1 inline-flex h-6 items-center gap-1.5 rounded px-1 text-[11.5px] transition-colors"
|
||||
class="-mx-1 inline-flex h-6 items-center gap-1.5 rounded-xs px-1 text-[11.5px] transition-colors"
|
||||
:class="selectedOrigin === entry.origin ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="selectedOrigin === entry.origin"
|
||||
@click="emit('toggleOrigin', entry.origin)"
|
||||
@@ -151,7 +151,7 @@ function handleOpenTrash() {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="-mx-1 mt-0.5 inline-flex h-6 items-center rounded px-1 text-[11.5px] font-semibold text-[var(--pill-repaired)] underline underline-offset-2"
|
||||
class="-mx-1 mt-0.5 inline-flex h-6 items-center rounded-xs px-1 text-[11.5px] font-semibold text-[var(--pill-repaired)] underline underline-offset-2"
|
||||
@click="handleReview"
|
||||
>
|
||||
{{ t('annotations.hub.reviewThem') }}
|
||||
@@ -183,7 +183,7 @@ function handleOpenTrash() {
|
||||
<RouterLink
|
||||
v-if="overview.books > overview.shelf.length"
|
||||
:to="{ name: 'libraries' }"
|
||||
class="mt-1 inline-flex h-6 flex-none items-center gap-1 rounded px-1.5 text-[11.5px] font-semibold text-muted-foreground transition-colors hover:text-primary"
|
||||
class="mt-1 inline-flex h-6 flex-none items-center gap-1 rounded-xs px-1.5 text-[11.5px] font-semibold text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
{{ t('annotations.hub.allBooks', { count: overview.books }) }}
|
||||
<ChevronRight :size="10" />
|
||||
|
||||
@@ -134,7 +134,7 @@ function handlePurge() {
|
||||
<button
|
||||
v-if="selecting"
|
||||
type="button"
|
||||
class="ml-auto grid size-[15px] place-items-center rounded border-[1.5px] transition-colors"
|
||||
class="ml-auto grid size-[15px] place-items-center rounded-xs border-[1.5px] transition-colors"
|
||||
:class="selected ? 'border-primary bg-primary text-primary-foreground' : 'border-border hover:border-primary'"
|
||||
:aria-pressed="selected"
|
||||
:aria-label="t('annotations.listItem.selectAnnotation')"
|
||||
|
||||
@@ -154,7 +154,7 @@ function handlePickStyle(style: string) {
|
||||
<span class="text-[8.5px] font-bold uppercase tracking-[0.13em] text-muted-foreground">{{ t('annotations.hub.positionAndSync') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto grid size-5 place-items-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
class="ml-auto grid size-5 place-items-center rounded-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:aria-label="t('common.close')"
|
||||
@click="handleClose"
|
||||
>
|
||||
|
||||
@@ -197,7 +197,7 @@ function handleClearFilters() {
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-7 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
class="grid size-7 place-items-center rounded-xs text-muted-foreground hover:text-foreground"
|
||||
:aria-label="t('annotations.bulk.clear')"
|
||||
@click="handleClearSelection"
|
||||
>
|
||||
@@ -233,7 +233,7 @@ function handleClearFilters() {
|
||||
<div class="flex h-8 shrink-0 items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded px-2.5 text-[11px] font-semibold transition-colors"
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-xs px-2.5 text-[11px] font-semibold transition-colors"
|
||||
:class="notesOnly ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="notesOnly"
|
||||
@click="handleToggleNotes"
|
||||
@@ -245,7 +245,7 @@ function handleClearFilters() {
|
||||
<button
|
||||
v-if="reviewCount > 0"
|
||||
type="button"
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded px-2.5 text-[11px] font-semibold transition-colors"
|
||||
class="inline-flex h-6 items-center gap-1.5 rounded-xs px-2.5 text-[11px] font-semibold transition-colors"
|
||||
:class="needsReviewOnly ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="needsReviewOnly"
|
||||
@click="handleToggleReview"
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface PillStyle {
|
||||
class: string
|
||||
}
|
||||
|
||||
const PILL_BASE = 'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium'
|
||||
const PILL_BASE = 'inline-flex items-center gap-1 rounded-xs border px-1.5 py-0.5 text-[10px] font-medium'
|
||||
|
||||
export const SOURCE_PILLS: Record<AnnotationOrigin, PillStyle> = {
|
||||
web: { label: 'Web', class: 'border-[var(--pill-web)]/40 bg-[var(--pill-web)]/10 text-[var(--pill-web)]' },
|
||||
|
||||
@@ -1,53 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { api } from '@/lib/api'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
||||
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const accentOpen = ref(false)
|
||||
const radiusOpen = ref(false)
|
||||
const backgroundOpen = ref(false)
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
||||
|
||||
function radiusPreview(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sharp: '0px',
|
||||
default: '4px',
|
||||
rounded: '8px',
|
||||
pill: '999px',
|
||||
}
|
||||
return map[id] ?? '4px'
|
||||
}
|
||||
|
||||
function openRadius() {
|
||||
radiusOpen.value = !radiusOpen.value
|
||||
accentOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function openBackground() {
|
||||
backgroundOpen.value = !backgroundOpen.value
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
}
|
||||
|
||||
function openAccent() {
|
||||
accentOpen.value = !accentOpen.value
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
const email = ref('')
|
||||
const submitted = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -77,129 +35,7 @@ async function handleSubmit() {
|
||||
|
||||
<template>
|
||||
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
||||
<!-- Compact theme picker -->
|
||||
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
|
||||
<!-- Dark / light toggle -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="themeStore.toggleTheme()">
|
||||
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Radius picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
||||
:class="
|
||||
themeStore.radius === opt.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
||||
"
|
||||
@click="themeStore.setRadius(opt.id)"
|
||||
>
|
||||
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openRadius()">
|
||||
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Background picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
||||
<div class="grid grid-cols-5 gap-1.5">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
||||
:class="
|
||||
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
||||
"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openBackground()">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Accent picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
||||
<div class="space-y-2 w-max">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:style="{
|
||||
backgroundColor: opt.color,
|
||||
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
||||
outlineOffset: '2px',
|
||||
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
||||
}"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openAccent()">
|
||||
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside backdrop -->
|
||||
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
|
||||
<AuthAppearanceToolbar placement="bottom" />
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
@@ -248,47 +84,6 @@ async function handleSubmit() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accent-popover {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.97);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
|
||||
@@ -3,56 +3,14 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { LoginErrorCode, type OidcProviderPublic } from '@bookorbit/types'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { LoginError, useAuth } from './composables/useAuth'
|
||||
import { useOidc } from './composables/useOidc'
|
||||
import { useSetupStatus } from './composables/useSetupStatus'
|
||||
import { useLoginOptions } from './composables/useLoginOptions'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
||||
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const accentOpen = ref(false)
|
||||
const radiusOpen = ref(false)
|
||||
const backgroundOpen = ref(false)
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
||||
|
||||
function radiusPreview(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sharp: '0px',
|
||||
default: '4px',
|
||||
rounded: '8px',
|
||||
pill: '999px',
|
||||
}
|
||||
return map[id] ?? '4px'
|
||||
}
|
||||
|
||||
function openRadius() {
|
||||
radiusOpen.value = !radiusOpen.value
|
||||
accentOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function openBackground() {
|
||||
backgroundOpen.value = !backgroundOpen.value
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
}
|
||||
|
||||
function openAccent() {
|
||||
accentOpen.value = !accentOpen.value
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
const { login } = useAuth()
|
||||
const { initiateLogin } = useOidc()
|
||||
const { setupStatusError } = useSetupStatus()
|
||||
@@ -127,132 +85,7 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
|
||||
<template>
|
||||
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
||||
<!-- Compact theme picker -->
|
||||
<div class="fixed top-5 right-5 z-20 flex items-center gap-1.5">
|
||||
<!-- Dark / light toggle -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="themeStore.toggleTheme()">
|
||||
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Radius picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="radiusOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
||||
:class="
|
||||
themeStore.radius === opt.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
||||
"
|
||||
@click="themeStore.setRadius(opt.id)"
|
||||
>
|
||||
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openRadius()">
|
||||
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Background picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="backgroundOpen" class="accent-popover absolute top-full right-0 mt-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
||||
<div class="grid grid-cols-5 gap-1.5">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
||||
:class="
|
||||
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
||||
"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openBackground()">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Accent picker -->
|
||||
<div class="relative">
|
||||
<!-- Colour popover -->
|
||||
<Transition name="popover">
|
||||
<div v-if="accentOpen" class="accent-popover absolute top-full right-0 mt-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
||||
<div class="space-y-2 w-max">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:style="{
|
||||
backgroundColor: opt.color,
|
||||
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
||||
outlineOffset: '2px',
|
||||
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
||||
}"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Swatch button showing current accent -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openAccent()">
|
||||
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside backdrop -->
|
||||
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
|
||||
<AuthAppearanceToolbar placement="top" />
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8 animate-fade-up">
|
||||
@@ -344,47 +177,6 @@ async function handleOidcLogin(provider: OidcProviderPublic) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accent-popover {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.97);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
||||
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
|
||||
import { RegisterError, useAuth } from './composables/useAuth'
|
||||
import { checkIdentityText } from './lib/identity-text'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const accentOpen = ref(false)
|
||||
const radiusOpen = ref(false)
|
||||
const backgroundOpen = ref(false)
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
||||
|
||||
function radiusPreview(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sharp: '0px',
|
||||
default: '4px',
|
||||
rounded: '8px',
|
||||
pill: '999px',
|
||||
}
|
||||
return map[id] ?? '4px'
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
|
||||
function openRadius() {
|
||||
radiusOpen.value = !radiusOpen.value
|
||||
accentOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function openBackground() {
|
||||
backgroundOpen.value = !backgroundOpen.value
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
}
|
||||
|
||||
function openAccent() {
|
||||
accentOpen.value = !accentOpen.value
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
const { register } = useAuth()
|
||||
|
||||
const username = ref('')
|
||||
@@ -112,131 +66,7 @@ async function handleSubmit() {
|
||||
|
||||
<template>
|
||||
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
||||
<!-- Compact theme picker -->
|
||||
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
|
||||
<!-- Dark / light toggle -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="handleToggleTheme">
|
||||
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Radius picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
||||
:class="
|
||||
themeStore.radius === opt.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
||||
"
|
||||
@click="themeStore.setRadius(opt.id)"
|
||||
>
|
||||
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openRadius">
|
||||
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Background picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
||||
<div class="grid grid-cols-5 gap-1.5">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
||||
:class="
|
||||
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
||||
"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openBackground">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Accent picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
||||
<div class="space-y-2 w-max">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:style="{
|
||||
backgroundColor: opt.color,
|
||||
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
||||
outlineOffset: '2px',
|
||||
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
||||
}"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Swatch button showing current accent -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openAccent">
|
||||
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside backdrop -->
|
||||
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll" />
|
||||
<AuthAppearanceToolbar placement="bottom" />
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-md rounded-2xl p-8">
|
||||
<div class="mb-6">
|
||||
@@ -337,47 +167,6 @@ async function handleSubmit() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accent-popover {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.97);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
|
||||
@@ -1,54 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { fetchWithAuthProxyRecovery } from '@/lib/api'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
||||
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const accentOpen = ref(false)
|
||||
const radiusOpen = ref(false)
|
||||
const backgroundOpen = ref(false)
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
||||
|
||||
function radiusPreview(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sharp: '0px',
|
||||
default: '4px',
|
||||
rounded: '8px',
|
||||
pill: '999px',
|
||||
}
|
||||
return map[id] ?? '4px'
|
||||
}
|
||||
|
||||
function openRadius() {
|
||||
radiusOpen.value = !radiusOpen.value
|
||||
accentOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function openBackground() {
|
||||
backgroundOpen.value = !backgroundOpen.value
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
}
|
||||
|
||||
function openAccent() {
|
||||
accentOpen.value = !accentOpen.value
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -98,129 +56,7 @@ async function handleSubmit() {
|
||||
|
||||
<template>
|
||||
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
||||
<!-- Compact theme picker -->
|
||||
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
|
||||
<!-- Dark / light toggle -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="themeStore.toggleTheme()">
|
||||
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Radius picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
||||
:class="
|
||||
themeStore.radius === opt.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
||||
"
|
||||
@click="themeStore.setRadius(opt.id)"
|
||||
>
|
||||
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openRadius()">
|
||||
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Background picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
||||
<div class="grid grid-cols-5 gap-1.5">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
||||
:class="
|
||||
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
||||
"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openBackground()">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Accent picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
||||
<div class="space-y-2 w-max">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:style="{
|
||||
backgroundColor: opt.color,
|
||||
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
||||
outlineOffset: '2px',
|
||||
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
||||
}"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openAccent()">
|
||||
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside backdrop -->
|
||||
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
|
||||
<AuthAppearanceToolbar placement="bottom" />
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-sm rounded-2xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
@@ -278,47 +114,6 @@ async function handleSubmit() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accent-popover {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.97);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
|
||||
@@ -1,53 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import { ACCENT_OPTIONS, ACCENT_ROWS, RADIUS_OPTIONS, BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import PublicLegalNotices from '@/components/legal/PublicLegalNotices.vue'
|
||||
import AuthAppearanceToolbar from './components/AuthAppearanceToolbar.vue'
|
||||
import { useAuth } from './composables/useAuth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const accentOpen = ref(false)
|
||||
const radiusOpen = ref(false)
|
||||
const backgroundOpen = ref(false)
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((o) => o.id === themeStore.accent))
|
||||
|
||||
function radiusPreview(id: string): string {
|
||||
const map: Record<string, string> = {
|
||||
sharp: '0px',
|
||||
default: '4px',
|
||||
rounded: '8px',
|
||||
pill: '999px',
|
||||
}
|
||||
return map[id] ?? '4px'
|
||||
}
|
||||
|
||||
function openRadius() {
|
||||
radiusOpen.value = !radiusOpen.value
|
||||
accentOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function openBackground() {
|
||||
backgroundOpen.value = !backgroundOpen.value
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
}
|
||||
|
||||
function openAccent() {
|
||||
accentOpen.value = !accentOpen.value
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
accentOpen.value = false
|
||||
radiusOpen.value = false
|
||||
backgroundOpen.value = false
|
||||
}
|
||||
|
||||
const { setup } = useAuth()
|
||||
|
||||
const username = ref('')
|
||||
@@ -87,131 +45,7 @@ async function handleSubmit() {
|
||||
|
||||
<template>
|
||||
<div class="login-bg min-h-screen flex flex-col items-center justify-center gap-3 px-4 overflow-hidden">
|
||||
<!-- Compact theme picker -->
|
||||
<div class="fixed bottom-5 right-5 z-20 flex items-center gap-1.5">
|
||||
<!-- Dark / light toggle -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="themeStore.toggleTheme()">
|
||||
<Sun v-if="themeStore.resolvedTheme === 'dark'" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{
|
||||
themeStore.resolvedTheme === 'dark' ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Radius picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="radiusOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in RADIUS_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all focus:outline-none"
|
||||
:class="
|
||||
themeStore.radius === opt.id
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
|
||||
"
|
||||
@click="themeStore.setRadius(opt.id)"
|
||||
>
|
||||
<span class="w-4 h-4 border-2 border-current block" :style="{ borderRadius: radiusPreview(opt.id) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openRadius()">
|
||||
<span class="w-3.5 h-3.5 border-2 border-current block" :style="{ borderRadius: radiusPreview(themeStore.radius) }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Background picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="backgroundOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-2.5 rounded-lg w-64 max-h-72 overflow-y-auto">
|
||||
<div class="grid grid-cols-5 gap-1.5">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-full h-9 rounded overflow-hidden ring-2 transition-all focus:outline-none shrink-0"
|
||||
:class="
|
||||
themeStore.background === opt.id ? 'ring-primary shadow-sm shadow-primary/20' : 'ring-border hover:ring-muted-foreground/40'
|
||||
"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ opt.label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openBackground()">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Accent picker -->
|
||||
<div class="relative">
|
||||
<Transition name="popover">
|
||||
<div v-if="accentOpen" class="accent-popover absolute bottom-full right-0 mb-2 p-3 rounded-lg space-y-2 w-96 max-w-[calc(100vw-2rem)]">
|
||||
<div class="overflow-x-auto no-scrollbar px-1 py-0.5">
|
||||
<div class="space-y-2 w-max">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="w-4 h-4 rounded-full transition-all hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
:style="{
|
||||
backgroundColor: opt.color,
|
||||
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
|
||||
outlineOffset: '2px',
|
||||
transform: themeStore.accent === opt.id ? 'scale(1.2)' : '',
|
||||
}"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Swatch button showing current accent -->
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="theme-btn" @click="openAccent()">
|
||||
<span class="w-3.5 h-3.5 rounded-full block" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside backdrop -->
|
||||
<div v-if="accentOpen || radiusOpen || backgroundOpen" class="fixed inset-0 z-10" @click="closeAll()" />
|
||||
<AuthAppearanceToolbar placement="bottom" />
|
||||
|
||||
<div class="login-card relative z-10 w-full max-w-md rounded-2xl p-8">
|
||||
<div class="mb-6">
|
||||
@@ -315,47 +149,6 @@ async function handleSubmit() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.accent-popover {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
|
||||
.popover-enter-active,
|
||||
.popover-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.popover-enter-from,
|
||||
.popover-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.97);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
|
||||
@@ -42,21 +42,8 @@ vi.mock('../composables/useSetupStatus', () => ({
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState }))
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
ACCENT_OPTIONS: [{ id: 'blue', color: '#00f', swatchClass: '', labelKey: 'common.close' }],
|
||||
ACCENT_ROWS: [],
|
||||
RADIUS_OPTIONS: [],
|
||||
BACKGROUND_OPTIONS: [],
|
||||
useThemeStore: () => ({
|
||||
accent: 'blue',
|
||||
radius: 'default',
|
||||
background: 'none',
|
||||
resolvedTheme: 'light',
|
||||
toggleTheme: vi.fn<() => void>(),
|
||||
setAccent: vi.fn<() => void>(),
|
||||
setRadius: vi.fn<() => void>(),
|
||||
setBackground: vi.fn<() => void>(),
|
||||
}),
|
||||
vi.mock('../components/AuthAppearanceToolbar.vue', () => ({
|
||||
default: { name: 'AuthAppearanceToolbar', template: '<div data-testid="auth-appearance-toolbar" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
|
||||
@@ -13,21 +13,8 @@ vi.mock('../composables/useAuth', async () => {
|
||||
return { RegisterError: actual.RegisterError, useAuth: () => ({ register: registerMock }) }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
ACCENT_OPTIONS: [{ id: 'blue', color: '#00f', swatchClass: '', labelKey: 'common.close' }],
|
||||
ACCENT_ROWS: [],
|
||||
RADIUS_OPTIONS: [],
|
||||
BACKGROUND_OPTIONS: [],
|
||||
useThemeStore: () => ({
|
||||
accent: 'blue',
|
||||
radius: 'default',
|
||||
background: 'none',
|
||||
resolvedTheme: 'light',
|
||||
toggleTheme: vi.fn<() => void>(),
|
||||
setAccent: vi.fn<() => void>(),
|
||||
setRadius: vi.fn<() => void>(),
|
||||
setBackground: vi.fn<() => void>(),
|
||||
}),
|
||||
vi.mock('../components/AuthAppearanceToolbar.vue', () => ({
|
||||
default: { name: 'AuthAppearanceToolbar', template: '<div data-testid="auth-appearance-toolbar" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Moon, Sun, Wallpaper } from '@lucide/vue'
|
||||
import AccentPicker from '@/components/AccentPicker.vue'
|
||||
import BackgroundPicker from '@/components/BackgroundPicker.vue'
|
||||
import RadiusPicker from '@/components/RadiusPicker.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ACCENT_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
|
||||
const props = withDefaults(defineProps<{ placement?: 'top' | 'bottom' }>(), { placement: 'bottom' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const isDark = computed(() => themeStore.resolvedTheme === 'dark')
|
||||
const themeToggleLabel = computed(() => (isDark.value ? t('auth.themePicker.switchToLight') : t('auth.themePicker.switchToDark')))
|
||||
const currentAccent = computed(() => ACCENT_OPTIONS.find((option) => option.id === themeStore.accent))
|
||||
const currentRadiusPreview = computed(() => RADIUS_OPTIONS.find((option) => option.id === themeStore.radius)?.previewRadius ?? '3px')
|
||||
const popoverSide = computed(() => (props.placement === 'top' ? 'bottom' : 'top'))
|
||||
|
||||
function handleToggleTheme() {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed right-5 z-20 flex items-center gap-1.5" :class="placement === 'top' ? 'top-5' : 'bottom-5'">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" class="auth-appearance-button" :aria-label="themeToggleLabel" @click="handleToggleTheme">
|
||||
<Sun v-if="isDark" :size="14" />
|
||||
<Moon v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ themeToggleLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Popover>
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="auth-appearance-button" :aria-label="t('auth.themePicker.changeRadius')">
|
||||
<span class="block size-3.5 border-2 border-current" :style="{ borderRadius: currentRadiusPreview }" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover w-auto p-2.5">
|
||||
<RadiusPicker />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeRadius') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Popover>
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="auth-appearance-button" :aria-label="t('auth.themePicker.changeBackground')">
|
||||
<Wallpaper :size="14" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover max-h-72 w-64 overflow-y-auto p-2.5">
|
||||
<BackgroundPicker />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeBackground') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Popover>
|
||||
<TooltipTrigger as-child>
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="auth-appearance-button" :aria-label="t('auth.themePicker.changeAccent')">
|
||||
<span class="block size-3.5 rounded-full" :class="currentAccent?.swatchClass" :style="{ backgroundColor: currentAccent?.color }" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<PopoverContent :side="popoverSide" align="end" class="auth-appearance-popover w-auto max-w-[calc(100vw-2rem)] overflow-x-auto p-3">
|
||||
<AccentPicker />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>{{ t('auth.themePicker.changeAccent') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-appearance-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in oklch, var(--card) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: var(--elevation-md);
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.auth-appearance-button:hover,
|
||||
.auth-appearance-button:focus-visible {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.auth-appearance-button:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:global(.auth-appearance-popover) {
|
||||
background: color-mix(in oklch, var(--card) 80%, transparent);
|
||||
border-color: color-mix(in oklch, var(--border) 55%, transparent);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
box-shadow: var(--elevation-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { h } from 'vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import AuthAppearanceToolbar from '../AuthAppearanceToolbar.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function mountToolbar(placement: 'top' | 'bottom') {
|
||||
return mount(TooltipProvider, {
|
||||
props: { delayDuration: 0 },
|
||||
slots: { default: () => h(AuthAppearanceToolbar, { placement }) },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn<(query: string) => Pick<MediaQueryList, 'matches' | 'media' | 'addEventListener' | 'removeEventListener'>>((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: vi.fn<MediaQueryList['addEventListener']>(),
|
||||
removeEventListener: vi.fn<MediaQueryList['removeEventListener']>(),
|
||||
})),
|
||||
)
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('AuthAppearanceToolbar', () => {
|
||||
it.each([
|
||||
{ placement: 'top', edge: 'top-5' },
|
||||
{ placement: 'bottom', edge: 'bottom-5' },
|
||||
] as const)('pins to the $placement edge', ({ placement, edge }) => {
|
||||
wrapper = mountToolbar(placement)
|
||||
|
||||
expect(wrapper.get('.fixed').classes()).toContain(edge)
|
||||
})
|
||||
|
||||
it('toggles between light and dark', async () => {
|
||||
wrapper = mountToolbar('top')
|
||||
const store = useThemeStore()
|
||||
|
||||
await wrapper.get('button[aria-label="Switch to dark"]').trigger('click')
|
||||
|
||||
expect(store.resolvedTheme).toBe('dark')
|
||||
expect(wrapper.find('button[aria-label="Switch to light"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('picks a corner radius from its popover', async () => {
|
||||
wrapper = mountToolbar('bottom')
|
||||
const store = useThemeStore()
|
||||
|
||||
await wrapper.get('button[aria-label="Change corner radius"]').trigger('click')
|
||||
await flushPromises()
|
||||
const pill = document.body.querySelector<HTMLButtonElement>('button[aria-label="Pill-shaped corners"]')
|
||||
pill!.click()
|
||||
|
||||
expect(store.radius).toBe('pill')
|
||||
})
|
||||
|
||||
it('picks a background from its popover', async () => {
|
||||
wrapper = mountToolbar('bottom')
|
||||
const store = useThemeStore()
|
||||
|
||||
await wrapper.get('button[aria-label="Change background"]').trigger('click')
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLButtonElement>('button[aria-label="Aurora"]')!.click()
|
||||
|
||||
expect(store.background).toBe('aurora')
|
||||
})
|
||||
})
|
||||
@@ -125,7 +125,7 @@ function handleCoverError() {
|
||||
|
||||
<span
|
||||
v-if="source === 'cover' && showSourceBadge"
|
||||
class="pointer-events-none absolute hidden size-4 place-items-center rounded bg-black/45 text-white/85 backdrop-blur-[2px] @[3.5rem]:grid"
|
||||
class="pointer-events-none absolute hidden size-4 place-items-center rounded-xs bg-black/45 text-white/85 backdrop-blur-[2px] @[3.5rem]:grid"
|
||||
:class="badgeInset"
|
||||
:title="t('author.portrait.fromTheirBook')"
|
||||
>
|
||||
|
||||
@@ -591,13 +591,13 @@ defineOptions({ name: 'AuthorsView' })
|
||||
class="aspect-square w-full animate-pulse bg-muted/60"
|
||||
:class="authorCoverShape === 'circle' ? 'rounded-full' : 'rounded-[10px]'"
|
||||
/>
|
||||
<div class="h-3 w-3/4 animate-pulse rounded bg-muted/50" />
|
||||
<div class="h-3 w-3/4 animate-pulse rounded-xs bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div v-for="index in INITIAL_SKELETON_COUNT" :key="`author-skeleton-row-${index}`" class="flex items-center gap-3 px-2 py-1">
|
||||
<div class="size-[30px] shrink-0 animate-pulse rounded-lg bg-muted/60" />
|
||||
<div class="h-3 w-40 max-w-[45%] animate-pulse rounded bg-muted/50" />
|
||||
<div class="h-3 w-40 max-w-[45%] animate-pulse rounded-xs bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ function handleClose() {
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabledFields.has(f.key)"
|
||||
class="size-3.5 rounded border-input accent-primary shrink-0"
|
||||
class="size-3.5 rounded-xs border-input accent-primary shrink-0"
|
||||
@change="toggleField(f.key)"
|
||||
/>
|
||||
<label class="flex-1">
|
||||
@@ -152,7 +152,7 @@ function handleClose() {
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input v-model="mergeArrays" type="checkbox" class="size-3.5 rounded border-input accent-primary" />
|
||||
<input v-model="mergeArrays" type="checkbox" class="size-3.5 rounded-xs border-input accent-primary" />
|
||||
{{ t('bookDock.bulkEdit.mergeArrays') }}
|
||||
</label>
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function onCoverLoad(event: Event) {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="book-dock-cover-current"
|
||||
class="relative flex h-[57px] w-[38px] cursor-zoom-in items-center justify-center overflow-hidden rounded bg-muted ring-1 ring-border transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
class="relative flex h-[57px] w-[38px] cursor-zoom-in items-center justify-center overflow-hidden rounded-xs bg-muted ring-1 ring-border transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:aria-label="t('bookDock.fileList.currentCoverAlt', { title: props.title })"
|
||||
@click="openCurrent"
|
||||
>
|
||||
@@ -72,7 +72,7 @@ function onCoverLoad(event: Event) {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="book-dock-cover-proposed"
|
||||
class="relative flex h-[57px] w-[38px] cursor-zoom-in items-center justify-center overflow-hidden rounded border transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
class="relative flex h-[57px] w-[38px] cursor-zoom-in items-center justify-center overflow-hidden rounded-xs border transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="props.applied ? 'border-emerald-500/45 bg-emerald-500/5' : 'border-amber-500/45 bg-amber-500/5'"
|
||||
:aria-label="t('bookDock.fileList.newCoverAlt', { title: props.title })"
|
||||
@click="openProposed"
|
||||
|
||||
@@ -163,11 +163,11 @@ function onUpload() {
|
||||
<div ref="listEl" class="book-dock-list" tabindex="-1" @keydown="onKeydown">
|
||||
<div v-if="!props.initialized && props.loading && !props.items.length" data-test="book-dock-loading-skeleton" class="divide-y divide-border">
|
||||
<div v-for="n in 6" :key="n" class="flex animate-pulse items-center gap-3 px-3 py-2.5">
|
||||
<div class="size-4 rounded bg-muted" />
|
||||
<div class="h-[57px] w-[38px] shrink-0 rounded bg-muted" />
|
||||
<div class="size-4 rounded-xs bg-muted" />
|
||||
<div class="h-[57px] w-[38px] shrink-0 rounded-xs bg-muted" />
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<div class="h-3 w-3/5 rounded bg-muted" />
|
||||
<div class="h-2.5 w-2/5 rounded bg-muted" />
|
||||
<div class="h-3 w-3/5 rounded-xs bg-muted" />
|
||||
<div class="h-2.5 w-2/5 rounded-xs bg-muted" />
|
||||
</div>
|
||||
<div class="h-7 w-24 rounded-lg bg-muted" />
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="!props.status.paused && (props.status.queued > 0 || props.status.processing > 0 || props.status.rateLimited > 0)"
|
||||
:disabled="acting"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted disabled:opacity-50"
|
||||
@click="handlePause"
|
||||
>
|
||||
<Square :size="12" />
|
||||
@@ -94,7 +94,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="props.status.paused && (props.status.queued > 0 || props.status.processing > 0 || props.status.rateLimited > 0)"
|
||||
:disabled="acting"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted disabled:opacity-50"
|
||||
@click="handleResume"
|
||||
>
|
||||
<Play :size="12" />
|
||||
@@ -103,7 +103,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="props.status.queued > 0 || props.status.rateLimited > 0"
|
||||
:disabled="acting"
|
||||
class="px-2 py-1 text-xs rounded border border-border hover:bg-muted text-destructive disabled:opacity-50"
|
||||
class="px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted text-destructive disabled:opacity-50"
|
||||
@click="confirmingCancel = true"
|
||||
>
|
||||
{{ t('bookMetadataFetch.actions.cancelQueued') }}
|
||||
@@ -118,12 +118,12 @@ async function handleCancelConfirm() {
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
:disabled="acting"
|
||||
class="px-2 py-1 text-xs rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
class="px-2 py-1 text-xs rounded-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
@click="handleCancelConfirm"
|
||||
>
|
||||
{{ t('bookMetadataFetch.cancel.confirm') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-muted" @click="confirmingCancel = false">
|
||||
<button class="px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted" @click="confirmingCancel = false">
|
||||
{{ t('bookMetadataFetch.cancel.keepRunning') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ onMounted(() => loadPage(1))
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="page <= 1"
|
||||
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
|
||||
class="px-2 py-1 text-xs border border-border rounded-xs hover:bg-muted disabled:opacity-40"
|
||||
@click="loadPage(page - 1)"
|
||||
>
|
||||
{{ t('common.previous') }}
|
||||
@@ -95,7 +95,7 @@ onMounted(() => loadPage(1))
|
||||
<span class="text-xs text-muted-foreground">{{ page }} / {{ Math.ceil(data.total / limit) }}</span>
|
||||
<button
|
||||
:disabled="page >= Math.ceil(data.total / limit)"
|
||||
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
|
||||
class="px-2 py-1 text-xs border border-border rounded-xs hover:bg-muted disabled:opacity-40"
|
||||
@click="loadPage(page + 1)"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
@@ -103,7 +103,7 @@ onMounted(() => loadPage(1))
|
||||
</div>
|
||||
<button
|
||||
:disabled="retrying"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-xs hover:bg-primary/90 disabled:opacity-50"
|
||||
@click="handleRetryAll"
|
||||
>
|
||||
<RefreshCw :size="12" :class="retrying ? 'animate-spin' : ''" />
|
||||
|
||||
@@ -81,7 +81,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="!props.status.paused && (props.status.queued > 0 || props.status.processing > 0)"
|
||||
:disabled="acting"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted disabled:opacity-50"
|
||||
@click="handlePause"
|
||||
>
|
||||
<Square :size="12" />
|
||||
@@ -90,7 +90,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="props.status.paused && (props.status.queued > 0 || props.status.processing > 0)"
|
||||
:disabled="acting"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted disabled:opacity-50"
|
||||
@click="handleResume"
|
||||
>
|
||||
<Play :size="12" />
|
||||
@@ -99,7 +99,7 @@ async function handleCancelConfirm() {
|
||||
<button
|
||||
v-if="props.status.queued > 0"
|
||||
:disabled="acting"
|
||||
class="px-2 py-1 text-xs rounded border border-border hover:bg-muted text-destructive disabled:opacity-50"
|
||||
class="px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted text-destructive disabled:opacity-50"
|
||||
@click="confirmingCancel = true"
|
||||
>
|
||||
{{ t('bookMetadataFetch.actions.cancelQueued') }}
|
||||
@@ -114,12 +114,12 @@ async function handleCancelConfirm() {
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
:disabled="acting"
|
||||
class="px-2 py-1 text-xs rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
class="px-2 py-1 text-xs rounded-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
|
||||
@click="handleCancelConfirm"
|
||||
>
|
||||
{{ t('bookMetadataFetch.cancel.confirm') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-muted" @click="confirmingCancel = false">
|
||||
<button class="px-2 py-1 text-xs rounded-xs border border-border hover:bg-muted" @click="confirmingCancel = false">
|
||||
{{ t('bookMetadataFetch.cancel.keepRunning') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -90,7 +90,7 @@ onMounted(() => loadPage(1))
|
||||
<button
|
||||
:disabled="page <= 1"
|
||||
@click="loadPage(page - 1)"
|
||||
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
|
||||
class="px-2 py-1 text-xs border border-border rounded-xs hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
{{ t('common.previous') }}
|
||||
</button>
|
||||
@@ -98,7 +98,7 @@ onMounted(() => loadPage(1))
|
||||
<button
|
||||
:disabled="page >= Math.ceil(data.total / limit)"
|
||||
@click="loadPage(page + 1)"
|
||||
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
|
||||
class="px-2 py-1 text-xs border border-border rounded-xs hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
{{ t('common.next') }}
|
||||
</button>
|
||||
@@ -106,7 +106,7 @@ onMounted(() => loadPage(1))
|
||||
<button
|
||||
@click="handleRetryAll"
|
||||
:disabled="retrying"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-xs hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw :size="12" :class="retrying ? 'animate-spin' : ''" />
|
||||
{{ t('bookMetadataFetch.report.retryAllFailed') }}
|
||||
|
||||
@@ -242,7 +242,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="absolute end-1.5 top-1.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="absolute end-1.5 top-1.5 rounded-xs p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('bookMetadataFetch.actions.dismissBookStatus')"
|
||||
@click="handleDismissBookCard"
|
||||
>
|
||||
@@ -275,7 +275,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="absolute end-1.5 top-1.5 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="absolute end-1.5 top-1.5 rounded-xs p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('bookMetadataFetch.actions.dismissAuthorStatus')"
|
||||
@click="handleDismissAuthorCard"
|
||||
>
|
||||
|
||||
@@ -686,7 +686,7 @@ function goToNextPage() {
|
||||
<input
|
||||
v-model="activeList.includeDismissed.value"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-border accent-primary"
|
||||
class="h-3.5 w-3.5 rounded-xs border-border accent-primary"
|
||||
@change="handleShowDismissedChange"
|
||||
/>
|
||||
{{ t('bookRequests.filters.showDismissed') }}
|
||||
|
||||
@@ -84,14 +84,14 @@ async function handleRequest() {
|
||||
<RouterLink
|
||||
v-if="state === 'owned' && ownedBookId !== null"
|
||||
:to="{ name: 'book-detail', params: { bookId: ownedBookId } }"
|
||||
class="shrink-0 rounded text-[11px] font-medium text-foreground underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
class="shrink-0 rounded-xs text-[11px] font-medium text-foreground underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
{{ t('bookRequests.missingFormat.open') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-else-if="state === 'following' && requestId !== null"
|
||||
:to="{ name: 'book-request-detail', params: { id: requestId }, query: { tab: 'mine' } }"
|
||||
class="shrink-0 rounded text-[11px] font-medium text-foreground underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
class="shrink-0 rounded-xs text-[11px] font-medium text-foreground underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
{{ t('bookRequests.missingFormat.viewRequest') }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -1421,7 +1421,7 @@ function showEverySource(): void {
|
||||
<button
|
||||
v-if="credits(release).length"
|
||||
type="button"
|
||||
class="ms-1 rounded text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
class="ms-1 rounded-xs text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
:aria-expanded="isExpanded(release)"
|
||||
@click="toggleWhy(release)"
|
||||
>
|
||||
@@ -1452,7 +1452,7 @@ function showEverySource(): void {
|
||||
<li
|
||||
v-for="reason in credits(release)"
|
||||
:key="reason.code"
|
||||
class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
class="rounded-xs border border-border bg-muted px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ reasonText(reason) }} <span class="tabular-nums text-foreground">{{ reasonPoints(reason.points) }}</span>
|
||||
</li>
|
||||
|
||||
@@ -61,12 +61,12 @@ function handleError() {
|
||||
v-if="showImage"
|
||||
:src="currentSrc ?? undefined"
|
||||
alt=""
|
||||
class="shrink-0 rounded border border-border bg-muted object-cover"
|
||||
class="shrink-0 rounded-xs border border-border bg-muted object-cover"
|
||||
:class="props.class"
|
||||
loading="lazy"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div v-else class="flex shrink-0 items-center justify-center rounded border border-border bg-muted" :class="props.class" aria-hidden="true">
|
||||
<div v-else class="flex shrink-0 items-center justify-center rounded-xs border border-border bg-muted" :class="props.class" aria-hidden="true">
|
||||
<component :is="icon" :size="iconSize" class="text-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -944,7 +944,7 @@ function dockFileMeta(file: BookDockFile): string {
|
||||
:book-id="book.id"
|
||||
type="thumbnail"
|
||||
:version="book.updatedAt"
|
||||
class="h-[42px] w-7 shrink-0 rounded bg-muted object-cover"
|
||||
class="h-[42px] w-7 shrink-0 rounded-xs bg-muted object-cover"
|
||||
:alt="''"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
|
||||
@@ -17,13 +17,13 @@ const { t } = useI18n()
|
||||
:key="row"
|
||||
class="flex animate-pulse items-center gap-3 border-b border-border/60 p-3 last:border-b-0 motion-reduce:animate-none"
|
||||
>
|
||||
<div class="h-[46px] w-8 shrink-0 rounded bg-muted" />
|
||||
<div class="h-[46px] w-8 shrink-0 rounded-xs bg-muted" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 w-2/5 rounded bg-muted" />
|
||||
<div class="h-3 w-1/4 rounded bg-muted" />
|
||||
<div class="h-3.5 w-2/5 rounded-xs bg-muted" />
|
||||
<div class="h-3 w-1/4 rounded-xs bg-muted" />
|
||||
</div>
|
||||
<div class="hidden h-5 w-20 shrink-0 rounded-full bg-muted lg:block" />
|
||||
<div class="hidden h-3 w-32 shrink-0 rounded bg-muted lg:block" />
|
||||
<div class="hidden h-3 w-32 shrink-0 rounded-xs bg-muted lg:block" />
|
||||
<div class="h-8 w-20 shrink-0 rounded-md bg-muted" />
|
||||
</div>
|
||||
<span class="sr-only">{{ t('bookRequests.list.loading') }}</span>
|
||||
|
||||
@@ -291,7 +291,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th v-if="hasSelectableRows" scope="col" class="w-10 px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-border accent-primary"
|
||||
class="h-3.5 w-3.5 rounded-xs border-border accent-primary"
|
||||
:checked="allSelected"
|
||||
:indeterminate.prop="someSelected && !allSelected"
|
||||
:aria-label="t('bookRequests.table.selectAll')"
|
||||
@@ -302,7 +302,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th scope="col" class="px-3 py-2.5 text-start" :aria-sort="ariaSort('title')">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
class="inline-flex items-center gap-1 rounded-xs text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
@click="sortByTitle"
|
||||
>
|
||||
{{ t('bookRequests.table.title') }}
|
||||
@@ -313,7 +313,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th scope="col" class="w-24 px-3 py-2.5 text-start" :aria-sort="ariaSort('mediaKind')">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
class="inline-flex items-center gap-1 rounded-xs text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
@click="sortByMediaKind"
|
||||
>
|
||||
{{ t('bookRequests.table.mediaKind') }}
|
||||
@@ -324,7 +324,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th scope="col" class="hidden w-36 px-3 py-2.5 text-start xl:table-cell" :aria-sort="ariaSort('requester')">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
class="inline-flex items-center gap-1 rounded-xs text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
@click="sortByRequester"
|
||||
>
|
||||
{{ t('bookRequests.table.requestedBy') }}
|
||||
@@ -335,7 +335,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th scope="col" class="w-32 px-3 py-2.5 text-start" :aria-sort="ariaSort('createdAt')">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
class="inline-flex items-center gap-1 rounded-xs text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
@click="sortByAge"
|
||||
>
|
||||
{{ t('bookRequests.table.age') }}
|
||||
@@ -346,7 +346,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<th scope="col" class="w-40 px-3 py-2.5 text-start" :aria-sort="ariaSort('status')">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
class="inline-flex items-center gap-1 rounded-xs text-xs font-semibold tracking-wide text-muted-foreground uppercase transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
@click="sortByState"
|
||||
>
|
||||
{{ t('bookRequests.table.state') }}
|
||||
@@ -375,7 +375,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<input
|
||||
v-if="isSelectable(request)"
|
||||
type="checkbox"
|
||||
class="h-3.5 w-3.5 rounded border-border accent-primary"
|
||||
class="h-3.5 w-3.5 rounded-xs border-border accent-primary"
|
||||
:checked="isSelected(request)"
|
||||
:aria-label="t('bookRequests.table.selectRow', { title: request.title })"
|
||||
@change="handleToggleSelect(request)"
|
||||
@@ -613,7 +613,7 @@ function rowDismissed(row: BookRequestItem): boolean {
|
||||
<input
|
||||
v-if="isSelectable(request)"
|
||||
type="checkbox"
|
||||
class="mt-1 h-3.5 w-3.5 shrink-0 rounded border-border accent-primary"
|
||||
class="mt-1 h-3.5 w-3.5 shrink-0 rounded-xs border-border accent-primary"
|
||||
:checked="isSelected(request)"
|
||||
:aria-label="t('bookRequests.table.selectRow', { title: request.title })"
|
||||
@change="handleToggleSelect(request)"
|
||||
|
||||
@@ -199,7 +199,7 @@ function confirmRemove() {
|
||||
<p class="text-xs text-muted-foreground">{{ t('bookRequests.seed.removeHint') }}</p>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input v-model="deleteFiles" type="checkbox" class="h-3.5 w-3.5 rounded border-border accent-primary" />
|
||||
<input v-model="deleteFiles" type="checkbox" class="h-3.5 w-3.5 rounded-xs border-border accent-primary" />
|
||||
{{ t('bookRequests.seed.deleteFiles') }}
|
||||
</label>
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<div class="group">
|
||||
<BookCoverSurface
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform,ring] duration-150 will-change-transform"
|
||||
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
|
||||
:class="isMissing || selectionMode ? '' : 'group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]'"
|
||||
:interactive="!isMissing && !selectionMode"
|
||||
:disable-spine="isAudiobook"
|
||||
:is-comic="isComic"
|
||||
@@ -503,7 +503,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<BookFormatChip
|
||||
:format-key="formatOverlayKey!"
|
||||
variant="solid"
|
||||
class="gap-0.5 rounded px-1.5 py-0.5 text-[8px] font-semibold tracking-widest opacity-90"
|
||||
class="gap-0.5 rounded-xs px-1.5 py-0.5 text-[8px] font-semibold tracking-widest opacity-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -523,7 +523,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
:class="selected ? 'bg-primary/20 ring-2 ring-inset ring-primary' : ''"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1.5 left-1.5 h-5 w-5 rounded flex items-center justify-center transition-colors"
|
||||
class="absolute top-1.5 left-1.5 h-5 w-5 rounded-xs flex items-center justify-center transition-colors"
|
||||
:class="selected ? 'bg-primary' : 'bg-black/40 border border-white/50'"
|
||||
>
|
||||
<Check v-if="selected" class="text-primary-foreground" :size="12" />
|
||||
@@ -532,7 +532,9 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
|
||||
<!-- Missing badge -->
|
||||
<div v-if="isMissing" class="absolute top-1.5 right-1.5 z-20">
|
||||
<span class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded bg-amber-600/95 text-white">
|
||||
<span
|
||||
class="flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest px-1.5 py-0.5 rounded-xs bg-amber-600/95 text-white"
|
||||
>
|
||||
<TriangleAlert class="size-2.5 shrink-0" />
|
||||
{{ t('book.card.missing') }}
|
||||
</span>
|
||||
@@ -763,7 +765,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
<div v-else class="flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors" data-testid="grid-card-kebab">
|
||||
<button class="shrink-0 p-0.5 rounded-xs text-muted-foreground hover:text-foreground transition-colors" data-testid="grid-card-kebab">
|
||||
<MoreVertical class="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -102,27 +102,27 @@ function handleExportPrimary() {
|
||||
<PopoverContent class="w-60 p-1" align="start">
|
||||
<!-- Multi-track audiobook: offer ZIP download, then any non-audio formats individually -->
|
||||
<template v-if="isMultiTrackAudio">
|
||||
<button class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors" @click="handleExportPrimary">
|
||||
<button class="flex w-full items-center gap-2 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors" @click="handleExportPrimary">
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
<span>{{ t('book.download.audiobookZip') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="file in nonAudioFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
|
||||
@click="handleFileDownload(file)"
|
||||
>
|
||||
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="file in audiolessEpubFiles"
|
||||
:key="`audioless-${file.id}`"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
|
||||
:title="t('book.download.audiolessEpubHint')"
|
||||
@click="handleAudiolessEpubDownload(file)"
|
||||
>
|
||||
<BookFormatChip format-key="epub" class="shrink-0 rounded px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<BookFormatChip format-key="epub" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.audiolessEpub') }}</span>
|
||||
<span class="text-[10px] font-medium text-muted-foreground shrink-0">{{ t('book.download.noAudio') }}</span>
|
||||
</button>
|
||||
@@ -133,21 +133,21 @@ function handleExportPrimary() {
|
||||
<button
|
||||
v-for="file in readableFiles"
|
||||
:key="file.id"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
|
||||
@click="handleFileDownload(file)"
|
||||
>
|
||||
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<BookFormatChip :format-key="fileFormatKey(file) ?? '?'" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ formatFileSize(file.sizeBytes) }}</span>
|
||||
<span v-if="file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">{{ t('book.file.primary') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="file in audiolessEpubFiles"
|
||||
:key="`audioless-${file.id}`"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
|
||||
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
|
||||
:title="t('book.download.audiolessEpubHint')"
|
||||
@click="handleAudiolessEpubDownload(file)"
|
||||
>
|
||||
<BookFormatChip format-key="epub" class="shrink-0 rounded px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<BookFormatChip format-key="epub" class="shrink-0 rounded-xs px-1.5 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="flex-1 text-left text-muted-foreground text-xs truncate">{{ t('book.download.audiolessEpub') }}</span>
|
||||
<span class="text-[10px] font-medium text-muted-foreground shrink-0">{{ t('book.download.noAudio') }}</span>
|
||||
<span v-if="isAudiolessEpubCandidate(file) && file.role === 'primary'" class="text-[10px] text-primary font-medium shrink-0">{{
|
||||
@@ -156,14 +156,14 @@ function handleExportPrimary() {
|
||||
</button>
|
||||
<div class="my-1 border-t border-border" />
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
@click="handleExportAll"
|
||||
>
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
<span>{{ t('book.download.allFormatsZip') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
class="flex w-full items-center gap-2 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors text-muted-foreground"
|
||||
@click="handleExportPrimary"
|
||||
>
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
|
||||
@@ -524,7 +524,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
<span
|
||||
v-for="libraryName in node.rule.valueChips"
|
||||
:key="libraryName"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded-xs bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
>
|
||||
{{ libraryName }}
|
||||
<button type="button" class="text-primary hover:text-primary leading-none" @click="removeLibraryChip(index, libraryName)">
|
||||
@@ -562,7 +562,7 @@ function showValueToInput(operator: RuleOperator): boolean {
|
||||
<span
|
||||
v-for="status in node.rule.valueChips"
|
||||
:key="status"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded-xs bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
>
|
||||
{{ READ_STATUS_LABELS[status] ?? status }}
|
||||
<button type="button" class="text-primary hover:text-primary leading-none" @click="removeStatusChip(index, status)">
|
||||
|
||||
@@ -305,7 +305,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
<div
|
||||
v-if="selectionMode"
|
||||
data-testid="book-selection-checkbox"
|
||||
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
|
||||
class="h-5 w-5 rounded-xs shrink-0 flex items-center justify-center transition-colors"
|
||||
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
|
||||
>
|
||||
<Check v-if="selected" class="text-primary-foreground" :size="12" />
|
||||
@@ -314,7 +314,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
<!-- Cover -->
|
||||
<BookCoverSurface
|
||||
size="mini"
|
||||
class="book-cover-surface--spine-fitted w-16 rounded shrink-0 overflow-hidden relative"
|
||||
class="book-cover-surface--spine-fitted w-16 rounded-xs shrink-0 overflow-hidden relative"
|
||||
:disable-spine="isAudiobook"
|
||||
:is-comic="isComic"
|
||||
:class="isMissing ? 'opacity-50 grayscale' : ''"
|
||||
@@ -373,7 +373,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="isMissing"
|
||||
class="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
class="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded-xs bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<TriangleAlert class="size-3 shrink-0" />
|
||||
<span class="hidden sm:inline">{{ t('book.card.missing') }}</span>
|
||||
@@ -382,7 +382,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
<Tooltip v-for="entry in formatEntries" :key="entry.key">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex items-center gap-0.5 text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded"
|
||||
class="inline-flex items-center gap-0.5 text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-xs"
|
||||
:class="formatButtonClasses(entry)"
|
||||
:style="formatButtonStyle(entry)"
|
||||
:aria-label="formatButtonTooltip(entry)"
|
||||
@@ -399,7 +399,7 @@ function handleRowClick(event: MouseEvent) {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
|
||||
<button class="p-1 rounded-xs hover:bg-muted transition-colors text-muted-foreground hover:text-foreground">
|
||||
<MoreHorizontal class="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -180,7 +180,7 @@ function handleDelete() {
|
||||
<!-- Header: cover + title block -->
|
||||
<div class="p-5 pt-10 border-b shrink-0">
|
||||
<div v-if="loading" class="flex gap-4 items-start">
|
||||
<Skeleton class="w-24 rounded shrink-0" :style="{ aspectRatio: coverAspectRatio }" />
|
||||
<Skeleton class="w-24 rounded-xs shrink-0" :style="{ aspectRatio: coverAspectRatio }" />
|
||||
<div class="flex-1 space-y-2 pt-1">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-3 w-3/4" />
|
||||
@@ -192,7 +192,7 @@ function handleDelete() {
|
||||
<!-- Cover -->
|
||||
<BookCoverSurface
|
||||
size="mini"
|
||||
class="book-cover-surface--spine-fitted w-24 shrink-0 rounded overflow-hidden relative"
|
||||
class="book-cover-surface--spine-fitted w-24 shrink-0 rounded-xs overflow-hidden relative"
|
||||
:disable-spine="isPrimaryAudio"
|
||||
:is-comic="isPrimaryComic"
|
||||
:class="detail.coverSource && !coverFailed ? 'cursor-zoom-in' : ''"
|
||||
@@ -232,7 +232,7 @@ function handleDelete() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="t('book.quickView.openIn', { provider: link.label })"
|
||||
class="inline-flex size-6 items-center justify-center rounded border transition-colors hover:bg-muted/60"
|
||||
class="inline-flex size-6 items-center justify-center rounded-xs border transition-colors hover:bg-muted/60"
|
||||
:style="providerLinkStyle(link.key)"
|
||||
>
|
||||
<img
|
||||
@@ -265,12 +265,12 @@ function handleDelete() {
|
||||
<div class="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
<template v-if="loading">
|
||||
<div class="flex gap-1.5">
|
||||
<Skeleton class="h-5 w-12 rounded" />
|
||||
<Skeleton class="h-5 w-16 rounded" />
|
||||
<Skeleton class="h-5 w-10 rounded" />
|
||||
<Skeleton class="h-5 w-12 rounded-xs" />
|
||||
<Skeleton class="h-5 w-16 rounded-xs" />
|
||||
<Skeleton class="h-5 w-10 rounded-xs" />
|
||||
</div>
|
||||
<Skeleton class="h-3 w-1/2" />
|
||||
<Skeleton class="h-32 w-full rounded" />
|
||||
<Skeleton class="h-32 w-full rounded-xs" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="detail">
|
||||
@@ -281,17 +281,17 @@ function handleDelete() {
|
||||
:key="entry.key"
|
||||
:format-key="entry.key"
|
||||
:primary="entry.primary"
|
||||
class="rounded px-2 py-0.5 text-[10px] tracking-wider"
|
||||
class="rounded-xs px-2 py-0.5 text-[10px] tracking-wider"
|
||||
/>
|
||||
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<span v-if="detail.pageCount" class="text-[10px] font-semibold px-2 py-0.5 rounded-xs bg-muted text-muted-foreground">
|
||||
{{ t('book.quickView.pages', { count: detail.pageCount }) }}
|
||||
</span>
|
||||
<span v-if="publishedDisplay" class="text-[10px] font-semibold px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<span v-if="publishedDisplay" class="text-[10px] font-semibold px-2 py-0.5 rounded-xs bg-muted text-muted-foreground">
|
||||
{{ publishedDisplay }}
|
||||
</span>
|
||||
<span
|
||||
v-if="detail.language"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-muted text-muted-foreground"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-xs bg-muted text-muted-foreground"
|
||||
>
|
||||
{{ detail.language }}
|
||||
</span>
|
||||
@@ -309,8 +309,8 @@ function handleDelete() {
|
||||
{{ primaryFile.filename ?? t('book.quickView.fileNumber', { id: primaryFile.id }) }}
|
||||
</p>
|
||||
<div class="mt-1.5 flex items-center gap-1.5">
|
||||
<BookFormatChip :format-key="fileFormatKey(primaryFile) ?? '?'" class="rounded px-2 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="text-[10px] px-2 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
<BookFormatChip :format-key="fileFormatKey(primaryFile) ?? '?'" class="rounded-xs px-2 py-0.5 text-[10px] tracking-wider" />
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-xs bg-muted text-muted-foreground">
|
||||
{{ formatBytes(primaryFile.sizeBytes) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ const canAddMore = computed(() => props.modelValue.length < allFields.value.leng
|
||||
<button
|
||||
@click="move(index, -1)"
|
||||
:disabled="index === 0"
|
||||
class="h-5 w-5 flex items-center justify-center rounded text-foreground hover:text-foreground disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
class="h-5 w-5 flex items-center justify-center rounded-xs text-foreground hover:text-foreground disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronUp :size="14" stroke-width="2.5" />
|
||||
</button>
|
||||
@@ -108,7 +108,7 @@ const canAddMore = computed(() => props.modelValue.length < allFields.value.leng
|
||||
<button
|
||||
@click="move(index, 1)"
|
||||
:disabled="index === modelValue.length - 1"
|
||||
class="h-5 w-5 flex items-center justify-center rounded text-foreground hover:text-foreground disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
class="h-5 w-5 flex items-center justify-center rounded-xs text-foreground hover:text-foreground disabled:opacity-25 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronDown :size="14" stroke-width="2.5" />
|
||||
</button>
|
||||
|
||||
@@ -228,7 +228,7 @@ watch(
|
||||
<button type="button" class="flex items-center gap-2 w-full text-left" @click="toggleField(key)">
|
||||
<div
|
||||
:class="[
|
||||
'w-4 h-4 rounded border transition-colors flex items-center justify-center shrink-0',
|
||||
'w-4 h-4 rounded-xs border transition-colors flex items-center justify-center shrink-0',
|
||||
enabledFields[key] ? 'bg-primary border-primary' : 'border-muted-foreground/40 hover:border-foreground/60',
|
||||
]"
|
||||
>
|
||||
@@ -255,7 +255,7 @@ watch(
|
||||
:key="m"
|
||||
type="button"
|
||||
:class="[
|
||||
'px-2.5 py-1 rounded text-xs font-medium transition-colors',
|
||||
'px-2.5 py-1 rounded-xs text-xs font-medium transition-colors',
|
||||
arrayModes[key] === m
|
||||
? m === 'clear'
|
||||
? 'bg-destructive text-destructive-foreground'
|
||||
@@ -285,7 +285,7 @@ watch(
|
||||
<button type="button" class="flex items-center gap-2 w-full text-left" @click="toggleField(key)">
|
||||
<div
|
||||
:class="[
|
||||
'w-4 h-4 rounded border transition-colors flex items-center justify-center shrink-0',
|
||||
'w-4 h-4 rounded-xs border transition-colors flex items-center justify-center shrink-0',
|
||||
enabledFields[key] ? 'bg-primary border-primary' : 'border-muted-foreground/40 hover:border-foreground/60',
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -208,7 +208,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
|
||||
<!-- Cover -->
|
||||
<div class="group">
|
||||
<BookCoverSurface
|
||||
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
|
||||
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]"
|
||||
:class="isStack ? 'book-cover-surface--spine-fitted' : ''"
|
||||
interactive
|
||||
:disable-spine="isAudiobook"
|
||||
|
||||
@@ -47,7 +47,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
role="radio"
|
||||
:aria-checked="props.modelValue === option.value"
|
||||
:tabindex="props.modelValue === option.value ? 0 : -1"
|
||||
class="inline-flex h-7 items-center rounded px-3 text-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
||||
class="inline-flex h-7 items-center rounded-xs px-3 text-xs transition-colors focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
||||
:class="
|
||||
props.modelValue === option.value
|
||||
? 'bg-background font-semibold text-foreground shadow-xs'
|
||||
|
||||
@@ -96,7 +96,7 @@ function onBlur() {
|
||||
<span
|
||||
v-for="chip in modelValue"
|
||||
:key="chip"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
class="flex items-center gap-1 h-5 px-1.5 rounded-xs bg-primary/15 text-primary text-xs font-medium shrink-0"
|
||||
>
|
||||
{{ chip }}
|
||||
<button type="button" @click="removeChip(chip)" class="text-primary hover:text-primary leading-none">
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
<template>
|
||||
<span class="inline-flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
class="text-[9px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded shrink-0"
|
||||
class="text-[9px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded-xs shrink-0"
|
||||
:class="node.join === 'OR' ? 'bg-amber-500/15 text-amber-500' : 'bg-sky-500/15 text-sky-500'"
|
||||
>
|
||||
{{ node.join === 'AND' ? t('book.filter.all') : t('book.filter.any') }}
|
||||
|
||||
@@ -68,7 +68,7 @@ const sections = computed(() => [
|
||||
<kbd
|
||||
v-for="key in shortcut.keys"
|
||||
:key="key"
|
||||
class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted/50 text-xs font-mono text-muted-foreground"
|
||||
class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded-xs border border-border bg-muted/50 text-xs font-mono text-muted-foreground"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
|
||||
@@ -148,14 +148,14 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.columnPanel.presets') }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('book.columnPanel.exportBackup')"
|
||||
@click="emit('export-backup')"
|
||||
>
|
||||
<Download :size="12" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('book.columnPanel.importBackup')"
|
||||
@click="handleImportBackupClick"
|
||||
>
|
||||
@@ -168,14 +168,14 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
<div
|
||||
v-for="preset in allPresets"
|
||||
:key="preset.id"
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs transition-colors hover:bg-accent"
|
||||
class="flex items-center gap-2 rounded-xs px-2 py-1 text-xs transition-colors hover:bg-accent"
|
||||
>
|
||||
<button class="min-w-0 flex-1 text-left" @click="emit('apply-preset', preset)">
|
||||
<span class="truncate">{{ preset.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!preset.isBuiltIn"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
:class="preset.favorite ? 'text-primary' : ''"
|
||||
@click="emit('favorite-preset', preset.id)"
|
||||
>
|
||||
@@ -183,14 +183,14 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
</button>
|
||||
<button
|
||||
v-if="!preset.isBuiltIn"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
@click="emit('duplicate-preset', preset.id)"
|
||||
>
|
||||
<Copy :size="11" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!preset.isBuiltIn"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
@click="handleRenamePreset(preset)"
|
||||
>
|
||||
{{ t('book.columnPanel.rename') }}
|
||||
@@ -198,7 +198,7 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
<span v-if="preset.isBuiltIn" class="text-[10px] text-muted-foreground">{{ t('book.columnPanel.builtIn') }}</span>
|
||||
<button
|
||||
v-if="!preset.isBuiltIn"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-background hover:text-destructive"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-destructive"
|
||||
@click="emit('delete-preset', preset.id)"
|
||||
>
|
||||
<X :size="11" />
|
||||
@@ -223,24 +223,24 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
<div class="space-y-2 rounded-md border border-border/60 p-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{{ t('book.columnPanel.savedViews') }}</div>
|
||||
<div v-if="savedViews.length > 0" class="space-y-1">
|
||||
<div v-for="view in savedViews" :key="view.id" class="flex items-center gap-2 rounded px-2 py-1 text-xs transition-colors hover:bg-accent">
|
||||
<div v-for="view in savedViews" :key="view.id" class="flex items-center gap-2 rounded-xs px-2 py-1 text-xs transition-colors hover:bg-accent">
|
||||
<button class="min-w-0 flex-1 text-left" @click="emit('apply-view', view)">
|
||||
<span class="truncate">{{ view.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
:class="view.favorite ? 'text-primary' : ''"
|
||||
@click="emit('favorite-view', view.id)"
|
||||
>
|
||||
<Star :size="11" :class="view.favorite ? 'fill-current' : ''" />
|
||||
</button>
|
||||
<button class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground" @click="emit('duplicate-view', view.id)">
|
||||
<button class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground" @click="emit('duplicate-view', view.id)">
|
||||
<Copy :size="11" />
|
||||
</button>
|
||||
<button class="rounded p-1 text-muted-foreground hover:bg-background hover:text-foreground" @click="handleRenameView(view)">
|
||||
<button class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-foreground" @click="handleRenameView(view)">
|
||||
{{ t('book.columnPanel.rename') }}
|
||||
</button>
|
||||
<button class="rounded p-1 text-muted-foreground hover:bg-background hover:text-destructive" @click="emit('delete-view', view.id)">
|
||||
<button class="rounded-xs p-1 text-muted-foreground hover:bg-background hover:text-destructive" @click="emit('delete-view', view.id)">
|
||||
<X :size="11" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -262,7 +262,7 @@ function columnLabel(column: ColumnDef & { visible: boolean }): string {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 rounded-md border border-border/60 p-2">
|
||||
<label v-for="col in allColumns" :key="col.id" class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-accent">
|
||||
<label v-for="col in allColumns" :key="col.id" class="flex items-center gap-2 rounded-xs px-1.5 py-1 text-xs hover:bg-accent">
|
||||
<input type="checkbox" :checked="col.visible" class="accent-primary" @change="emit('toggle-column', col.id)" />
|
||||
<span class="flex-1">{{ columnLabel(col) }}</span>
|
||||
<span v-if="pinBadge(col)" class="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">{{ pinBadge(col) }}</span>
|
||||
|
||||
@@ -756,7 +756,7 @@ defineExpose({
|
||||
<tbody v-if="!initialized && loading" role="rowgroup">
|
||||
<tr v-for="i in SKELETON_ROW_COUNT" :key="`skeleton-${i}`" role="row" class="border-b border-border/50">
|
||||
<td v-if="selectionMode" role="gridcell" class="px-2 align-middle" :class="rowPaddingClass" :style="{ width: '36px', minWidth: '36px' }">
|
||||
<div class="mx-auto h-4 w-4 rounded bg-muted animate-pulse" />
|
||||
<div class="mx-auto h-4 w-4 rounded-xs bg-muted animate-pulse" />
|
||||
</td>
|
||||
<td
|
||||
v-for="col in displayColumns"
|
||||
@@ -771,8 +771,8 @@ defineExpose({
|
||||
<div v-for="s in 5" :key="s" class="h-3 w-3 rounded-full bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div v-else-if="col.cellType === 'progress'" class="h-1.5 w-full rounded-full bg-muted animate-pulse" />
|
||||
<div v-else-if="col.cellType === 'actions'" class="h-5 w-5 rounded bg-muted animate-pulse" />
|
||||
<div v-else class="h-4 rounded bg-muted animate-pulse" :style="{ width: `${40 + ((i * 17 + col.defaultWidth) % 60)}%` }" />
|
||||
<div v-else-if="col.cellType === 'actions'" class="h-5 w-5 rounded-xs bg-muted animate-pulse" />
|
||||
<div v-else class="h-4 rounded-xs bg-muted animate-pulse" :style="{ width: `${40 + ((i * 17 + col.defaultWidth) % 60)}%` }" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -793,7 +793,7 @@ defineExpose({
|
||||
:class="rowPaddingClass"
|
||||
:style="{ width: `${col.defaultWidth}px`, minWidth: `${col.minWidth}px` }"
|
||||
>
|
||||
<div class="h-3.5 max-w-32 animate-pulse rounded bg-foreground/5" data-testid="book-row-skeleton" />
|
||||
<div class="h-3.5 max-w-32 animate-pulse rounded-xs bg-foreground/5" data-testid="book-row-skeleton" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
@@ -1002,7 +1002,7 @@ defineExpose({
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded border border-border/70 bg-background text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded-xs border border-border/70 bg-background text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
:title="t('book.tableView.keyboardShortcutsTitle')"
|
||||
:aria-label="t('book.tableView.showKeyboardShortcuts')"
|
||||
@click="shortcutOverlayOpen = true"
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('BookCoverCard — missing state', () => {
|
||||
const wrapper = mountCard(missingBook)
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
const classes = coverDiv.classes().join(' ')
|
||||
expect(classes).not.toContain('group-hover:scale-[1.02]')
|
||||
expect(classes).not.toContain('group-hover:[transform:var(--cover-hover-transform)]')
|
||||
})
|
||||
|
||||
it('renders the amber missing badge with TriangleAlert icon', () => {
|
||||
@@ -306,7 +306,7 @@ describe('BookCoverCard - present state', () => {
|
||||
const wrapper = mountCard(presentBook)
|
||||
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
|
||||
const classes = coverDiv.classes().join(' ')
|
||||
expect(classes).toContain('group-hover:scale-[1.02]')
|
||||
expect(classes).toContain('group-hover:[transform:var(--cover-hover-transform)]')
|
||||
})
|
||||
|
||||
it('clamps fallback title to 2 lines for 2/3 covers and keeps author to one line', () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ defineExpose({ scroll })
|
||||
@click="navigateToBook(book.id)"
|
||||
>
|
||||
<BookCoverSurface
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-transform duration-150 group-hover:scale-[1.02]"
|
||||
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-transform duration-150 group-hover:[transform:var(--cover-hover-transform)] motion-reduce:group-hover:[transform:none]"
|
||||
:interactive="true"
|
||||
:disable-spine="isAudiobook(book)"
|
||||
:is-comic="isComic(book)"
|
||||
|
||||
@@ -86,7 +86,7 @@ function handleDismiss() {
|
||||
<span>{{ autoDisabledWarning }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="shrink-0 rounded p-0.5 hover:bg-muted transition-colors text-muted-foreground" @click="handleDismiss">
|
||||
<button class="shrink-0 rounded-xs p-0.5 hover:bg-muted transition-colors text-muted-foreground" @click="handleDismiss">
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +112,7 @@ function rowMeasure(row: BookFormatEntry): string {
|
||||
<button
|
||||
v-if="progressFor(row)?.resetFileId != null"
|
||||
type="button"
|
||||
class="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
class="inline-flex size-5 shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:aria-label="t('book.detail.details.resetEditionProgress', { format: formatKeyName(row.key) })"
|
||||
:disabled="isResetting(row)"
|
||||
@click="handleReset(row)"
|
||||
|
||||
@@ -134,7 +134,7 @@ function dayLabel(day: string, minutes: number): string {
|
||||
<BookFormatChip
|
||||
v-if="session.format"
|
||||
:format-key="session.format"
|
||||
class="h-4.5 shrink-0 rounded px-1.5 text-[9px] leading-none tracking-wider"
|
||||
class="h-4.5 shrink-0 rounded-xs px-1.5 text-[9px] leading-none tracking-wider"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -119,25 +119,25 @@ function handleCopyPath() {
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="file.role === 'primary'"
|
||||
class="inline-flex h-[18px] items-center rounded bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary"
|
||||
class="inline-flex h-[18px] items-center rounded-xs bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary"
|
||||
>
|
||||
{{ t('book.detail.files.roles.primary') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isWriteTarget"
|
||||
class="inline-flex h-[18px] items-center rounded bg-success/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-success"
|
||||
class="inline-flex h-[18px] items-center rounded-xs bg-success/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-success"
|
||||
>
|
||||
{{ t('book.detail.files.writeTarget') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="writeSkipLabel"
|
||||
class="inline-flex min-h-[18px] items-center rounded bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
class="inline-flex min-h-[18px] items-center rounded-xs bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{{ writeSkipLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isAudioTrack"
|
||||
class="inline-flex h-[18px] items-center rounded bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground tabular-nums"
|
||||
class="inline-flex h-[18px] items-center rounded-xs bg-muted px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ t('book.detail.files.trackOf', { track: file.track, total: audioFiles.length }) }}
|
||||
</span>
|
||||
@@ -150,7 +150,7 @@ function handleCopyPath() {
|
||||
<bdi>{{ file.absolutePath }}</bdi>
|
||||
</code>
|
||||
<button
|
||||
class="inline-flex size-[22px] shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="inline-flex size-[22px] shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="t('book.detail.files.copyPath')"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
|
||||
@@ -160,7 +160,7 @@ function handleCopyPath(file: TreeFile) {
|
||||
</span>
|
||||
<button
|
||||
v-if="isCollapsible(group)"
|
||||
class="-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="-mr-1 inline-flex size-6 shrink-0 items-center justify-center rounded-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-expanded="!isRolled(group)"
|
||||
:aria-label="isRolled(group) ? t('book.detail.files.showAllTracks') : t('book.detail.files.collapseTracks')"
|
||||
@click="toggleGroup(group)"
|
||||
@@ -228,7 +228,7 @@ function handleCopyPath(file: TreeFile) {
|
||||
<FileRoleBadge v-if="file.role !== 'content' && file.role !== 'primary'" :role="file.role" class="pointer-events-none relative" />
|
||||
<span
|
||||
v-if="progressOf(file) > 0"
|
||||
class="pointer-events-none relative inline-flex h-[18px] shrink-0 items-center rounded bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary tabular-nums"
|
||||
class="pointer-events-none relative inline-flex h-[18px] shrink-0 items-center rounded-xs bg-primary/15 px-1.5 text-[9.5px] font-bold uppercase tracking-wider text-primary tabular-nums"
|
||||
>
|
||||
{{ t('book.detail.files.percentRead', { percent: formatPercent(progressOf(file) / 100) }) }}
|
||||
</span>
|
||||
@@ -283,7 +283,7 @@ function handleCopyPath(file: TreeFile) {
|
||||
t('book.detail.files.moreTracks', { count: hiddenCount(group) })
|
||||
}}</span>
|
||||
<button
|
||||
class="rounded text-[12.5px] font-medium text-primary transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
class="rounded-xs text-[12.5px] font-medium text-primary transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
@click="revealAllTracks(group)"
|
||||
>
|
||||
{{ t('book.detail.files.showAllTracks') }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user