Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16c667ca15 | |||
| dab13518ef | |||
| f087551454 | |||
| 555ac9ea63 | |||
| 0a13b6b58c | |||
| 5dcadd1c20 |
@@ -50,6 +50,11 @@ ALLOW_PRIVATE_NETWORK_FETCH="false"
|
||||
# scaling horizontally so only one node runs schedulers.
|
||||
RUN_SCHEDULERS="true"
|
||||
|
||||
# Directory where backup archives are written. Defaults to /app/data/backups
|
||||
# in production and <repo>/data/backups in development. Override if you want
|
||||
# backups on a separate mount.
|
||||
BACKUPS_DIR=""
|
||||
|
||||
# Optional bearer token for /api/metrics. When set, scrapers must send
|
||||
# `Authorization: Bearer <token>`. When unset, the endpoint is open (typical
|
||||
# when the scraper lives on the same private network).
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# web-app-launcher — project guide for Claude
|
||||
|
||||
SvelteKit 2 + Svelte 5 (runes) + Tailwind 4 + Prisma + Vitest. Cozy Home design system (warm cream / dusk, terracotta accent, Fraunces + Figtree, soft shadows). Token contract lives in `src/app.css`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Basic-component reuse — MANDATORY
|
||||
|
||||
When you need any of the following, **use the existing primitive from `src/lib/components/ui/`. Do not hand-roll a new Tailwind class string for a control that already has a primitive.**
|
||||
|
||||
| Need | Primitive | Why |
|
||||
|---|---|---|
|
||||
| Boolean on/off setting | `Switch.svelte` | Pill toggle, `role="switch"`, AA contrast, terracotta track when on. Default for any "enable X" / "show Y" / "is default" field. **Never use `<input type="checkbox">` for booleans.** |
|
||||
| Multi-select item in a list | `Checkbox.svelte` | Rounded square with animated check-draw. Only use when the control is truly "pick any number of these," not a single boolean. |
|
||||
| Dropdown of fixed options | `Select.svelte` | Styled chevron, matches Cozy input radius. Wraps native `<select>`. **Do not use raw `<select>`.** |
|
||||
| Single-line text / number / email / url / password | `Input.svelte` | Standard rounded-xl, focus ring, invalid state. **Do not repeat the `w-full rounded-xl border border-input bg-background px-3 py-2 ...` string anywhere.** |
|
||||
| Number in a range (refresh interval, hue, blur, etc.) | `Slider.svelte` | Cozy gradient track, terracotta-bordered knob, value tooltip, `aria-valuetext`. **Do not use raw `<input type="range">`.** |
|
||||
| Action button (submit, save, cancel, link-as-CTA) | `Button.svelte` | Variants `primary | secondary | outline | ghost | destructive`, sizes `sm | md | lg | icon`, built-in `loading` spinner, press-squash. **Do not repeat `rounded-xl bg-primary px-4 py-2 ...` strings.** |
|
||||
| Label + hint + error wrapper around a control | `Field.svelte` | Consolidates `<label> + control + <p class="text-xs text-destructive">`. |
|
||||
| Confirm-before-destructive | `ConfirmDialog.svelte` | Already exists. Use it. |
|
||||
| Entity / icon / tag picker | `EntityPicker`, `MultiEntityPicker`, `IconPickerButton`, `TagsInput` | Already exist. Reuse. |
|
||||
|
||||
### Process
|
||||
|
||||
1. Before writing any form control in a `.svelte` file, **scan `src/lib/components/ui/` first**. If a matching primitive exists, import and use it.
|
||||
2. If you find yourself copying a Tailwind class string verbatim from another file, **stop**: that's the trigger to extract a primitive (or expand an existing one).
|
||||
3. If you genuinely need a new primitive, add it to `src/lib/components/ui/`, give it a `class?: string` prop merged via `cn()`, document it in this table, and migrate at least two call sites in the same PR so it's not dead code.
|
||||
4. Tokens (`--primary`, `--card`, `--room-*`, `--shadow-soft`, etc.) are defined once in `src/app.css`. Never hardcode hex/HSL — read from the token.
|
||||
|
||||
### Cozy spec quick reminders
|
||||
|
||||
- Hero cards: `rounded-[1.4rem]` + `shadow-[var(--shadow-soft)]`. Dense panels: `rounded-xl`. **Never** `rounded-lg` on a section wrapper.
|
||||
- Headings (`h1`, `h2`, `h3`) automatically get Fraunces via base layer — no need to add `font-display` unless overriding non-heading text.
|
||||
- Focus uses `focus-visible:ring-2 focus-visible:ring-primary/30` — primitives already do this; mirror it on anything hand-rolled.
|
||||
- Motion is gentle and present: prefer `cozy-rise` / `cozy-expand` from `app.css` over generic Tailwind animations. All motion classes already respect `prefers-reduced-motion`.
|
||||
|
||||
## Backend
|
||||
|
||||
- Auth: session cookie + optional OAuth. Roles: `admin` / user / guest. Always check role at the route load function, not the component.
|
||||
- Validation: Zod schemas live in `src/lib/utils/validators.ts`. Reuse the same schema on client (superForms) and server.
|
||||
- DB: Prisma. Never query the DB directly from a route — go through `src/lib/server/services/*Service.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Vitest, Node environment, no DOM (existing pattern). Component tests use the module-scope helpers (e.g., `buttonClass` in `Button.svelte`) rather than rendering — keep that convention.
|
||||
- Run before committing: `npm run check && npm run lint && npm test && npm run build`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev on :5181
|
||||
npm run check # svelte-check (TS + Svelte)
|
||||
npm run lint # eslint
|
||||
npm test # vitest run
|
||||
npm run build # production build
|
||||
```
|
||||
@@ -0,0 +1,789 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Command Deck</title>
|
||||
<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=Saira:wght@400;500;600;700&family=Saira+Condensed:wght@500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070a0d;
|
||||
--panel: #0d1217;
|
||||
--panel-2: #10171e;
|
||||
--line: #1d2730;
|
||||
--line-bright: #2b3946;
|
||||
--ink: #e7eef3;
|
||||
--ink-dim: #7c8b97;
|
||||
--ink-faint: #4a5763;
|
||||
--accent: #36e0a4; /* tactical green */
|
||||
--accent-2: #ffb020; /* amber */
|
||||
--danger: #ff4d5e;
|
||||
--warn: #ffb020;
|
||||
--grid: rgba(54, 224, 164, 0.04);
|
||||
--radius: 4px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Saira', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* subtle scanline grid backdrop */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 70% 0%, #000 30%, transparent 90%);
|
||||
}
|
||||
.app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.cond {
|
||||
font-family: 'Saira Condensed', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Rail ===== */
|
||||
.rail {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel), #080c10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
margin-bottom: 18px;
|
||||
box-shadow:
|
||||
0 0 0 1px #0a1f18,
|
||||
0 0 18px -4px var(--accent);
|
||||
position: relative;
|
||||
}
|
||||
.logo svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.rail-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.rail-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.rail-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rail-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
}
|
||||
.rail-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.rail-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #1a2a22, #0f1a14);
|
||||
border: 1px solid var(--line-bright);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 60px;
|
||||
padding: 0 26px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(8, 12, 16, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.crumbs {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.crumbs b {
|
||||
color: var(--ink-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
max-width: 38vw;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 12px;
|
||||
color: var(--ink-dim);
|
||||
font-size: 13px;
|
||||
cursor: text;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.search:hover {
|
||||
border-color: var(--line-bright);
|
||||
}
|
||||
.search svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.search .kbd {
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.ico-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
background: var(--panel);
|
||||
}
|
||||
.ico-btn:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--line-bright);
|
||||
}
|
||||
.ico-btn svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 26px;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* status bar */
|
||||
.statline {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
background: var(--line);
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
background: var(--panel);
|
||||
padding: 16px 18px;
|
||||
position: relative;
|
||||
}
|
||||
.stat .lbl {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.stat .val {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.stat .val.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat .val.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stat .val.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.stat .sub {
|
||||
font-size: 12px;
|
||||
color: var(--ink-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stat::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), transparent);
|
||||
}
|
||||
.stat.s2::after {
|
||||
background: linear-gradient(90deg, var(--accent-2), transparent);
|
||||
}
|
||||
.stat.s3::after {
|
||||
background: linear-gradient(90deg, #3aa0ff, transparent);
|
||||
}
|
||||
.stat.s4::after {
|
||||
background: linear-gradient(90deg, var(--danger), transparent);
|
||||
}
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 30px 0 16px;
|
||||
}
|
||||
.sec-head h2 {
|
||||
font-family: 'Saira Condensed';
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.sec-head .rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--line-bright), transparent);
|
||||
}
|
||||
.sec-head .count {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* app grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.node {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 100% 0%, rgba(54, 224, 164, 0.08), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.node:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--line-bright);
|
||||
box-shadow: 0 10px 30px -12px #000;
|
||||
}
|
||||
.node:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.node-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.node-ico {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius);
|
||||
background: #0a0f13;
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.node-cat {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.led {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.led.ok {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
.led.ok::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--accent);
|
||||
opacity: 0.4;
|
||||
animation: ping 2s ease-out infinite;
|
||||
}
|
||||
.led.warn {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 10px var(--warn);
|
||||
}
|
||||
.led.bad {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 10px var(--danger);
|
||||
}
|
||||
@keyframes ping {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.node-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.node-foot .up {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.node-foot .up b {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.node-foot .up.bad b {
|
||||
color: var(--danger);
|
||||
}
|
||||
.spark {
|
||||
height: 22px;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* entrance */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.node,
|
||||
.stat {
|
||||
animation: rise 0.5s both;
|
||||
}
|
||||
.stat:nth-child(2) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
.stat:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.stat:nth-child(4) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.grid .node:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.grid .node:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.grid .node:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.grid .node:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.grid .node:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.grid .node:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.grid .node:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.grid .node:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Rail -->
|
||||
<nav class="rail">
|
||||
<div class="logo" title="Launcher">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<a class="rail-btn active" title="Overview"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Apps"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Status"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Admin"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1l-.4-2.6h-4l-.4 2.6a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.4 2.6h4l.4-2.6a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6a7 7 0 0 0 .1-1z"
|
||||
/></svg
|
||||
></a>
|
||||
<div class="rail-spacer"></div>
|
||||
<div class="rail-avatar">AD</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="crumbs">SYSTEMS / <b>OVERVIEW</b></div>
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search apps, boards, commands…
|
||||
<span class="kbd">⌘K</span>
|
||||
</div>
|
||||
<div class="ico-btn" title="Notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ico-btn" title="Theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Status line -->
|
||||
<div class="statline">
|
||||
<div class="stat">
|
||||
<div class="lbl">Services Online</div>
|
||||
<div class="val ok">08 / 10</div>
|
||||
<div class="sub">2 require attention</div>
|
||||
</div>
|
||||
<div class="stat s2">
|
||||
<div class="lbl">Avg Response</div>
|
||||
<div class="val warn">
|
||||
142<span style="font-size: 14px; color: var(--ink-faint)"> ms</span>
|
||||
</div>
|
||||
<div class="sub">p95 over 24h</div>
|
||||
</div>
|
||||
<div class="stat s3">
|
||||
<div class="lbl">Fleet Uptime</div>
|
||||
<div class="val" style="color: #3aa0ff">99.4%</div>
|
||||
<div class="sub">rolling 30 days</div>
|
||||
</div>
|
||||
<div class="stat s4">
|
||||
<div class="lbl">UPS Load</div>
|
||||
<div class="val bad">61%</div>
|
||||
<div class="sub">est. 38 min on battery</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites / pinned -->
|
||||
<div class="sec-head">
|
||||
<h2>Pinned Services</h2>
|
||||
<span class="rule"></span><span class="count">8 ACTIVE</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<!-- 1 Jellyfin -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🎬</div>
|
||||
<div>
|
||||
<div class="node-name">Jellyfin</div>
|
||||
<div class="node-cat">Media</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,16 12,14 24,17 36,9 48,12 60,7 72,10 84,5 96,8"
|
||||
/></svg
|
||||
><span class="up"><b>99.9%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2 Immich -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">📷</div>
|
||||
<div>
|
||||
<div class="node-name">Immich</div>
|
||||
<div class="node-cat">Photos</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,12 12,13 24,11 36,12 48,10 60,11 72,9 84,11 96,10"
|
||||
/></svg
|
||||
><span class="up"><b>100%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 3 Gitea -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🌿</div>
|
||||
<div>
|
||||
<div class="node-name">Gitea</div>
|
||||
<div class="node-cat">Git</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,14 12,10 24,12 36,8 48,11 60,9 72,13 84,8 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>99.8%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 4 Portainer -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🐳</div>
|
||||
<div>
|
||||
<div class="node-name">Portainer</div>
|
||||
<div class="node-cat">Containers</div>
|
||||
</div>
|
||||
<div class="led warn"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ffb020"
|
||||
stroke-width="1.5"
|
||||
points="0,10 12,12 24,9 36,15 48,11 60,18 72,12 84,16 96,13"
|
||||
/></svg
|
||||
><span class="up"><b style="color: var(--warn)">98.1%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5 Pi-hole -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🛡️</div>
|
||||
<div>
|
||||
<div class="node-name">Pi-hole</div>
|
||||
<div class="node-cat">DNS</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,13 12,11 24,12 36,10 48,11 60,9 72,10 84,8 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>100%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 6 Planka -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">📋</div>
|
||||
<div>
|
||||
<div class="node-name">Planka</div>
|
||||
<div class="node-cat">Kanban</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,15 12,12 24,14 36,11 48,12 60,10 72,12 84,9 96,11"
|
||||
/></svg
|
||||
><span class="up"><b>99.5%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 7 Deluge -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">⬇️</div>
|
||||
<div>
|
||||
<div class="node-name">Deluge</div>
|
||||
<div class="node-cat">Downloads</div>
|
||||
</div>
|
||||
<div class="led bad"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ff4d5e"
|
||||
stroke-width="1.5"
|
||||
points="0,9 12,11 24,14 36,12 48,18 60,16 72,20 84,19 96,21"
|
||||
/></svg
|
||||
><span class="up bad"><b>OFFLINE</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 8 Pi-hole / NPM -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🔀</div>
|
||||
<div>
|
||||
<div class="node-name">Nginx Proxy Mgr</div>
|
||||
<div class="node-cat">Network</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,12 12,11 24,12 36,10 48,11 60,11 72,9 84,10 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>99.9%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">
|
||||
// COMMAND DECK — Saira + JetBrains Mono · tactical dark · LED telemetry · monospace
|
||||
data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,915 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Aurora Glass</title>
|
||||
<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=Outfit:wght@300;400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a14;
|
||||
--ink: #f3f2fb;
|
||||
--ink-dim: #a7a6c4;
|
||||
--ink-faint: #6f6e90;
|
||||
--accent-h: 265; /* user-tunable hue → this is the killer feature */
|
||||
--accent: hsl(var(--accent-h) 90% 66%);
|
||||
--accent-2: hsl(calc(var(--accent-h) + 60) 85% 64%);
|
||||
--accent-soft: hsl(var(--accent-h) 90% 66% / 0.14);
|
||||
--glass: rgba(255, 255, 255, 0.05);
|
||||
--glass-2: rgba(255, 255, 255, 0.07);
|
||||
--glass-line: rgba(255, 255, 255, 0.1);
|
||||
--ok: #34e0a1;
|
||||
--warn: #ffc24b;
|
||||
--bad: #ff5d73;
|
||||
--radius: 18px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Manrope', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* aurora mesh */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(60px);
|
||||
opacity: 0.9;
|
||||
background:
|
||||
radial-gradient(40% 40% at 18% 22%, hsl(var(--accent-h) 90% 60% / 0.55), transparent 70%),
|
||||
radial-gradient(
|
||||
38% 38% at 82% 18%,
|
||||
hsl(calc(var(--accent-h) + 70) 90% 60% / 0.42),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
45% 45% at 70% 85%,
|
||||
hsl(calc(var(--accent-h) - 40) 90% 58% / 0.4),
|
||||
transparent 72%
|
||||
),
|
||||
radial-gradient(
|
||||
40% 40% at 25% 90%,
|
||||
hsl(calc(var(--accent-h) + 120) 80% 55% / 0.3),
|
||||
transparent 72%
|
||||
);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-3%, 2%) scale(1.08);
|
||||
}
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
120% 120% at 50% -10%,
|
||||
transparent 40%,
|
||||
rgba(10, 10, 20, 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* sidebar (glass) */
|
||||
.side {
|
||||
margin: 16px 0 16px 16px;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(26px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(160%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin-bottom: 26px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.brand .mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px -6px var(--accent);
|
||||
}
|
||||
.brand .mark svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.brand .name {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.brand .name span {
|
||||
display: block;
|
||||
font-family: 'Manrope';
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.nav-grp {
|
||||
font-family: 'Outfit';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.13em;
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
margin: 14px 8px 8px;
|
||||
}
|
||||
.nav-i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
color: var(--ink-dim);
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-i svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.nav-i:hover {
|
||||
color: var(--ink);
|
||||
background: var(--glass-2);
|
||||
}
|
||||
.nav-i.on {
|
||||
color: var(--ink);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--accent-h) 90% 66% / 0.35);
|
||||
}
|
||||
.nav-i.on::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.nav-i .dot {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
}
|
||||
.av {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.side-foot .who {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.side-foot .who span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* main */
|
||||
.main {
|
||||
padding: 30px 34px;
|
||||
min-width: 0;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.hello {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.hello em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.sub {
|
||||
color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.searchwrap {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 300px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 13.5px;
|
||||
cursor: text;
|
||||
}
|
||||
.search svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.search .k {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
background: var(--glass-2);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.gbtn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(20px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
}
|
||||
.gbtn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--glass-2);
|
||||
}
|
||||
.gbtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* metric row */
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.metric {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric .ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.metric .ic svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.metric .v {
|
||||
font-family: 'Outfit';
|
||||
font-size: 27px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.metric .l {
|
||||
color: var(--ink-dim);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.metric .trend {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
background: rgba(52, 224, 161, 0.12);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.metric .trend.dn {
|
||||
color: var(--bad);
|
||||
background: rgba(255, 93, 115, 0.12);
|
||||
}
|
||||
|
||||
.sectitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.sectitle h2 {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sectitle a {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
box-shadow 0.22s,
|
||||
border-color 0.22s;
|
||||
}
|
||||
.card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: hsl(var(--accent-h) 90% 66% / 0.5);
|
||||
box-shadow: 0 24px 50px -20px hsl(var(--accent-h) 90% 50% / 0.55);
|
||||
}
|
||||
.card .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
.ico {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 15.5px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ct {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.pill {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.pill.ok {
|
||||
color: var(--ok);
|
||||
background: rgba(52, 224, 161, 0.13);
|
||||
}
|
||||
.pill.warn {
|
||||
color: var(--warn);
|
||||
background: rgba(255, 194, 75, 0.13);
|
||||
}
|
||||
.pill.bad {
|
||||
color: var(--bad);
|
||||
background: rgba(255, 93, 115, 0.13);
|
||||
}
|
||||
.pill .b {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.meta .up {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.meta .up b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.spark {
|
||||
height: 24px;
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.metric,
|
||||
.card {
|
||||
animation: rise 0.55s both;
|
||||
}
|
||||
.metric:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
.metric:nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.metric:nth-child(4) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.apps .card:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.apps .card:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.apps .card:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.apps .card:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.apps .card:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.apps .card:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.apps .card:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.apps .card:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 30px;
|
||||
justify-content: center;
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sw {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--glass-line);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<!-- Sidebar -->
|
||||
<aside class="side">
|
||||
<div class="brand">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="name">Launcher<span>home cloud</span></div>
|
||||
</div>
|
||||
<div class="nav-grp">Workspace</div>
|
||||
<div class="nav-i on">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
Overview
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
|
||||
</svg>
|
||||
All Apps <span class="dot">10</span>
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
Status
|
||||
</div>
|
||||
<div class="nav-grp">Boards</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
</svg>
|
||||
Media Center
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
</svg>
|
||||
Infrastructure
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
New board…
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
<div class="av">AD</div>
|
||||
<div class="who">Alexei<span>Administrator</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="hello">Good evening, <em>Alexei</em></div>
|
||||
<div class="sub">All systems nominal — 8 of 10 services responding</div>
|
||||
</div>
|
||||
<div class="searchwrap">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search… <span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="gbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="gbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metrics -->
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">8<span style="color: var(--ink-faint); font-size: 18px">/10</span></div>
|
||||
<div class="l">Services online</div>
|
||||
<span class="trend">+2</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">142<span style="color: var(--ink-faint); font-size: 16px">ms</span></div>
|
||||
<div class="l">Avg response</div>
|
||||
<span class="trend dn">+18ms</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l3 8 4-16 3 8h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">99.4%</div>
|
||||
<div class="l">Uptime · 30d</div>
|
||||
<span class="trend">+0.2</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">61%</div>
|
||||
<div class="l">UPS load · 38m</div>
|
||||
<span class="trend dn">batt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sectitle">
|
||||
<h2>Favorites</h2>
|
||||
<a href="#">View all apps →</a>
|
||||
</div>
|
||||
<div class="apps">
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🎬</div>
|
||||
<div>
|
||||
<div class="nm">Jellyfin</div>
|
||||
<div class="ct">Media</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.9%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,18 11,15 22,17 33,9 44,12 55,7 66,11 76,5 84,8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">📷</div>
|
||||
<div>
|
||||
<div class="nm">Immich</div>
|
||||
<div class="ct">Photos</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>100%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,13 11,14 22,12 33,13 44,11 55,12 66,10 76,12 84,11"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🌿</div>
|
||||
<div>
|
||||
<div class="nm">Gitea</div>
|
||||
<div class="ct">Git server</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.8%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,15 11,10 22,13 33,8 44,12 55,9 66,14 76,8 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🐳</div>
|
||||
<div>
|
||||
<div class="nm">Portainer</div>
|
||||
<div class="ct">Containers</div>
|
||||
</div>
|
||||
<div class="pill warn"><span class="b"></span>Slow</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>98.1%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--warn)"
|
||||
stroke-width="2"
|
||||
points="0,11 11,13 22,9 33,16 44,11 55,19 66,12 76,17 84,13"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🛡️</div>
|
||||
<div>
|
||||
<div class="nm">Pi-hole</div>
|
||||
<div class="ct">DNS · Ads</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>100%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,14 11,12 22,13 33,11 44,12 55,10 66,11 76,9 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">📋</div>
|
||||
<div>
|
||||
<div class="nm">Planka</div>
|
||||
<div class="ct">Kanban</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.5%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,16 11,13 22,15 33,12 44,13 55,11 66,13 76,10 84,12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">⬇️</div>
|
||||
<div>
|
||||
<div class="nm">Deluge</div>
|
||||
<div class="ct">Downloads</div>
|
||||
</div>
|
||||
<div class="pill bad"><span class="b"></span>Down</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up" style="color: var(--bad)"
|
||||
><b style="color: var(--bad)">offline</b> · 4m</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--bad)"
|
||||
stroke-width="2"
|
||||
points="0,10 11,12 22,15 33,13 44,19 55,17 66,21 76,20 84,22"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🔀</div>
|
||||
<div>
|
||||
<div class="nm">Proxy Mgr</div>
|
||||
<div class="ct">Network</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.9%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,13 11,12 22,13 33,11 44,12 55,12 66,10 76,11 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="swatches">
|
||||
Accent (user-tunable):
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(265 90% 66%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '265')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(210 90% 60%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '210')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(150 80% 55%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '150')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(20 90% 62%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '20')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(330 85% 65%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '330')"
|
||||
></span>
|
||||
— try clicking; the whole UI + aurora retints live
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,643 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Editorial</title>
|
||||
<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=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Instrument+Serif:ital@0;1&family=Hanken+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f4f1ea; /* warm paper */
|
||||
--paper-2: #ece7db;
|
||||
--card: #fbfaf6;
|
||||
--ink: #191712;
|
||||
--ink-2: #5a554a;
|
||||
--ink-faint: #9b9484;
|
||||
--line: #1a1712;
|
||||
--line-soft: #d8d2c4;
|
||||
--accent: #ff5436; /* vermilion */
|
||||
--accent-ink: #cf3a1f;
|
||||
--blue: #1f4ae0;
|
||||
--ok: #1f8a4c;
|
||||
--warn: #b8730a;
|
||||
--bad: #cf2020;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: 'Hanken Grotesk', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-image: radial-gradient(rgba(0, 0, 0, 0.022) 1px, transparent 1px);
|
||||
background-size: 5px 5px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 0 26px;
|
||||
}
|
||||
|
||||
/* top bar */
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 22px 0 18px;
|
||||
border-bottom: 2.5px solid var(--line);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.logo .glyph {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.logo .glyph svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.logo .tt {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 23px;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.9;
|
||||
}
|
||||
.logo .tt small {
|
||||
display: block;
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 18px;
|
||||
}
|
||||
.nav a {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 2px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.nav a:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.nav a.on {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.tools {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 2px;
|
||||
padding: 9px 13px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
cursor: text;
|
||||
background: var(--card);
|
||||
}
|
||||
.search svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.search .k {
|
||||
margin-left: 8px;
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
border: 1.5px solid var(--line-soft);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.ib {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--card);
|
||||
transition: 0.15s;
|
||||
}
|
||||
.ib:hover {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.ib svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* hero */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.45fr 1fr;
|
||||
gap: 0;
|
||||
border-bottom: 2.5px solid var(--line);
|
||||
}
|
||||
.hero-l {
|
||||
padding: 46px 40px 46px 0;
|
||||
border-right: 2.5px solid var(--line);
|
||||
}
|
||||
.kicker {
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 12px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 62px;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.035em;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.hero h1 em {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
}
|
||||
.hero p {
|
||||
font-size: 16px;
|
||||
color: var(--ink-2);
|
||||
max-width: 30ch;
|
||||
margin-top: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.btn {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--line);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.solid {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.btn.solid:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.hero-r {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
.figure {
|
||||
padding: 20px 0 20px 36px;
|
||||
border-bottom: 1.5px solid var(--line-soft);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
}
|
||||
.figure:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.figure .num {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 46px;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.85;
|
||||
min-width: 120px;
|
||||
}
|
||||
.figure .num.acc {
|
||||
color: var(--accent);
|
||||
}
|
||||
.figure .num.bl {
|
||||
color: var(--blue);
|
||||
}
|
||||
.figure .desc {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.figure .desc b {
|
||||
display: block;
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* section label */
|
||||
.slab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 36px 0 20px;
|
||||
}
|
||||
.slab h2 {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.slab .ln {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
}
|
||||
.slab .meta {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* apps — asymmetric editorial grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.tile {
|
||||
grid-column: span 3;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
position: relative;
|
||||
box-shadow: 4px 4px 0 var(--line);
|
||||
}
|
||||
.tile:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 7px 7px 0 var(--accent);
|
||||
}
|
||||
.tile.wide {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.tile.tall {
|
||||
grid-column: span 3;
|
||||
}
|
||||
.t-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.t-ico {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
background: var(--paper);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-name {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-cat {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tag {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1.5px solid currentColor;
|
||||
}
|
||||
.tag.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.tag.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.tag.bad {
|
||||
color: var(--bad);
|
||||
background: var(--bad);
|
||||
color: #fff;
|
||||
border-color: var(--bad);
|
||||
}
|
||||
.t-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
padding-top: 13px;
|
||||
border-top: 1.5px solid var(--line-soft);
|
||||
}
|
||||
.t-foot .up {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.t-foot .up small {
|
||||
font-weight: 500;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.spark {
|
||||
height: 24px;
|
||||
width: 90px;
|
||||
}
|
||||
.tile.wide .blurb {
|
||||
font-size: 14px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
margin-top: 14px;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.tile,
|
||||
.figure {
|
||||
animation: pop 0.5s both;
|
||||
}
|
||||
.grid .tile:nth-child(1) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
.grid .tile:nth-child(2) {
|
||||
animation-delay: 0.11s;
|
||||
}
|
||||
.grid .tile:nth-child(3) {
|
||||
animation-delay: 0.17s;
|
||||
}
|
||||
.grid .tile:nth-child(4) {
|
||||
animation-delay: 0.23s;
|
||||
}
|
||||
.grid .tile:nth-child(5) {
|
||||
animation-delay: 0.29s;
|
||||
}
|
||||
.grid .tile:nth-child(6) {
|
||||
animation-delay: 0.35s;
|
||||
}
|
||||
.grid .tile:nth-child(7) {
|
||||
animation-delay: 0.41s;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin: 46px 0 30px;
|
||||
padding-top: 18px;
|
||||
border-top: 2.5px solid var(--line);
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 15px;
|
||||
color: var(--ink-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<!-- Masthead -->
|
||||
<div class="masthead">
|
||||
<div class="logo">
|
||||
<div class="glyph">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="tt">LAUNCHER<small>the home cloud edition</small></div>
|
||||
</div>
|
||||
<nav class="nav"><a class="on">Overview</a><a>Apps</a><a>Boards</a><a>Status</a></nav>
|
||||
<div class="tools">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search<span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="ib">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="hero-l">
|
||||
<div class="kicker">Tuesday · 27 May · 19:42</div>
|
||||
<h1>Your stack,<br />all in <em>one place.</em></h1>
|
||||
<p>
|
||||
Ten services under one roof. Eight humming, two asking for attention. Everything
|
||||
launches from here.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn solid">Open a board →</a><a class="btn ghost">Add an app</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-r">
|
||||
<div class="figure">
|
||||
<div class="num acc">8/10</div>
|
||||
<div class="desc"><b>Services online</b>Deluge offline · Portainer slow to respond</div>
|
||||
</div>
|
||||
<div class="figure">
|
||||
<div class="num bl">99.4%</div>
|
||||
<div class="desc"><b>Fleet uptime</b>Rolling 30-day average across all monitors</div>
|
||||
</div>
|
||||
<div class="figure">
|
||||
<div class="num">142<span style="font-size: 20px">ms</span></div>
|
||||
<div class="desc"><b>Median response</b>p95 latency over the last 24 hours</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Apps -->
|
||||
<div class="slab">
|
||||
<h2>Favorites</h2>
|
||||
<div class="ln"></div>
|
||||
<div class="meta">eight pinned</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="tile wide">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🎬</div>
|
||||
<div>
|
||||
<div class="t-name">Jellyfin</div>
|
||||
<div class="t-cat">Media server · the crown jewel</div>
|
||||
</div>
|
||||
<span class="tag ok">Online</span>
|
||||
</div>
|
||||
<p class="blurb">
|
||||
Streaming to 3 devices right now. Library scan completed 2 hours ago — 4,212 movies, 318
|
||||
shows indexed and healthy.
|
||||
</p>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.9% <small>uptime · 24h</small></div>
|
||||
<svg class="spark" viewBox="0 0 90 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ff5436"
|
||||
stroke-width="2.2"
|
||||
points="0,18 12,15 24,17 36,9 48,12 60,7 72,11 82,5 90,8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">📷</div>
|
||||
<div>
|
||||
<div class="t-name">Immich</div>
|
||||
<div class="t-cat">Photos</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">100% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🌿</div>
|
||||
<div>
|
||||
<div class="t-name">Gitea</div>
|
||||
<div class="t-cat">Git</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.8% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🐳</div>
|
||||
<div>
|
||||
<div class="t-name">Portainer</div>
|
||||
<div class="t-cat">Containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">98.1% <small>24h</small></div>
|
||||
<span class="tag warn">Slow</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🛡️</div>
|
||||
<div>
|
||||
<div class="t-name">Pi-hole</div>
|
||||
<div class="t-cat">DNS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">100% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">📋</div>
|
||||
<div>
|
||||
<div class="t-name">Planka</div>
|
||||
<div class="t-cat">Kanban</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.5% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">⬇️</div>
|
||||
<div>
|
||||
<div class="t-name">Deluge</div>
|
||||
<div class="t-cat">Downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up" style="color: var(--bad)">—</div>
|
||||
<span class="tag bad">Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colophon">
|
||||
<span>Editorial — Bricolage Grotesque + Instrument Serif</span
|
||||
><span>warm paper · ink rules · hard shadows · asymmetric grid</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,723 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Cozy Home</title>
|
||||
<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=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fdf8f2; /* warm cream */
|
||||
--bg-2: #f6efe4;
|
||||
--card: #fffdfa;
|
||||
--ink: #3a322b;
|
||||
--ink-2: #857a6d;
|
||||
--ink-faint: #b3a899;
|
||||
--line: #ece2d3;
|
||||
--peach: #ff9a76;
|
||||
--terra: #e8754f;
|
||||
--sage: #7fb069;
|
||||
--sky: #6ca9d6;
|
||||
--butter: #f3c969;
|
||||
--lav: #b09fd6;
|
||||
--ok: #5fa86c;
|
||||
--warn: #d99a2b;
|
||||
--bad: #e0685f;
|
||||
--radius: 22px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Figtree', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(50% 40% at 12% 0%, rgba(255, 154, 118, 0.16), transparent 70%),
|
||||
radial-gradient(45% 40% at 95% 8%, rgba(108, 169, 214, 0.14), transparent 70%),
|
||||
radial-gradient(50% 45% at 85% 100%, rgba(127, 176, 105, 0.12), transparent 70%);
|
||||
}
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 236px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* sidebar */
|
||||
.side {
|
||||
padding: 24px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand .m {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--peach), var(--terra));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px -8px var(--terra);
|
||||
}
|
||||
.brand .m svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
.brand .t {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.brand .t span {
|
||||
display: block;
|
||||
font-family: 'Figtree';
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.nlabel {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-faint);
|
||||
margin: 16px 10px 8px;
|
||||
}
|
||||
.ni {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
color: var(--ink-2);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: 0.16s;
|
||||
}
|
||||
.ni svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.ni:hover {
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.ni.on {
|
||||
background: var(--card);
|
||||
color: var(--terra);
|
||||
box-shadow:
|
||||
0 6px 16px -8px rgba(0, 0, 0, 0.18),
|
||||
inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.ni .c {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.side-card {
|
||||
margin-top: auto;
|
||||
background: linear-gradient(135deg, rgba(127, 176, 105, 0.16), rgba(108, 169, 214, 0.14));
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.side-card p {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.side-card .who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.side-card .av {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--lav), var(--sky));
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* main */
|
||||
.main {
|
||||
padding: 30px 36px 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.top .search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 14px;
|
||||
width: 320px;
|
||||
cursor: text;
|
||||
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.top .search svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.top .search .k {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rbtn {
|
||||
margin-left: auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 15px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.rbtn + .rbtn {
|
||||
margin-left: 0;
|
||||
}
|
||||
.rbtn:hover {
|
||||
color: var(--terra);
|
||||
}
|
||||
.rbtn svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.greet {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 18px 0 4px;
|
||||
}
|
||||
.greet .wave {
|
||||
display: inline-block;
|
||||
animation: wave 2.4s ease-in-out infinite;
|
||||
transform-origin: 70% 70%;
|
||||
}
|
||||
@keyframes wave {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(16deg);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
}
|
||||
.gsub {
|
||||
color: var(--ink-2);
|
||||
font-size: 15px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.gsub b {
|
||||
color: var(--sage);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* summary chips */
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 8px 20px -16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.chip .ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chip .ic svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: #fff;
|
||||
}
|
||||
.chip .v {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chip .l {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 6px 0 18px;
|
||||
}
|
||||
.sec h2 {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sec .more {
|
||||
margin-left: auto;
|
||||
font-size: 13.5px;
|
||||
color: var(--terra);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 26px -20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-6px) rotate(-0.4deg);
|
||||
box-shadow: 0 22px 40px -22px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
opacity: 0.5;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
}
|
||||
.ico {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 26px;
|
||||
position: relative;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin-top: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ct {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dot .b {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.dot.ok .b {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 4px rgba(95, 168, 108, 0.18);
|
||||
}
|
||||
.dot.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.dot.warn .b {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 4px rgba(217, 154, 43, 0.18);
|
||||
}
|
||||
.dot.bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
.dot.bad .b {
|
||||
background: var(--bad);
|
||||
box-shadow: 0 0 0 4px rgba(224, 104, 95, 0.18);
|
||||
}
|
||||
.up {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.card,
|
||||
.chip {
|
||||
animation: rise 0.55s both;
|
||||
}
|
||||
.chip:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
.chip:nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.chip:nth-child(4) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.apps .card:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.apps .card:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.apps .card:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.apps .card:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.apps .card:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.apps .card:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.apps .card:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.apps .card:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
|
||||
.note {
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<!-- Sidebar -->
|
||||
<aside class="side">
|
||||
<div class="brand">
|
||||
<div class="m">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="2" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="t">Launcher<span>our home cloud</span></div>
|
||||
</div>
|
||||
<div class="nlabel">Menu</div>
|
||||
<div class="ni on">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M3 11l9-8 9 8M5 10v10h14V10" />
|
||||
</svg>
|
||||
Home
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
|
||||
</svg>
|
||||
All apps <span class="c">10</span>
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
Status
|
||||
</div>
|
||||
<div class="nlabel">Rooms</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
</svg>
|
||||
Movie night
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
</svg>
|
||||
The basement rack
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<p>“Everything’s running smoothly today. ☕ Two apps want a peek when you get a sec.”</p>
|
||||
<div class="who">
|
||||
<div class="av">AD</div>
|
||||
<div>
|
||||
<div style="font-weight: 700; font-size: 13px">Alexei</div>
|
||||
<div style="font-size: 11px; color: var(--ink-faint)">Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<div class="top">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search your apps… <span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="rbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="greet">Hi Alexei <span class="wave">👋</span></h1>
|
||||
<p class="gsub">It’s a calm evening — <b>8 of your 10 apps</b> are happy and online.</p>
|
||||
|
||||
<div class="chips">
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--sage)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">8/10</div>
|
||||
<div class="l">Apps online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--sky)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">142ms</div>
|
||||
<div class="l">Avg speed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--butter)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M3 12h4l3 8 4-16 3 8h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">99.4%</div>
|
||||
<div class="l">Uptime · 30d</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--lav)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">38 min</div>
|
||||
<div class="l">Battery left</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<h2>Your favorites</h2>
|
||||
<a class="more" href="#">See all →</a>
|
||||
</div>
|
||||
<div class="apps">
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--terra)"></div>
|
||||
<div class="ico" style="background: rgba(232, 117, 79, 0.16)">🎬</div>
|
||||
<div class="nm">Jellyfin</div>
|
||||
<div class="ct">Movies & shows</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sky)"></div>
|
||||
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">📷</div>
|
||||
<div class="nm">Immich</div>
|
||||
<div class="ct">Photos</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sage)"></div>
|
||||
<div class="ico" style="background: rgba(127, 176, 105, 0.18)">🌿</div>
|
||||
<div class="nm">Gitea</div>
|
||||
<div class="ct">Code</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.8%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--butter)"></div>
|
||||
<div class="ico" style="background: rgba(243, 201, 105, 0.22)">🐳</div>
|
||||
<div class="nm">Portainer</div>
|
||||
<div class="ct">Containers</div>
|
||||
<div class="foot">
|
||||
<span class="dot warn"><span class="b"></span>A bit slow</span
|
||||
><span class="up">98.1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--lav)"></div>
|
||||
<div class="ico" style="background: rgba(176, 159, 214, 0.2)">🛡️</div>
|
||||
<div class="nm">Pi-hole</div>
|
||||
<div class="ct">Ad blocker</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--peach)"></div>
|
||||
<div class="ico" style="background: rgba(255, 154, 118, 0.2)">📋</div>
|
||||
<div class="nm">Planka</div>
|
||||
<div class="ct">To-dos</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--bad)"></div>
|
||||
<div class="ico" style="background: rgba(224, 104, 95, 0.16)">⬇️</div>
|
||||
<div class="nm">Deluge</div>
|
||||
<div class="ct">Downloads</div>
|
||||
<div class="foot">
|
||||
<span class="dot bad"><span class="b"></span>Asleep</span><span class="up">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sky)"></div>
|
||||
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">🔀</div>
|
||||
<div class="nm">Proxy</div>
|
||||
<div class="ct">Networking</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Cozy Home — Fraunces + Figtree · warm cream · soft pastel rooms · gentle motion
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,639 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Cozy Home — Design System Reference</title>
|
||||
<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=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
/* === The exact tokens now living in src/app.css (light/cream) === */
|
||||
:root {
|
||||
--background: hsl(35 56% 97%);
|
||||
--foreground: hsl(33 18% 18%);
|
||||
--muted: hsl(36 42% 93%);
|
||||
--muted-foreground: hsl(34 12% 47%);
|
||||
--card: hsl(40 60% 99%);
|
||||
--border: hsl(36 35% 88%);
|
||||
--primary: hsl(16 72% 56%);
|
||||
--primary-foreground: hsl(40 60% 99%);
|
||||
--accent: hsl(34 44% 90%);
|
||||
--status-online: #5fa86c;
|
||||
--status-offline: #e0685f;
|
||||
--status-degraded: #d99a2b;
|
||||
--status-unknown: #b3a899;
|
||||
--room-sage: #7fb069;
|
||||
--room-sky: #6ca9d6;
|
||||
--room-butter: #f3c969;
|
||||
--room-lav: #b09fd6;
|
||||
--room-peach: #ff9a76;
|
||||
--room-terra: #e8754f;
|
||||
--radius: 1rem;
|
||||
--shadow-soft: 0 10px 26px -20px rgba(80, 50, 20, 0.45);
|
||||
--shadow-lift: 0 22px 40px -22px rgba(80, 50, 20, 0.4);
|
||||
--font-sans: 'Figtree', system-ui, sans-serif;
|
||||
--font-display: 'Fraunces', serif;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
padding: 40px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lede {
|
||||
color: var(--muted-foreground);
|
||||
margin: 6px 0 8px;
|
||||
max-width: 64ch;
|
||||
}
|
||||
.path {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--room-terra);
|
||||
background: color-mix(in srgb, var(--room-terra) 12%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
section {
|
||||
margin-top: 42px;
|
||||
}
|
||||
.h {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* tokens */
|
||||
.swatch {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.swatch .c {
|
||||
height: 56px;
|
||||
}
|
||||
.swatch .n {
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.swatch .n b {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
.swatch .n span {
|
||||
color: var(--muted-foreground);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 11px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, transparent);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.btn-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* inputs */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
max-width: 340px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.input {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: 0.16s;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* badges / status pills */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pill .b {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.pill.ok {
|
||||
color: var(--status-online);
|
||||
background: color-mix(in srgb, var(--status-online) 14%, transparent);
|
||||
}
|
||||
.pill.warn {
|
||||
color: var(--status-degraded);
|
||||
background: color-mix(in srgb, var(--status-degraded) 14%, transparent);
|
||||
}
|
||||
.pill.bad {
|
||||
color: var(--status-offline);
|
||||
background: color-mix(in srgb, var(--status-offline) 14%, transparent);
|
||||
}
|
||||
.pill .b {
|
||||
background: currentColor;
|
||||
}
|
||||
.room-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
background: var(--muted);
|
||||
padding: 5px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.tab {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.tab.on {
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
/* card */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
max-width: 300px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card .blob {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
opacity: 0.45;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
}
|
||||
.card .ic {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 25px;
|
||||
position: relative;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card .ct {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.card .f {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* dialog */
|
||||
.dialog {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-lift);
|
||||
max-width: 380px;
|
||||
}
|
||||
.dialog h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.2rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted-foreground);
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tr:hover td {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* empty state */
|
||||
.empty {
|
||||
text-align: center;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 48px 24px;
|
||||
background: color-mix(in srgb, var(--card) 50%, transparent);
|
||||
}
|
||||
.empty .e-ic {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto 16px;
|
||||
background: color-mix(in srgb, var(--room-peach) 20%, transparent);
|
||||
color: var(--room-terra);
|
||||
}
|
||||
.empty h3 {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin: 6px auto 18px;
|
||||
max-width: 36ch;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1 class="title">Cozy Home — Design System</h1>
|
||||
<p class="lede">
|
||||
The component pattern sheet for the migration. Every phase styles its components to match
|
||||
these primitives. Tokens here mirror what now lives in
|
||||
<span class="path">src/app.css</span> — change them there and the whole app follows.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<div class="h">Color tokens</div>
|
||||
<div class="grid">
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--background)"></div>
|
||||
<div class="n"><b>background</b><span>cream #fdf8f2</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--card)"></div>
|
||||
<div class="n"><b>card</b><span>#fffdfa</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--primary)"></div>
|
||||
<div class="n"><b>primary</b><span>terracotta · tunable</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--foreground)"></div>
|
||||
<div class="n"><b>foreground</b><span>warm ink</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--muted)"></div>
|
||||
<div class="n"><b>muted</b><span>#f3ecde</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--border)"></div>
|
||||
<div class="n"><b>border</b><span>#ece2d3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px" class="grid">
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-terra)"></div>
|
||||
<div class="n"><b>room · terra</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-peach)"></div>
|
||||
<div class="n"><b>room · peach</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-butter)"></div>
|
||||
<div class="n"><b>room · butter</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-sage)"></div>
|
||||
<div class="n"><b>room · sage</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-sky)"></div>
|
||||
<div class="n"><b>room · sky</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-lav)"></div>
|
||||
<div class="n"><b>room · lav</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Typography — Fraunces (display) · Figtree (body)</div>
|
||||
<h1 style="font-size: 46px; font-weight: 600">Good evening, Alexei</h1>
|
||||
<h2 style="font-size: 28px; font-weight: 600; margin-top: 10px">Your favorites</h2>
|
||||
<h3 style="font-size: 19px; font-weight: 600; margin-top: 10px">Jellyfin</h3>
|
||||
<p style="margin-top: 8px; max-width: 60ch">
|
||||
Body copy is Figtree — friendly, legible, rounded. It carries descriptions, hints, and
|
||||
plain-language status like “a bit slow” or “asleep”.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Buttons</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-primary">Open a board</button>
|
||||
<button class="btn btn-secondary">Add an app</button>
|
||||
<button class="btn btn-ghost">Cancel</button>
|
||||
<button class="btn btn-secondary btn-icon" title="icon">🔔</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Form fields</div>
|
||||
<div class="row" style="align-items: flex-start">
|
||||
<div class="field">
|
||||
<label>App name</label><input class="input" value="Jellyfin" /><span class="hint"
|
||||
>Shown on the card and in search.</span
|
||||
>
|
||||
</div>
|
||||
<div class="field"><label>URL</label><input class="input" placeholder="https://…" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Status pills & room tags</div>
|
||||
<div class="row">
|
||||
<span class="pill ok"><span class="b"></span>Online</span>
|
||||
<span class="pill warn"><span class="b"></span>A bit slow</span>
|
||||
<span class="pill bad"><span class="b"></span>Asleep</span>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-terra);
|
||||
background: color-mix(in srgb, var(--room-terra) 16%, transparent);
|
||||
"
|
||||
>Media</span
|
||||
>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-sky);
|
||||
background: color-mix(in srgb, var(--room-sky) 16%, transparent);
|
||||
"
|
||||
>Network</span
|
||||
>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-sage);
|
||||
background: color-mix(in srgb, var(--room-sage) 16%, transparent);
|
||||
"
|
||||
>Git</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Tabs</div>
|
||||
<div class="tabs">
|
||||
<div class="tab on">Overview</div>
|
||||
<div class="tab">Activity</div>
|
||||
<div class="tab">Settings</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">App card</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--room-terra)"></div>
|
||||
<div
|
||||
class="ic"
|
||||
style="
|
||||
background: color-mix(in srgb, var(--room-terra) 18%, transparent);
|
||||
color: var(--room-terra);
|
||||
"
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
<h3>Jellyfin</h3>
|
||||
<div class="ct">Movies & shows</div>
|
||||
<div class="f">
|
||||
<span class="pill ok"><span class="b"></span>Online</span
|
||||
><span class="hint">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Dialog</div>
|
||||
<div class="dialog">
|
||||
<h3>Remove Deluge?</h3>
|
||||
<p>This deletes the app and its uptime history. This can’t be undone.</p>
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<button class="btn btn-ghost">Keep it</button
|
||||
><button class="btn btn-primary" style="background: var(--status-offline)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Table (admin)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Alexei</td>
|
||||
<td>Admin</td>
|
||||
<td>
|
||||
<span class="pill ok"><span class="b"></span>Active</span>
|
||||
</td>
|
||||
<td>just now</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Guest</td>
|
||||
<td>Viewer</td>
|
||||
<td>
|
||||
<span class="pill warn"><span class="b"></span>Idle</span>
|
||||
</td>
|
||||
<td>2h ago</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Empty state</div>
|
||||
<div class="empty">
|
||||
<div class="e-ic">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No apps yet</h3>
|
||||
<p>Add your first service and it’ll show up here with live status.</p>
|
||||
<button class="btn btn-primary">+ Add an app</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p
|
||||
style="
|
||||
margin: 48px 0 20px;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
color: var(--muted-foreground);
|
||||
"
|
||||
>
|
||||
Cozy Home design system · mirrors src/app.css · use as the pattern for every migrated
|
||||
component
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Launcher — Redesign Mockups</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'Figtree', system-ui, sans-serif;
|
||||
background: #0e0e12;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
p.sub {
|
||||
color: #9a99a6;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
max-width: 62ch;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
a.card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid #24242e;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
background: #15151c;
|
||||
transition: 0.2s;
|
||||
}
|
||||
a.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: #3a3a4a;
|
||||
background: #191920;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 21px;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.ds {
|
||||
color: #9a99a6;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.swatch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.swatch span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Web App Launcher — Redesign Directions</h1>
|
||||
<p class="sub">
|
||||
Four aesthetic directions for the same launcher, built as theme presets of one modernized
|
||||
design system. Open each, resize, hover the cards, and try the live accent swatches in
|
||||
Aurora Glass. Pick the one that fits — or mix and match.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="01-command-deck.html">
|
||||
<span class="badge" style="background: #0d1f18; color: #36e0a4"
|
||||
>01 · Dark · Power-user</span
|
||||
>
|
||||
<div class="nm">Command Deck</div>
|
||||
<div class="ds">
|
||||
Mission-control / terminal. Dense, glanceable telemetry, LED status, monospace data.
|
||||
Saira + JetBrains Mono.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #070a0d"></span><span style="background: #36e0a4"></span
|
||||
><span style="background: #ffb020"></span><span style="background: #ff4d5e"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="02-aurora-glass.html">
|
||||
<span class="badge" style="background: #1d1340; color: #b69cff">02 · Dark · Premium</span>
|
||||
<div class="nm">Aurora Glass</div>
|
||||
<div class="ds">
|
||||
Frosted glass over a living gradient mesh. Soft glows, generous space, fully retintable
|
||||
accent. Outfit + Manrope.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #0a0a14"></span
|
||||
><span style="background: hsl(265 90% 66%)"></span
|
||||
><span style="background: hsl(325 85% 65%)"></span
|
||||
><span style="background: #34e0a1"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="03-editorial.html">
|
||||
<span class="badge" style="background: #2a1109; color: #ff5436">03 · Light · Bold</span>
|
||||
<div class="nm">Editorial</div>
|
||||
<div class="ds">
|
||||
Magazine masthead, big display type, ink rules, hard shadows, asymmetric grid. Bricolage
|
||||
Grotesque + Instrument Serif.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #f4f1ea"></span><span style="background: #191712"></span
|
||||
><span style="background: #ff5436"></span><span style="background: #1f4ae0"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="04-cozy-home.html">
|
||||
<span class="badge" style="background: #2a1c12; color: #ff9a76"
|
||||
>04 · Light · Friendly</span
|
||||
>
|
||||
<div class="nm">Cozy Home</div>
|
||||
<div class="ds">
|
||||
Warm cream, soft rounded cards, pastel “rooms”, gentle motion. Friendly for the whole
|
||||
household. Fraunces + Figtree.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #fdf8f2"></span><span style="background: #e8754f"></span
|
||||
><span style="background: #7fb069"></span><span style="background: #6ca9d6"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+124
-9
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"name": "web-app-launcher",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web-app-launcher",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.2.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bits-ui": "^1.3.0",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -30,11 +28,14 @@
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"sveltekit-superforms": "^2.22.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tar": "^7.5.15",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@sveltejs/package": "^2.3.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@testing-library/svelte": "^5.2.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -976,6 +977,17 @@
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1888,6 +1900,7 @@
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
@@ -1899,6 +1912,7 @@
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -2840,6 +2854,14 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -2967,6 +2989,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
@@ -4497,6 +4520,25 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -6130,7 +6172,8 @@
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -6145,6 +6188,21 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/timers-ext": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
|
||||
@@ -6867,7 +6925,8 @@
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
@@ -7179,6 +7238,14 @@
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
@@ -7741,6 +7808,14 @@
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"requires": {
|
||||
"minipass": "^7.0.4"
|
||||
}
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -8274,6 +8349,7 @@
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
@@ -8282,6 +8358,7 @@
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -8944,6 +9021,11 @@
|
||||
"readdirp": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
|
||||
},
|
||||
"citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
@@ -9049,7 +9131,8 @@
|
||||
"cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"d": {
|
||||
"version": "1.0.2",
|
||||
@@ -10121,6 +10204,19 @@
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"requires": {
|
||||
"minipass": "^7.1.2"
|
||||
}
|
||||
},
|
||||
"mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -11017,7 +11113,8 @@
|
||||
"tailwindcss": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -11025,6 +11122,18 @@
|
||||
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
|
||||
"dev": true
|
||||
},
|
||||
"tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"requires": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"timers-ext": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
|
||||
@@ -11426,7 +11535,8 @@
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
@@ -11581,6 +11691,11 @@
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"sveltekit-superforms": "^2.22.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tar": "^7.5.15",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"prisma": {
|
||||
|
||||
+279
-83
@@ -4,83 +4,140 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
/* HSL-based primary color (overridden by theme store via JS) */
|
||||
--primary-h: 220;
|
||||
--primary-s: 70%;
|
||||
--primary-l: 50%;
|
||||
/* =====================================================================
|
||||
COZY HOME design system
|
||||
---------------------------------------------------------------------
|
||||
Tokens are intentionally organised as a single swappable "bundle":
|
||||
the neutral ramp + accent + shape + type live here in :root / .dark.
|
||||
Swapping these blocks for another set (e.g. Command Deck / Aurora /
|
||||
Editorial) is all a future theme-preset system needs to do — no
|
||||
component edits required, because the whole app reads these vars.
|
||||
Accent stays user-tunable via --primary-h / --primary-s.
|
||||
===================================================================== */
|
||||
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
:root {
|
||||
/* Accent — terracotta by default, still user-tunable from settings */
|
||||
--primary-h: 16;
|
||||
--primary-s: 72%;
|
||||
--primary-l: 56%;
|
||||
|
||||
/* Neutrals — warm cream "paper" ramp */
|
||||
--background: hsl(35 56% 97%); /* #fdf8f2 warm cream */
|
||||
--foreground: hsl(33 18% 18%); /* #3a322b warm ink */
|
||||
--muted: hsl(36 42% 93%); /* #f3ecde */
|
||||
--muted-foreground: hsl(34 12% 47%); /* #857a6d */
|
||||
--popover: hsl(40 60% 99%);
|
||||
--popover-foreground: hsl(33 18% 18%);
|
||||
--card: hsl(40 60% 99%); /* #fffdfa */
|
||||
--card-foreground: hsl(33 18% 18%);
|
||||
--border: hsl(36 35% 88%); /* #ece2d3 */
|
||||
--input: hsl(36 35% 88%);
|
||||
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--destructive: hsl(0 72.2% 50.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(40 60% 99%);
|
||||
--secondary: hsl(36 42% 93%);
|
||||
--secondary-foreground: hsl(33 18% 22%);
|
||||
--accent: hsl(34 44% 90%); /* hover wash */
|
||||
--accent-foreground: hsl(33 18% 20%);
|
||||
--destructive: hsl(6 68% 56%);
|
||||
--destructive-foreground: hsl(40 60% 99%);
|
||||
--ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--status-online: #22c55e;
|
||||
--status-offline: #ef4444;
|
||||
--status-degraded: #eab308;
|
||||
--status-unknown: #6b7280;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
|
||||
/* Status — vivid values for dots / bars / rings / sparklines */
|
||||
--status-online: #5fa86c;
|
||||
--status-offline: #e0685f;
|
||||
--status-degraded: #d99a2b;
|
||||
--status-unknown: #b3a899;
|
||||
/* Status "ink" — darker, AA-legible as small text on cream + tinted washes */
|
||||
--status-online-ink: #2c723f;
|
||||
--status-offline-ink: #bd382e;
|
||||
--status-degraded-ink: #785406;
|
||||
--status-unknown-ink: #6b5f50;
|
||||
|
||||
/* Pastel "rooms" — category / board accents */
|
||||
--room-sage: #7fb069;
|
||||
--room-sky: #6ca9d6;
|
||||
--room-butter: #f3c969;
|
||||
--room-lav: #b09fd6;
|
||||
--room-peach: #ff9a76;
|
||||
--room-terra: #e8754f;
|
||||
|
||||
/* Shape — cozy rounding */
|
||||
--radius: 1rem;
|
||||
|
||||
/* Soft warm shadows */
|
||||
--shadow-soft: 0 10px 26px -20px rgba(80, 50, 20, 0.45);
|
||||
--shadow-lift: 0 22px 40px -22px rgba(80, 50, 20, 0.4);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Figtree', system-ui, -apple-system, sans-serif;
|
||||
--font-display: 'Fraunces', 'Figtree', Georgia, serif;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: hsl(36 48% 95%);
|
||||
--sidebar-foreground: hsl(34 14% 32%);
|
||||
--sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||
--sidebar-border: hsl(220 13% 91%);
|
||||
--sidebar-primary-foreground: hsl(40 60% 99%);
|
||||
--sidebar-accent: hsl(34 44% 90%);
|
||||
--sidebar-accent-foreground: hsl(33 18% 20%);
|
||||
--sidebar-border: hsl(36 35% 87%);
|
||||
--sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--primary-l: 60%;
|
||||
/* "Dusk" — warm charcoal, not cold black */
|
||||
--primary-l: 62%;
|
||||
|
||||
--background: hsl(240 10% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(240 3.7% 15.9%);
|
||||
--muted-foreground: hsl(240 5% 64.9%);
|
||||
--popover: hsl(240 10% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--card: hsl(240 6% 7%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 3.7% 15.9%);
|
||||
--input: hsl(240 3.7% 15.9%);
|
||||
--background: hsl(30 14% 9%); /* #1a1714 */
|
||||
--foreground: hsl(35 30% 90%); /* #f0e9df */
|
||||
--muted: hsl(30 14% 16%); /* #2b2520 */
|
||||
--muted-foreground: hsl(35 14% 64%); /* #b3a899 */
|
||||
--popover: hsl(30 16% 12%);
|
||||
--popover-foreground: hsl(35 30% 90%);
|
||||
--card: hsl(30 16% 13%); /* #262019 */
|
||||
--card-foreground: hsl(35 30% 90%);
|
||||
--border: hsl(31 16% 19%); /* #352d24 */
|
||||
--input: hsl(31 16% 19%);
|
||||
--primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--primary-foreground: hsl(240 5.9% 10%);
|
||||
--secondary: hsl(240 3.7% 15.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--accent: hsl(240 3.7% 15.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(30 18% 10%);
|
||||
--secondary: hsl(30 14% 16%);
|
||||
--secondary-foreground: hsl(35 30% 90%);
|
||||
--accent: hsl(30 14% 18%);
|
||||
--accent-foreground: hsl(35 30% 90%);
|
||||
--destructive: hsl(6 58% 46%);
|
||||
--destructive-foreground: hsl(40 60% 99%);
|
||||
--ring: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--sidebar: hsl(240 5.9% 6%);
|
||||
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||
|
||||
--status-online: #6dba79;
|
||||
--status-offline: #ea7a72;
|
||||
--status-degraded: #e3ab4a;
|
||||
--status-unknown: #9a8f80;
|
||||
/* On dusk charcoal the vivid values already clear AA — ink == vivid */
|
||||
--status-online-ink: #6dba79;
|
||||
--status-offline-ink: #ea7a72;
|
||||
--status-degraded-ink: #e3ab4a;
|
||||
--status-unknown-ink: #9a8f80;
|
||||
|
||||
--shadow-soft: 0 12px 30px -20px rgba(0, 0, 0, 0.65);
|
||||
--shadow-lift: 0 26px 46px -22px rgba(0, 0, 0, 0.6);
|
||||
|
||||
--sidebar: hsl(30 16% 11%);
|
||||
--sidebar-foreground: hsl(35 22% 82%);
|
||||
--sidebar-primary: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||
--sidebar-primary-foreground: hsl(30 18% 10%);
|
||||
--sidebar-accent: hsl(30 14% 18%);
|
||||
--sidebar-accent-foreground: hsl(35 30% 90%);
|
||||
--sidebar-border: hsl(31 16% 19%);
|
||||
--sidebar-ring: hsl(var(--primary-h) calc(var(--primary-s) * 1.2) 60%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 8px);
|
||||
--radius-md: calc(var(--radius) - 4px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-xl: calc(var(--radius) + 6px);
|
||||
|
||||
--font-sans: var(--font-sans);
|
||||
--font-display: var(--font-display);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -101,6 +158,23 @@
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-status-online: var(--status-online);
|
||||
--color-status-offline: var(--status-offline);
|
||||
--color-status-degraded: var(--status-degraded);
|
||||
--color-status-unknown: var(--status-unknown);
|
||||
--color-status-online-ink: var(--status-online-ink);
|
||||
--color-status-offline-ink: var(--status-offline-ink);
|
||||
--color-status-degraded-ink: var(--status-degraded-ink);
|
||||
--color-status-unknown-ink: var(--status-unknown-ink);
|
||||
|
||||
--color-room-sage: var(--room-sage);
|
||||
--color-room-sky: var(--room-sky);
|
||||
--color-room-butter: var(--room-butter);
|
||||
--color-room-lav: var(--room-lav);
|
||||
--color-room-peach: var(--room-peach);
|
||||
--color-room-terra: var(--room-terra);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
@@ -117,10 +191,21 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'ss01', 'cv01';
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
/* Display face for headings — gives the cozy/editorial warmth */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
font-optical-sizing: auto;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Status Indicator Pulse ===== */
|
||||
@@ -136,29 +221,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-flash {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.25);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.status-online {
|
||||
animation: status-pulse 2s ease-in-out infinite;
|
||||
color: hsl(142 71% 45%);
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.status-degraded {
|
||||
animation: status-breathe 2.6s ease-in-out infinite;
|
||||
color: var(--status-degraded);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
animation: status-flash 0.6s ease-out 1;
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
/* ===== Card Style Variants ===== */
|
||||
.card-solid {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.card-glass {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--card) 60%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 30%, transparent);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05);
|
||||
background: color-mix(in srgb, var(--card) 70%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.dark .card-glass {
|
||||
background: color-mix(in srgb, var(--card) 50%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 25%, transparent);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.15);
|
||||
background: color-mix(in srgb, var(--card) 55%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 35%, transparent);
|
||||
}
|
||||
|
||||
.card-outline {
|
||||
@@ -170,24 +290,17 @@
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ===== Card Hover Effects ===== */
|
||||
/* ===== Card Hover Effects — gentle cozy lift + micro-tilt ===== */
|
||||
.card-hover {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 4px 10px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark .card-hover:hover {
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.4),
|
||||
0 4px 10px -5px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-5px) rotate(-0.35deg);
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
|
||||
/* ===== Skeleton Loading ===== */
|
||||
@@ -201,14 +314,14 @@
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 4.8% 85%) 50%, var(--muted) 75%);
|
||||
background: linear-gradient(90deg, var(--muted) 25%, hsl(36 30% 86%) 50%, var(--muted) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(90deg, var(--muted) 25%, hsl(240 3.7% 22%) 50%, var(--muted) 75%);
|
||||
background: linear-gradient(90deg, var(--muted) 25%, hsl(30 12% 22%) 50%, var(--muted) 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@@ -236,7 +349,7 @@
|
||||
[data-keyboard-selected='true'] {
|
||||
outline: 2px solid hsl(var(--primary-h) var(--primary-s) var(--primary-l));
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius, 0.5rem);
|
||||
border-radius: var(--radius, 1rem);
|
||||
}
|
||||
|
||||
/* ===== Aurora Keyframes ===== */
|
||||
@@ -251,3 +364,86 @@
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Cozy entrance reveal ===== */
|
||||
@keyframes cozy-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cozy-rise {
|
||||
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* For staggered grid reveals — set --i as 0,1,2,... per item */
|
||||
.cozy-rise-stagger {
|
||||
animation: cozy-rise 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
animation-delay: calc(var(--i, 0) * 55ms);
|
||||
}
|
||||
|
||||
/* ===== Cozy accordion (height slide for show/hide) ===== */
|
||||
@keyframes cozy-expand {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
max-height: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
max-height: 1200px;
|
||||
}
|
||||
}
|
||||
|
||||
.cozy-expand {
|
||||
overflow: hidden;
|
||||
animation: cozy-expand 0.32s cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* ===== Cozy greeting wave ===== */
|
||||
@keyframes cozy-wave {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(16deg);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
.cozy-wave {
|
||||
display: inline-block;
|
||||
transform-origin: 70% 70%;
|
||||
animation: cozy-wave 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cozy-wave,
|
||||
.status-online,
|
||||
.status-degraded,
|
||||
.status-offline,
|
||||
.cozy-rise,
|
||||
.cozy-rise-stagger,
|
||||
.cozy-expand {
|
||||
animation: none;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -5,11 +5,14 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="theme-color" content="#e8754f" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Launcher" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
|
||||
<!-- Cozy Home typography: Fraunces (display) + Figtree (body).
|
||||
Self-hosted from /static/fonts so offline/LAN installs work with no external calls. -->
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
|
||||
<script>
|
||||
// Inline script to prevent FOUC — set theme class before first paint
|
||||
(function () {
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as apiTokenService from '$lib/server/services/apiTokenService.js';
|
||||
import { extractBearerToken } from '$lib/server/middleware/authenticate.js';
|
||||
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
|
||||
import { initBackupScheduler } from '$lib/server/jobs/backupScheduler.js';
|
||||
import { isRestoring, isDegraded, getDegradedReason } from '$lib/server/services/backupService.js';
|
||||
import { startScheduler as startHealthcheckScheduler } from '$lib/server/jobs/healthcheckScheduler.js';
|
||||
import {
|
||||
clearSessionCookies,
|
||||
@@ -52,6 +53,57 @@ function isPublicPath(pathname: string): boolean {
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const reqPath = event.url.pathname;
|
||||
|
||||
// While a restore is mid-flight, Prisma is disconnected and the live DB
|
||||
// file (and uploads tree) is being swapped. Any other request that
|
||||
// touches the DB or the uploads dir would crash; return 503 instead.
|
||||
//
|
||||
// Whitelist: bundled SvelteKit assets (immutable, served from disk paths
|
||||
// that are not affected by restore) and /api/health (so liveness probes
|
||||
// can still observe the degraded state). /uploads/ is NOT whitelisted —
|
||||
// uploaded files live in the dir being renamed and concurrent reads on
|
||||
// Windows can block the rename outright.
|
||||
if (isRestoring()) {
|
||||
const isBundledAsset = reqPath.startsWith('/_app/') || reqPath.startsWith('/favicon');
|
||||
if (!(isBundledAsset || reqPath === '/api/health')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Database restore in progress. Please retry in a moment.'
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '15'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// After a failed restore + failed rollback the process is in an unknown
|
||||
// state. Return 503 for everything except the health endpoint so the
|
||||
// orchestrator can observe and recycle the container.
|
||||
if (isDegraded() && reqPath !== '/api/health') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
data: null,
|
||||
error: `Service degraded: ${getDegradedReason() ?? 'unknown reason'}. Restart required.`
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': '60'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
event.locals.apiTokenScope = null;
|
||||
|
||||
@@ -96,11 +96,11 @@
|
||||
}
|
||||
|
||||
function actionBadgeClass(action: string): string {
|
||||
if (action.includes('deleted')) return 'bg-red-500/10 text-red-500';
|
||||
if (action.includes('created')) return 'bg-green-500/10 text-green-500';
|
||||
if (action.includes('updated')) return 'bg-blue-500/10 text-blue-500';
|
||||
if (action === 'import') return 'bg-purple-500/10 text-purple-500';
|
||||
if (action === 'export') return 'bg-yellow-500/10 text-yellow-500';
|
||||
if (action.includes('deleted')) return 'bg-destructive/10 text-destructive';
|
||||
if (action.includes('created')) return 'bg-status-online/10 text-status-online-ink';
|
||||
if (action.includes('updated')) return 'bg-room-sky/15 text-room-sky';
|
||||
if (action === 'import') return 'bg-room-lav/15 text-room-lav';
|
||||
if (action === 'export') return 'bg-status-degraded/10 text-status-degraded-ink';
|
||||
return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<select
|
||||
id="filter-action"
|
||||
bind:value={filterAction}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
{#each actionOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
@@ -151,7 +151,7 @@
|
||||
<select
|
||||
id="filter-entity"
|
||||
bind:value={filterEntityType}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
{#each entityTypeOptions as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
@@ -165,7 +165,7 @@
|
||||
id="filter-from"
|
||||
type="date"
|
||||
bind:value={filterDateFrom}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -175,14 +175,14 @@
|
||||
id="filter-to"
|
||||
type="date"
|
||||
bind:value={filterDateTo}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={applyFilters}
|
||||
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
class="rounded-xl bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
@@ -190,7 +190,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={exportCsv}
|
||||
class="ml-auto rounded-md border border-input px-4 py-1.5 text-sm font-medium text-foreground hover:bg-accent"
|
||||
class="ml-auto rounded-xl border border-input px-4 py-1.5 text-sm font-medium text-foreground hover:bg-accent"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
@@ -202,7 +202,7 @@
|
||||
<p class="text-muted-foreground">No audit log entries found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface BackupInfo {
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
format: 'tar.gz' | 'db';
|
||||
}
|
||||
|
||||
interface BackupSchedule {
|
||||
@@ -14,6 +18,14 @@
|
||||
backupMaxCount: number;
|
||||
}
|
||||
|
||||
interface SchedulerStats {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastSuccessAt: string | null;
|
||||
lastFailureAt: string | null;
|
||||
lastFailureReason: string | null;
|
||||
}
|
||||
|
||||
type CronPreset = 'daily' | 'twice_daily' | 'weekly' | 'custom';
|
||||
|
||||
let backups: BackupInfo[] = $state([]);
|
||||
@@ -22,6 +34,13 @@
|
||||
backupCronExpression: '0 3 * * *',
|
||||
backupMaxCount: 10
|
||||
});
|
||||
let stats: SchedulerStats = $state({
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
lastSuccessAt: null,
|
||||
lastFailureAt: null,
|
||||
lastFailureReason: null
|
||||
});
|
||||
|
||||
let creating = $state(false);
|
||||
let savingSchedule = $state(false);
|
||||
@@ -29,6 +48,9 @@
|
||||
let deletingFilename: string | null = $state(null);
|
||||
let confirmRestore: string | null = $state(null);
|
||||
let confirmDelete: string | null = $state(null);
|
||||
let confirmSchemaMismatch = $state(false);
|
||||
let pendingSchemaMismatchFile: string | null = $state(null);
|
||||
let pendingSchemaMismatchMessage = $state('');
|
||||
let statusMessage = $state('');
|
||||
let statusType: 'success' | 'error' | '' = $state('');
|
||||
let customCron = $state('');
|
||||
@@ -48,7 +70,8 @@
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -69,6 +92,7 @@
|
||||
if (result.success) {
|
||||
backups = result.data.backups;
|
||||
schedule = result.data.schedule;
|
||||
if (result.data.stats) stats = result.data.stats;
|
||||
cronPreset = detectPreset(schedule.backupCronExpression);
|
||||
if (cronPreset === 'custom') {
|
||||
customCron = schedule.backupCronExpression;
|
||||
@@ -111,23 +135,62 @@
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
clearStatus();
|
||||
confirmRestore = null;
|
||||
restoringFilename = filename;
|
||||
|
||||
try {
|
||||
async function performRestore(filename: string, allowSchemaMismatch: boolean): Promise<void> {
|
||||
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ allowSchemaMismatch })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.status === 409) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
pendingSchemaMismatchFile = filename;
|
||||
pendingSchemaMismatchMessage = errBody.error || $t('admin.backup_restore_schema_mismatch');
|
||||
confirmSchemaMismatch = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to restore backup');
|
||||
}
|
||||
|
||||
statusMessage = $t('admin.backup_restore_success');
|
||||
statusType = 'success';
|
||||
|
||||
if (result.data?.forceLogout) {
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
clearStatus();
|
||||
confirmRestore = null;
|
||||
restoringFilename = filename;
|
||||
|
||||
try {
|
||||
await performRestore(filename, false);
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
|
||||
statusType = 'error';
|
||||
} finally {
|
||||
restoringFilename = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSchemaMismatchConfirm() {
|
||||
const filename = pendingSchemaMismatchFile;
|
||||
confirmSchemaMismatch = false;
|
||||
pendingSchemaMismatchFile = null;
|
||||
pendingSchemaMismatchMessage = '';
|
||||
if (!filename) return;
|
||||
|
||||
clearStatus();
|
||||
restoringFilename = filename;
|
||||
try {
|
||||
await performRestore(filename, true);
|
||||
} catch (err) {
|
||||
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
|
||||
statusType = 'error';
|
||||
@@ -195,26 +258,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load backups on mount (untrack to avoid infinite re-trigger)
|
||||
$effect(() => {
|
||||
untrack(() => loadBackups());
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-card-foreground">{$t('admin.backup_title')}</h2>
|
||||
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.backup_description')}</p>
|
||||
|
||||
<!-- Create Backup -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
disabled={creating}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={creating} loading={creating}>
|
||||
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
@@ -229,6 +286,7 @@
|
||||
<thead>
|
||||
<tr class="border-b border-border text-xs uppercase text-muted-foreground">
|
||||
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_filename')}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_format')}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_size')}</th>
|
||||
<th class="pb-2 pr-4 font-medium">{$t('admin.backup_date')}</th>
|
||||
<th class="pb-2 font-medium">{$t('admin.backup_actions')}</th>
|
||||
@@ -238,6 +296,20 @@
|
||||
{#each backups as backup (backup.filename)}
|
||||
<tr class="border-b border-border/50">
|
||||
<td class="py-2.5 pr-4 font-mono text-xs text-foreground">{backup.filename}</td>
|
||||
<td class="py-2.5 pr-4 text-xs text-muted-foreground">
|
||||
{#if backup.format === 'tar.gz'}
|
||||
<span class="rounded-md bg-status-online/10 px-2 py-0.5 text-status-online-ink">
|
||||
{$t('admin.backup_format_full')}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="rounded-md bg-status-degraded/10 px-2 py-0.5 text-status-degraded-ink"
|
||||
title={$t('admin.backup_format_legacy_tooltip')}
|
||||
>
|
||||
{$t('admin.backup_format_legacy')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2.5 pr-4 text-muted-foreground">{formatBytes(backup.size)}</td>
|
||||
<td class="py-2.5 pr-4 text-muted-foreground">{formatDate(backup.createdAt)}</td>
|
||||
<td class="py-2.5">
|
||||
@@ -253,7 +325,7 @@
|
||||
type="button"
|
||||
onclick={() => (confirmRestore = backup.filename)}
|
||||
disabled={restoringFilename === backup.filename}
|
||||
class="rounded px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-600/10 disabled:opacity-50 dark:text-amber-400"
|
||||
class="rounded-lg px-2 py-1 text-xs font-medium text-status-degraded-ink hover:bg-status-degraded/10 disabled:opacity-50"
|
||||
>
|
||||
{restoringFilename === backup.filename
|
||||
? '...'
|
||||
@@ -281,14 +353,23 @@
|
||||
|
||||
<!-- Restore Confirmation Dialog -->
|
||||
{#if confirmRestore}
|
||||
{@const target = backups.find((b) => b.filename === confirmRestore)}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
|
||||
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('admin.backup_restore_confirm_title')}
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
{$t('admin.backup_restore_confirm')}
|
||||
</p>
|
||||
{#if target?.format === 'db'}
|
||||
<p class="mb-3 rounded-md border border-status-degraded/30 bg-status-degraded/10 p-3 text-xs text-status-degraded-ink">
|
||||
{$t('admin.backup_restore_legacy_warning')}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mb-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-300">
|
||||
{$t('admin.backup_restore_logout_warning')}
|
||||
</p>
|
||||
<p class="mb-4 font-mono text-xs text-foreground">{confirmRestore}</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@@ -301,7 +382,8 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => confirmRestore && handleRestore(confirmRestore)}
|
||||
class="rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
|
||||
class="rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5"
|
||||
style="background: var(--status-degraded);"
|
||||
>
|
||||
{$t('admin.backup_restore')}
|
||||
</button>
|
||||
@@ -310,10 +392,44 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Schema-mismatch follow-up confirmation -->
|
||||
{#if confirmSchemaMismatch}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
|
||||
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('admin.backup_restore_schema_mismatch_title')}
|
||||
</h3>
|
||||
<p class="mb-3 text-sm text-muted-foreground">
|
||||
{$t('admin.backup_restore_schema_mismatch_intro')}
|
||||
</p>
|
||||
<pre class="mb-4 max-h-32 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 font-mono text-[10px] text-foreground">{pendingSchemaMismatchMessage}</pre>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
confirmSchemaMismatch = false;
|
||||
pendingSchemaMismatchFile = null;
|
||||
}}
|
||||
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSchemaMismatchConfirm}
|
||||
class="rounded-xl bg-destructive px-4 py-2 text-sm font-semibold text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{$t('admin.backup_restore_schema_mismatch_force')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
{#if confirmDelete}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="mx-4 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<div class="mx-4 w-full max-w-md rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]">
|
||||
<h3 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('admin.backup_delete_confirm_title')}
|
||||
</h3>
|
||||
@@ -350,11 +466,10 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Enable toggle -->
|
||||
<label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<Switch
|
||||
bind:checked={schedule.backupEnabled}
|
||||
class="h-4 w-4 rounded border-border text-primary focus:ring-ring"
|
||||
ariaLabel={$t('admin.backup_schedule_enabled')}
|
||||
/>
|
||||
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
|
||||
</label>
|
||||
@@ -365,16 +480,12 @@
|
||||
<label for="cron-preset" class="mb-1 block text-sm text-muted-foreground">
|
||||
{$t('admin.backup_schedule_cron')}
|
||||
</label>
|
||||
<select
|
||||
id="cron-preset"
|
||||
bind:value={cronPreset}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-auto"
|
||||
>
|
||||
<Select id="cron-preset" bind:value={cronPreset} class="sm:w-auto">
|
||||
<option value="daily">{$t('admin.backup_schedule_preset_daily')}</option>
|
||||
<option value="twice_daily">{$t('admin.backup_schedule_preset_twice_daily')}</option>
|
||||
<option value="weekly">{$t('admin.backup_schedule_preset_weekly')}</option>
|
||||
<option value="custom">{$t('admin.backup_schedule_preset_custom')}</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{#if cronPreset === 'custom'}
|
||||
@@ -383,7 +494,7 @@
|
||||
type="text"
|
||||
bind:value={customCron}
|
||||
placeholder="0 3 * * *"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -399,28 +510,44 @@
|
||||
bind:value={schedule.backupMaxCount}
|
||||
min="1"
|
||||
max="100"
|
||||
class="w-24 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-24 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scheduler stats -->
|
||||
<div class="rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
<span>{$t('admin.backup_stats_success_count')}</span>
|
||||
<span class="text-right font-mono text-foreground">{stats.successCount}</span>
|
||||
<span>{$t('admin.backup_stats_failure_count')}</span>
|
||||
<span class="text-right font-mono {stats.failureCount > 0 ? 'text-destructive' : 'text-foreground'}">{stats.failureCount}</span>
|
||||
{#if stats.lastSuccessAt}
|
||||
<span>{$t('admin.backup_stats_last_success')}</span>
|
||||
<span class="text-right font-mono text-foreground">{formatDate(stats.lastSuccessAt)}</span>
|
||||
{/if}
|
||||
{#if stats.lastFailureAt}
|
||||
<span>{$t('admin.backup_stats_last_failure')}</span>
|
||||
<span class="text-right font-mono text-destructive">{formatDate(stats.lastFailureAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if stats.lastFailureReason}
|
||||
<p class="mt-2 break-words font-mono text-[10px] text-destructive">{stats.lastFailureReason}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSaveSchedule}
|
||||
disabled={savingSchedule}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Button onclick={handleSaveSchedule} disabled={savingSchedule} loading={savingSchedule}>
|
||||
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
{#if statusMessage}
|
||||
<div
|
||||
class="mt-4 rounded-md p-3 text-sm {statusType === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}"
|
||||
class="mt-4 rounded-xl border p-3 text-sm {statusType === 'success'
|
||||
? 'border-status-online/30 bg-status-online/10 text-status-online-ink'
|
||||
: 'border-destructive/30 bg-destructive/10 text-destructive'}"
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Checkbox from '$lib/components/ui/Checkbox.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface DiscoveredService {
|
||||
name: string;
|
||||
@@ -137,25 +139,24 @@
|
||||
const selectableCount = $derived(services.filter((s) => !s.alreadyRegistered).length);
|
||||
</script>
|
||||
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_title')}</h2>
|
||||
<p class="mb-6 text-xs text-muted-foreground">{$t('admin.discovery_description')}</p>
|
||||
|
||||
<!-- Scan Button -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onclick={handleScan}
|
||||
disabled={scanning || (!dockerSocketPath && !traefikApiUrl)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
loading={scanning}
|
||||
>
|
||||
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Scan Errors -->
|
||||
{#if scanErrors.length > 0}
|
||||
<div class="mb-4 rounded-md bg-yellow-100 p-3 text-sm text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
<div class="mb-4 rounded-xl border border-status-degraded/30 bg-status-degraded/10 p-3 text-sm text-status-degraded-ink">
|
||||
{#each scanErrors as scanError, idx (idx)}
|
||||
<p>{scanError}</p>
|
||||
{/each}
|
||||
@@ -169,12 +170,12 @@
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="px-2 py-2 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={selected.size === selectableCount && selectableCount > 0}
|
||||
indeterminate={selected.size > 0 && selected.size < selectableCount}
|
||||
onchange={toggleSelectAll}
|
||||
disabled={selectableCount === 0}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
ariaLabel={$t('admin.discovery_select_all') ?? 'Select all'}
|
||||
/>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-left font-medium text-muted-foreground">{$t('common.name')}</th>
|
||||
@@ -187,12 +188,11 @@
|
||||
{#each services as service, i (service.url)}
|
||||
<tr class="border-b border-border/50 hover:bg-muted/50">
|
||||
<td class="px-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={selected.has(i)}
|
||||
onchange={() => toggleSelect(i)}
|
||||
disabled={service.alreadyRegistered}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
ariaLabel={`Select ${service.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-2 font-medium text-foreground">{service.name}</td>
|
||||
@@ -204,8 +204,8 @@
|
||||
<td class="px-2 py-2">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{service.source === 'docker'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
? 'bg-room-sky/15 text-room-sky'
|
||||
: 'bg-room-lav/15 text-room-lav'
|
||||
}"
|
||||
>
|
||||
{service.source === 'docker' ? $t('admin.discovery_source_docker') : $t('admin.discovery_source_traefik')}
|
||||
@@ -215,7 +215,7 @@
|
||||
{#if service.alreadyRegistered}
|
||||
<span class="text-xs text-muted-foreground">{$t('admin.discovery_already_registered')}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-green-600 dark:text-green-400">{$t('admin.discovery_new')}</span>
|
||||
<span class="text-xs font-medium text-status-online-ink dark:text-status-online-ink">{$t('admin.discovery_new')}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -227,21 +227,20 @@
|
||||
<!-- Approve button -->
|
||||
{#if selectableCount > 0}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onclick={handleApprove}
|
||||
disabled={approving || selected.size === 0}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
loading={approving}
|
||||
>
|
||||
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Status Message -->
|
||||
{#if statusMessage}
|
||||
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
|
||||
<div class="mt-4 rounded-xl border p-3 text-sm {statusType === 'success' ? 'border-status-online/30 bg-status-online/10 text-status-online-ink' : 'border-destructive/30 bg-destructive/10 text-destructive'}">
|
||||
{statusMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { enhance } from '$app/forms';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
|
||||
interface GroupWithCount {
|
||||
id: string;
|
||||
@@ -27,7 +28,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
@@ -64,8 +65,13 @@
|
||||
placeholder={$t('common.description')}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
<label class="flex items-center gap-1 text-xs text-foreground">
|
||||
<input name="isDefault" type="checkbox" bind:checked={editIsDefault} class="h-3.5 w-3.5" />
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
|
||||
<Switch
|
||||
name="isDefault"
|
||||
bind:checked={editIsDefault}
|
||||
size="sm"
|
||||
ariaLabel={$t('admin.default_column')}
|
||||
/>
|
||||
{$t('admin.default_column')}
|
||||
</label>
|
||||
<button type="submit" class="text-xs text-primary hover:underline">{$t('common.save')}</button>
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Grant form -->
|
||||
<div class="rounded-lg border border-border bg-card p-4">
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('admin.perm_title')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
|
||||
<div>
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
<!-- Existing permissions list -->
|
||||
{#if permissions.length > 0}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import type { updateSystemSettingsSchema } from '$lib/utils/validators.js';
|
||||
import type { z } from 'zod';
|
||||
import CustomCssEditor from '$lib/components/settings/CustomCssEditor.svelte';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let {
|
||||
form: formData,
|
||||
@@ -46,32 +49,34 @@
|
||||
|
||||
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
||||
<!-- Authentication -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.authentication')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="authMode" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.auth_mode')}</label>
|
||||
<select
|
||||
<Select
|
||||
id="authMode"
|
||||
name="authMode"
|
||||
bind:value={$form.authMode}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="local">{$t('admin.auth_local')}</option>
|
||||
<option value="oauth">{$t('admin.auth_oauth')}</option>
|
||||
<option value="both">{$t('admin.auth_both')}</option>
|
||||
</select>
|
||||
</Select>
|
||||
{#if $errors.authMode}<span class="text-xs text-destructive">{$errors.authMode}</span>{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pt-6">
|
||||
<input
|
||||
<div class="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
id="registrationEnabled"
|
||||
name="registrationEnabled"
|
||||
type="checkbox"
|
||||
bind:checked={$form.registrationEnabled}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
ariaLabelledby="registrationEnabledLabel"
|
||||
/>
|
||||
<label for="registrationEnabled" class="text-sm font-medium text-foreground">
|
||||
<label
|
||||
id="registrationEnabledLabel"
|
||||
for="registrationEnabled"
|
||||
class="cursor-pointer text-sm font-medium text-foreground"
|
||||
>
|
||||
{$t('admin.registration_enabled')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -79,7 +84,7 @@
|
||||
</section>
|
||||
|
||||
<!-- OAuth Configuration -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.oauth_config')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">
|
||||
{$t('admin.oauth_description')}
|
||||
@@ -92,7 +97,7 @@
|
||||
name="oauthClientId"
|
||||
type="text"
|
||||
bind:value={$form.oauthClientId}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder={$t('admin.oauth_client_id_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -103,7 +108,7 @@
|
||||
name="oauthClientSecret"
|
||||
type="password"
|
||||
bind:value={$form.oauthClientSecret}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder={$t('admin.oauth_client_secret_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -114,22 +119,22 @@
|
||||
name="oauthDiscoveryUrl"
|
||||
type="url"
|
||||
bind:value={$form.oauthDiscoveryUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder={$t('admin.oauth_discovery_url_placeholder')}
|
||||
/>
|
||||
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={testOAuthConnection}
|
||||
disabled={oauthTesting}
|
||||
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
loading={oauthTesting}
|
||||
>
|
||||
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
|
||||
</button>
|
||||
</Button>
|
||||
{#if oauthTestResult}
|
||||
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-green-600 dark:text-green-400' : 'text-destructive'}">
|
||||
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-status-online-ink dark:text-status-online-ink' : 'text-destructive'}">
|
||||
{oauthTestResult}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -138,20 +143,19 @@
|
||||
</section>
|
||||
|
||||
<!-- Theme Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.theme_defaults')}</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="defaultTheme" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_theme')}</label>
|
||||
<select
|
||||
<Select
|
||||
id="defaultTheme"
|
||||
name="defaultTheme"
|
||||
bind:value={$form.defaultTheme}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="dark">{$t('theme.dark')}</option>
|
||||
<option value="light">{$t('theme.light')}</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="defaultPrimaryColor" class="mb-1 block text-sm font-medium text-foreground">{$t('admin.default_primary_color')}</label>
|
||||
@@ -161,8 +165,8 @@
|
||||
name="defaultPrimaryColor"
|
||||
type="text"
|
||||
bind:value={$form.defaultPrimaryColor}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="#6366f1"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="#e8754f"
|
||||
pattern="^#[0-9a-fA-F]{6}$"
|
||||
/>
|
||||
{#if $form.defaultPrimaryColor}
|
||||
@@ -178,7 +182,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Healthcheck Defaults -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.healthcheck_defaults')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.healthcheck_defaults_description')}</p>
|
||||
<div>
|
||||
@@ -188,7 +192,7 @@
|
||||
name="healthcheckDefaults"
|
||||
bind:value={$form.healthcheckDefaults}
|
||||
rows="4"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-sm text-foreground"
|
||||
placeholder={'{"interval": 300, "timeout": 5000, "method": "GET"}'}
|
||||
></textarea>
|
||||
{#if $errors.healthcheckDefaults}<span class="text-xs text-destructive">{$errors.healthcheckDefaults}</span>{/if}
|
||||
@@ -196,7 +200,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Service Discovery Configuration -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.discovery_config')}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.discovery_config_description')}</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -206,7 +210,7 @@
|
||||
id="dockerSocketPath"
|
||||
type="text"
|
||||
bind:value={dockerSocketPath}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="/var/run/docker.sock"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_docker_socket_hint')}</p>
|
||||
@@ -217,7 +221,7 @@
|
||||
id="traefikApiUrl"
|
||||
type="url"
|
||||
bind:value={traefikApiUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
placeholder="http://traefik:8080"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{$t('admin.discovery_traefik_url_hint')}</p>
|
||||
@@ -226,7 +230,7 @@
|
||||
</section>
|
||||
|
||||
<!-- System Custom CSS -->
|
||||
<section class="rounded-lg border border-border bg-card p-6">
|
||||
<section class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">{$t('admin.custom_css') ?? 'Custom CSS'}</h2>
|
||||
<p class="mb-4 text-xs text-muted-foreground">{$t('admin.custom_css_description') ?? 'System-wide custom CSS applied to all pages. Scoped to .custom-css-scope to prevent breaking core UI.'}</p>
|
||||
<input type="hidden" name="customCss" value={$form.customCss ?? ''} />
|
||||
@@ -242,12 +246,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
disabled={$delayed}
|
||||
>
|
||||
<Button type="submit" size="lg" disabled={$delayed} loading={$delayed}>
|
||||
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
@@ -14,13 +15,13 @@
|
||||
|
||||
// Create form
|
||||
let newName = $state('');
|
||||
let newColor = $state('#6366f1');
|
||||
let newColor = $state('#e8754f');
|
||||
let showCreateForm = $state(false);
|
||||
|
||||
// Edit form
|
||||
let editingTag = $state<Tag | null>(null);
|
||||
let editName = $state('');
|
||||
let editColor = $state('#6366f1');
|
||||
let editColor = $state('#e8754f');
|
||||
|
||||
// Delete confirmation
|
||||
let confirmDeleteId = $state<string | null>(null);
|
||||
@@ -56,7 +57,7 @@
|
||||
});
|
||||
if (res.ok) {
|
||||
newName = '';
|
||||
newColor = '#6366f1';
|
||||
newColor = '#e8754f';
|
||||
showCreateForm = false;
|
||||
await loadTags();
|
||||
} else {
|
||||
@@ -71,7 +72,7 @@
|
||||
function startEdit(tag: Tag) {
|
||||
editingTag = tag;
|
||||
editName = tag.name;
|
||||
editColor = tag.color ?? '#6366f1';
|
||||
editColor = tag.color ?? '#e8754f';
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
@@ -115,13 +116,9 @@
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-card-foreground">Tag Management</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateForm = !showCreateForm)}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Button onclick={() => (showCreateForm = !showCreateForm)}>
|
||||
{showCreateForm ? 'Cancel' : 'New Tag'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
@@ -132,7 +129,7 @@
|
||||
|
||||
<!-- Create Form -->
|
||||
{#if showCreateForm}
|
||||
<div class="mb-6 rounded-lg border border-border bg-card p-4">
|
||||
<div class="cozy-expand mb-6 rounded-xl border border-border bg-card p-4">
|
||||
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label for="tag-name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
@@ -141,7 +138,7 @@
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Tag name"
|
||||
class="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -157,12 +154,7 @@
|
||||
<span class="text-xs text-muted-foreground">{newColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create Tag
|
||||
</button>
|
||||
<Button type="submit">Create Tag</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
let selectedGroupId = $state('');
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -126,4 +126,13 @@
|
||||
.status-ring-unknown {
|
||||
animation: ring-rotate-dash 8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-ring-online,
|
||||
.status-ring-offline,
|
||||
.status-ring-degraded,
|
||||
.status-ring-unknown {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Cozy "room" pastel tint — stable per app, derived from its name
|
||||
const roomTints = ['var(--room-terra)', 'var(--room-sky)', 'var(--room-sage)', 'var(--room-butter)', 'var(--room-lav)', 'var(--room-peach)'];
|
||||
const tint = $derived.by(() => {
|
||||
let h = 0;
|
||||
for (const ch of app.name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return roomTints[h % roomTints.length];
|
||||
});
|
||||
|
||||
const iconDisplay = $derived.by(() => {
|
||||
if (!app.icon) return null;
|
||||
|
||||
@@ -82,32 +90,39 @@
|
||||
tabindex="0"
|
||||
onclick={() => window.open(app.url, '_blank', 'noopener,noreferrer')}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') window.open(app.url, '_blank', 'noopener,noreferrer'); }}
|
||||
class="card-hover group flex cursor-pointer flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50"
|
||||
class="card-hover group relative flex cursor-pointer flex-col overflow-hidden rounded-[1.4rem] border border-border bg-card p-5 shadow-[var(--shadow-soft)]"
|
||||
title={app.description ?? app.name}
|
||||
>
|
||||
<!-- soft blob accent -->
|
||||
<span
|
||||
class="pointer-events-none absolute -right-10 -top-12 h-28 w-28 rounded-full opacity-40 blur-2xl transition-opacity duration-300 group-hover:opacity-70"
|
||||
style="background: {tint};"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-2xl text-lg"
|
||||
style="background: color-mix(in srgb, {tint} 18%, transparent); color: {tint};"
|
||||
>
|
||||
{#if iconDisplay?.kind === 'emoji'}
|
||||
<span class="text-xl">{iconDisplay.value}</span>
|
||||
<span class="text-2xl">{iconDisplay.value}</span>
|
||||
{:else if iconDisplay?.kind === 'image'}
|
||||
<img
|
||||
src={iconDisplay.src}
|
||||
alt="{app.name} icon"
|
||||
class="h-6 w-6 rounded object-contain"
|
||||
class="h-7 w-7 rounded-lg object-contain"
|
||||
/>
|
||||
{:else if iconDisplay?.kind === 'text'}
|
||||
<span class="text-xs font-medium">{iconDisplay.value}</span>
|
||||
<span class="text-sm font-bold">{iconDisplay.value}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-bold">{app.name.charAt(0).toUpperCase()}</span>
|
||||
<span class="text-sm font-bold">{app.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<a
|
||||
href="/apps/{app.id}/edit"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
class="rounded-md p-1 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
class="rounded-xl p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
title={$t('app.edit')}
|
||||
>
|
||||
<svg
|
||||
@@ -128,12 +143,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
|
||||
<h3 class="truncate font-display text-base font-semibold text-card-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</h3>
|
||||
|
||||
{#if app.description}
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{app.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Sparkline -->
|
||||
@@ -143,14 +158,15 @@
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
<span class="text-[11px] font-medium text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.category}
|
||||
<span
|
||||
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
class="mt-3 inline-block self-start rounded-full px-2.5 py-0.5 text-[11px] font-semibold"
|
||||
style="background: color-mix(in srgb, {tint} 18%, transparent); color: color-mix(in srgb, {tint} 68%, var(--foreground));"
|
||||
>
|
||||
{app.category}
|
||||
</span>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import TagsInput from '$lib/components/ui/TagsInput.svelte';
|
||||
import IconGrid from '$lib/components/ui/IconGrid.svelte';
|
||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
type AppSchema = z.infer<typeof createAppSchema>;
|
||||
|
||||
@@ -121,7 +123,7 @@
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={$form.name}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder={$t('app.name_placeholder')}
|
||||
/>
|
||||
{#if $errors.name}
|
||||
@@ -138,7 +140,7 @@
|
||||
name="url"
|
||||
type="url"
|
||||
bind:value={$form.url}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder={$t('app.url_placeholder')}
|
||||
/>
|
||||
{#if $errors.url}
|
||||
@@ -170,7 +172,7 @@
|
||||
name="description"
|
||||
type="text"
|
||||
bind:value={$form.description}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder={$t('app.description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +188,7 @@
|
||||
bind:value={$form.category}
|
||||
suggestions={categorySuggestions}
|
||||
placeholder={$t('app.category_placeholder')}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +202,7 @@
|
||||
bind:value={$form.tags}
|
||||
suggestions={tagSuggestions}
|
||||
placeholder={$t('app.tags_placeholder')}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,22 +221,34 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showAdvanced}
|
||||
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform duration-200 {showAdvanced ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 4 10 8 6 12" />
|
||||
</svg>
|
||||
{showAdvanced ? $t('app.healthcheck_hide') : $t('app.healthcheck_show')} {$t('app.healthcheck_toggle')}
|
||||
</button>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="space-y-4 rounded-md border border-border p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="healthcheckEnabled"
|
||||
name="healthcheckEnabled"
|
||||
type="checkbox"
|
||||
bind:checked={$form.healthcheckEnabled}
|
||||
class="rounded border-input"
|
||||
ariaLabelledby="healthcheckEnabledLabel"
|
||||
/>
|
||||
<label for="healthcheckEnabled" class="text-sm text-card-foreground">
|
||||
<label id="healthcheckEnabledLabel" for="healthcheckEnabled" class="cursor-pointer text-sm text-card-foreground">
|
||||
{$t('app.healthcheck_enabled')}
|
||||
</label>
|
||||
</div>
|
||||
@@ -269,7 +283,7 @@
|
||||
name="healthcheckExpectedStatus"
|
||||
type="number"
|
||||
bind:value={$form.healthcheckExpectedStatus}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
min="100"
|
||||
max="599"
|
||||
/>
|
||||
@@ -287,7 +301,7 @@
|
||||
name="healthcheckTimeout"
|
||||
type="number"
|
||||
bind:value={$form.healthcheckTimeout}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
min="1000"
|
||||
max="30000"
|
||||
step="1000"
|
||||
@@ -307,7 +321,7 @@
|
||||
name="healthcheckInterval"
|
||||
type="number"
|
||||
bind:value={$form.healthcheckInterval}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
min="30"
|
||||
max="86400"
|
||||
/>
|
||||
@@ -320,22 +334,34 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showIntegration = !showIntegration)}
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showIntegration}
|
||||
class="group inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform duration-200 {showIntegration ? 'rotate-90' : ''}"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 4 10 8 6 12" />
|
||||
</svg>
|
||||
{showIntegration ? 'Hide' : 'Show'} Integration Settings
|
||||
</button>
|
||||
|
||||
{#if showIntegration}
|
||||
<div class="space-y-4 rounded-md border border-border p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
<div class="cozy-expand space-y-4 rounded-xl border border-border p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="integrationEnabled"
|
||||
name="integrationEnabled"
|
||||
type="checkbox"
|
||||
bind:checked={$form.integrationEnabled}
|
||||
class="rounded border-input"
|
||||
ariaLabelledby="integrationEnabledLabel"
|
||||
/>
|
||||
<label for="integrationEnabled" class="text-sm text-card-foreground">
|
||||
<label id="integrationEnabledLabel" for="integrationEnabled" class="cursor-pointer text-sm text-card-foreground">
|
||||
Enable Integration
|
||||
</label>
|
||||
</div>
|
||||
@@ -349,7 +375,7 @@
|
||||
id="integrationType"
|
||||
name="integrationType"
|
||||
bind:value={$form.integrationType}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{#each availableIntegrations as integration (integration.id)}
|
||||
@@ -395,7 +421,7 @@
|
||||
{testingConnection ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{#if testResult}
|
||||
<span class="text-sm {testResult.success ? 'text-green-500' : 'text-destructive'}">
|
||||
<span class="text-sm {testResult.success ? 'text-status-online-ink' : 'text-destructive'}">
|
||||
{testResult.message}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -409,16 +435,12 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={$submitting}
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" size="lg" disabled={$submitting} loading={$submitting}>
|
||||
{#if $submitting}
|
||||
{$t('app.saving')}
|
||||
{:else}
|
||||
{mode === 'edit' ? $t('app.update') : $t('app.save')}
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,18 +10,27 @@
|
||||
const config = $derived.by(() => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return { color: 'bg-green-500', cssClass: 'status-online', textKey: 'status.online' };
|
||||
return { color: 'var(--status-online)', ink: 'var(--status-online-ink)', cssClass: 'status-online', textKey: 'status.online' };
|
||||
case 'offline':
|
||||
return { color: 'bg-red-500', cssClass: '', textKey: 'status.offline' };
|
||||
return { color: 'var(--status-offline)', ink: 'var(--status-offline-ink)', cssClass: 'status-offline', textKey: 'status.offline' };
|
||||
case 'degraded':
|
||||
return { color: 'bg-yellow-500', cssClass: '', textKey: 'status.degraded' };
|
||||
return { color: 'var(--status-degraded)', ink: 'var(--status-degraded-ink)', cssClass: 'status-degraded', textKey: 'status.degraded' };
|
||||
default:
|
||||
return { color: 'bg-gray-500', cssClass: '', textKey: 'status.unknown' };
|
||||
return { color: 'var(--status-unknown)', ink: 'var(--status-unknown-ink)', cssClass: '', textKey: 'status.unknown' };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span class="inline-block h-2 w-2 rounded-full {config.color} {config.cssClass}"></span>
|
||||
<span class="text-muted-foreground">{$t(config.textKey)}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style="color: {config.ink}; background: color-mix(in srgb, {config.color} 14%, transparent);"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full {config.cssClass}"
|
||||
style="background: {config.color};"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span>{$t(config.textKey)}</span>
|
||||
</span>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
: iconType === 'url'
|
||||
? $t('app.icon_url_placeholder')
|
||||
: $t('app.icon_emoji_placeholder')}
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="flex-1 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface LinkItem {
|
||||
id: string;
|
||||
@@ -164,13 +165,13 @@
|
||||
type="text"
|
||||
bind:value={newLabel}
|
||||
placeholder="Link label"
|
||||
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={newUrl}
|
||||
placeholder="https://..."
|
||||
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
@@ -178,13 +179,13 @@
|
||||
type="text"
|
||||
bind:value={newIcon}
|
||||
placeholder="Icon (optional)"
|
||||
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
class="flex-1 rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addLink}
|
||||
disabled={!newLabel.trim() || !newUrl.trim()}
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
@@ -192,12 +193,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveLinks}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Button onclick={saveLinks} disabled={saving} loading={saving}>
|
||||
{saving ? 'Saving...' : 'Save Links'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
const statusColor = $derived(() => {
|
||||
if (!result) return '';
|
||||
if (result.error) return 'text-destructive';
|
||||
if (result.status >= 200 && result.status < 300) return 'text-green-500';
|
||||
if (result.status >= 300 && result.status < 400) return 'text-yellow-500';
|
||||
if (result.status >= 200 && result.status < 300) return 'text-status-online-ink';
|
||||
if (result.status >= 300 && result.status < 400) return 'text-status-degraded-ink';
|
||||
return 'text-destructive';
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { IntegrationFieldDescriptor } from '$lib/server/integrations/types.js';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
|
||||
interface Props {
|
||||
fields: IntegrationFieldDescriptor[];
|
||||
@@ -10,7 +11,7 @@
|
||||
|
||||
let { fields, values, onchange, idPrefix = 'int' }: Props = $props();
|
||||
|
||||
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -23,16 +24,15 @@
|
||||
{/if}
|
||||
</label>
|
||||
{#if field.type === 'boolean'}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
id="{idPrefix}-{field.name}"
|
||||
type="checkbox"
|
||||
checked={!!values[field.name]}
|
||||
onchange={(e) => onchange(field.name, e.currentTarget.checked)}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
onchange={(checked) => onchange(field.name, checked)}
|
||||
ariaLabel={field.label}
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">{field.description ?? ''}</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if field.type === 'number'}
|
||||
<input
|
||||
id="{idPrefix}-{field.name}"
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
let { data, width = 80, height = 20 }: Props = $props();
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
online: '#22c55e',
|
||||
offline: '#ef4444',
|
||||
degraded: '#eab308',
|
||||
unknown: '#6b7280'
|
||||
online: 'var(--status-online)',
|
||||
offline: 'var(--status-offline)',
|
||||
degraded: 'var(--status-degraded)',
|
||||
unknown: 'var(--status-unknown)'
|
||||
};
|
||||
|
||||
const barWidth = $derived(data.length > 0 ? Math.max(1, (width - 2) / data.length) : 1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte.js';
|
||||
import CozyAmbient from './CozyAmbient.svelte';
|
||||
import MeshGradient from './MeshGradient.svelte';
|
||||
import ParticleField from './ParticleField.svelte';
|
||||
import AuroraEffect from './AuroraEffect.svelte';
|
||||
@@ -16,7 +17,9 @@
|
||||
|
||||
{#if theme.backgroundType !== 'none'}
|
||||
<div class="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
|
||||
{#if theme.backgroundType === 'mesh'}
|
||||
{#if theme.backgroundType === 'cozy'}
|
||||
<CozyAmbient />
|
||||
{:else if theme.backgroundType === 'mesh'}
|
||||
<MeshGradient />
|
||||
{:else if theme.backgroundType === 'particles'}
|
||||
<ParticleField />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
Cozy Home ambient backdrop — static, soft warm-corner radial gradients.
|
||||
Calm "lit room" atmosphere (no animation), retints with the accent hue.
|
||||
-->
|
||||
<div class="cozy-ambient absolute inset-0"></div>
|
||||
|
||||
<style>
|
||||
.cozy-ambient {
|
||||
background:
|
||||
radial-gradient(50% 42% at 12% 0%, color-mix(in srgb, var(--room-peach) 26%, transparent), transparent 70%),
|
||||
radial-gradient(45% 40% at 95% 6%, color-mix(in srgb, var(--room-sky) 22%, transparent), transparent 70%),
|
||||
radial-gradient(52% 46% at 85% 100%, color-mix(in srgb, var(--room-sage) 20%, transparent), transparent 72%),
|
||||
radial-gradient(46% 42% at 8% 96%, color-mix(in srgb, var(--room-lav) 16%, transparent), transparent 72%);
|
||||
}
|
||||
|
||||
:global(.dark) .cozy-ambient {
|
||||
background:
|
||||
radial-gradient(52% 44% at 12% 0%, color-mix(in srgb, var(--room-terra) 20%, transparent), transparent 70%),
|
||||
radial-gradient(46% 42% at 95% 6%, color-mix(in srgb, var(--room-sky) 16%, transparent), transparent 70%),
|
||||
radial-gradient(54% 48% at 85% 100%, color-mix(in srgb, var(--room-sage) 14%, transparent), transparent 72%);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -42,6 +42,13 @@
|
||||
|
||||
$effect(() => {
|
||||
blobs = initBlobs();
|
||||
|
||||
// Respect reduced-motion: render a static mesh, skip the rAF loop.
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (prefersReducedMotion) return;
|
||||
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -34,21 +34,21 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-sm">
|
||||
<div class="flex items-center gap-2 rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-soft)]">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
bind:value={title}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={$t('board.section_title') ?? 'Section title...'}
|
||||
class="flex-1 rounded-lg border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class="flex-1 rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
<IconPickerButton value={icon} onchange={(v) => { icon = v; }} size="sm" />
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSubmit}
|
||||
disabled={!title.trim()}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{$t('common.add') ?? 'Add'}
|
||||
</button>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Grant form -->
|
||||
<div class="rounded-lg border border-border bg-card p-4">
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-card-foreground">{$t('board.access_grant')}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
@@ -131,7 +131,7 @@
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
{#if searchQuery.length > 0 && filteredTargetOptions.length > 0}
|
||||
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||
<div class="absolute z-10 mt-1 max-h-40 w-full overflow-y-auto rounded border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
{#each filteredTargetOptions as option (option.id)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -190,7 +190,7 @@
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">{$t('board.access_loading')}</p>
|
||||
{:else if permissions.length > 0}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -20,32 +20,53 @@
|
||||
let { board }: Props = $props();
|
||||
|
||||
const sectionCount = $derived(board._count?.sections ?? 0);
|
||||
|
||||
// Stable per-board pastel "room" tint derived from the name
|
||||
const roomTints = ['var(--room-terra)', 'var(--room-sky)', 'var(--room-sage)', 'var(--room-butter)', 'var(--room-lav)', 'var(--room-peach)'];
|
||||
const tint = $derived.by(() => {
|
||||
let h = 0;
|
||||
for (const ch of board.name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return roomTints[h % roomTints.length];
|
||||
});
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="card-hover group block rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50"
|
||||
class="card-hover group relative block overflow-hidden rounded-[1.4rem] border border-border bg-card p-5 shadow-[var(--shadow-soft)]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<span
|
||||
class="pointer-events-none absolute -right-10 -top-12 h-28 w-28 rounded-full opacity-40 blur-2xl transition-opacity duration-300 group-hover:opacity-70"
|
||||
style="background: {tint};"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="flex items-start gap-3.5">
|
||||
{#if board.icon}
|
||||
<span
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
|
||||
style="background: color-mix(in srgb, {tint} 18%, transparent); color: {tint};"
|
||||
>
|
||||
<DynamicIcon name={board.icon} size={22} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
|
||||
B
|
||||
<span
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl text-sm font-bold text-white"
|
||||
style="background: {tint};"
|
||||
>
|
||||
{board.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="truncate font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
<h3 class="truncate font-display text-base font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||
{board.name}
|
||||
</h3>
|
||||
{#if board.isDefault}
|
||||
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary">
|
||||
<span class="shrink-0 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
|
||||
{$t('board.default')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if board.isGuestAccessible}
|
||||
<span class="shrink-0 flex items-center gap-1 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground" title={$t('board.guest_accessible')}>
|
||||
<span class="shrink-0 flex items-center gap-1 rounded-full bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground" title={$t('board.guest_accessible')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
@@ -54,7 +75,7 @@
|
||||
{$t('board.guest')}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="shrink-0 flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground" title={$t('board.access_private')}>
|
||||
<span class="shrink-0 flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground" title={$t('board.access_private')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
@@ -62,7 +83,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if board.hasSharedPermissions}
|
||||
<span class="shrink-0 flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-xs text-blue-500" title={$t('board.access_shared')}>
|
||||
<span class="shrink-0 flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium" style="background: color-mix(in srgb, var(--room-sky) 18%, transparent); color: color-mix(in srgb, var(--room-sky) 70%, var(--foreground));" title={$t('board.access_shared')}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
|
||||
@@ -29,13 +29,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3.5">
|
||||
{#if icon}
|
||||
<DynamicIcon name={icon} size={28} />
|
||||
<span
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl text-primary shadow-[var(--shadow-soft)]"
|
||||
style="background: color-mix(in srgb, var(--primary) 14%, transparent);"
|
||||
>
|
||||
<DynamicIcon name={icon} size={26} />
|
||||
</span>
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
|
||||
<h1 class="font-display text-3xl font-semibold text-foreground">{name}</h1>
|
||||
{#if description}
|
||||
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
@@ -45,7 +50,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
href="/boards"
|
||||
class="rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
class="rounded-xl border border-border bg-card px-3.5 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
|
||||
>
|
||||
{$t('board.all_boards')}
|
||||
</a>
|
||||
@@ -53,7 +58,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={onShare}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
class="flex items-center gap-1.5 rounded-xl border border-border bg-card px-3.5 py-2 text-sm font-medium text-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 hover:border-primary/40"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
@@ -69,9 +74,9 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleEditToggle}
|
||||
class="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors {editMode.active
|
||||
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
|
||||
class="flex items-center gap-1.5 rounded-xl px-3.5 py-2 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5 {editMode.active
|
||||
? 'bg-primary ring-2 ring-primary/30'
|
||||
: 'bg-primary hover:shadow-[var(--shadow-lift)]'}"
|
||||
>
|
||||
{#if editMode.active}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import { editMode } from '$lib/stores/editMode.svelte.js';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import IconPickerButton from '$lib/components/ui/IconPickerButton.svelte';
|
||||
import Slider from '$lib/components/ui/Slider.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface BoardData {
|
||||
id: string;
|
||||
@@ -82,7 +85,7 @@
|
||||
|
||||
<!-- Side panel -->
|
||||
<div
|
||||
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-border bg-card shadow-2xl"
|
||||
class="fixed right-0 top-0 z-50 flex h-full w-full max-w-md flex-col border-l border-border bg-card shadow-[var(--shadow-lift)]"
|
||||
transition:fly={{ x: 400, duration: 250 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
@@ -107,7 +110,7 @@
|
||||
<div>
|
||||
<label for="bp-name" class="mb-1 block text-sm font-medium text-foreground">{$t('common.name') ?? 'Name'}</label>
|
||||
<input id="bp-name" type="text" bind:value={name}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
@@ -121,53 +124,82 @@
|
||||
<div>
|
||||
<label for="bp-desc" class="mb-1 block text-sm font-medium text-foreground">{$t('common.description') ?? 'Description'}</label>
|
||||
<textarea id="bp-desc" rows="2" bind:value={description}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Theme preview swatch -->
|
||||
<div class="flex items-center gap-3 rounded-xl border border-border bg-muted/30 p-3">
|
||||
<span
|
||||
class="h-12 w-12 shrink-0 rounded-2xl shadow-[var(--shadow-soft)]"
|
||||
style="background: hsl({themeHue} {themeSaturation}% 56%);"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium text-foreground">{$t('board.theme_preview') ?? 'Theme preview'}</p>
|
||||
<p class="font-mono text-xs text-muted-foreground">hsl({themeHue}°, {themeSaturation}%, 56%)</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl px-3 py-1.5 text-xs font-medium text-primary-foreground shadow-[var(--shadow-soft)]"
|
||||
style="background: hsl({themeHue} {themeSaturation}% 56%);"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{$t('common.sample') ?? 'Sample'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme Hue -->
|
||||
<div>
|
||||
<label for="bp-hue" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_hue') ?? 'Theme Hue'}</label>
|
||||
<input id="bp-hue" type="range" min="0" max="360" bind:value={themeHue}
|
||||
class="w-full accent-primary" />
|
||||
<span class="text-xs text-muted-foreground">{themeHue}°</span>
|
||||
<label for="bp-hue" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
|
||||
<span>{$t('board.theme_hue') ?? 'Theme Hue'}</span>
|
||||
<span class="tabular-nums text-xs text-muted-foreground">{themeHue}°</span>
|
||||
</label>
|
||||
<Slider id="bp-hue" min={0} max={360} bind:value={themeHue} ariaLabel={$t('board.theme_hue') ?? 'Theme Hue'} />
|
||||
</div>
|
||||
|
||||
<!-- Theme Saturation -->
|
||||
<div>
|
||||
<label for="bp-sat" class="mb-1 block text-sm font-medium text-foreground">{$t('board.theme_saturation') ?? 'Saturation'}</label>
|
||||
<input id="bp-sat" type="range" min="0" max="100" bind:value={themeSaturation}
|
||||
class="w-full accent-primary" />
|
||||
<span class="text-xs text-muted-foreground">{themeSaturation}%</span>
|
||||
<label for="bp-sat" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
|
||||
<span>{$t('board.theme_saturation') ?? 'Saturation'}</span>
|
||||
<span class="tabular-nums text-xs text-muted-foreground">{themeSaturation}%</span>
|
||||
</label>
|
||||
<Slider id="bp-sat" min={0} max={100} bind:value={themeSaturation} ariaLabel={$t('board.theme_saturation') ?? 'Saturation'} />
|
||||
</div>
|
||||
|
||||
<!-- Background Type -->
|
||||
<div>
|
||||
<label for="bp-bg" class="mb-1 block text-sm font-medium text-foreground">{$t('board.background') ?? 'Background'}</label>
|
||||
<select id="bp-bg" bind:value={backgroundType}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
|
||||
<Select id="bp-bg" bind:value={backgroundType}>
|
||||
<option value="none">None</option>
|
||||
<option value="mesh">Mesh Gradient</option>
|
||||
<option value="particles">Particles</option>
|
||||
<option value="aurora">Aurora</option>
|
||||
<option value="wallpaper">Wallpaper</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Wallpaper settings (conditional) -->
|
||||
{#if backgroundType === 'wallpaper'}
|
||||
<div class="space-y-3 rounded-lg border border-border bg-background/50 p-3">
|
||||
<div class="cozy-expand space-y-3 rounded-xl border border-border bg-background/50 p-3">
|
||||
<div>
|
||||
<label for="bp-wp-url" class="mb-1 block text-sm font-medium text-foreground">Wallpaper URL</label>
|
||||
<input id="bp-wp-url" type="text" bind:value={wallpaperUrl} placeholder="https://..."
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30" />
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="bp-wp-blur" class="mb-1 block text-sm font-medium text-foreground">Blur ({wallpaperBlur}px)</label>
|
||||
<input id="bp-wp-blur" type="range" min="0" max="20" bind:value={wallpaperBlur} class="w-full accent-primary" />
|
||||
<label for="bp-wp-blur" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
|
||||
<span>Blur</span>
|
||||
<span class="tabular-nums text-xs text-muted-foreground">{wallpaperBlur}px</span>
|
||||
</label>
|
||||
<Slider id="bp-wp-blur" min={0} max={20} bind:value={wallpaperBlur} ariaLabel="Blur" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="bp-wp-overlay" class="mb-1 block text-sm font-medium text-foreground">Overlay ({Math.round(wallpaperOverlay * 100)}%)</label>
|
||||
<input id="bp-wp-overlay" type="range" min="0" max="1" step="0.05" bind:value={wallpaperOverlay} class="w-full accent-primary" />
|
||||
<label for="bp-wp-overlay" class="mb-1 flex items-center justify-between text-sm font-medium text-foreground">
|
||||
<span>Overlay</span>
|
||||
<span class="tabular-nums text-xs text-muted-foreground">{Math.round(wallpaperOverlay * 100)}%</span>
|
||||
</label>
|
||||
<Slider id="bp-wp-overlay" min={0} max={1} step={0.05} bind:value={wallpaperOverlay} ariaLabel="Overlay" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -175,38 +207,29 @@
|
||||
<!-- Card Size -->
|
||||
<div>
|
||||
<label for="bp-cardsize" class="mb-1 block text-sm font-medium text-foreground">{$t('board.card_size') ?? 'Card Size'}</label>
|
||||
<select id="bp-cardsize" bind:value={cardSize}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30">
|
||||
<Select id="bp-cardsize" bind:value={cardSize}>
|
||||
<option value="compact">Compact</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<div>
|
||||
<label for="bp-css" class="mb-1 block text-sm font-medium text-foreground">{$t('board.custom_css') ?? 'Custom CSS'}</label>
|
||||
<textarea id="bp-css" rows="4" bind:value={customCss} placeholder={'.board { ... }'}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"></textarea>
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<Button variant="outline" onclick={onClose}>
|
||||
{$t('common.cancel') ?? 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave}>
|
||||
{$t('common.apply') ?? 'Apply'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { TargetType, PermissionLevel } from '$lib/utils/constants.js';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import {
|
||||
loadBoardPermissions,
|
||||
grantBoardPermission,
|
||||
@@ -153,7 +154,7 @@
|
||||
onclick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="mx-4 w-full max-w-lg rounded-xl border border-border bg-card p-6 shadow-xl" role="dialog" aria-modal="true">
|
||||
<div class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]" role="dialog" aria-modal="true">
|
||||
<!-- Header -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-card-foreground">
|
||||
@@ -177,7 +178,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCopyLink}
|
||||
class="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
class="flex items-center gap-2 rounded-xl border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
@@ -188,19 +189,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Guest access toggle -->
|
||||
<div class="mb-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<label class="flex items-center gap-3 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div class="mb-4 rounded-xl border border-border bg-muted/30 p-3">
|
||||
<div class="flex items-center gap-3 text-sm text-foreground">
|
||||
<Switch
|
||||
checked={isGuestAccessible}
|
||||
onchange={(e) => onGuestToggle(e.currentTarget.checked)}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
onchange={onGuestToggle}
|
||||
ariaLabel={$t('board.guest_accessible')}
|
||||
/>
|
||||
<div>
|
||||
<span class="font-medium">{$t('board.guest_accessible')}</span>
|
||||
<p class="text-xs text-muted-foreground">{$t('board.share_guest_description')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick add permission -->
|
||||
@@ -210,7 +210,7 @@
|
||||
<select
|
||||
bind:value={selectedTargetType}
|
||||
onchange={() => { selectedTargetId = ''; searchQuery = ''; }}
|
||||
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value={TargetType.USER}>{$t('admin.perm_user')}</option>
|
||||
<option value={TargetType.GROUP}>{$t('admin.perm_group')}</option>
|
||||
@@ -220,10 +220,10 @@
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$t('board.access_search_placeholder')}
|
||||
class="w-full rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
{#if searchQuery.length > 0 && filteredTargetOptions.length > 0 && !selectedTargetId}
|
||||
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded border border-border bg-card shadow-lg">
|
||||
<div class="absolute z-10 mt-1 max-h-32 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
{#each filteredTargetOptions as option (option.id)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -238,7 +238,7 @@
|
||||
</div>
|
||||
<select
|
||||
bind:value={selectedLevel}
|
||||
class="rounded border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value={PermissionLevel.VIEW}>{$t('admin.perm_view')}</option>
|
||||
<option value={PermissionLevel.EDIT}>{$t('admin.perm_edit')}</option>
|
||||
@@ -248,7 +248,7 @@
|
||||
type="button"
|
||||
onclick={handleGrant}
|
||||
disabled={!selectedTargetId}
|
||||
class="shrink-0 rounded bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
class="shrink-0 rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{$t('common.add')}
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
transition:fly={{ y: 60, duration: 250 }}
|
||||
>
|
||||
<!-- Toolbar pill -->
|
||||
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-xl backdrop-blur-sm">
|
||||
<div class="flex items-center gap-1 rounded-2xl border border-border bg-card/95 px-2 py-1.5 shadow-[var(--shadow-lift)] backdrop-blur-sm">
|
||||
<!-- Save -->
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
onclick={() => selectTemplate(template.id)}
|
||||
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-colors {selected === template.id ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
{#if template.icon}
|
||||
<DynamicIcon name={template.icon} size={20} />
|
||||
{:else}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</script>
|
||||
|
||||
{#if favorites.hasFavorites}
|
||||
<div class="mb-4 rounded-lg border border-border bg-card/50 px-3 py-2 backdrop-blur-sm">
|
||||
<div class="mb-4 rounded-2xl border border-border bg-card/60 px-3 py-2.5 shadow-[var(--shadow-soft)] backdrop-blur-sm">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
use:dndzone={{
|
||||
@@ -75,7 +75,7 @@
|
||||
href={item.app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group relative flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
class="group relative flex items-center gap-1.5 rounded-xl bg-muted/60 px-3 py-1.5 text-xs font-semibold text-foreground transition-all hover:-translate-y-0.5 hover:bg-accent hover:text-accent-foreground hover:shadow-[var(--shadow-soft)]"
|
||||
title={item.app.name}
|
||||
oncontextmenu={(e) => handleRemove(e, item.appId)}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import { buttonClass } from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
|
||||
@@ -21,6 +22,7 @@
|
||||
}
|
||||
|
||||
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
||||
{ value: 'cozy', labelKey: 'bg.cozy' },
|
||||
{ value: 'mesh', labelKey: 'bg.mesh' },
|
||||
{ value: 'particles', labelKey: 'bg.particles' },
|
||||
{ value: 'aurora', labelKey: 'bg.aurora' },
|
||||
@@ -29,14 +31,14 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
|
||||
class="sticky top-0 z-20 flex h-16 items-center gap-3 bg-background/70 px-5 backdrop-blur-md"
|
||||
>
|
||||
<!-- Mobile hamburger -->
|
||||
{#if ui.isMobile}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label={$t('sidebar.toggle')}
|
||||
>
|
||||
<svg
|
||||
@@ -64,7 +66,7 @@
|
||||
<!-- Background selector -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title={$t('bg.title')}
|
||||
aria-label={$t('bg.aria_label')}
|
||||
>
|
||||
@@ -84,13 +86,13 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
class="z-50 w-44 rounded-2xl border border-border bg-popover p-1.5 shadow-[var(--shadow-lift)]"
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
>
|
||||
{#each bgOptions as opt (opt.value)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-2.5 py-2 text-sm transition-colors {theme.backgroundType === opt.value
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-popover-foreground hover:bg-accent/50'}"
|
||||
onSelect={() => theme.setBackground(opt.value)}
|
||||
@@ -131,10 +133,11 @@
|
||||
{#if user}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
class="flex items-center gap-2.5 rounded-2xl px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-xl text-xs font-bold text-white shadow-[var(--shadow-soft)]"
|
||||
style="background: linear-gradient(135deg, var(--room-lav), var(--room-sky));"
|
||||
>
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
@@ -144,7 +147,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
class="z-50 w-48 rounded-2xl border border-border bg-popover p-1.5 shadow-[var(--shadow-lift)]"
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
>
|
||||
@@ -154,7 +157,7 @@
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
onSelect={() => goto('/settings')}
|
||||
>
|
||||
<svg
|
||||
@@ -174,7 +177,7 @@
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
onSelect={() => goto('/settings/api-tokens')}
|
||||
>
|
||||
<svg
|
||||
@@ -197,7 +200,7 @@
|
||||
|
||||
<form method="POST" action="/auth/logout" id="logout-form" class="contents">
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
onSelect={submitLogout}
|
||||
>
|
||||
<svg
|
||||
@@ -221,11 +224,6 @@
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{$t('auth.login')}
|
||||
</a>
|
||||
<a href="/login" class={buttonClass()}>{$t('auth.login')}</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { Download, X } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
const DISMISS_KEY = 'wal-install-prompt-dismissed';
|
||||
|
||||
@@ -67,10 +68,10 @@
|
||||
{#if visible}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||
class="fixed bottom-4 left-4 right-4 z-50 mx-auto flex max-w-lg items-center gap-3 rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
|
||||
@@ -83,13 +84,9 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={install}
|
||||
class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Button onclick={install} class="shrink-0">
|
||||
{$t('install.button')}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleLocale}
|
||||
class="inline-flex items-center justify-center rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="inline-flex h-10 w-10 items-center justify-center rounded-xl text-xs font-bold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
title={$locale === 'ru' ? 'Switch to English' : 'Переключить на русский'}
|
||||
>
|
||||
{$locale === 'ru' ? 'RU' : 'EN'}
|
||||
|
||||
@@ -24,83 +24,83 @@
|
||||
function isActive(path: string): boolean {
|
||||
return $page.url.pathname.startsWith(path);
|
||||
}
|
||||
|
||||
// Cozy "room" accent palette — board chips rotate through these
|
||||
const roomColors = [
|
||||
'var(--room-terra)',
|
||||
'var(--room-sky)',
|
||||
'var(--room-sage)',
|
||||
'var(--room-butter)',
|
||||
'var(--room-lav)',
|
||||
'var(--room-peach)'
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
|
||||
class="flex h-full flex-col bg-sidebar p-3 transition-all duration-200"
|
||||
class:w-64={!collapsed}
|
||||
class:w-16={collapsed}
|
||||
class:w-[4.75rem]={collapsed}
|
||||
>
|
||||
<!-- Brand -->
|
||||
<div class="flex h-14 items-center border-b border-sidebar-border px-4">
|
||||
<div class="mb-5 flex h-12 items-center px-1.5 {collapsed ? 'justify-center' : 'gap-3'}">
|
||||
<a href="/" class="flex items-center gap-3 text-sidebar-foreground" title={$t('app_name')}>
|
||||
<span
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl text-primary-foreground shadow-[var(--shadow-soft)]"
|
||||
style="background: linear-gradient(135deg, var(--room-peach), var(--room-terra));"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="2" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="2" />
|
||||
</svg>
|
||||
</span>
|
||||
{#if !collapsed}
|
||||
<a href="/" class="flex items-center gap-2 text-sidebar-foreground">
|
||||
<svg
|
||||
class="h-6 w-6 text-sidebar-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">{$t('app_name')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/" class="mx-auto text-sidebar-primary" title={$t('app_name')}>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</a>
|
||||
<span class="leading-tight">
|
||||
<span class="block font-display text-base font-semibold">{$t('app_name')}</span>
|
||||
<span class="block text-[11px] font-medium text-sidebar-foreground/50">home cloud</span>
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 py-3">
|
||||
<nav class="flex flex-1 flex-col overflow-y-auto">
|
||||
<!-- Main Links -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-2">
|
||||
{#if !collapsed}
|
||||
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50">
|
||||
<p class="mb-1.5 px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45">
|
||||
{$t('nav.navigation')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/boards"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/boards')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/boards')
|
||||
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
|
||||
title={collapsed ? $t('nav.boards') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
class="h-5 w-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" />
|
||||
</svg>
|
||||
@@ -109,44 +109,42 @@
|
||||
|
||||
<a
|
||||
href="/apps"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/apps')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/apps')
|
||||
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
|
||||
title={collapsed ? $t('nav.apps') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
class="h-5 w-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
|
||||
</svg>
|
||||
{#if !collapsed}<span>{$t('nav.apps')}</span>{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/status"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/status')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/status')
|
||||
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
|
||||
title={collapsed ? 'Status Page' : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
class="h-5 w-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
@@ -156,18 +154,18 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Board List -->
|
||||
<!-- Board List ("Rooms") -->
|
||||
{#if boards.length > 0}
|
||||
<div class="mb-3">
|
||||
<div class="mb-2 mt-1">
|
||||
{#if !collapsed}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (boardsExpanded = !boardsExpanded)}
|
||||
class="mb-1 flex w-full items-center justify-between px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground/80"
|
||||
class="mb-1.5 flex w-full items-center justify-between px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45 transition-colors hover:text-sidebar-foreground/70"
|
||||
>
|
||||
<span>{$t('nav.boards')}</span>
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform duration-200"
|
||||
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||
class:rotate-180={boardsExpanded}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -182,13 +180,13 @@
|
||||
{/if}
|
||||
|
||||
{#if boardsExpanded || collapsed}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#each boards as board (board.id)}
|
||||
<div class="max-h-56 overflow-y-auto">
|
||||
{#each boards as board, i (board.id)}
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
class="flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all {isActive(`/boards/${board.id}`)
|
||||
? 'bg-card text-sidebar-foreground shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
|
||||
: 'text-sidebar-foreground/75 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
|
||||
title={collapsed ? board.name : undefined}
|
||||
onclick={() => ui.closeMobileSidebar()}
|
||||
>
|
||||
@@ -196,7 +194,8 @@
|
||||
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
|
||||
{:else}
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-[11px] font-bold text-white"
|
||||
style="background: {roomColors[i % roomColors.length]};"
|
||||
>
|
||||
{board.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
@@ -213,29 +212,27 @@
|
||||
|
||||
<!-- Admin -->
|
||||
{#if isAdmin}
|
||||
<div class="mt-auto border-t border-sidebar-border pt-3">
|
||||
<div class="mt-auto pt-2">
|
||||
{#if !collapsed}
|
||||
<p
|
||||
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
|
||||
>
|
||||
<p class="mb-1.5 px-3 text-[10.5px] font-bold uppercase tracking-[0.1em] text-sidebar-foreground/45">
|
||||
{$t('nav.admin')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/admin/users"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/admin')
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
class="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-semibold transition-all {isActive('/admin')
|
||||
? 'bg-card text-primary shadow-[var(--shadow-soft)] ring-1 ring-sidebar-border'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground'}"
|
||||
title={collapsed ? $t('nav.admin_panel') : undefined}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
class="h-5 w-5 shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-width="1.9"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
@@ -252,11 +249,11 @@
|
||||
|
||||
<!-- Keyboard Shortcuts Hint + Collapse Toggle -->
|
||||
{#if !ui.isMobile}
|
||||
<div class="border-t border-sidebar-border p-2 flex items-center {collapsed ? 'flex-col gap-1' : 'gap-1'}">
|
||||
<div class="mt-2 flex items-center {collapsed ? 'flex-col gap-1.5' : 'gap-1.5'}">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => keyboard.toggleOverlay()}
|
||||
class="flex items-center justify-center rounded-md p-2 text-sidebar-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
class="flex items-center justify-center rounded-xl p-2.5 text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
title="Keyboard Shortcuts (?)"
|
||||
>
|
||||
<svg
|
||||
@@ -277,7 +274,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
class="flex w-full items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
|
||||
class="flex w-full items-center justify-center rounded-xl p-2.5 text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
title={collapsed ? $t('sidebar.expand') : $t('sidebar.collapse')}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => theme.cycleMode()}
|
||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
title={$t('theme.title', { values: { mode: $t(currentIcon.labelKey) } })}
|
||||
aria-label={$t('theme.toggle', { values: { mode: $t(currentIcon.labelKey) } })}
|
||||
>
|
||||
|
||||
@@ -47,11 +47,11 @@
|
||||
function eventColor(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online':
|
||||
return 'text-green-500';
|
||||
return 'text-status-online-ink';
|
||||
case 'app_offline':
|
||||
return 'text-red-500';
|
||||
return 'text-status-offline-ink';
|
||||
case 'app_degraded':
|
||||
return 'text-yellow-500';
|
||||
return 'text-status-degraded-ink';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="relative inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
class="relative inline-flex items-center justify-center rounded-xl p-2.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg"
|
||||
class="absolute right-0 top-full z-50 mt-1 w-80 rounded-xl border border-border bg-popover shadow-[var(--shadow-soft)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Eye, EyeOff } from 'lucide-svelte';
|
||||
import { NotificationType } from '$lib/utils/constants.js';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface ChannelData {
|
||||
readonly id?: string;
|
||||
@@ -112,7 +114,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h3 class="mb-4 text-lg font-semibold text-card-foreground">
|
||||
{channel ? 'Edit Channel' : 'Add Notification Channel'}
|
||||
</h3>
|
||||
@@ -126,7 +128,7 @@
|
||||
<select
|
||||
id="channel-type"
|
||||
bind:value={channelType}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="slack">Slack</option>
|
||||
@@ -146,7 +148,7 @@
|
||||
type="url"
|
||||
bind:value={discordWebhookUrl}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -160,7 +162,7 @@
|
||||
type="url"
|
||||
bind:value={slackWebhookUrl}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -174,7 +176,7 @@
|
||||
type="text"
|
||||
bind:value={telegramBotToken}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -187,7 +189,7 @@
|
||||
type="text"
|
||||
bind:value={telegramChatId}
|
||||
placeholder="-1001234567890"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -201,7 +203,7 @@
|
||||
type="url"
|
||||
bind:value={httpUrl}
|
||||
placeholder="https://example.com/webhook"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -212,7 +214,7 @@
|
||||
<select
|
||||
id="http-method"
|
||||
bind:value={httpMethod}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
@@ -230,7 +232,7 @@
|
||||
bind:value={httpSecret}
|
||||
placeholder="Shared secret for HMAC-SHA256 signature"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 pr-10 font-mono text-sm text-foreground"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -262,7 +264,7 @@
|
||||
bind:value={httpSignatureHeader}
|
||||
placeholder="X-Signature-256"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Defaults to <code class="rounded bg-muted/40 px-1">X-Signature-256</code>. Override for receivers expecting a different name (e.g. <code class="rounded bg-muted/40 px-1">X-Hub-Signature-256</code>).
|
||||
@@ -271,48 +273,31 @@
|
||||
{/if}
|
||||
|
||||
<!-- Enabled Toggle -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="channel-enabled"
|
||||
type="checkbox"
|
||||
bind:checked={enabled}
|
||||
class="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<label for="channel-enabled" class="text-sm text-foreground">Enabled</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch id="channel-enabled" bind:checked={enabled} ariaLabelledby="channel-enabled-label" />
|
||||
<label id="channel-enabled-label" for="channel-enabled" class="cursor-pointer text-sm text-foreground">Enabled</label>
|
||||
</div>
|
||||
|
||||
<!-- Test Result -->
|
||||
{#if testResult}
|
||||
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-emerald-500'}">
|
||||
<p class="text-sm {testResult.startsWith('Failed') ? 'text-destructive' : 'text-status-online-ink'}">
|
||||
{testResult}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Button type="submit">
|
||||
{channel ? 'Update' : 'Create'} Channel
|
||||
</button>
|
||||
</Button>
|
||||
{#if channel?.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={sendTest}
|
||||
disabled={testing}
|
||||
class="rounded-md border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<Button variant="outline" onclick={sendTest} disabled={testing} loading={testing}>
|
||||
{testing ? 'Sending...' : 'Send Test'}
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Button variant="ghost" onclick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
|
||||
function eventBadgeClass(event: string): string {
|
||||
switch (event) {
|
||||
case 'app_online': return 'bg-green-500/10 text-green-500';
|
||||
case 'app_offline': return 'bg-red-500/10 text-red-500';
|
||||
case 'app_degraded': return 'bg-yellow-500/10 text-yellow-500';
|
||||
case 'app_online': return 'bg-status-online/15 text-status-online-ink';
|
||||
case 'app_offline': return 'bg-status-offline/15 text-status-offline-ink';
|
||||
case 'app_degraded': return 'bg-status-degraded/15 text-status-degraded-ink';
|
||||
default: return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@
|
||||
<select
|
||||
bind:value={filterEvent}
|
||||
onchange={applyFilters}
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
class="rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
<option value="app_online">Online</option>
|
||||
@@ -104,7 +104,7 @@
|
||||
<p class="text-muted-foreground">No notifications found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
@@ -20,7 +22,7 @@
|
||||
|
||||
// Theme form
|
||||
let defaultTheme = $state<'dark' | 'light'>('dark');
|
||||
let defaultPrimaryColor = $state('#6366f1');
|
||||
let defaultPrimaryColor = $state('#e8754f');
|
||||
|
||||
// Board form
|
||||
let boardName = $state('My Dashboard');
|
||||
@@ -169,6 +171,7 @@
|
||||
}
|
||||
|
||||
const primaryColorOptions = [
|
||||
{ label: 'Terracotta', value: '#e8754f' },
|
||||
{ label: 'Indigo', value: '#6366f1' },
|
||||
{ label: 'Blue', value: '#3b82f6' },
|
||||
{ label: 'Emerald', value: '#10b981' },
|
||||
@@ -182,7 +185,7 @@
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div
|
||||
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl"
|
||||
class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-lift)]"
|
||||
>
|
||||
<!-- Progress bar -->
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
@@ -227,7 +230,7 @@
|
||||
{:else if currentStep === 'admin'}
|
||||
<h2 class="mb-4 text-xl font-bold text-foreground">Create Admin Account</h2>
|
||||
{#if adminCreated}
|
||||
<div class="rounded-lg bg-green-500/10 p-4 text-sm text-green-600 dark:text-green-400">
|
||||
<div class="rounded-lg bg-status-online/10 p-4 text-sm text-status-online-ink dark:text-status-online-ink">
|
||||
Admin account created successfully. You can proceed to the next step.
|
||||
</div>
|
||||
{:else}
|
||||
@@ -238,7 +241,7 @@
|
||||
id="ob-display-name"
|
||||
type="text"
|
||||
bind:value={adminDisplayName}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</div>
|
||||
@@ -248,7 +251,7 @@
|
||||
id="ob-email"
|
||||
type="email"
|
||||
bind:value={adminEmail}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -258,7 +261,7 @@
|
||||
id="ob-password"
|
||||
type="password"
|
||||
bind:value={adminPassword}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder="Min. 6 characters"
|
||||
/>
|
||||
</div>
|
||||
@@ -298,19 +301,19 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={oauthClientId}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
placeholder="Client ID"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={oauthClientSecret}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
placeholder="Client Secret"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={oauthDiscoveryUrl}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
placeholder="Discovery URL (https://.../.well-known/openid-configuration)"
|
||||
/>
|
||||
</div>
|
||||
@@ -369,7 +372,7 @@
|
||||
id="ob-board-name"
|
||||
type="text"
|
||||
bind:value={boardName}
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder="My Dashboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -413,12 +416,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNext}
|
||||
disabled={loading}
|
||||
class="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Button size="lg" onclick={handleNext} disabled={loading} loading={loading}>
|
||||
{#if loading}
|
||||
Processing...
|
||||
{:else if isLastStep}
|
||||
@@ -428,7 +426,7 @@
|
||||
{:else}
|
||||
Next
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-popover shadow-2xl"
|
||||
class="flex w-full max-w-lg flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
|
||||
style="max-height: 60vh; animation: searchSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
role="dialog"
|
||||
aria-label={$t('search.placeholder')}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => search.toggle()}
|
||||
class="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
class="flex w-full max-w-sm items-center gap-2.5 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm text-muted-foreground shadow-[var(--shadow-soft)] transition-all hover:border-primary/40 hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0"
|
||||
@@ -27,7 +27,7 @@
|
||||
</svg>
|
||||
<span class="flex-1 text-left">{$t('search.trigger')}</span>
|
||||
<kbd
|
||||
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
|
||||
class="hidden rounded-lg bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground sm:inline"
|
||||
>
|
||||
{isMac ? '\u2318' : 'Ctrl'}K
|
||||
</kbd>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div class="rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Section drag handle -->
|
||||
@@ -142,7 +142,7 @@
|
||||
<!-- Card size selector -->
|
||||
<select
|
||||
onchange={handleCardSizeChange}
|
||||
class="rounded-md border border-input bg-background px-2 py-1 text-xs text-foreground transition-colors focus:border-primary focus:outline-none"
|
||||
class="rounded-xl border border-input bg-background px-2 py-1 text-xs text-foreground transition-colors focus:border-primary focus:outline-none"
|
||||
title={$t('board.card_size') ?? 'Card size'}
|
||||
>
|
||||
<option value="" selected={!section.cardSize}>{$t('section.inherit_size') ?? 'Inherit'}</option>
|
||||
@@ -153,7 +153,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onToggleAddWidget(section.id)}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
class="rounded-xl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{$t('widget.add')}
|
||||
</button>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
let expanded = $state(section.isExpandedByDefault);
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card/30 shadow-sm {editMode.active ? 'ring-1 ring-primary/10' : ''}">
|
||||
<div class="rounded-[1.4rem] border border-border bg-card/40 shadow-[var(--shadow-soft)] backdrop-blur-sm {editMode.active ? 'ring-1 ring-primary/15' : ''}">
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
bind:value={editTitle}
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={handleEditBlur}
|
||||
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
class="flex-1 rounded border border-input bg-background px-2 py-0.5 text-sm font-medium text-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
<IconPickerButton
|
||||
value={editIcon}
|
||||
@@ -135,7 +135,7 @@
|
||||
{#if icon}
|
||||
<DynamicIcon name={icon} size={18} />
|
||||
{/if}
|
||||
<span class="font-medium text-foreground">{title}</span>
|
||||
<span class="font-display text-lg font-semibold text-foreground">{title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
@@ -8,7 +10,7 @@
|
||||
let { onCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card p-6">
|
||||
<div class="rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">Generate API Token</h2>
|
||||
|
||||
<form method="POST" action="?/create" use:enhance class="space-y-4">
|
||||
@@ -21,7 +23,7 @@
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="e.g., CI/CD Pipeline"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
required
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">A descriptive name to identify this token</p>
|
||||
@@ -31,15 +33,11 @@
|
||||
<label for="token-scope" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Scope
|
||||
</label>
|
||||
<select
|
||||
id="token-scope"
|
||||
name="scope"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<Select id="token-scope" name="scope">
|
||||
<option value="read">Read — View apps, boards, and status</option>
|
||||
<option value="write">Write — Modify apps, boards, and settings</option>
|
||||
<option value="admin">Admin — Full access including user management</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -50,25 +48,14 @@
|
||||
id="token-expires"
|
||||
name="expiresAt"
|
||||
type="datetime-local"
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Leave empty for a non-expiring token</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Generate Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-md px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button type="submit">Generate Token</Button>
|
||||
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
|
||||
function scopeBadgeClass(scope: string): string {
|
||||
switch (scope) {
|
||||
case 'admin': return 'bg-red-500/10 text-red-500';
|
||||
case 'write': return 'bg-yellow-500/10 text-yellow-500';
|
||||
default: return 'bg-green-500/10 text-green-500';
|
||||
case 'admin': return 'bg-destructive/10 text-destructive';
|
||||
case 'write': return 'bg-status-degraded/10 text-status-degraded-ink';
|
||||
default: return 'bg-status-online/10 text-status-online-ink';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -54,7 +54,7 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||
<div class="rounded-xl border border-border bg-card p-6 shadow-[var(--shadow-soft)]">
|
||||
<h2 class="mb-2 text-lg font-semibold text-card-foreground">
|
||||
{$t('settings.bookmarklet_title')}
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@@ -88,7 +89,7 @@
|
||||
value={localValue}
|
||||
oninput={handleInput}
|
||||
rows="8"
|
||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class="w-full rounded-xl border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
placeholder={`/* Custom CSS */\n.custom-css-scope .my-widget {\n background: #333;\n}`}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
@@ -98,11 +99,11 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||
<Switch
|
||||
bind:checked={livePreview}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
size="sm"
|
||||
ariaLabel={$t('settings.live_preview') ?? 'Live preview'}
|
||||
/>
|
||||
{$t('settings.live_preview') ?? 'Live preview'}
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { t, locale as i18nLocale } from 'svelte-i18n';
|
||||
import { theme, type ThemeMode, type BackgroundType, type CardStyle } from '$lib/stores/theme.svelte.js';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface UserPreferences {
|
||||
themeMode: string | null;
|
||||
@@ -128,7 +129,7 @@
|
||||
type="button"
|
||||
onclick={() => setMode(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.mode === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
@@ -167,7 +168,7 @@
|
||||
max="360"
|
||||
step="1"
|
||||
bind:value={theme.primaryHue}
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-[var(--shadow-soft)] [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-[var(--shadow-soft)]"
|
||||
style="color: {previewColor};"
|
||||
/>
|
||||
</div>
|
||||
@@ -188,7 +189,7 @@
|
||||
max="100"
|
||||
step="1"
|
||||
bind:value={theme.primarySaturation}
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-md [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-md"
|
||||
class="absolute inset-0 h-3 w-full cursor-pointer appearance-none bg-transparent [&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-current [&::-moz-range-thumb]:shadow-[var(--shadow-soft)] [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-current [&::-webkit-slider-thumb]:shadow-[var(--shadow-soft)]"
|
||||
style="color: {previewColor};"
|
||||
/>
|
||||
</div>
|
||||
@@ -204,7 +205,7 @@
|
||||
type="button"
|
||||
onclick={() => setBackground(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.backgroundType === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{$t(opt.labelKey)}
|
||||
@@ -222,7 +223,7 @@
|
||||
type="button"
|
||||
onclick={() => setCardStyle(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {theme.cardStyle === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{$t(opt.labelKey) ?? opt.value}
|
||||
@@ -240,7 +241,7 @@
|
||||
type="button"
|
||||
onclick={() => setLocale(opt.value)}
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {$i18nLocale === opt.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
? 'bg-background text-foreground shadow-[var(--shadow-soft)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{opt.label}
|
||||
@@ -251,16 +252,11 @@
|
||||
|
||||
<!-- Save button -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={savePreferences}
|
||||
disabled={saving}
|
||||
class="rounded-md bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<Button size="lg" onclick={savePreferences} disabled={saving} loading={saving}>
|
||||
{saving ? $t('settings.saving') : $t('settings.save')}
|
||||
</button>
|
||||
</Button>
|
||||
{#if saved}
|
||||
<span class="text-sm text-green-500">{$t('settings.saved')}</span>
|
||||
<span class="text-sm text-status-online-ink">{$t('settings.saved')}</span>
|
||||
{/if}
|
||||
{#if errorMessage}
|
||||
<span class="text-sm text-destructive">{errorMessage}</span>
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
/>
|
||||
|
||||
{#if open && filtered.length > 0}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
{#each filtered as item, i (item)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts" module>
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-primary text-primary-foreground shadow-[var(--shadow-soft)] hover:bg-primary/90 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lift)] active:translate-y-0 active:scale-[0.98]',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
outline:
|
||||
'border border-border bg-card text-foreground shadow-[var(--shadow-soft)] hover:-translate-y-0.5 hover:border-primary/40 active:translate-y-0 active:scale-[0.98]',
|
||||
ghost:
|
||||
'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-[var(--shadow-soft)] hover:bg-destructive/90 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]'
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-2.5 text-sm',
|
||||
icon: 'p-2'
|
||||
};
|
||||
|
||||
export function buttonClass(opts: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
extra?: string;
|
||||
} = {}): string {
|
||||
const variant = opts.variant ?? 'primary';
|
||||
const size = opts.size ?? 'md';
|
||||
return cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-medium whitespace-nowrap transition-all duration-150 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-[var(--shadow-soft)]',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
opts.fullWidth && 'w-full',
|
||||
opts.extra
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<HTMLButtonAttributes, 'class' | 'children'> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
disabled,
|
||||
type = 'button',
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
disabled={disabled || loading}
|
||||
class={buttonClass({ variant, size, fullWidth, extra: className })}
|
||||
{...rest}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</button>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
|
||||
interface Props {
|
||||
checked?: boolean | undefined;
|
||||
onchange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledby?: string;
|
||||
indeterminate?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
onchange,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
indeterminate = false,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : checked}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
{id}
|
||||
{disabled}
|
||||
onclick={toggle}
|
||||
onkeydown={onKeydown}
|
||||
class={cn(
|
||||
'inline-flex h-[18px] w-[18px] shrink-0 cursor-pointer items-center justify-center rounded-md border transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked || indeterminate
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-[inset_0_1px_2px_rgba(0,0,0,0.15)]'
|
||||
: 'border-input bg-background hover:border-primary/60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{#if indeterminate}
|
||||
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="4" y1="9" x2="14" y2="9" />
|
||||
</svg>
|
||||
{:else if checked}
|
||||
<svg viewBox="0 0 18 18" class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="3.5 9.5 7.5 13.5 14.5 5.5" class="check-draw" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if name !== undefined}
|
||||
<input type="hidden" {name} value={checked ? (value ?? 'on') : ''} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.check-draw {
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
animation: check-draw-in 180ms ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes check-draw-in {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.check-draw {
|
||||
animation: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-sm rounded-2xl border border-border bg-card p-6 shadow-2xl"
|
||||
class="mx-4 w-full max-w-sm rounded-[1.4rem] border border-border bg-card p-6 shadow-[var(--shadow-lift)]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
transition:scale={{ start: 0.95, duration: 150 }}
|
||||
@@ -49,21 +49,21 @@
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
aria-describedby="confirm-dialog-message"
|
||||
>
|
||||
<h2 id="confirm-dialog-title" class="mb-2 text-base font-semibold text-foreground">{title}</h2>
|
||||
<p id="confirm-dialog-message" class="mb-5 text-sm text-muted-foreground">{message}</p>
|
||||
<h2 id="confirm-dialog-title" class="mb-2 font-display text-lg font-semibold text-foreground">{title}</h2>
|
||||
<p id="confirm-dialog-message" class="mb-5 text-sm leading-relaxed text-muted-foreground">{message}</p>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onCancel}
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
class="rounded-xl border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{cancelLabel ?? ($t('common.cancel') ?? 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onConfirm}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors
|
||||
class="rounded-xl px-4 py-2 text-sm font-semibold shadow-[var(--shadow-soft)] transition-all hover:-translate-y-0.5
|
||||
{destructive
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'}"
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
onclick={openPicker}
|
||||
>
|
||||
{#if selectedItem}
|
||||
@@ -157,7 +157,7 @@
|
||||
style="animation: epFadeIn 0.15s ease-out"
|
||||
>
|
||||
<div
|
||||
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
|
||||
class="flex w-full max-w-md flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
|
||||
style="max-height: 60vh; animation: epSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
role="dialog"
|
||||
aria-label={searchPlaceholder || 'Select entity'}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class="relative z-10 flex min-h-screen items-center justify-center bg-background/80 p-4 text-foreground"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-xl backdrop-blur-sm"
|
||||
class="w-full max-w-lg rounded-xl border border-border bg-card/90 p-8 text-center shadow-[var(--shadow-lift)] backdrop-blur-sm"
|
||||
>
|
||||
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
|
||||
{status}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
labelFor?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
labelFor,
|
||||
hint,
|
||||
error,
|
||||
required = false,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('space-y-1.5', className)}>
|
||||
{#if label}
|
||||
<label for={labelFor} class="block text-sm font-medium text-foreground">
|
||||
{label}
|
||||
{#if required}<span class="text-destructive" aria-hidden="true">*</span>{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{@render children()}
|
||||
{#if error}
|
||||
<p class="text-xs text-destructive" role="alert">{error}</p>
|
||||
{:else if hint}
|
||||
<p class="text-xs text-muted-foreground">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-grid-trigger flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="icon-grid-trigger flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
bind:this={triggerEl}
|
||||
onclick={toggle}
|
||||
>
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-lg border border-border bg-popover shadow-xl"
|
||||
class="icon-grid-popup fixed z-50 overflow-y-auto rounded-xl border border-border bg-popover shadow-[var(--shadow-lift)]"
|
||||
bind:this={popupEl}
|
||||
style="animation: iconGridSlideIn 0.15s ease-out"
|
||||
>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleOpen}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-input bg-background transition-colors hover:bg-accent
|
||||
class="flex items-center gap-1.5 rounded-xl border border-input bg-background transition-colors hover:bg-accent
|
||||
{size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'}"
|
||||
title={$t('app.icon') ?? 'Select icon'}
|
||||
>
|
||||
@@ -105,7 +105,7 @@
|
||||
class="fixed inset-0 z-50"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) open = false; }}
|
||||
>
|
||||
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-2xl">
|
||||
<div class="fixed left-1/2 top-1/2 z-50 w-80 -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-3 shadow-[var(--shadow-lift)]">
|
||||
<!-- Search -->
|
||||
<div class="relative mb-2">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -116,7 +116,7 @@
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={$t('common.search') ?? 'Search icons...'}
|
||||
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
value={value}
|
||||
oninput={(e) => { const v = (e.target as HTMLInputElement).value; value = v; onchange(v); }}
|
||||
placeholder={$t('app.icon_manual') ?? 'Or type icon name...'}
|
||||
class="w-full rounded-lg border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
class="w-full rounded-xl border border-input bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" module>
|
||||
export const inputClass =
|
||||
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<HTMLInputAttributes, 'class' | 'value'> {
|
||||
value?: string | number;
|
||||
class?: string;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
type = 'text',
|
||||
invalid = false,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
{type}
|
||||
bind:value
|
||||
class={cn(inputClass, invalid && 'border-destructive focus:border-destructive', className)}
|
||||
aria-invalid={invalid || undefined}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -48,7 +48,7 @@
|
||||
onkeydown={(e) => e.key === 'Escape' && keyboard.closeOverlay()}
|
||||
>
|
||||
<div
|
||||
class="mx-4 w-full max-w-xl rounded-2xl border border-border bg-card shadow-2xl"
|
||||
class="mx-4 w-full max-w-xl rounded-2xl border border-border bg-card shadow-[var(--shadow-lift)]"
|
||||
role="dialog"
|
||||
aria-label="Keyboard Shortcuts"
|
||||
>
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
class="flex w-full items-center gap-2 rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors hover:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
onclick={openPicker}
|
||||
>
|
||||
{#if selectedCount > 0}
|
||||
@@ -148,7 +148,7 @@
|
||||
style="animation: mepFadeIn 0.15s ease-out"
|
||||
>
|
||||
<div
|
||||
class="flex w-full max-w-md flex-col rounded-lg border border-border bg-popover shadow-2xl"
|
||||
class="flex w-full max-w-md flex-col rounded-[1.4rem] border border-border bg-popover shadow-[var(--shadow-lift)]"
|
||||
style="max-height: 60vh; animation: mepSlideDown 0.2s cubic-bezier(0.16, 1, 0.3, 1)"
|
||||
role="dialog"
|
||||
aria-label={searchPlaceholder || 'Select items'}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" module>
|
||||
export const selectClass =
|
||||
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 appearance-none bg-no-repeat bg-[right_0.75rem_center] bg-[length:0.85em] pr-9';
|
||||
|
||||
export const chevronBg =
|
||||
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='currentColor' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 6 8 10 12 6'/></svg>\")";
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends Omit<HTMLSelectAttributes, 'class' | 'value' | 'children'> {
|
||||
value?: string | number | undefined;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable<string | number | undefined>(''),
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
bind:value
|
||||
class={cn(selectClass, className)}
|
||||
style="background-image: {chevronBg};"
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledby?: string;
|
||||
showValue?: boolean;
|
||||
formatValue?: (v: number) => string;
|
||||
class?: string;
|
||||
oninput?: (value: number) => void;
|
||||
onchange?: (value: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(0),
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
showValue = false,
|
||||
formatValue,
|
||||
class: className = '',
|
||||
oninput,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
|
||||
const pct = $derived(((value - min) / (max - min)) * 100);
|
||||
const displayValue = $derived(formatValue ? formatValue(value) : String(value));
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
value = Number(target.value);
|
||||
oninput?.(value);
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.currentTarget as HTMLInputElement;
|
||||
onchange?.(Number(target.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('cozy-slider relative w-full', className)} style="--pct: {pct}%;">
|
||||
<input
|
||||
type="range"
|
||||
{id}
|
||||
{name}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
aria-valuetext={displayValue}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
onchange={handleChange}
|
||||
class="cozy-slider-input"
|
||||
/>
|
||||
{#if showValue}
|
||||
<span class="mt-1 inline-block text-xs tabular-nums text-muted-foreground">{displayValue}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cozy-slider-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--primary) 0%,
|
||||
var(--primary) var(--pct, 0%),
|
||||
color-mix(in srgb, var(--muted-foreground) 35%, transparent) var(--pct, 0%),
|
||||
color-mix(in srgb, var(--muted-foreground) 35%, transparent) 100%
|
||||
);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.cozy-slider-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cozy-slider-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(80, 50, 20, 0.3),
|
||||
0 1px 2px rgba(80, 50, 20, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cozy-slider-input::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.cozy-slider-input::-webkit-slider-thumb:active {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
|
||||
.cozy-slider-input::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(80, 50, 20, 0.3),
|
||||
0 1px 2px rgba(80, 50, 20, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.cozy-slider-input::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.cozy-slider-input:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 40%, transparent);
|
||||
outline-offset: 4px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cozy-slider-input::-webkit-slider-thumb,
|
||||
.cozy-slider-input::-moz-range-thumb {
|
||||
transition: none;
|
||||
}
|
||||
.cozy-slider-input::-webkit-slider-thumb:hover,
|
||||
.cozy-slider-input::-webkit-slider-thumb:active {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/cn.js';
|
||||
|
||||
interface Props {
|
||||
checked?: boolean | undefined;
|
||||
onchange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledby?: string;
|
||||
size?: 'sm' | 'md';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
onchange,
|
||||
disabled = false,
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
ariaLabel,
|
||||
ariaLabelledby,
|
||||
size = 'md',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
function toggle() {
|
||||
if (disabled) return;
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
|
||||
const trackBase =
|
||||
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50';
|
||||
const trackSize = $derived(size === 'sm' ? 'h-5 w-9' : 'h-6 w-11');
|
||||
const knobSize = $derived(size === 'sm' ? 'h-4 w-4' : 'h-5 w-5');
|
||||
const knobTranslate = $derived(
|
||||
size === 'sm'
|
||||
? checked
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-0.5'
|
||||
: checked
|
||||
? 'translate-x-[1.375rem]'
|
||||
: 'translate-x-0.5'
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel ?? label}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
{id}
|
||||
{disabled}
|
||||
onclick={toggle}
|
||||
onkeydown={onKeydown}
|
||||
class={cn(
|
||||
trackBase,
|
||||
trackSize,
|
||||
checked ? 'bg-primary shadow-[inset_0_1px_2px_rgba(0,0,0,0.18)]' : 'bg-muted-foreground/35',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
'pointer-events-none inline-block transform rounded-full bg-white shadow-[0_2px_4px_rgba(80,50,20,0.35),0_1px_2px_rgba(80,50,20,0.18)] ring-0 transition-transform duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
||||
knobSize,
|
||||
knobTranslate
|
||||
)}
|
||||
></span>
|
||||
{#if name !== undefined}
|
||||
<input type="hidden" {name} value={checked ? 'on' : ''} />
|
||||
{/if}
|
||||
</button>
|
||||
@@ -100,7 +100,7 @@
|
||||
{#if tags.length > 0}
|
||||
<div class="mb-1.5 flex flex-wrap gap-1">
|
||||
{#each tags as tag (tag)}
|
||||
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
<span class="flex items-center gap-1 rounded-xl bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
@@ -128,7 +128,7 @@
|
||||
/>
|
||||
|
||||
{#if open && filtered.length > 0}
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
|
||||
<div class="absolute left-0 top-full z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
{#each filtered as item, i (item)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buttonClass } from '../Button.svelte';
|
||||
|
||||
describe('buttonClass', () => {
|
||||
it('returns primary md by default', () => {
|
||||
const cls = buttonClass();
|
||||
expect(cls).toContain('bg-primary');
|
||||
expect(cls).toContain('px-4');
|
||||
expect(cls).toContain('py-2');
|
||||
expect(cls).toContain('text-sm');
|
||||
});
|
||||
|
||||
it('applies secondary variant', () => {
|
||||
const cls = buttonClass({ variant: 'secondary' });
|
||||
expect(cls).toContain('bg-secondary');
|
||||
expect(cls).not.toContain('bg-primary ');
|
||||
});
|
||||
|
||||
it('applies destructive variant', () => {
|
||||
const cls = buttonClass({ variant: 'destructive' });
|
||||
expect(cls).toContain('bg-destructive');
|
||||
});
|
||||
|
||||
it('applies sm size', () => {
|
||||
const cls = buttonClass({ size: 'sm' });
|
||||
expect(cls).toContain('px-3');
|
||||
expect(cls).toContain('text-xs');
|
||||
});
|
||||
|
||||
it('applies lg size', () => {
|
||||
const cls = buttonClass({ size: 'lg' });
|
||||
expect(cls).toContain('px-6');
|
||||
});
|
||||
|
||||
it('adds fullWidth', () => {
|
||||
const cls = buttonClass({ fullWidth: true });
|
||||
expect(cls).toContain('w-full');
|
||||
});
|
||||
|
||||
it('merges extra class', () => {
|
||||
const cls = buttonClass({ extra: 'custom-class' });
|
||||
expect(cls).toContain('custom-class');
|
||||
});
|
||||
|
||||
it('always includes focus-visible ring', () => {
|
||||
const cls = buttonClass();
|
||||
expect(cls).toContain('focus-visible:ring-2');
|
||||
expect(cls).toContain('focus-visible:ring-primary/30');
|
||||
});
|
||||
|
||||
it('always includes disabled state', () => {
|
||||
const cls = buttonClass();
|
||||
expect(cls).toContain('disabled:cursor-not-allowed');
|
||||
expect(cls).toContain('disabled:opacity-50');
|
||||
});
|
||||
});
|
||||
@@ -139,14 +139,14 @@
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card-hover group flex items-center gap-2 rounded-lg {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
|
||||
class="card-hover group flex items-center gap-2 rounded-2xl {cardStyleClass} px-3 py-2 text-left transition-colors hover:border-primary/50"
|
||||
data-app-widget
|
||||
data-app-url={app.url}
|
||||
oncontextmenu={handleContextMenu}
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-muted transition-colors group-hover:bg-accent">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-base">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
@@ -198,7 +198,7 @@
|
||||
<!-- Large: icon + name + description + sparkline + tags + links -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="card-hover group rounded-xl {cardStyleClass} p-5 transition-colors hover:border-primary/50"
|
||||
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-5 transition-colors hover:border-primary/50"
|
||||
data-app-widget
|
||||
data-app-url={app.url}
|
||||
oncontextmenu={handleContextMenu}
|
||||
@@ -211,7 +211,7 @@
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-muted transition-colors group-hover:bg-accent">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-3xl">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
@@ -294,7 +294,7 @@
|
||||
<!-- Medium (default): icon + name + status + sparkline on hover + links -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="card-hover group rounded-xl {cardStyleClass} p-4 transition-colors hover:border-primary/50"
|
||||
class="card-hover group rounded-[1.4rem] {cardStyleClass} p-4 transition-colors hover:border-primary/50"
|
||||
data-app-widget
|
||||
data-app-url={app.url}
|
||||
oncontextmenu={handleContextMenu}
|
||||
@@ -307,7 +307,7 @@
|
||||
onclick={recordClick}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if app.iconType === 'emoji' && app.icon}
|
||||
<span class="text-2xl">{app.icon}</span>
|
||||
{:else if iconSrc}
|
||||
@@ -378,12 +378,12 @@
|
||||
<!-- Context Menu -->
|
||||
{#if showContextMenu}
|
||||
<div
|
||||
class="fixed z-50 rounded-lg border border-border bg-popover p-1 shadow-lg"
|
||||
class="fixed z-50 rounded-2xl border border-border bg-popover p-1 shadow-[var(--shadow-soft)]"
|
||||
style="left: {contextMenuPos.x}px; top: {contextMenuPos.y}px"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
class="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||
onclick={toggleFavorite}
|
||||
>
|
||||
{#if favorites.isFavorite(app.id)}
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
href={config.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
|
||||
class="card-hover group flex flex-col items-center gap-2 rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4 text-center transition-colors hover:border-primary/50"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted transition-colors group-hover:bg-accent">
|
||||
{#if config.icon}
|
||||
<span class="text-2xl">{config.icon}</span>
|
||||
{:else}
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
<!-- Badge -->
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-blue-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full" style="background: var(--room-sky);"></span>
|
||||
<span class="text-muted-foreground">Bookmark</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Calendar class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">Calendar</span>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card overflow-hidden">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] overflow-hidden">
|
||||
<!-- Stream view -->
|
||||
<div
|
||||
class="relative w-full bg-black"
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
{#if clockStyle === 'analog'}
|
||||
<!-- Analog clock face -->
|
||||
<svg viewBox="0 0 100 100" class="h-32 w-32">
|
||||
@@ -154,7 +154,7 @@
|
||||
<p class="mt-2 text-xs text-muted-foreground">{dateStr}</p>
|
||||
{:else}
|
||||
<!-- Digital clock -->
|
||||
<p class="font-mono text-4xl font-bold tabular-nums text-foreground">{timeStr}</p>
|
||||
<p class="font-display text-4xl font-semibold tabular-nums text-foreground">{timeStr}</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{dateStr}</p>
|
||||
{#if config.timezone}
|
||||
<p class="mt-0.5 text-xs text-muted-foreground/70">{config.timezone.replace(/_/g, ' ')}</p>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col rounded-xl border border-border bg-card">
|
||||
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<div class="relative" style="height: {iframeHeight}px;">
|
||||
{#if !safeUrl}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
const links = $derived(config.links ?? []);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<!-- Header -->
|
||||
{#if isCollapsible}
|
||||
<button
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-end border-b border-border px-3 py-1.5">
|
||||
<button
|
||||
|
||||
@@ -64,13 +64,13 @@
|
||||
});
|
||||
|
||||
const trendColor = $derived.by(() => {
|
||||
if (trend === 'up') return 'text-green-500';
|
||||
if (trend === 'down') return 'text-red-500';
|
||||
return 'text-muted-foreground';
|
||||
if (trend === 'up') return 'var(--status-online-ink)';
|
||||
if (trend === 'down') return 'var(--status-offline-ink)';
|
||||
return 'var(--muted-foreground)';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
{#if loading}
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="h-10 w-24 animate-pulse rounded bg-muted"></div>
|
||||
@@ -80,7 +80,7 @@
|
||||
<span class="text-xs text-muted-foreground">Failed to load metric</span>
|
||||
{:else if currentValue !== null}
|
||||
<!-- Trend arrow -->
|
||||
<div class="mb-1 {trendColor}">
|
||||
<div class="mb-1" style="color: {trendColor};">
|
||||
{#if trend === 'up'}
|
||||
<TrendingUp class="h-5 w-5" />
|
||||
{:else if trend === 'down'}
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
<!-- Big number -->
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold tabular-nums text-foreground">
|
||||
<span class="font-display text-4xl font-semibold tabular-nums text-foreground">
|
||||
{formatNumber(currentValue)}
|
||||
</span>
|
||||
{#if config.unit}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
|
||||
{@html renderedContent}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Rss class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">RSS Feed</span>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<!-- Header -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -63,28 +63,28 @@
|
||||
<div class="mt-3 flex gap-1">
|
||||
{#if statusCounts.online > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-green-500"
|
||||
class="h-2 rounded-full bg-status-online"
|
||||
style="flex: {statusCounts.online}"
|
||||
title="{statusCounts.online} online"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.degraded > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-yellow-500"
|
||||
class="h-2 rounded-full bg-status-degraded"
|
||||
style="flex: {statusCounts.degraded}"
|
||||
title="{statusCounts.degraded} degraded"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.offline > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
class="h-2 rounded-full bg-status-offline"
|
||||
style="flex: {statusCounts.offline}"
|
||||
title="{statusCounts.offline} offline"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.unknown > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-gray-500"
|
||||
class="h-2 rounded-full bg-status-unknown"
|
||||
style="flex: {statusCounts.unknown}"
|
||||
title="{statusCounts.unknown} unknown"
|
||||
></div>
|
||||
@@ -95,25 +95,25 @@
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{#if statusCounts.online > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
|
||||
{statusCounts.online} online
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.degraded > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-yellow-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
|
||||
{statusCounts.degraded} degraded
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.offline > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
|
||||
{statusCounts.offline} offline
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.unknown > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-gray-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
|
||||
{statusCounts.unknown} unknown
|
||||
</span>
|
||||
{/if}
|
||||
@@ -126,12 +126,12 @@
|
||||
{@const status = app.statuses[0]?.status ?? 'unknown'}
|
||||
{@const statusColor =
|
||||
status === 'online'
|
||||
? 'bg-green-500'
|
||||
? 'bg-status-online'
|
||||
: status === 'offline'
|
||||
? 'bg-red-500'
|
||||
? 'bg-status-offline'
|
||||
: status === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-gray-500'}
|
||||
? 'bg-status-degraded'
|
||||
: 'bg-status-unknown'}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-foreground">{app.name}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
|
||||
|
||||
function thresholdColor(value: number): string {
|
||||
if (value >= 85) return 'text-red-500';
|
||||
if (value >= 60) return 'text-yellow-500';
|
||||
return 'text-green-500';
|
||||
if (value >= 85) return 'text-status-offline-ink';
|
||||
if (value >= 60) return 'text-status-degraded-ink';
|
||||
return 'text-status-online-ink';
|
||||
}
|
||||
|
||||
function thresholdStroke(value: number): string {
|
||||
if (value >= 85) return 'stroke-red-500';
|
||||
if (value >= 60) return 'stroke-yellow-500';
|
||||
return 'stroke-green-500';
|
||||
if (value >= 85) return 'stroke-status-offline';
|
||||
if (value >= 60) return 'stroke-status-degraded';
|
||||
return 'stroke-status-online';
|
||||
}
|
||||
|
||||
function thresholdTrack(_value: number): string {
|
||||
@@ -80,7 +80,7 @@
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
|
||||
|
||||
{#if loading}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { tick } from 'svelte';
|
||||
import DynamicIcon from '$lib/components/ui/DynamicIcon.svelte';
|
||||
import MultiEntityPicker from '$lib/components/ui/MultiEntityPicker.svelte';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Slider from '$lib/components/ui/Slider.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
|
||||
interface AppInfo {
|
||||
id: string;
|
||||
@@ -72,7 +75,7 @@
|
||||
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
|
||||
|
||||
// Calendar
|
||||
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
|
||||
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#e8754f', label: '' }]);
|
||||
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
|
||||
|
||||
// Markdown
|
||||
@@ -155,7 +158,7 @@
|
||||
}
|
||||
|
||||
function addCalendarUrl() {
|
||||
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
|
||||
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#e8754f', label: '' }];
|
||||
}
|
||||
|
||||
function removeCalendarUrl(index: number) {
|
||||
@@ -163,7 +166,7 @@
|
||||
}
|
||||
|
||||
// Helper for input styling
|
||||
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
|
||||
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
|
||||
|
||||
let firstInput: HTMLElement | undefined = $state();
|
||||
@@ -171,7 +174,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-border bg-card p-4 shadow-lg"
|
||||
class="rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onkeydown={handleKeydown}
|
||||
role="dialog"
|
||||
@@ -201,11 +204,11 @@
|
||||
bind:value={appSearchQuery}
|
||||
bind:this={firstInput}
|
||||
placeholder={$t('common.search') ?? 'Search apps...'}
|
||||
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
|
||||
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
<!-- App grid -->
|
||||
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
|
||||
<div class="max-h-48 overflow-y-auto rounded-xl border border-input bg-background p-1">
|
||||
{#if filteredApps.length === 0}
|
||||
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
|
||||
{:else}
|
||||
@@ -269,13 +272,12 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.format') ?? 'Format'}
|
||||
<select bind:value={noteFormat} class={inputClass}>
|
||||
<div class={labelClass}>{$t('widget.format') ?? 'Format'}</div>
|
||||
<Select bind:value={noteFormat}>
|
||||
<option value="markdown">Markdown</option>
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'embed'}
|
||||
@@ -285,9 +287,8 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)
|
||||
<input type="range" min="100" max="800" bind:value={embedHeight} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>{$t('widget.height') ?? 'Height'} ({embedHeight}px)</div>
|
||||
<Slider min={100} max={800} bind:value={embedHeight} ariaLabel="Height" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Sandbox
|
||||
@@ -318,16 +319,15 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.style') ?? 'Style'}
|
||||
<select bind:value={clockStyle} class={inputClass}>
|
||||
<div class={labelClass}>{$t('widget.style') ?? 'Style'}</div>
|
||||
<Select bind:value={clockStyle}>
|
||||
<option value="digital">Digital</option>
|
||||
<option value="analog">Analog</option>
|
||||
<option value="24h">24h</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={clockShowWeather} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
|
||||
<Switch bind:checked={clockShowWeather} size="sm" ariaLabel={$t('widget.show_weather') ?? 'Show Weather'} />
|
||||
{$t('widget.show_weather') ?? 'Show Weather'}
|
||||
</label>
|
||||
{#if clockShowWeather}
|
||||
@@ -352,18 +352,16 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Source Type
|
||||
<select bind:value={sysStatsSourceType} class={inputClass}>
|
||||
<div class={labelClass}>Source Type</div>
|
||||
<Select bind:value={sysStatsSourceType}>
|
||||
<option value="glances">Glances</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
|
||||
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Refresh ({sysStatsRefreshInterval}s)</div>
|
||||
<Slider min={5} max={300} bind:value={sysStatsRefreshInterval} ariaLabel="Refresh interval" />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'rss'}
|
||||
@@ -373,12 +371,11 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Max Items ({rssMaxItems})
|
||||
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Max Items ({rssMaxItems})</div>
|
||||
<Slider min={1} max={50} bind:value={rssMaxItems} ariaLabel="Max items" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={rssShowSummary} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
|
||||
<Switch bind:checked={rssShowSummary} size="sm" ariaLabel="Show Summary" />
|
||||
Show Summary
|
||||
</label>
|
||||
|
||||
@@ -398,9 +395,8 @@
|
||||
<button type="button" onclick={addCalendarUrl} class="text-xs text-primary hover:underline">+ Add URL</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Days Ahead ({calendarDaysAhead})
|
||||
<input type="range" min="1" max="30" bind:value={calendarDaysAhead} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Days Ahead ({calendarDaysAhead})</div>
|
||||
<Slider min={1} max={30} bind:value={calendarDaysAhead} ariaLabel="Days ahead" />
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'markdown'}
|
||||
@@ -417,13 +413,12 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Source
|
||||
<select bind:value={metricSource} class={inputClass}>
|
||||
<div class={labelClass}>Source</div>
|
||||
<Select bind:value={metricSource}>
|
||||
<option value="static">Static</option>
|
||||
<option value="json">JSON Endpoint</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
{#if metricSource === 'static'}
|
||||
<div>
|
||||
@@ -461,9 +456,8 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({metricRefreshInterval}s)
|
||||
<input type="range" min="5" max="300" bind:value={metricRefreshInterval} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Refresh ({metricRefreshInterval}s)</div>
|
||||
<Slider min={5} max={300} bind:value={metricRefreshInterval} ariaLabel="Refresh interval" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -482,8 +476,8 @@
|
||||
{/each}
|
||||
<button type="button" onclick={addLinkGroupLink} class="text-xs text-primary hover:underline">+ Add Link</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" bind:checked={linkGroupCollapsible} class="h-3.5 w-3.5 rounded border-input accent-primary" />
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm text-foreground">
|
||||
<Switch bind:checked={linkGroupCollapsible} size="sm" ariaLabel="Collapsible" />
|
||||
Collapsible
|
||||
</label>
|
||||
|
||||
@@ -494,39 +488,35 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Type
|
||||
<select bind:value={cameraType} class={inputClass}>
|
||||
<div class={labelClass}>Type</div>
|
||||
<Select bind:value={cameraType}>
|
||||
<option value="image">Image</option>
|
||||
<option value="mjpeg">MJPEG</option>
|
||||
<option value="hls">HLS</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({cameraRefreshInterval}s)
|
||||
<input type="range" min="1" max="60" bind:value={cameraRefreshInterval} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Refresh ({cameraRefreshInterval}s)</div>
|
||||
<Slider min={1} max={60} bind:value={cameraRefreshInterval} ariaLabel="Refresh interval" />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Aspect Ratio
|
||||
<select bind:value={cameraAspectRatio} class={inputClass}>
|
||||
<div class={labelClass}>Aspect Ratio</div>
|
||||
<Select bind:value={cameraAspectRatio}>
|
||||
<option value="16/9">16:9</option>
|
||||
<option value="4/3">4:3</option>
|
||||
<option value="1/1">1:1</option>
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{:else if widgetType === 'integration'}
|
||||
<div>
|
||||
<label class={labelClass}>{$t('widget.app') ?? 'App'}
|
||||
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
|
||||
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
|
||||
<Select bind:value={integrationAppId}>
|
||||
<option value="">Select app...</option>
|
||||
{#each apps as app (app.id)}
|
||||
<option value={app.id}>{app.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Endpoint ID
|
||||
@@ -534,9 +524,8 @@
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelClass}>Refresh ({integrationRefreshInterval}s)
|
||||
<input type="range" min="10" max="600" bind:value={integrationRefreshInterval} class="w-full accent-primary" />
|
||||
</label>
|
||||
<div class={labelClass}>Refresh ({integrationRefreshInterval}s)</div>
|
||||
<Slider min={10} max={600} bind:value={integrationRefreshInterval} ariaLabel="Refresh interval" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -548,7 +537,7 @@
|
||||
{$t('common.cancel') ?? 'Cancel'}
|
||||
</button>
|
||||
<button type="button" onclick={handleSave}
|
||||
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
import EntityPicker from '$lib/components/ui/EntityPicker.svelte';
|
||||
import type { IconGridItem } from '$lib/components/ui/IconGrid.svelte';
|
||||
import type { EntityPickerItem } from '$lib/components/ui/EntityPicker.svelte';
|
||||
import Switch from '$lib/components/ui/Switch.svelte';
|
||||
import Slider from '$lib/components/ui/Slider.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Checkbox from '$lib/components/ui/Checkbox.svelte';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
@@ -60,7 +64,7 @@
|
||||
|
||||
// Calendar fields
|
||||
let calendarUrls = $state<Array<{ url: string; color: string; label: string }>>([
|
||||
{ url: '', color: '#6366f1', label: '' }
|
||||
{ url: '', color: '#e8754f', label: '' }
|
||||
]);
|
||||
let calendarDaysAhead = $state(7);
|
||||
|
||||
@@ -198,7 +202,7 @@
|
||||
rssFeedUrl = '';
|
||||
rssMaxItems = 10;
|
||||
rssShowSummary = true;
|
||||
calendarUrls = [{ url: '', color: '#6366f1', label: '' }];
|
||||
calendarUrls = [{ url: '', color: '#e8754f', label: '' }];
|
||||
calendarDaysAhead = 7;
|
||||
markdownContent = '';
|
||||
metricLabel = '';
|
||||
@@ -350,7 +354,7 @@
|
||||
}
|
||||
|
||||
function addCalendarUrl() {
|
||||
calendarUrls = [...calendarUrls, { url: '', color: '#6366f1', label: '' }];
|
||||
calendarUrls = [...calendarUrls, { url: '', color: '#e8754f', label: '' }];
|
||||
}
|
||||
|
||||
function removeCalendarUrl(index: number) {
|
||||
@@ -367,7 +371,7 @@
|
||||
|
||||
// Input CSS class for reuse
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
|
||||
'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
|
||||
</script>
|
||||
|
||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||
@@ -505,14 +509,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">Select Apps</span>
|
||||
<div class="max-h-40 space-y-1 overflow-y-auto rounded-lg border border-input bg-background p-2">
|
||||
<div class="max-h-40 space-y-1 overflow-y-auto rounded-xl border border-input bg-background p-2">
|
||||
{#each apps as app (app.id)}
|
||||
<label class="flex items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<Checkbox
|
||||
checked={statusAppIds.includes(app.id)}
|
||||
onchange={() => toggleStatusApp(app.id)}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
ariaLabel={app.name}
|
||||
/>
|
||||
{app.name}
|
||||
</label>
|
||||
@@ -549,12 +552,8 @@
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">Leave empty for local time</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={clockShowWeather}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
|
||||
<Switch bind:checked={clockShowWeather} ariaLabel="Show Weather" />
|
||||
Show Weather
|
||||
</label>
|
||||
</div>
|
||||
@@ -599,26 +598,24 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="sys-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Source Type</label>
|
||||
<select
|
||||
<Select
|
||||
id="sys-type-{sectionId}"
|
||||
bind:value={sysStatsSourceType}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="glances">Glances</option>
|
||||
<option value="prometheus">Prometheus</option>
|
||||
<option value="custom">Custom JSON</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-foreground">Metrics</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ['cpu', 'ram', 'disk', 'swap', 'network'] as metric (metric)}
|
||||
<label class="flex items-center gap-1.5 rounded-md border border-input px-2 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<input
|
||||
type="checkbox"
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-xl border border-input px-2.5 py-1 text-sm text-foreground hover:bg-accent">
|
||||
<Checkbox
|
||||
checked={sysStatsMetrics.includes(metric)}
|
||||
onchange={() => toggleSysStatsMetric(metric)}
|
||||
class="h-3.5 w-3.5 rounded border-input accent-primary"
|
||||
ariaLabel={metric}
|
||||
/>
|
||||
<span class="capitalize">{metric}</span>
|
||||
</label>
|
||||
@@ -629,14 +626,13 @@
|
||||
<label for="sys-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh Interval: {sysStatsRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="sys-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={sysStatsRefreshInterval}
|
||||
min="5"
|
||||
max="300"
|
||||
step="5"
|
||||
class="w-full accent-primary"
|
||||
min={5}
|
||||
max={300}
|
||||
step={5}
|
||||
ariaLabel="Refresh interval"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -658,23 +654,18 @@
|
||||
<label for="rss-max-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Max Items: {rssMaxItems}
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="rss-max-{sectionId}"
|
||||
type="range"
|
||||
bind:value={rssMaxItems}
|
||||
min="3"
|
||||
max="30"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
min={3}
|
||||
max={30}
|
||||
step={1}
|
||||
ariaLabel="Max items"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={rssShowSummary}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
|
||||
<Switch bind:checked={rssShowSummary} ariaLabel="Show Summaries" />
|
||||
Show Summaries
|
||||
</label>
|
||||
</div>
|
||||
@@ -704,7 +695,7 @@
|
||||
<input
|
||||
type="color"
|
||||
bind:value={calendarUrls[i].color}
|
||||
class="h-9 w-9 cursor-pointer rounded-lg border border-input bg-background"
|
||||
class="h-9 w-9 cursor-pointer rounded-xl border border-input bg-background"
|
||||
title="Calendar color"
|
||||
/>
|
||||
</div>
|
||||
@@ -733,14 +724,13 @@
|
||||
<label for="cal-days-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Days Ahead: {calendarDaysAhead}
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="cal-days-{sectionId}"
|
||||
type="range"
|
||||
bind:value={calendarDaysAhead}
|
||||
min="1"
|
||||
max="30"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
ariaLabel="Days ahead"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -855,14 +845,13 @@
|
||||
<label for="metric-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh: {metricRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="metric-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={metricRefreshInterval}
|
||||
min="10"
|
||||
max="600"
|
||||
step="10"
|
||||
class="w-full accent-primary"
|
||||
min={10}
|
||||
max={600}
|
||||
step={10}
|
||||
ariaLabel="Refresh interval"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -916,12 +905,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={linkGroupCollapsible}
|
||||
class="h-4 w-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<label class="flex cursor-pointer items-center gap-3 text-sm font-medium text-foreground">
|
||||
<Switch bind:checked={linkGroupCollapsible} ariaLabel="Collapsible" />
|
||||
Collapsible
|
||||
</label>
|
||||
</div>
|
||||
@@ -942,43 +927,40 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="cam-type-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Stream Type</label>
|
||||
<select
|
||||
<Select
|
||||
id="cam-type-{sectionId}"
|
||||
bind:value={cameraType}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="image">Snapshot (Image)</option>
|
||||
<option value="mjpeg">MJPEG Stream</option>
|
||||
<option value="hls">HLS Stream</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="cam-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh: {cameraRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="cam-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={cameraRefreshInterval}
|
||||
min="1"
|
||||
max="120"
|
||||
step="1"
|
||||
class="w-full accent-primary"
|
||||
min={1}
|
||||
max={120}
|
||||
step={1}
|
||||
ariaLabel="Refresh interval"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="cam-ratio-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Aspect Ratio</label>
|
||||
<select
|
||||
<Select
|
||||
id="cam-ratio-{sectionId}"
|
||||
bind:value={cameraAspectRatio}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="16/9">16:9</option>
|
||||
<option value="4/3">4:3</option>
|
||||
<option value="1/1">1:1</option>
|
||||
<option value="21/9">21:9</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -990,44 +972,41 @@
|
||||
{#if integrationApps.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No apps with integrations configured. Add an integration to an app first.</p>
|
||||
{:else}
|
||||
<select
|
||||
<Select
|
||||
id="int-app-{sectionId}"
|
||||
bind:value={integrationAppId}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="">Select an app...</option>
|
||||
{#each integrationApps as app (app.id)}
|
||||
<option value={app.id}>{app.name} ({app.integrationType})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Select>
|
||||
{/if}
|
||||
</div>
|
||||
{#if integrationAppId && integrationEndpoints.length > 0}
|
||||
<div>
|
||||
<label for="int-endpoint-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">Data Endpoint</label>
|
||||
<select
|
||||
<Select
|
||||
id="int-endpoint-{sectionId}"
|
||||
bind:value={integrationEndpointId}
|
||||
class={inputClass}
|
||||
>
|
||||
<option value="">Select endpoint...</option>
|
||||
{#each integrationEndpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="int-refresh-{sectionId}" class="mb-1 block text-sm font-medium text-foreground">
|
||||
Refresh: {integrationRefreshInterval}s
|
||||
</label>
|
||||
<input
|
||||
<Slider
|
||||
id="int-refresh-{sectionId}"
|
||||
type="range"
|
||||
bind:value={integrationRefreshInterval}
|
||||
min="10"
|
||||
max="600"
|
||||
step="10"
|
||||
class="w-full accent-primary"
|
||||
min={10}
|
||||
max={600}
|
||||
step={10}
|
||||
ariaLabel="Refresh interval"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1038,7 +1017,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSubmitWidget}
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
class="rounded-xl bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{$t('common.add')}
|
||||
</button>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="absolute inset-0 z-10 rounded-xl bg-black/5 transition-opacity">
|
||||
<!-- Top-left: drag handle -->
|
||||
<div class="absolute left-1.5 top-1.5">
|
||||
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-sm backdrop-blur-sm" title="Drag to reorder">
|
||||
<div class="cursor-grab rounded-md bg-card/90 p-1 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm" title="Drag to reorder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="9" cy="5" r="1" /><circle cx="15" cy="5" r="1" />
|
||||
<circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" />
|
||||
@@ -62,7 +62,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showSizePicker = !showSizePicker; previewSpan = null; }}
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors
|
||||
{showSizePicker ? 'bg-primary text-primary-foreground' : 'hover:bg-accent hover:text-foreground'}"
|
||||
title={$t('widget.resize') ?? 'Resize'}
|
||||
>
|
||||
@@ -78,7 +78,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onEdit(widgetId)}
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
title={$t('common.edit') ?? 'Edit'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -91,7 +91,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showDeleteConfirm = true; }}
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-sm backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
class="rounded-md bg-card/90 p-1.5 text-muted-foreground shadow-[var(--shadow-soft)] backdrop-blur-sm transition-colors hover:bg-destructive hover:text-destructive-foreground"
|
||||
title={$t('common.delete') ?? 'Delete'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
<!-- Size picker popover -->
|
||||
{#if showSizePicker && onResize}
|
||||
<div class="absolute right-1.5 top-10 z-20 rounded-lg border border-border bg-card p-2 shadow-xl backdrop-blur-sm">
|
||||
<div class="absolute right-1.5 top-10 z-20 rounded-xl border border-border bg-card p-2 shadow-[var(--shadow-lift)] backdrop-blur-sm">
|
||||
<div class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{$t('widget.width') ?? 'Width'}
|
||||
</div>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet skeleton()}
|
||||
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<span class="text-xs text-muted-foreground">…</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -163,7 +163,7 @@
|
||||
}} />
|
||||
{/await}
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<span class="text-xs text-muted-foreground">{$t('widget.type', { values: { type: widget.type } })}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl lg:max-w-2xl"
|
||||
class="mx-4 w-full max-w-lg rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-lift)] lg:max-w-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
transition:scale={{ start: 0.95, duration: 150 }}
|
||||
>
|
||||
@@ -110,7 +110,7 @@
|
||||
type="text"
|
||||
bind:value={filterQuery}
|
||||
placeholder={$t('widget.search_type') ?? 'Search widget types...'}
|
||||
class="w-full rounded-lg border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||
class="w-full rounded-xl border border-input bg-background py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +127,7 @@
|
||||
onclick={() => onSelect(wt.value)}
|
||||
class="flex items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div class="mt-0.5 shrink-0 rounded-lg bg-primary/10 p-2 text-primary">
|
||||
<div class="mt-0.5 shrink-0 rounded-xl bg-primary/10 p-2 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#each iconFor(wt.value).split('|') as segment, si (si)}
|
||||
{#if segment.includes('m') || segment.includes('M') || segment.includes('a') || segment.includes('z') || segment.includes('A') || segment.includes('c') || segment.includes('l') || segment.includes('v') || segment.includes('h') || segment.includes('V') || segment.includes('H')}
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
|
||||
const severityStyles = $derived.by(() => {
|
||||
switch (data.severity) {
|
||||
case 'critical': return 'border-red-500/50 bg-red-500/10 text-red-400';
|
||||
case 'warning': return 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400';
|
||||
default: return 'border-blue-500/50 bg-blue-500/10 text-blue-400';
|
||||
case 'critical':
|
||||
return 'border-status-offline/40 bg-status-offline/10 text-status-offline-ink';
|
||||
case 'warning':
|
||||
return 'border-status-degraded/40 bg-status-degraded/10 text-status-degraded-ink';
|
||||
default:
|
||||
return 'border-room-sky/40 bg-room-sky/10 text-room-sky';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,7 +23,7 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3 rounded-lg border p-3 {severityStyles}">
|
||||
<div class="flex items-start gap-3 rounded-2xl border p-3.5 {severityStyles}">
|
||||
<span class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-bold">
|
||||
{severityIcon}
|
||||
</span>
|
||||
|
||||
@@ -15,7 +15,15 @@
|
||||
data.labels.length > 0 ? Math.max(100 / data.labels.length - 2, 4) : 10
|
||||
);
|
||||
|
||||
const defaultColors = ['#6366f1', '#22c55e', '#eab308', '#ef4444', '#06b6d4'];
|
||||
// Cozy "room" palette for multi-series charts
|
||||
const defaultColors = [
|
||||
'var(--room-terra)',
|
||||
'var(--room-sky)',
|
||||
'var(--room-sage)',
|
||||
'var(--room-butter)',
|
||||
'var(--room-lav)',
|
||||
'var(--room-peach)'
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-3">
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
const color = $derived.by(() => {
|
||||
const warn = data.thresholds?.warning ?? 60;
|
||||
const crit = data.thresholds?.critical ?? 85;
|
||||
if (percentage >= crit) return '#ef4444'; // red
|
||||
if (percentage >= warn) return '#eab308'; // yellow
|
||||
return '#22c55e'; // green
|
||||
if (percentage >= crit) return 'var(--status-offline)';
|
||||
if (percentage >= warn) return 'var(--status-degraded)';
|
||||
return 'var(--status-online)';
|
||||
});
|
||||
|
||||
// SVG circle math
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
);
|
||||
|
||||
const trendColor = $derived(
|
||||
data.trend === 'up' ? 'text-green-500' : data.trend === 'down' ? 'text-red-500' : 'text-muted-foreground'
|
||||
data.trend === 'up' ? 'text-status-online-ink' : data.trend === 'down' ? 'text-status-offline-ink' : 'text-muted-foreground'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center gap-1 p-4 text-center">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-3xl font-bold text-foreground">{data.value}</span>
|
||||
<span class="font-display text-3xl font-semibold text-foreground">{data.value}</span>
|
||||
{#if data.unit}
|
||||
<span class="text-sm text-muted-foreground">{data.unit}</span>
|
||||
{/if}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user