Redesign frontend UI with Observatory theme
All checks were successful
Validate / Hassfest (push) Successful in 3s
All checks were successful
Validate / Hassfest (push) Successful in 3s
New teal-accent color system, DM Sans + JetBrains Mono typography, glow effects, animated gradient login page, animated dashboard counters with gradient-border stat cards, event timeline, sidebar with active glow indicators, and polished components (modals, cards, snackbar). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,80 +1,105 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #fafafa;
|
--color-background: #f8f9fb;
|
||||||
--color-foreground: #18181b;
|
--color-foreground: #1a1a2e;
|
||||||
--color-muted: #f4f4f5;
|
--color-muted: #eef0f4;
|
||||||
--color-muted-foreground: #71717a;
|
--color-muted-foreground: #6b7280;
|
||||||
--color-border: #e4e4e7;
|
--color-border: #e2e4ea;
|
||||||
--color-primary: #18181b;
|
--color-primary: #0d9488;
|
||||||
--color-primary-foreground: #fafafa;
|
--color-primary-foreground: #ffffff;
|
||||||
--color-accent: #f4f4f5;
|
--color-accent: #eef0f4;
|
||||||
--color-accent-foreground: #18181b;
|
--color-accent-foreground: #1a1a2e;
|
||||||
--color-destructive: #ef4444;
|
--color-destructive: #ef4444;
|
||||||
--color-card: #ffffff;
|
--color-card: #ffffff;
|
||||||
--color-card-foreground: #18181b;
|
--color-card-foreground: #1a1a2e;
|
||||||
--color-success-bg: #f0fdf4;
|
--color-success-bg: #ecfdf5;
|
||||||
--color-success-fg: #15803d;
|
--color-success-fg: #059669;
|
||||||
--color-warning-bg: #fefce8;
|
--color-warning-bg: #fffbeb;
|
||||||
--color-warning-fg: #a16207;
|
--color-warning-fg: #d97706;
|
||||||
--color-error-bg: #fef2f2;
|
--color-error-bg: #fef2f2;
|
||||||
--color-error-fg: #dc2626;
|
--color-error-fg: #dc2626;
|
||||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
--color-glow: rgba(13, 148, 136, 0.15);
|
||||||
--radius: 0.5rem;
|
--color-glow-strong: rgba(13, 148, 136, 0.3);
|
||||||
|
--color-sidebar: #ffffff;
|
||||||
|
--color-sidebar-active: rgba(13, 148, 136, 0.08);
|
||||||
|
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
--radius: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme overrides */
|
/* Dark theme overrides */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--color-background: #09090b;
|
--color-background: #0c0e14;
|
||||||
--color-foreground: #fafafa;
|
--color-foreground: #e4e6ed;
|
||||||
--color-muted: #27272a;
|
--color-muted: #1a1d28;
|
||||||
--color-muted-foreground: #a1a1aa;
|
--color-muted-foreground: #8b8fa4;
|
||||||
--color-border: #3f3f46;
|
--color-border: #252836;
|
||||||
--color-primary: #3f3f46;
|
--color-primary: #14b8a6;
|
||||||
--color-primary-foreground: #fafafa;
|
--color-primary-foreground: #0c0e14;
|
||||||
--color-accent: #27272a;
|
--color-accent: #1a1d28;
|
||||||
--color-accent-foreground: #fafafa;
|
--color-accent-foreground: #e4e6ed;
|
||||||
--color-destructive: #f87171;
|
--color-destructive: #f87171;
|
||||||
--color-card: #18181b;
|
--color-card: #13151e;
|
||||||
--color-card-foreground: #fafafa;
|
--color-card-foreground: #e4e6ed;
|
||||||
--color-success-bg: #052e16;
|
--color-success-bg: #052e16;
|
||||||
--color-success-fg: #4ade80;
|
--color-success-fg: #34d399;
|
||||||
--color-warning-bg: #422006;
|
--color-warning-bg: #422006;
|
||||||
--color-warning-fg: #facc15;
|
--color-warning-fg: #fbbf24;
|
||||||
--color-error-bg: #450a0a;
|
--color-error-bg: #450a0a;
|
||||||
--color-error-fg: #f87171;
|
--color-error-fg: #f87171;
|
||||||
|
--color-glow: rgba(20, 184, 166, 0.12);
|
||||||
|
--color-glow-strong: rgba(20, 184, 166, 0.25);
|
||||||
|
--color-sidebar: #10121a;
|
||||||
|
--color-sidebar-active: rgba(20, 184, 166, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
transition: background-color 0.2s, color 0.2s;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure all form controls respect the theme */
|
/* Subtle background pattern */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.4;
|
||||||
|
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form controls */
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global focus-visible styles for accessibility */
|
|
||||||
input:focus-visible, select:focus-visible, textarea:focus-visible {
|
input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: none;
|
||||||
outline-offset: 2px;
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow), 0 0 12px var(--color-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:focus-visible {
|
button:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:focus-visible {
|
a:focus-visible {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override browser autofill styles in dark mode */
|
/* Override browser autofill styles in dark mode */
|
||||||
@@ -82,16 +107,82 @@ a:focus-visible {
|
|||||||
[data-theme="dark"] input:-webkit-autofill:hover,
|
[data-theme="dark"] input:-webkit-autofill:hover,
|
||||||
[data-theme="dark"] input:-webkit-autofill:focus,
|
[data-theme="dark"] input:-webkit-autofill:focus,
|
||||||
[data-theme="dark"] select:-webkit-autofill {
|
[data-theme="dark"] select:-webkit-autofill {
|
||||||
-webkit-box-shadow: 0 0 0 1000px #18181b inset !important;
|
-webkit-box-shadow: 0 0 0 1000px #13151e inset !important;
|
||||||
-webkit-text-fill-color: #fafafa !important;
|
-webkit-text-fill-color: #e4e6ed !important;
|
||||||
caret-color: #fafafa;
|
caret-color: #e4e6ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode color-scheme for native controls (scrollbars, checkboxes) */
|
/* Color scheme for native controls */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] { color-scheme: dark; }
|
||||||
color-scheme: dark;
|
[data-theme="light"] { color-scheme: light; }
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); }
|
||||||
|
|
||||||
|
/* Stagger animation utility */
|
||||||
|
@keyframes fadeSlideIn {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
@keyframes shimmer {
|
||||||
color-scheme: light;
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px var(--color-glow); }
|
||||||
|
50% { box-shadow: 0 0 16px var(--color-glow-strong); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countUp {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-slide-in {
|
||||||
|
animation: fadeSlideIn 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-glow {
|
||||||
|
animation: pulseGlow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-count-up {
|
||||||
|
animation: countUp 0.5s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children utility — add .stagger-children to parent */
|
||||||
|
.stagger-children > * {
|
||||||
|
animation: fadeSlideIn 0.4s ease-out both;
|
||||||
|
}
|
||||||
|
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
||||||
|
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
||||||
|
.stagger-children > *:nth-child(4) { animation-delay: 180ms; }
|
||||||
|
.stagger-children > *:nth-child(5) { animation-delay: 240ms; }
|
||||||
|
.stagger-children > *:nth-child(6) { animation-delay: 300ms; }
|
||||||
|
.stagger-children > *:nth-child(7) { animation-delay: 360ms; }
|
||||||
|
.stagger-children > *:nth-child(8) { animation-delay: 420ms; }
|
||||||
|
.stagger-children > *:nth-child(9) { animation-delay: 480ms; }
|
||||||
|
.stagger-children > *:nth-child(10) { animation-delay: 540ms; }
|
||||||
|
|
||||||
|
/* Mono text utility */
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
<title>Immich Watcher</title>
|
<title>Immich Watcher</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,6 +6,22 @@
|
|||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-4 {hover ? 'transition-all duration-150 hover:shadow-md hover:-translate-y-px' : ''} {className}">
|
<div
|
||||||
|
class="card-component {hover ? 'card-hover' : ''} {className}"
|
||||||
|
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1.25rem;"
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card-component {
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 4px 16px var(--color-glow), 0 0 0 1px var(--color-glow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
let { open = false, title = '', message = '', onconfirm, oncancel } = $props<{
|
let { open = false, title = '', message = '', onconfirm, oncancel } = $props<{
|
||||||
@@ -12,15 +13,59 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
|
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-4">{message}</p>
|
<div class="flex items-start gap-3 mb-5">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||||
|
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{message}</p>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2 justify-end">
|
<div class="flex gap-2 justify-end">
|
||||||
<button onclick={oncancel}
|
<button onclick={oncancel}
|
||||||
class="px-3 py-1.5 rounded-md text-sm border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
class="confirm-btn-cancel">
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={onconfirm}
|
<button onclick={onconfirm}
|
||||||
class="px-3 py-1.5 rounded-md text-sm bg-[var(--color-destructive)] text-white hover:opacity-90 transition-opacity">
|
class="confirm-btn-delete">
|
||||||
|
<MdiIcon name="mdiDelete" size={15} />
|
||||||
{t('common.delete')}
|
{t('common.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.confirm-btn-cancel {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn-cancel:hover {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn-delete {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-destructive);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn-delete:hover {
|
||||||
|
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,17 +10,56 @@
|
|||||||
size?: number;
|
size?: number;
|
||||||
class?: string;
|
class?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const variantClasses = {
|
|
||||||
default: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)]',
|
|
||||||
danger: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-destructive)] hover:bg-[var(--color-error-bg)]',
|
|
||||||
success: 'text-[var(--color-muted-foreground)] hover:text-[var(--color-success-fg)] hover:bg-[var(--color-success-bg)]',
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button type="button" {title} {onclick} {disabled}
|
<button type="button" {title} {onclick} {disabled}
|
||||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors
|
class="icon-btn icon-btn-{variant} {className}"
|
||||||
disabled:opacity-40 disabled:pointer-events-none {variantClasses[variant]} {className}"
|
|
||||||
>
|
>
|
||||||
<MdiIcon name={icon} {size} />
|
<MdiIcon name={icon} {size} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-default {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.icon-btn-default:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-danger {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.icon-btn-danger:hover {
|
||||||
|
color: var(--color-destructive);
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn-success {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.icon-btn-success:hover {
|
||||||
|
color: var(--color-success-fg);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,23 @@
|
|||||||
let { lines = 3 } = $props<{ lines?: number }>();
|
let { lines = 3 } = $props<{ lines?: number }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-3 animate-pulse">
|
<div class="space-y-3">
|
||||||
{#each Array(lines) as _}
|
{#each Array(lines) as _, i}
|
||||||
<div class="bg-[var(--color-muted)] rounded-lg h-16"></div>
|
<div class="loading-bar" style="animation-delay: {i * 100}ms;"></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-bar {
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
|
||||||
let { open = false, title = '', onclose, children } = $props<{
|
let { open = false, title = '', onclose, children } = $props<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -6,6 +9,17 @@
|
|||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
let visible = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Small delay for enter animation
|
||||||
|
requestAnimationFrame(() => { visible = true; });
|
||||||
|
} else {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') onclose();
|
if (e.key === 'Escape') onclose();
|
||||||
}
|
}
|
||||||
@@ -17,23 +31,79 @@
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5);"
|
class="modal-backdrop"
|
||||||
|
class:visible
|
||||||
|
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;"
|
||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.3); width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;"
|
class="modal-panel"
|
||||||
|
class:visible
|
||||||
|
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.25rem 1.25rem 0.75rem;">
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;">
|
||||||
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
||||||
<button onclick={onclose}
|
<button class="modal-close" onclick={onclose}>
|
||||||
style="color: var(--color-muted-foreground); font-size: 1.25rem; line-height: 1; cursor: pointer; background: none; border: none; padding: 0.25rem;">
|
<MdiIcon name="mdiClose" size={18} />
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding: 0 1.25rem 1.25rem; overflow-y: auto;">
|
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
backdrop-filter: blur(0px);
|
||||||
|
transition: background 0.25s ease, backdrop-filter 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.visible {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px) scale(0.97);
|
||||||
|
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) .modal-panel {
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 48px var(--color-glow),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,14 +6,16 @@
|
|||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div class="animate-fade-slide-in">
|
||||||
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{description}</p>
|
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if children}
|
{#if children}
|
||||||
|
<div class="animate-fade-slide-in" style="animation-delay: 60ms;">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,15 +21,8 @@
|
|||||||
warning: 'mdiAlert',
|
warning: 'mdiAlert',
|
||||||
};
|
};
|
||||||
|
|
||||||
const borderColorMap: Record<string, string> = {
|
const accentMap: Record<string, string> = {
|
||||||
success: '#22c55e',
|
success: '#059669',
|
||||||
error: '#ef4444',
|
|
||||||
info: '#3b82f6',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconColorMap: Record<string, string> = {
|
|
||||||
success: '#22c55e',
|
|
||||||
error: '#ef4444',
|
error: '#ef4444',
|
||||||
info: '#3b82f6',
|
info: '#3b82f6',
|
||||||
warning: '#f59e0b',
|
warning: '#f59e0b',
|
||||||
@@ -38,38 +31,32 @@
|
|||||||
|
|
||||||
{#if snacks.length > 0}
|
{#if snacks.length > 0}
|
||||||
<div
|
<div
|
||||||
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 28rem; pointer-events: none;"
|
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 26rem; pointer-events: none;"
|
||||||
class="snackbar-container"
|
class="snackbar-container"
|
||||||
>
|
>
|
||||||
{#each snacks as snack (snack.id)}
|
{#each snacks as snack (snack.id)}
|
||||||
<div
|
<div
|
||||||
in:fly={{ y: 50, duration: 250 }}
|
in:fly={{ y: 40, duration: 300 }}
|
||||||
out:fade={{ duration: 150 }}
|
out:fade={{ duration: 200 }}
|
||||||
style="pointer-events: auto; border-left: 4px solid {borderColorMap[snack.type]}; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(8px); border-radius: 0.5rem; padding: 0.75rem 1rem; display: flex; align-items: flex-start; gap: 0.5rem; color: #f1f5f9; box-shadow: 0 4px 12px rgba(0,0,0,0.3);"
|
class="snack-item"
|
||||||
|
style="--snack-accent: {accentMap[snack.type]};"
|
||||||
>
|
>
|
||||||
<span style="color: {iconColorMap[snack.type]}; flex-shrink: 0; margin-top: 1px;">
|
<span class="snack-icon" style="color: {accentMap[snack.type]};">
|
||||||
<MdiIcon name={iconMap[snack.type]} size={18} />
|
<MdiIcon name={iconMap[snack.type]} size={18} />
|
||||||
</span>
|
</span>
|
||||||
<div style="flex: 1; min-width: 0;">
|
<div style="flex: 1; min-width: 0;">
|
||||||
<p style="font-size: 0.875rem; line-height: 1.25rem; margin: 0;">{snack.message}</p>
|
<p class="snack-message">{snack.message}</p>
|
||||||
{#if snack.detail}
|
{#if snack.detail}
|
||||||
<button
|
<button class="snack-detail-toggle" onclick={() => toggleDetail(snack.id)}>
|
||||||
onclick={() => toggleDetail(snack.id)}
|
|
||||||
style="font-size: 0.75rem; color: #94a3b8; background: none; border: none; padding: 0; margin-top: 0.25rem; cursor: pointer; text-decoration: underline;"
|
|
||||||
>
|
|
||||||
{expandedIds.has(snack.id) ? 'Hide details' : 'Show details'}
|
{expandedIds.has(snack.id) ? 'Hide details' : 'Show details'}
|
||||||
</button>
|
</button>
|
||||||
{#if expandedIds.has(snack.id)}
|
{#if expandedIds.has(snack.id)}
|
||||||
<pre style="font-size: 0.75rem; color: #94a3b8; margin: 0.25rem 0 0; white-space: pre-wrap; word-break: break-word;">{snack.detail}</pre>
|
<pre class="snack-detail">{snack.detail}</pre>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button class="snack-close" onclick={() => removeSnack(snack.id)} aria-label="Dismiss">
|
||||||
onclick={() => removeSnack(snack.id)}
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
style="flex-shrink: 0; background: none; border: none; color: #94a3b8; cursor: pointer; padding: 0; line-height: 1; font-size: 1.125rem;"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<MdiIcon name="mdiClose" size={16} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -85,4 +72,78 @@
|
|||||||
bottom: 1.5rem;
|
bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snack-item {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border-left: 3px solid var(--snack-accent);
|
||||||
|
background: var(--color-card);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) .snack-item {
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-message {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-detail-toggle {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-detail-toggle:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-detail {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snack-close:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -79,101 +79,146 @@
|
|||||||
localStorage.setItem('sidebar_collapsed', String(collapsed));
|
localStorage.setItem('sidebar_collapsed', String(collapsed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
return page.url.pathname === href;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isAuthPage}
|
{#if isAuthPage}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if auth.loading}
|
{:else if auth.loading}
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{:else if auth.user}
|
{:else if auth.user}
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside class="{collapsed ? 'w-14' : 'w-56'} border-r border-[var(--color-border)] bg-[var(--color-card)] flex flex-col transition-all duration-200 max-md:hidden">
|
<aside
|
||||||
<div class="p-2 border-b border-[var(--color-border)] flex items-center {collapsed ? 'justify-center' : 'justify-between px-4 py-4'}">
|
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
|
||||||
|
style="background: var(--color-sidebar); border-right: 1px solid var(--color-border); transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<div>
|
<div class="animate-fade-slide-in">
|
||||||
<h1 class="text-base font-semibold tracking-tight">{t('app.name')}</h1>
|
<h1 class="text-base font-semibold tracking-tight" style="color: var(--color-foreground);">
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-0.5">{t('app.tagline')}</p>
|
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||||
|
</h1>
|
||||||
|
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button onclick={toggleSidebar}
|
<button onclick={toggleSidebar}
|
||||||
class="flex items-center justify-center w-8 h-8 rounded-md text-base text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-muted)] transition-colors"
|
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground); background: transparent;"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
|
||||||
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||||
{collapsed ? '▶' : '◀'}
|
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 p-2 space-y-0.5">
|
<!-- Nav -->
|
||||||
|
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="flex items-center gap-2 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-md text-sm transition-colors
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
{page.url.pathname === item.href
|
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(item.href) ? '500' : '400'};"
|
||||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
onmouseenter={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
onmouseleave={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
title={collapsed ? t(item.key) : ''}
|
title={collapsed ? t(item.key) : ''}
|
||||||
>
|
>
|
||||||
|
{#if isActive(item.href)}
|
||||||
|
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
<MdiIcon name={item.icon} size={18} />
|
<MdiIcon name={item.icon} size={18} />
|
||||||
{#if !collapsed}{t(item.key)}{/if}
|
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#if auth.isAdmin}
|
{#if auth.isAdmin}
|
||||||
<a
|
<a
|
||||||
href="/users"
|
href="/users"
|
||||||
class="flex items-center gap-2 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-md text-sm transition-colors
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
{page.url.pathname === '/users'
|
style="color: {isActive('/users') ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive('/users') ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive('/users') ? '500' : '400'};"
|
||||||
? 'bg-[var(--color-accent)] text-[var(--color-accent-foreground)] font-medium'
|
onmouseenter={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
: 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-accent)] hover:text-[var(--color-accent-foreground)]'}"
|
onmouseleave={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
title={collapsed ? t('nav.users') : ''}
|
title={collapsed ? t('nav.users') : ''}
|
||||||
>
|
>
|
||||||
|
{#if isActive('/users')}
|
||||||
|
<div style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
<MdiIcon name="mdiAccountGroup" size={18} />
|
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||||
{#if !collapsed}{t('nav.users')}{/if}
|
{#if !collapsed}<span class="truncate">{t('nav.users')}</span>{/if}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Settings + User footer -->
|
<!-- Footer -->
|
||||||
<div class="border-t border-[var(--color-border)]">
|
<div style="border-top: 1px solid var(--color-border);">
|
||||||
<!-- Theme & Language -->
|
<!-- Theme & Language -->
|
||||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-1.5' : 'gap-1.5 px-3 py-2'}">
|
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
|
||||||
<button onclick={toggleLocale}
|
<button onclick={toggleLocale}
|
||||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
|
||||||
|
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
title={t('common.language')}>
|
title={t('common.language')}>
|
||||||
{getLocale().toUpperCase()}
|
{getLocale().toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={cycleTheme}
|
<button onclick={cycleTheme}
|
||||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2 py-1'} rounded-md text-xs bg-[var(--color-muted)] text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
|
||||||
|
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
title={t('common.theme')}>
|
title={t('common.theme')}>
|
||||||
{theme.resolved === 'dark' ? '🌙' : '☀️'}
|
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User info -->
|
<!-- User info -->
|
||||||
<div class="p-2 border-t border-[var(--color-border)]">
|
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={logout}
|
<button onclick={logout}
|
||||||
class="w-full flex justify-center py-2 text-sm text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] rounded hover:bg-[var(--color-muted)] transition-colors"
|
class="w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
title={t('nav.logout')}>
|
title={t('nav.logout')}>
|
||||||
⏻
|
<MdiIcon name="mdiLogout" size={16} />
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="px-1">
|
<div class="px-1.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold"
|
||||||
|
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||||
|
{auth.user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium">{auth.user.username}</p>
|
<p class="text-sm font-medium">{auth.user.username}</p>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)]">{auth.user.role}</p>
|
<p class="text-[0.65rem] tracking-wide uppercase" style="color: var(--color-muted-foreground);">{auth.user.role}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={logout}
|
<button onclick={logout}
|
||||||
class="text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] transition-colors"
|
class="p-1.5 rounded-lg transition-all duration-200"
|
||||||
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||||
title={t('nav.logout')}>
|
title={t('nav.logout')}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<MdiIcon name="mdiLogout" size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => showPasswordForm = true}
|
<button onclick={() => showPasswordForm = true}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)] mt-1">
|
class="text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"
|
||||||
🔑 {t('common.changePassword')}
|
style="color: var(--color-muted-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-primary)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}>
|
||||||
|
<MdiIcon name="mdiKeyVariant" size={12} />
|
||||||
|
{t('common.changePassword')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -182,18 +227,16 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Mobile bottom nav -->
|
<!-- Mobile bottom nav -->
|
||||||
<nav class="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-card)] border-t border-[var(--color-border)] flex justify-around py-1.5">
|
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||||
{#each navItems.slice(0, 5) as item}
|
{#each navItems.slice(0, 5) as item}
|
||||||
<a href={item.href}
|
<a href={item.href}
|
||||||
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs rounded-md transition-colors
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||||
{page.url.pathname === item.href
|
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||||
? 'text-[var(--color-accent-foreground)] font-medium'
|
|
||||||
: 'text-[var(--color-muted-foreground)]'}">
|
|
||||||
<MdiIcon name={item.icon} size={20} />
|
<MdiIcon name={item.icon} size={20} />
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<button onclick={logout}
|
<button onclick={logout}
|
||||||
class="flex flex-col items-center gap-0.5 px-2 py-1 text-xs text-[var(--color-muted-foreground)]">
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||||
<MdiIcon name="mdiLogout" size={20} />
|
<MdiIcon name="mdiLogout" size={20} />
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -201,17 +244,19 @@
|
|||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||||
{#key page.url.pathname}
|
{#key page.url.pathname}
|
||||||
<div class="max-w-5xl mx-auto p-4 md:p-6" in:fade={{ duration: 150, delay: 50 }}>
|
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/key}
|
{/key}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Redirect in progress -->
|
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Password change modal -->
|
<!-- Password change modal -->
|
||||||
@@ -220,20 +265,30 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if pwdMsg}
|
{#if pwdMsg}
|
||||||
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity">
|
<button type="submit"
|
||||||
|
class="w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||||
|
style="background: var(--color-primary); color: var(--color-primary-foreground);"
|
||||||
|
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
|
||||||
|
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Snackbar />
|
<Snackbar />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-nav { display: flex !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,15 +11,72 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
|
// Animated counters
|
||||||
|
let displayServers = $state(0);
|
||||||
|
let displayActive = $state(0);
|
||||||
|
let displayTotal = $state(0);
|
||||||
|
let displayTargets = $state(0);
|
||||||
|
|
||||||
|
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||||
|
if (to === 0) { setter(0); return; }
|
||||||
|
const start = performance.now();
|
||||||
|
function frame(now: number) {
|
||||||
|
const elapsed = now - start;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
||||||
|
setter(Math.round(from + (to - from) * eased));
|
||||||
|
if (progress < 1) requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
status = await api('/status');
|
status = await api('/status');
|
||||||
|
// Animate counts
|
||||||
|
setTimeout(() => {
|
||||||
|
animateCount(0, status.servers, (v) => displayServers = v);
|
||||||
|
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||||
|
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
||||||
|
animateCount(0, status.targets, (v) => displayTargets = v);
|
||||||
|
}, 200);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message || t('common.error');
|
error = err.message || t('common.error');
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
loaded = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statCards = $derived(status ? [
|
||||||
|
{ icon: 'mdiServer', label: 'dashboard.servers', value: displayServers, color: '#0d9488' },
|
||||||
|
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||||
|
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||||
|
] : []);
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIcons: Record<string, string> = {
|
||||||
|
assets_added: 'mdiImagePlus',
|
||||||
|
assets_removed: 'mdiImageMinus',
|
||||||
|
album_renamed: 'mdiRename',
|
||||||
|
album_deleted: 'mdiDeleteAlert',
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventColors: Record<string, string> = {
|
||||||
|
assets_added: '#059669',
|
||||||
|
assets_removed: '#ef4444',
|
||||||
|
album_renamed: '#6366f1',
|
||||||
|
album_deleted: '#dc2626',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||||
@@ -28,71 +85,176 @@
|
|||||||
<Loading lines={4} />
|
<Loading lines={4} />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<Card>
|
<Card>
|
||||||
<div class="flex items-center gap-2 text-[var(--color-error-fg)]">
|
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
|
||||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||||
<p class="text-sm">{error}</p>
|
<p class="text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{:else if status}
|
{:else if status}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
<!-- Stat cards -->
|
||||||
<Card hover>
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8 stagger-children">
|
||||||
|
{#each statCards as card, i}
|
||||||
|
<div class="stat-card" style="--accent: {card.color};">
|
||||||
|
<div class="stat-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
||||||
<MdiIcon name="mdiServer" size={22} />
|
<MdiIcon name={card.icon} size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.servers')}</p>
|
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
|
||||||
<p class="text-2xl font-semibold">{status.servers}</p>
|
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||||
|
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
<Card hover>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
|
||||||
<MdiIcon name="mdiRadar" size={22} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.activeTrackers')}</p>
|
|
||||||
<p class="text-2xl font-semibold">{status.trackers.active}<span class="text-base font-normal text-[var(--color-muted-foreground)]"> / {status.trackers.total}</span></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card hover>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="p-2 rounded-lg bg-[var(--color-muted)]">
|
|
||||||
<MdiIcon name="mdiTarget" size={22} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('dashboard.targets')}</p>
|
|
||||||
<p class="text-2xl font-semibold">{status.targets}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-lg font-medium mb-3">{t('dashboard.recentEvents')}</h3>
|
|
||||||
{#if status.recent_events.length === 0}
|
|
||||||
<Card>
|
|
||||||
<div class="flex flex-col items-center py-4 gap-2 text-[var(--color-muted-foreground)]">
|
|
||||||
<MdiIcon name="mdiCalendarBlank" size={32} />
|
|
||||||
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{:else}
|
|
||||||
<Card>
|
|
||||||
<div class="divide-y divide-[var(--color-border)]">
|
|
||||||
{#each status.recent_events as event}
|
|
||||||
<div class="py-3 first:pt-0 last:pb-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<span class="text-sm font-medium">{event.album_name}</span>
|
|
||||||
<span class="text-xs ml-2 px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{event.event_type}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(event.created_at).toLocaleString()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent events -->
|
||||||
|
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiPulse" size={18} />
|
||||||
|
{t('dashboard.recentEvents')}
|
||||||
|
</h3>
|
||||||
|
{#if status.recent_events.length === 0}
|
||||||
|
<Card>
|
||||||
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||||
|
<div style="opacity: 0.4;">
|
||||||
|
<MdiIcon name="mdiCalendarBlank" size={40} />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="event-timeline stagger-children">
|
||||||
|
{#each status.recent_events as event, i}
|
||||||
|
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
||||||
|
<!-- Timeline dot -->
|
||||||
|
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
||||||
|
{#if i < status.recent_events.length - 1}
|
||||||
|
<div class="event-line"></div>
|
||||||
|
{/if}
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="event-content">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
||||||
|
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium truncate">{event.album_name}</span>
|
||||||
|
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border));
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-inner {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: calc(0.75rem - 1px);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
animation: countUp 0.5s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-suffix {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.event-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 18px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-content:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 12px var(--color-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -5,16 +5,19 @@
|
|||||||
import { login } from '$lib/auth.svelte';
|
import { login } from '$lib/auth.svelte';
|
||||||
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
|
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
|
||||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
const theme = getTheme();
|
const theme = getTheme();
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initLocale();
|
initLocale();
|
||||||
initTheme();
|
initTheme();
|
||||||
|
mounted = true;
|
||||||
try {
|
try {
|
||||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||||
if (res.needs_setup) goto('/setup');
|
if (res.needs_setup) goto('/setup');
|
||||||
@@ -35,42 +38,235 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
<div class="auth-page">
|
||||||
<div class="w-full max-w-sm">
|
<!-- Animated gradient mesh background -->
|
||||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
<div class="auth-bg"></div>
|
||||||
<div class="flex justify-end gap-1 mb-4">
|
<div class="auth-grid"></div>
|
||||||
|
|
||||||
|
<!-- Login card -->
|
||||||
|
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||||
|
<div class="auth-card">
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex justify-end gap-1.5 mb-6">
|
||||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||||
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
class="auth-control-btn">
|
||||||
{getLocale().toUpperCase()}
|
{getLocale().toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
||||||
class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
|
class="auth-control-btn">
|
||||||
{theme.resolved === 'dark' ? '🌙' : '☀️'}
|
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-xl font-semibold text-center mb-1">{t('app.name')}</h1>
|
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)] text-center mb-6">{t('auth.signInTitle')}</p>
|
<!-- Logo / title -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<MdiIcon name="mdiEye" size={28} />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||||
|
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>
|
<div class="auth-error animate-fade-slide-in">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium mb-1.5">{t('auth.username')}</label>
|
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||||
<input id="username" type="text" bind:value={username} required
|
<input id="username" type="text" bind:value={username} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]" />
|
class="auth-input" placeholder="admin" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium mb-1.5">{t('auth.password')}</label>
|
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||||
<input id="password" type="password" bind:value={password} required
|
<input id="password" type="password" bind:value={password} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--color-background)]" />
|
class="auth-input" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={submitting}
|
<button type="submit" disabled={submitting} class="auth-submit">
|
||||||
class="w-full py-2 px-4 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50">
|
{#if submitting}
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||||
|
{/if}
|
||||||
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||||
|
animation: gradientShift 12s ease-in-out infinite;
|
||||||
|
background-size: 200% 200%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.3;
|
||||||
|
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-wrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
padding: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.98);
|
||||||
|
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-wrapper.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) .auth-card {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 48px var(--color-glow),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-control-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-control-btn:hover {
|
||||||
|
color: var(--color-foreground);
|
||||||
|
box-shadow: 0 0 8px var(--color-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -79,11 +79,14 @@
|
|||||||
|
|
||||||
<PageHeader title={t('servers.title')} description={t('servers.description')}>
|
<PageHeader title={t('servers.title')} description={t('servers.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
class="header-action-btn"
|
||||||
|
style="background: {showForm ? 'var(--color-muted)' : 'var(--color-primary)'}; color: {showForm ? 'var(--color-foreground)' : 'var(--color-primary-foreground)'};">
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
{t('servers.cancel')}
|
{t('servers.cancel')}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="flex items-center gap-1"><MdiIcon name="mdiPlus" size={14} />{t('servers.addServer')}</span>
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
{t('servers.addServer')}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -94,14 +97,22 @@
|
|||||||
|
|
||||||
{#if loadError}
|
{#if loadError}
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">{loadError}</div>
|
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={18} />
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<div in:slide={{ duration: 200 }}>
|
<div in:slide={{ duration: 200 }}>
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}
|
||||||
|
<div class="flex items-center gap-2 text-sm rounded-lg p-3 mb-4" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<form onsubmit={save} class="space-y-3">
|
<form onsubmit={save} class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
@@ -109,18 +120,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||||
<input id="srv-name" bind:value={form.name} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="srv-name" bind:value={form.name} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="srv-url" class="block text-sm font-medium mb-1">{t('servers.url')}</label>
|
<label for="srv-url" class="block text-sm font-medium mb-1">{t('servers.url')}</label>
|
||||||
<input id="srv-url" bind:value={form.url} required placeholder={t('servers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="srv-url" bind:value={form.url} required placeholder={t('servers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="srv-key" class="block text-sm font-medium mb-1">{editing ? t('servers.apiKeyKeep') : t('servers.apiKey')}</label>
|
<label for="srv-key" class="block text-sm font-medium mb-1">{editing ? t('servers.apiKeyKeep') : t('servers.apiKey')}</label>
|
||||||
<input id="srv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="srv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
<button type="submit" disabled={submitting}
|
||||||
|
class="form-submit-btn">
|
||||||
|
{#if submitting}
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||||
|
{/if}
|
||||||
{submitting ? t('servers.connecting') : (editing ? t('common.save') : t('servers.addServer'))}
|
{submitting ? t('servers.connecting') : (editing ? t('common.save') : t('servers.addServer'))}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -129,19 +144,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if servers.length === 0 && !showForm}
|
{#if servers.length === 0 && !showForm}
|
||||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('servers.noServers')}</p></Card>
|
<Card>
|
||||||
|
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||||
|
<div style="opacity: 0.4;"><MdiIcon name="mdiServerOff" size={40} /></div>
|
||||||
|
<p class="text-sm">{t('servers.noServers')}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each servers as server}
|
{#each servers as server}
|
||||||
<Card hover>
|
<Card hover>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
<span class="inline-block w-2.5 h-2.5 rounded-full {health[server.id] === true ? 'bg-green-500' : health[server.id] === false ? 'bg-red-500' : 'bg-yellow-400 animate-pulse'}"
|
<div class="health-dot {health[server.id] === true ? 'online' : health[server.id] === false ? 'offline' : 'checking'}"></div>
|
||||||
title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}></span>
|
{#if server.icon}
|
||||||
{#if server.icon}<MdiIcon name={server.icon} />{/if}
|
<span style="color: var(--color-primary);"><MdiIcon name={server.icon} size={20} /></span>
|
||||||
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{server.name}</p>
|
<p class="font-medium">{server.name}</p>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{server.url}</p>
|
<p class="text-sm font-mono" style="color: var(--color-muted-foreground); font-size: 0.75rem;">{server.url}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -158,3 +179,77 @@
|
|||||||
|
|
||||||
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
|
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
|
||||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-action-btn:hover {
|
||||||
|
box-shadow: 0 0 16px var(--color-glow);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 16px var(--color-glow-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot.online {
|
||||||
|
background: #059669;
|
||||||
|
box-shadow: 0 0 8px rgba(5, 150, 105, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot.offline {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot.checking {
|
||||||
|
background: #f59e0b;
|
||||||
|
animation: pulseCheck 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseCheck {
|
||||||
|
0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); }
|
||||||
|
50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,14 +4,16 @@
|
|||||||
import { setup } from '$lib/auth.svelte';
|
import { setup } from '$lib/auth.svelte';
|
||||||
import { t, initLocale } from '$lib/i18n';
|
import { t, initLocale } from '$lib/i18n';
|
||||||
import { initTheme } from '$lib/theme.svelte';
|
import { initTheme } from '$lib/theme.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
let username = $state('admin');
|
let username = $state('admin');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let confirmPassword = $state('');
|
let confirmPassword = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
|
let mounted = $state(false);
|
||||||
|
|
||||||
onMount(() => { initLocale(); initTheme(); });
|
onMount(() => { initLocale(); initTheme(); mounted = true; });
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -27,30 +29,201 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-[var(--color-background)]">
|
<div class="auth-page">
|
||||||
<div class="w-full max-w-sm">
|
<div class="auth-bg"></div>
|
||||||
<div class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-6 shadow-sm">
|
<div class="auth-grid"></div>
|
||||||
<h1 class="text-xl font-semibold text-center mb-1">{t('auth.setupTitle')}</h1>
|
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)] text-center mb-6">{t('auth.setupDescription')}</p>
|
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
<div class="auth-card">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<MdiIcon name="mdiShieldAccount" size={28} />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||||
|
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="auth-error animate-fade-slide-in">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium mb-1.5">{t('auth.username')}</label>
|
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||||
<input id="username" type="text" bind:value={username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="username" type="text" bind:value={username} required class="auth-input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium mb-1.5">{t('auth.password')}</label>
|
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||||
<input id="password" type="password" bind:value={password} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="password" type="password" bind:value={password} required class="auth-input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="confirm" class="block text-sm font-medium mb-1.5">{t('auth.confirmPassword')}</label>
|
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
|
||||||
<input id="confirm" type="password" bind:value={confirmPassword} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={submitting}
|
<button type="submit" disabled={submitting} class="auth-submit">
|
||||||
class="w-full py-2 px-4 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
{#if submitting}
|
||||||
|
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||||
|
{/if}
|
||||||
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
|
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||||
|
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||||
|
animation: gradientShift 12s ease-in-out infinite;
|
||||||
|
background-size: 200% 200%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.3;
|
||||||
|
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-wrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 24rem;
|
||||||
|
padding: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.98);
|
||||||
|
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-wrapper.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-theme="dark"]) .auth-card {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 48px var(--color-glow),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-submit:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user