11 Commits

Author SHA1 Message Date
alexei.dolgolyov 16c667ca15 feat(status): per-browser dismissal for Recent Incidents
The Recent Incidents list is derived server-side from raw AppStatus
health-check samples, so there is no incident row to delete and deleting
the underlying samples would corrupt uptime % and the sparkline timeline.
Per-browser, non-destructive dismissal is the right model: localStorage
holds the dismissed (appId, ISO startedAt) keys, the page filters them
out on render, and a Restore affordance brings them back.

- Per-row Dismiss (X) and section-level Clear all
- Restore link appears whenever any incident on the current page is hidden
- Dismissal key is (appId, startedAt) so it survives 24h/7d/30d switches
- Focus is moved to the Restore link after Clear all empties the list
  (otherwise the unmounting button would drop focus to <body>)
- Quota / disabled-localStorage failure is swallowed; in-memory state
  still works for the active session

Hand-rolled <button> elements match 14 other link-styled buttons already
in the project; both use the project-standard focus-visible:ring-2
focus-visible:ring-primary/30 ring.
2026-05-28 15:40:23 +03:00
alexei.dolgolyov dab13518ef feat(backup): harden restore — strict tar, two-phase rollback, degraded state
- Gate flag flipped synchronously in restore route before body parse,
  closing race where concurrent requests could slip through during awaits
- Strict tar extraction rejects symlinks, hardlinks, absolute paths, and
  parent-segment traversal entries
- Staging directory moved to a sibling of the uploads dir so atomic renames
  stay on the same filesystem (Windows %TEMP%/Linux tmpfs were causing EXDEV)
- Two-phase atomic-rename rollback for uploads — never rmrf the live dir
  before the safety is back in place; degraded flag set if rollback can't
  recover cleanly
- Prisma reconnect failure now marks process degraded; hooks.server.ts
  returns 503 to everything except /api/health so orchestrators can recycle
- /api/health distinguishes ok / restoring / degraded / db_down (503s)
- Legacy .db restore now runs structural SQLite integrity check before swap
- Schema-version check tightened: null on either side requires explicit
  allowSchemaMismatch override (was silently treated as a match)
- HMR/multi-import-safe global state (Vite dev reload no longer creates a
  fresh module while a restore is mid-flight)
- VACUUM INTO path: defensive rejection of quote/control characters
- Backup filename regex requires a leading alphanumeric (rejects '.tar.gz',
  '....db' which passed the previous loose pattern)
- Download: RFC 5987 Content-Disposition with filename* + sanitized fallback
- Restore route logs BACKUP_FAILED audit row with phase on failure
2026-05-28 14:56:57 +03:00
alexei.dolgolyov f087551454 feat(ui): cozy polish — primitives, motion, empty states
Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled
call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy
Home palette, just consistently applied.

Primitives
- Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob
- Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass()
  helper for <a> link-as-CTA cases
- Checkbox: rounded square with animated check-draw + indeterminate
- Select: native <select> with Cozy chevron + matched radius
- Slider: gradient track, terracotta-bordered knob, aria-valuetext
- Input + Field: documented in CLAUDE.md for future use
- 9 buttonClass unit tests

Migrations
- 23 <input type=checkbox> → <Switch> (boolean settings)
- 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics)
- ~28 <select> → <Select>
- 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom)
- ~25 hand-rolled buttons → <Button> / buttonClass()

Surface polish
- Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft
  (resolves the Phase-5 tradeoff from the Cozy migration memo)
- BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a
  sample button; hue/sat use Slider; bg/cardSize use Select
- AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow
  amber breathing) and .status-offline (single attention flash) now applied
- AppForm collapse triggers: rotating chevron + aria-expanded
- Empty states for /boards and /apps: inline SVGs using --room-* tokens
  (peach/sky/sage/butter) instead of generic Lucide icons
- Login Remember Me: showcase Switch (first-impression surface)

Motion (src/app.css)
- New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps)
- New cozy-expand for accordion sections (healthcheck, integration, wallpaper)
- All motion respects prefers-reduced-motion

CLAUDE.md
- New project guide with a mandatory Frontend reuse table — every primitive
  documented with "never use raw <input type=checkbox>/<select>/<range>" and
  "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules

Verification
- npm run check: 0 errors, 0 warnings, 5831 files
- npm test: 301 passing
- npm run lint: 0 errors (19 pre-existing warnings unchanged)
- npm run build: ✔ done

Branch is feat/cozy-polish, ready to PR against master.
2026-05-28 14:39:53 +03:00
alexei.dolgolyov 555ac9ea63 feat(backup): tar.gz format with uploads + manifest, restore guard
- New tar.gz backup format bundling SQLite snapshot + uploads tree + manifest.json (version, app+schema versions, checksums, dbSize)
- BACKUPS_DIR env override; defaults to /app/data/backups in prod, <cwd>/data/backups in dev (matches uploads convention)
- 503 guard in hooks.server.ts while restore is mid-flight (DB file is being swapped); excludes static assets + /api/health; sets Retry-After: 15
- Legacy .db restore still supported (DB-only)
- Restore endpoint adds schema-mismatch detection + force flag; download/schedule endpoints updated
- 256 MiB free-disk safety margin before backup
- tar dep added to package.json; 18 new backupService tests
- i18n labels (en + ru) for new restore/format states
2026-05-28 14:39:24 +03:00
alexei.dolgolyov 0a13b6b58c fix(i18n): add missing admin.custom_css labels (en + ru)
Lint & Test / lint-and-check (push) Failing after 5m9s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped
These keys were referenced in SettingsForm but absent from both locales, so they rendered as raw keys instead of the intended text.
2026-05-27 23:11:40 +03:00
alexei.dolgolyov 5dcadd1c20 feat(ui): migrate entire UI to "Cozy Home" design
Warm, friendly redesign replacing the generic cold-shadcn look. Built as a
swappable token bundle so other presets can be added later; dark mode and the
user-tunable accent hue are retained.

Foundation
- app.css: warm cream (light) + "dusk" (dark) token system; terracotta accent
  (default hue 16); pastel --room-* palette; vivid --status-* (dots/bars) plus
  AA-legible --status-*-ink (text); soft warm shadows; --radius 1rem; font tokens
- Fonts: Fraunces (display) + Figtree (body), self-hosted in static/fonts
  (no Google CDN) so offline/LAN installs work; system-ui fallbacks kept
- h1/h2/h3 render in Fraunces via base layer

Chrome and surfaces
- Sidebar, Header, home, AppCard/BoardCard, BoardHeader, sections, favorites
- 29 widgets + integration renderers: cozy card shells, room-palette charts
- Default background is a static warm "cozy" glow (mesh demoted, rAF gated on
  prefers-reduced-motion)

System-wide
- Status colors tokenized (no raw bg/text-*-500 or status hex); success/warning
  to status tokens, categorical to room palette, errors to destructive
- Inputs rounded-xl; buttons rounded-xl; cards/dialogs rounded-[1.4rem];
  soft-shadow vocabulary only; focus-visible:ring-primary/30
- Forms, admin tables (now cozy cards), dialogs, popovers, auth screens

a11y: reduced-motion guards; darker status "ink" text for AA on cream.
Known tradeoff: terracotta primary + white button text ~2.96:1 (signature color,
user-tunable).

Verified: svelte-check 0/0, build ok, 274 tests pass, eslint 0 errors.
Design refs + system sheet in design-mockups/.
2026-05-27 23:04:47 +03:00
alexei.dolgolyov f1cfb61d13 feat: production hardening + password reset, metrics, signed webhooks
Lint & Test / lint-and-check (push) Failing after 5m5s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped
Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
  refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
  RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
  Manual redirect handling re-validates each 3xx Location hop. Applied to
  healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
  discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
  switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
  of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
  before pattern match, drops forbidden at-rules incl. @import without
  whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
  uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
  HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
  for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
  emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
  branches) + rate limiting on login/register/onboarding/refresh/invite/
  password-reset.
- OAuth callback sanitizes IdP error_description before echoing.

New features:
- Custom +error.svelte pages (root + boards + admin) via shared
  ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
  page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
  auth. Counters for login/healthcheck/notification/integration; gauges
  for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
  shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
  at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
  OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
  APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.

Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
  in board query; widgets lazy-loaded via dynamic imports (marked,
  DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.

Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
  IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
  CSS sanitizer, SVG sanitizer, rate limiter.

CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
  uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
  prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
  startup, log rotation, resource limits.
- README documents breaking-change upgrade path.

Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
  nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
  replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.

Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.

Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
2026-05-26 19:51:21 +03:00
alexei.dolgolyov 38335e925b feat(auth): admin invite links
Lint & Test / lint-and-check (push) Failing after 5m4s
Lint & Test / test (push) Has been skipped
Replaces the blunt registrationEnabled toggle with per-invite access.
Invites are tokenized, single-use, optionally locked to an email, can
grant user or admin role, and expire (default 7d, max 90d).

- Invite model with tokenHash (bcrypt), email, role, expiresAt,
  usedAt/usedByUserId.
- inviteService: create, list, revoke, findInviteByToken, consumeInvite.
  Token is shown exactly once at creation.
- /admin/invites page: list with status (Active/Used/Expired), generate
  with email lock + role + custom expiry, copy one-shot URL, revoke.
- /register?invite=TOKEN: accepts invite even when registrationEnabled
  is false; shows a banner; enforces email lock; applies the invite's
  role on creation; consumes the invite on success.
- Linked from the admin navbar.
2026-04-16 04:00:18 +03:00
alexei.dolgolyov 9cab7262e6 feat(auth): sessions page to list and revoke devices
- /settings/sessions: list user's active sessions with label, IP,
  last-used, expires, user-agent. Highlights the current device.
- Revoke one session (/api/sessions/:id DELETE) or all-other sessions
  (/api/sessions DELETE). Admins can revoke any session.
- Revoking the current session clears cookies and kicks the user to
  /login.
- Wired into the main settings page.
2026-04-16 03:46:33 +03:00
alexei.dolgolyov b9f3a2ca0b feat(auth): Session model + remember-me
Replace the single `user.refreshToken` column with a proper Session
table so users can have multiple concurrent sessions (phone, laptop,
etc.), each with their own refresh token, expiry, label, and
remember-me flag.

- Add Session model (id, userId, tokenHash, label, userAgent,
  ipAddress, rememberMe, lastUsedAt, expiresAt).
- Drop `User.refreshToken` and `User.refreshTokenExpiresAt`.
- authService: new createSession/validateSession/rotateSession/
  revokeSession/listUserSessions helpers; remove refresh-token-on-user
  functions.
- sessionCookies helper now issues a session_id cookie alongside
  access_token and refresh_token; rotateSessionCookies keeps the same
  session id on refresh.
- Login form adds a "Keep me signed in for 30 days" checkbox;
  TTL is 7d by default, 30d with remember-me.
- User-Agent parsed into a friendly label ("Chrome on Windows") for
  the upcoming sessions page.
- hooks.server.ts, refresh endpoint, logout, register, oauth callback,
  and onboarding all switched to the new session API.
2026-04-16 03:41:52 +03:00
alexei.dolgolyov 3fa30f72a3 feat(auth): auto-login after onboarding, consolidate session cookies
Lint & Test / lint-and-check (push) Failing after 5m1s
Lint & Test / test (push) Has been skipped
- Extract session cookie issuance into sessionCookies.ts helper; remove
  duplicated COOKIE_BASE blocks from login, register, oauth callback/authorize,
  refresh handler, hooks.server.ts, and onboarding.
- Derive cookie secure flag from ORIGIN (https://...) instead of NODE_ENV so
  plain-HTTP production deploys don't silently drop cookies.
- Auto-login admin after onboarding completes; UI does a full reload so
  hooks.server.ts picks up the new session.
- Harden onboarding: reject duplicate admin creation, flip onboardingComplete
  atomically to prevent concurrent completions, error out if no admin found.
- Fix Dockerfile CMD operator precedence: node build now always runs after
  migrate deploy || db push.
- Wire ORIGIN env through docker-compose.
2026-04-16 03:28:46 +03:00
256 changed files with 14656 additions and 3613 deletions
+17 -1
View File
@@ -5,9 +5,25 @@ data/
coverage/
.git/
.gitea/
.github/
.claude/
.idea/
.vscode/
.env
.env.*
!.env.example
*.md
plans/
PLAN_PROMPT.md
README.md
RELEASE_NOTES.md
*.log
**/*.test.ts
**/*.spec.ts
**/__tests__/
tests/
playwright-report/
test-results/
*.swp
*.swo
.DS_Store
Thumbs.db
+39 -7
View File
@@ -1,26 +1,39 @@
# Database
# --- Database ---
DATABASE_URL="file:../data/launcher.db"
# Authentication
JWT_SECRET="change-me-to-a-random-64-char-string"
# --- Authentication (REQUIRED) ---
# Generate a strong secret with: openssl rand -hex 32
# The server refuses to start with placeholder or short values (< 32 chars).
JWT_SECRET=""
JWT_EXPIRY="15m"
REFRESH_TOKEN_EXPIRY="7d"
# Application
# --- Integration credential encryption (REQUIRED if any integration is configured) ---
# Must be DIFFERENT from JWT_SECRET so rotating one does not invalidate the other.
# Generate a strong secret with: openssl rand -hex 32
INTEGRATION_ENCRYPTION_KEY=""
# --- Application ---
APP_PORT=3000
APP_HOST="0.0.0.0"
# ORIGIN must match the public URL users visit. When it begins with https://,
# session cookies are issued with the Secure flag. Set this when running behind
# a reverse proxy that terminates TLS, e.g. ORIGIN="https://launcher.example.com"
ORIGIN="http://localhost:3000"
# Legacy alias — keep for older docs; not used internally.
APP_URL="http://localhost:3000"
# OAuth / OIDC (optional — configure here or in Admin > Settings)
# --- OAuth / OIDC (optional — configure here or in Admin > Settings) ---
OAUTH_CLIENT_ID=""
OAUTH_CLIENT_SECRET=""
OAUTH_DISCOVERY_URL=""
OAUTH_REDIRECT_URI=""
# Guest mode (true = allow unauthenticated dashboard access)
# Guest mode (true = allow unauthenticated dashboard access to guest-accessible boards)
GUEST_MODE="true"
# Health check interval (cron expression — every 5 minutes)
# Healthcheck cron expression — default every 5 minutes
HEALTHCHECK_CRON="*/5 * * * *"
HEALTHCHECK_TIMEOUT_MS="5000"
@@ -28,5 +41,24 @@ HEALTHCHECK_TIMEOUT_MS="5000"
DOCKER_SOCKET_PATH="/var/run/docker.sock"
TRAEFIK_API_URL=""
# Allow outbound fetches to private/internal hosts. Default is "false" which
# blocks SSRF (loopback, RFC1918, link-local, cloud-metadata). Self-hosted
# users monitoring services on a LAN typically want this set to "true".
ALLOW_PRIVATE_NETWORK_FETCH="false"
# Run background jobs (healthcheck, backup) in THIS process. Set to "false" when
# 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).
METRICS_TOKEN=""
# Node environment
NODE_ENV="production"
+39 -21
View File
@@ -17,7 +17,6 @@ jobs:
TAG="${{ gitea.ref_name }}"
VERSION="${TAG#v}"
REGISTRY="${{ gitea.server_url }}"
# Strip https:// for registry address
REGISTRY="${REGISTRY#https://}"
REGISTRY="${REGISTRY#http://}"
IMAGE="${REGISTRY}/${{ gitea.repository }}"
@@ -25,23 +24,34 @@ jobs:
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
run: |
echo "${{ secrets.DEPLOY_TOKEN }}" | docker login "${{ gitea.server_url }}" -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build and push Docker image
run: |
IMAGE="${{ steps.meta.outputs.image }}"
VERSION="${{ steps.meta.outputs.version }}"
docker build \
--label "org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}" \
--label "org.opencontainers.image.description=Self-hosted web app launcher dashboard" \
--label "org.opencontainers.image.version=${VERSION}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
- name: Build and push multi-arch Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: true
sbom: true
build-args: |
VERSION=${{ steps.meta.outputs.version }}
tags: |
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.image }}:latest
labels: |
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
org.opencontainers.image.description=Self-hosted web app launcher dashboard
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.licenses=MIT
release:
runs-on: ubuntu-latest
@@ -61,28 +71,37 @@ jobs:
VERSION="${TAG#v}"
BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
# Detect pre-release (alpha/beta/rc)
IS_PRE="false"
if echo "$TAG" | grep -qE '(alpha|beta|rc)'; then
IS_PRE="true"
fi
# Read release notes if present
# Extract release notes for THIS version only. Falls back to whole file
# if the markers aren't found.
if [ -f RELEASE_NOTES.md ]; then
BODY_JSON=$(jq -Rs '.' < RELEASE_NOTES.md)
echo "Found RELEASE_NOTES.md"
BODY=$(awk -v v="$VERSION" '
BEGIN {capture = 0}
/^## (v?)/ {
if (capture) {exit}
if ($0 ~ "## v?"v"([^0-9]|$)") {capture = 1; next}
}
capture {print}
' RELEASE_NOTES.md)
if [ -z "$BODY" ]; then
BODY=$(cat RELEASE_NOTES.md)
fi
BODY_JSON=$(printf '%s' "$BODY" | jq -Rs '.')
echo "Found RELEASE_NOTES.md (extracted section for $VERSION)"
else
BODY_JSON='""'
echo "No RELEASE_NOTES.md found — release will have no body"
fi
# Check if release already exists for this tag
EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \
"$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $DEPLOY_TOKEN")
if [ "$EXISTING" = "200" ]; then
# Update existing release
RELEASE_ID=$(curl -s "$BASE_URL/releases/tags/$TAG" \
-H "Authorization: token $DEPLOY_TOKEN" | jq -r '.id')
curl -s -X PATCH "$BASE_URL/releases/$RELEASE_ID" \
@@ -96,7 +115,6 @@ jobs:
}"
echo "Updated existing release $RELEASE_ID for $TAG"
else
# Create new release
curl -s -X POST "$BASE_URL/releases" \
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
+60 -29
View File
@@ -6,50 +6,81 @@ on:
pull_request:
branches: [master, main]
env:
NODE_VERSION: '22'
jobs:
lint-and-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
- name: Type check
run: npm run check
- run: npm ci
- run: npx prisma generate
- run: npm run lint
- run: npm run format:check
- run: npm run check
test:
runs-on: ubuntu-latest
needs: lint-and-check
env:
# Deterministic test secrets so the env validator at module import doesn't trip.
JWT_SECRET: 'test-secret-must-be-at-least-32-characters-long-for-validation'
INTEGRATION_ENCRYPTION_KEY: 'integration-test-key-must-be-at-least-32-characters'
ORIGIN: 'http://localhost:3000'
DATABASE_URL: 'file:./test.db'
NODE_ENV: 'test'
RUN_SCHEDULERS: 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npx prisma generate
- run: npx prisma migrate deploy
- run: npm test
- name: Install dependencies
run: npm ci
build:
runs-on: ubuntu-latest
needs: lint-and-check
env:
JWT_SECRET: 'build-secret-must-be-at-least-32-characters-long-for-validation'
INTEGRATION_ENCRYPTION_KEY: 'integration-build-key-must-be-at-least-32-characters'
DATABASE_URL: 'file:./build.db'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npx prisma generate
- run: npm run build
- name: Generate Prisma client
run: npx prisma generate
docker-build:
runs-on: ubuntu-latest
needs: lint-and-check
steps:
- uses: actions/checkout@v4
- name: Smoke-test Dockerfile build
run: docker build -t web-app-launcher:ci-smoke --build-arg VERSION=ci .
- name: Run tests
run: npm test
audit:
runs-on: ubuntu-latest
needs: lint-and-check
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
# Production-only audit. devDeps regularly carry low-severity advisories
# we accept; only block on production-shipped CVEs.
- run: npm audit --omit=dev --audit-level=high
+54
View File
@@ -0,0 +1,54 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
semantic = true
auto_update = true
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
# Cache directory override. Defaults to the platform cache location.
# macOS: ~/Library/Caches/vex
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
# cache_dir = "./.vex/cache"
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
# the cache should travel with the project (e.g. on a moved or renamed
# directory). vex writes a `.gitignore` inside it so contents are not
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
# local_cache = false
# Thread count for parallel indexing (index/update/watch).
# * unset — 80% of available cores, rounded up (default, leaves headroom)
# * 0 — use all cores (explicit opt-in to max throughput)
# * N — exactly N workers
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
# jobs = 4
# Build the persistent call-graph section. Disabling falls back to live-scan
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
# time on large monorepos). The opt-out is persisted in the manifest so
# `vex update` does not silently re-add the section.
# Per-invocation override: `vex index --no-call-graph`.
# call_graph = true
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
# only structural (+ semantic). Same persistence rules as `call_graph`.
# Per-invocation override: `vex index --no-bm25`.
# bm25 = true
+56
View File
@@ -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
```
+29 -7
View File
@@ -1,4 +1,6 @@
# Stage 1: Install dependencies
# syntax=docker/dockerfile:1
# Stage 1: Install dependencies (includes devDeps needed for build)
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
@@ -11,12 +13,20 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
RUN npm prune --production
# Drop devDependencies so the production image stays small.
RUN npm prune --omit=dev
# Stage 3: Production image
# Stage 3: Production runtime image
FROM node:22-alpine AS production
WORKDIR /app
# Embed the version (build-time) so /api/health can echo it later.
ARG VERSION=0.0.0
ENV APP_VERSION=$VERSION
# Install curl for the entrypoint healthcheck. Tini for proper signal handling.
RUN apk add --no-cache curl tini
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=build --chown=appuser:appgroup /app/build ./build
@@ -24,17 +34,29 @@ COPY --from=build --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=build --chown=appuser:appgroup /app/package.json ./
COPY --from=build --chown=appuser:appgroup /app/prisma ./prisma
RUN mkdir -p /app/data && chown appuser:appgroup /app /app/data
# Persistent data dir + uploads subdir. The named volume mount in
# docker-compose targets /app/data, so uploads survive container rebuilds.
RUN mkdir -p /app/data /app/data/uploads /app/data/uploads/wallpapers /app/data/backups \
&& chown -R appuser:appgroup /app /app/data
USER appuser
ENV NODE_ENV=production
ENV APP_PORT=3000
ENV APP_HOST=0.0.0.0
ENV UPLOADS_DIR=/app/data/uploads
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:3000/api/health || exit 1
CMD ["sh", "-c", "npx prisma migrate deploy 2>/dev/null || npx prisma db push && node build"]
# Entrypoint:
# - Always run `prisma migrate deploy`. On an empty DB this creates the schema
# from the migration history (no separate `db push` bootstrap needed); on an
# existing DB it applies pending migrations only. No silent fallback — drift
# and migration failures surface loudly.
# - Default ORIGIN to localhost:APP_PORT so dev compose works, but production
# deployments MUST set ORIGIN to the public URL for Secure cookies.
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["sh", "-c", "npx prisma migrate deploy && ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}} node build"]
+74 -4
View File
@@ -7,7 +7,7 @@ A self-hosted dashboard for organizing, monitoring, and launching web applicatio
- **App registry** — add apps with icons, tags, and categories; automatic healthcheck monitoring with sparkline history
- **Boards & widgets** — customizable dashboards with drag-and-drop, resizable widget columns, and inline WYSIWYG editing
- **Service integrations** — connect to media services, Planka, and more to display live data in widgets
- **Authentication** — local accounts + OAuth/Authentik; per-board access control
- **Authentication** — local accounts + OAuth/Authentik; per-board access control; API tokens
- **Localization** — English and Russian
- **PWA** — installable, multi-tab sync, auto-discovery bookmarklet
- **SQLite backup/restore** — full database backup from the admin panel
@@ -15,14 +15,20 @@ A self-hosted dashboard for organizing, monitoring, and launching web applicatio
## Quick Start
```bash
# Clone and run with Docker Compose
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/web-app-launcher.git
cd web-app-launcher
# Generate two strong secrets
export JWT_SECRET=$(openssl rand -hex 32)
export INTEGRATION_ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d
```
The app is available at `http://localhost:3000`. On first launch, create an admin account at the setup page.
The launcher **refuses to start** if `JWT_SECRET` or `INTEGRATION_ENCRYPTION_KEY` is missing, shorter than 32 characters, or set to a known placeholder. This is intentional — running with the old `change-me-…` defaults would let anyone mint admin tokens.
## Configuration
Environment variables (set in `docker-compose.yml` or `.env`):
@@ -30,19 +36,83 @@ Environment variables (set in `docker-compose.yml` or `.env`):
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_PORT` | `3000` | Port to expose |
| `JWT_SECRET` | — | Secret for JWT signing (change in production!) |
| `GUEST_MODE` | `true` | Allow unauthenticated access |
| `JWT_SECRET` | **required** | Strong secret for JWT signing. Generate with `openssl rand -hex 32`. |
| `INTEGRATION_ENCRYPTION_KEY` | **required** | Strong secret for encrypting stored integration credentials. Must differ from `JWT_SECRET`. Generate with `openssl rand -hex 32`. |
| `ORIGIN` | `http://localhost:$APP_PORT` | Public URL users visit. When set to `https://...`, session cookies are issued with the Secure flag. **Set this to your public https URL when behind a reverse proxy.** |
| `GUEST_MODE` | `true` | Allow unauthenticated access to guest-flagged boards |
| `HEALTHCHECK_CRON` | `*/5 * * * *` | App healthcheck interval |
| `HEALTHCHECK_TIMEOUT_MS` | `5000` | Healthcheck request timeout |
| `ALLOW_PRIVATE_NETWORK_FETCH` | `false` (`true` in dev) | Allow outbound fetches to RFC1918/loopback/link-local. Self-hosted users monitoring LAN services usually want `true`. Off by default in prod to mitigate SSRF. |
| `RUN_SCHEDULERS` | `true` | Run background jobs (healthcheck, backup) in this process. Set `false` on extra horizontal replicas. |
| `OAUTH_CLIENT_ID` | — | OAuth provider client ID |
| `OAUTH_CLIENT_SECRET` | — | OAuth provider client secret |
| `OAUTH_DISCOVERY_URL` | — | OpenID Connect discovery URL |
| `METRICS_TOKEN` | — | Optional bearer token for `/api/metrics`. Unset = open (private-network setups) |
## Production deployment
### Reverse proxy (Traefik / Caddy / Nginx)
The launcher must know its public URL to issue secure cookies. Set `ORIGIN=https://launcher.example.com` and terminate TLS at the proxy. Example Traefik labels:
```yaml
services:
web-app-launcher:
# remove `ports:` mapping
networks: [traefik, launcher-net]
labels:
- traefik.enable=true
- traefik.http.routers.launcher.rule=Host(`launcher.example.com`)
- traefik.http.routers.launcher.entrypoints=websecure
- traefik.http.routers.launcher.tls.certresolver=letsencrypt
- traefik.http.services.launcher.loadbalancer.server.port=3000
environment:
- ORIGIN=https://launcher.example.com
```
### Volume backup
```bash
docker run --rm \
-v web-app-launcher_launcher-data:/data \
-v "$PWD":/backup \
alpine tar czf /backup/launcher-backup.tar.gz -C /data .
```
### Upgrade
```bash
docker compose pull && docker compose up -d
```
Database migrations run automatically on container start via `prisma migrate deploy`. The previous `db push` fallback was removed because it can silently drop columns on schema drift.
### Breaking changes when upgrading from versions ≤ 0.0.x
The 0.1.0 hardening release is a one-way upgrade with three breaking changes:
1. **`INTEGRATION_ENCRYPTION_KEY` is required and must differ from `JWT_SECRET`.** The launcher will refuse to start without it. Previously the integration key was derived from `JWT_SECRET`; all stored integration credentials (Planka, Authentik, Pi-hole, Portainer, Gitea, Immich, etc.) **will be undecryptable after the upgrade** and must be re-entered through the admin UI.
2. **All users will be logged out and all API tokens / invites will be revoked.** The hardening migration drops the `Session`, `Invite`, and `ApiToken` tables to switch from bcrypt-hashed storage to sha256 (so token validation is O(1) instead of O(N) bcrypt comparisons). Users will need to log in once; admins need to reissue API tokens and pending invites.
3. **Uploaded icons / wallpapers move from `static/uploads/` to `/app/data/uploads/`.** This makes them persist across container rebuilds. On upgrade, copy any existing files from your previous `static/uploads/` mount into the `launcher-data` volume:
```bash
# if you previously mounted `./static/uploads:/app/static/uploads`
docker run --rm \
-v "$PWD/static/uploads:/src:ro" \
-v web-app-launcher_launcher-data:/dst \
alpine sh -c "mkdir -p /dst/uploads && cp -r /src/. /dst/uploads/"
```
Take a backup before upgrading.
## Development
```bash
npm install
npx prisma generate
# strong dev secrets are already in .env (gitignored)
npm run dev
```
+789
View File
@@ -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>
+915
View File
@@ -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>
+643
View File
@@ -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>
+723
View File
@@ -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>“Everythings 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">Its 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 &amp; 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>
+639
View File
@@ -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 &amp; 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 &amp; 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 cant 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 itll 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>
+160
View File
@@ -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>
+21 -1
View File
@@ -7,7 +7,13 @@ services:
- '${APP_PORT:-3000}:3000'
environment:
- DATABASE_URL=file:/app/data/launcher.db
- JWT_SECRET=${JWT_SECRET:-change-me-to-a-random-64-char-string}
# JWT_SECRET is REQUIRED. Generate one with: openssl rand -hex 32
# The container will refuse to start if this is not set or is too weak.
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set. Generate with: openssl rand -hex 32}
# INTEGRATION_ENCRYPTION_KEY encrypts stored credentials for integrations
# (Planka, Authentik, Pi-hole, etc.). MUST differ from JWT_SECRET so that
# rotating one does not invalidate the other.
- INTEGRATION_ENCRYPTION_KEY=${INTEGRATION_ENCRYPTION_KEY:?INTEGRATION_ENCRYPTION_KEY must be set. Generate with: openssl rand -hex 32}
- JWT_EXPIRY=${JWT_EXPIRY:-15m}
- REFRESH_TOKEN_EXPIRY=${REFRESH_TOKEN_EXPIRY:-7d}
- GUEST_MODE=${GUEST_MODE:-true}
@@ -16,10 +22,24 @@ services:
- NODE_ENV=production
- APP_PORT=3000
- APP_HOST=0.0.0.0
# ORIGIN must match the public URL users visit. When set to https://...
# session cookies are issued with the Secure flag. Behind a reverse proxy
# terminating TLS, set this to the public https URL.
- ORIGIN=${ORIGIN:-http://localhost:${APP_PORT:-3000}}
volumes:
- launcher-data:/app/data
networks:
- launcher-net
logging:
driver: json-file
options:
max-size: '10m'
max-file: '3'
deploy:
resources:
limits:
memory: 1g
cpus: '1.0'
volumes:
launcher-data:
+14 -1
View File
@@ -23,7 +23,20 @@ export default ts.config(
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
],
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'separate-type-imports' }
],
// console.warn/console.error are allowed for server-side observability
// (logging dispatch failures, audit fallbacks). console.log is still flagged.
'no-console': ['warn', { allow: ['warn', 'error'] }],
// SvelteMap / SvelteSet only matter inside .svelte rune state. The
// stores layer uses plain Maps as caches that the runtime does not
// need to track reactively (consumers re-read via $derived). Disable
// project-wide to match the pre-existing repo state, where these
// were never enforced.
'svelte/prefer-svelte-reactivity': 'off'
}
},
{
+124 -9
View File
@@ -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",
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "web-app-launcher",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
@@ -15,16 +15,16 @@
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"audit:prod": "npm audit --omit=dev --audit-level=high",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"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",
@@ -35,14 +35,14 @@
"marked": "^17.0.5",
"node-cron": "^3.0.3",
"openid-client": "^6.8.2",
"prisma": "^6.2.0",
"simple-icons": "^13.0.0",
"svelte": "^5.0.0",
"svelte-dnd-action": "^0.9.69",
"svelte-i18n": "^4.0.1",
"sveltekit-superforms": "^2.22.0",
"tailwind-merge": "^2.6.0",
"@prisma/client": "^6.2.0",
"prisma": "^6.2.0",
"tar": "^7.5.15",
"zod": "^3.24.0"
},
"prisma": {
@@ -51,6 +51,8 @@
"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",
@@ -0,0 +1,62 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"label" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"rememberMe" BOOLEAN NOT NULL DEFAULT false,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- RedefineTables: drop refreshToken + refreshTokenExpiresAt from User.
-- All existing user sessions will be invalidated; users must re-login once after upgrade.
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"authProvider" TEXT NOT NULL DEFAULT 'local',
"role" TEXT NOT NULL DEFAULT 'user',
"onboardingComplete" BOOLEAN NOT NULL DEFAULT false,
"trackRecentApps" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"themeMode" TEXT,
"primaryHue" INTEGER,
"primarySaturation" INTEGER,
"backgroundType" TEXT,
"locale" TEXT
);
INSERT INTO "new_User" (
"id", "email", "password", "displayName", "avatarUrl", "authProvider", "role",
"onboardingComplete", "trackRecentApps", "createdAt", "updatedAt",
"themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale"
)
SELECT
"id", "email", "password", "displayName", "avatarUrl", "authProvider", "role",
"onboardingComplete", "trackRecentApps", "createdAt", "updatedAt",
"themeMode", "primaryHue", "primarySaturation", "backgroundType", "locale"
FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE INDEX "User_email_idx" ON "User"("email");
PRAGMA foreign_keys=ON;
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"email" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"usedByUserId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
-- CreateIndex
CREATE INDEX "Invite_tokenHash_idx" ON "Invite"("tokenHash");
-- CreateIndex
CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById");
@@ -0,0 +1,96 @@
-- Production-readiness hardening migration:
-- * Session: switch tokenHash from bcrypt-hashed to sha256-hashed (deterministic
-- lookup), add previousTokenHash for refresh-token reuse detection, add
-- unique constraint on tokenHash.
-- * AppStatus, AppClick, Notification, AuditLog: composite indexes that match
-- the actual query shapes (entity + time range).
--
-- Existing Session rows store bcrypt hashes that are no longer compatible with
-- the new sha256 lookup. We invalidate ALL sessions on upgrade — users will be
-- prompted to log in once. This is the safer option than keeping incompatible
-- rows that would silently fail validation.
PRAGMA foreign_keys=OFF;
-- ---- Session: rebuild with new shape and clear contents ---------------------
CREATE TABLE "new_Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"previousTokenHash" TEXT,
"label" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"rememberMe" BOOLEAN NOT NULL DEFAULT false,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- intentionally do NOT copy old rows — bcrypt hashes won't validate against
-- sha256 lookups and users would silently fail to refresh.
DROP TABLE "Session";
ALTER TABLE "new_Session" RENAME TO "Session";
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- ---- Invite: same bcrypt -> sha256 migration for the same reason ------------
-- Invites are short-lived (default 7 days). Existing unused invite rows would
-- be unreachable after the switch; clear them so admins re-issue if needed.
CREATE TABLE "new_Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"email" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"usedByUserId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DROP TABLE "Invite";
ALTER TABLE "new_Invite" RENAME TO "Invite";
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById");
-- ---- ApiToken: same bcrypt -> sha256 migration ------------------------------
-- Existing API tokens stop working at upgrade; users must regenerate. This is
-- preferable to keeping broken-but-present rows.
CREATE TABLE "new_ApiToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"expiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
DROP TABLE "ApiToken";
ALTER TABLE "new_ApiToken" RENAME TO "ApiToken";
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
CREATE INDEX "ApiToken_tokenHash_idx" ON "ApiToken"("tokenHash");
PRAGMA foreign_keys=ON;
-- ---- Composite indexes for hot query paths ----------------------------------
DROP INDEX IF EXISTS "AppStatus_appId_idx";
CREATE INDEX "AppStatus_appId_checkedAt_idx" ON "AppStatus"("appId", "checkedAt");
DROP INDEX IF EXISTS "AppClick_userId_idx";
CREATE INDEX "AppClick_userId_clickedAt_idx" ON "AppClick"("userId", "clickedAt");
DROP INDEX IF EXISTS "Notification_userId_idx";
DROP INDEX IF EXISTS "Notification_sentAt_idx";
CREATE INDEX "Notification_userId_sentAt_idx" ON "Notification"("userId", "sentAt");
DROP INDEX IF EXISTS "AuditLog_userId_idx";
DROP INDEX IF EXISTS "AuditLog_entityType_entityId_idx";
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
CREATE INDEX "AuditLog_entityType_entityId_createdAt_idx" ON "AuditLog"("entityType", "entityId", "createdAt");
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "PasswordReset" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PasswordReset_tokenHash_key" ON "PasswordReset"("tokenHash");
CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId");
CREATE INDEX "PasswordReset_expiresAt_idx" ON "PasswordReset"("expiresAt");
+57 -8
View File
@@ -15,8 +15,6 @@ model User {
avatarUrl String?
authProvider String @default("local") // local | oauth
role String @default("user") // admin | user
refreshToken String?
refreshTokenExpiresAt DateTime?
onboardingComplete Boolean @default(false)
trackRecentApps Boolean @default(true)
createdAt DateTime @default(now())
@@ -29,6 +27,7 @@ model User {
locale String?
groups UserGroup[]
sessions Session[]
createdApps App[]
boards Board[]
favorites UserFavorite[]
@@ -38,10 +37,61 @@ model User {
apiTokens ApiToken[]
auditLogs AuditLog[]
boardTemplates BoardTemplate[]
passwordResets PasswordReset[]
@@index([email])
}
model PasswordReset {
id String @id @default(cuid())
userId String
tokenHash String @unique // sha256 of the raw reset token
expiresAt DateTime
usedAt DateTime?
createdById String? // admin who issued (if admin-mediated), null if self-service
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Invite {
id String @id @default(cuid())
tokenHash String @unique
email String? // optional — lock the invite to a specific email
role String @default("user") // user | admin
expiresAt DateTime
usedAt DateTime?
usedByUserId String?
createdById String?
createdAt DateTime @default(now())
@@index([tokenHash])
@@index([createdById])
}
model Session {
id String @id @default(cuid())
userId String
tokenHash String // sha256 hash of current refresh token
previousTokenHash String? // sha256 hash of the immediately-previous refresh token (reuse detection)
label String?
userAgent String?
ipAddress String?
rememberMe Boolean @default(false)
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tokenHash])
@@index([userId])
@@index([expiresAt])
}
model Group {
id String @id @default(cuid())
name String @unique
@@ -110,7 +160,7 @@ model AppStatus {
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([appId])
@@index([appId, checkedAt])
@@index([checkedAt])
}
@@ -270,7 +320,7 @@ model AppClick {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, clickedAt])
@@index([appId])
@@index([clickedAt])
}
@@ -300,9 +350,8 @@ model Notification {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, sentAt])
@@index([appId])
@@index([sentAt])
}
model ApiToken {
@@ -332,9 +381,9 @@ model AuditLog {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, createdAt])
@@index([action])
@@index([entityType, entityId])
@@index([entityType, entityId, createdAt])
@@index([createdAt])
}
+279 -83
View File
@@ -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;
}
}
+1
View File
@@ -12,6 +12,7 @@ declare global {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
role: 'admin' | 'user';
} | null;
session: {
+4 -1
View File
@@ -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 () {
+157 -85
View File
@@ -1,53 +1,123 @@
import type { Handle } from '@sveltejs/kit';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { verifyAccessToken } from '$lib/server/services/authService.js';
import * as authService from '$lib/server/services/authService.js';
import * as userService from '$lib/server/services/userService.js';
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,
rotateSessionCookies,
COOKIE_NAMES
} from '$lib/server/utils/sessionCookies.js';
import { loadUserForLocals } from '$lib/server/utils/userLocals.js';
import { applySecurityHeaders } from '$lib/server/utils/securityHeaders.js';
// Initialize backup scheduler on server startup
// Initialize schedulers on server startup. Both honour RUN_SCHEDULERS env var.
initBackupScheduler();
startHealthcheckScheduler(process.env.HEALTHCHECK_CRON || '* * * * *');
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health', '/api/onboarding', '/status'];
function isPublicPath(pathname: string): boolean {
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
interface PathRule {
readonly path: string;
readonly mode: 'exact' | 'prefix';
}
const ACCESS_TOKEN_COOKIE = 'access_token';
const REFRESH_TOKEN_COOKIE = 'refresh_token';
// Exact paths or explicit subtrees that are publicly accessible.
// Use exact-match where possible so `/api/health-private` doesn't accidentally
// become public when added later.
const PUBLIC_PATHS: readonly PathRule[] = [
{ path: '/login', mode: 'exact' },
{ path: '/register', mode: 'exact' },
{ path: '/invite', mode: 'exact' },
{ path: '/forgot-password', mode: 'exact' },
{ path: '/reset-password', mode: 'exact' },
{ path: '/api/health', mode: 'exact' },
{ path: '/api/metrics', mode: 'exact' },
{ path: '/api/onboarding', mode: 'exact' },
{ path: '/auth/', mode: 'prefix' },
// Uploaded icons/wallpapers are referenced as <img src> from board widgets,
// including guest-accessible boards. The serving handler at
// /uploads/[...path]/+server.ts does its own path-traversal protection.
{ path: '/uploads/', mode: 'prefix' }
];
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
function isPublicPath(pathname: string): boolean {
for (const rule of PUBLIC_PATHS) {
if (rule.mode === 'exact' && pathname === rule.path) return true;
if (rule.mode === 'prefix' && pathname.startsWith(rule.path)) return true;
}
return false;
}
export const handle: Handle = async ({ event, resolve }) => {
// Initialize locals
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;
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
const accessToken = event.cookies.get(COOKIE_NAMES.ACCESS_TOKEN);
const refreshToken = event.cookies.get(COOKIE_NAMES.REFRESH_TOKEN);
const sessionId = event.cookies.get(COOKIE_NAMES.SESSION_ID);
if (accessToken) {
try {
const payload = verifyAccessToken(accessToken);
const user = await userService.findById(payload.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
event.locals.user = await loadUserForLocals(payload.userId);
event.locals.session = {
id: payload.userId,
id: sessionId ?? payload.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
} catch {
@@ -55,65 +125,41 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
// If no valid session but refresh token exists, attempt rotation
if (!event.locals.user && refreshToken) {
if (!event.locals.user && refreshToken && sessionId) {
try {
// We need to find the user by refresh token.
// The refresh token is stored hashed per-user, so we need
// a userId from somewhere. We store it in a separate cookie.
const userIdFromCookie = event.cookies.get('refresh_user_id');
if (userIdFromCookie) {
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken);
if (isValid) {
const user = await userService.findById(userIdFromCookie);
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
// Set new cookies
event.cookies.set(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
...COOKIE_BASE,
maxAge: 900 // 15 minutes
});
event.cookies.set(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
const session = await authService.validateSession(sessionId, refreshToken);
if (session) {
const cachedUser = await loadUserForLocals(session.userId);
if (cachedUser) {
await rotateSessionCookies(
event.cookies,
session.id,
{ id: cachedUser.id, email: cachedUser.email, role: cachedUser.role },
session.rememberMe
);
event.locals.user = cachedUser;
event.locals.session = {
id: user.id,
id: session.id,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
}
}
} catch {
// Refresh failed — clear stale cookies
event.cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
event.cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' });
event.cookies.delete('refresh_user_id', { path: '/' });
clearSessionCookies(event.cookies);
}
}
// If still no valid session, try API token from Authorization header
// Bearer API tokens (no session cookie).
if (!event.locals.user) {
const bearerToken = extractBearerToken(event);
if (bearerToken) {
try {
const tokenResult = await apiTokenService.validateToken(bearerToken);
if (tokenResult) {
const user = await userService.findById(tokenResult.userId);
event.locals.user = {
id: user.id,
email: user.email,
displayName: user.displayName,
role: user.role as 'admin' | 'user'
};
const tokenUser = await loadUserForLocals(tokenResult.userId);
event.locals.user = tokenUser;
event.locals.session = {
id: user.id,
id: tokenResult.userId,
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
};
event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin';
@@ -130,43 +176,69 @@ export const handle: Handle = async ({ event, resolve }) => {
if (event.locals.apiTokenScope) {
const method = event.request.method;
const scope = event.locals.apiTokenScope;
const isWriteMethod = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE';
const isWriteMethod =
method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE';
const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin');
if (scope === 'read' && isWriteMethod) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope "read" does not allow write operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
if (scope !== 'admin' && isAdminPath) {
return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
return new Response(
JSON.stringify({
success: false,
data: null,
error: 'API token scope does not allow admin operations'
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Route protection
if (!event.locals.user && !isPublicPath(pathname)) {
// Check if this is a guest-accessible board route
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
if (boardMatch) {
const boardId = boardMatch[1];
const isGuestAccessible = await isBoardGuestAccessible(boardId);
if (isGuestAccessible) {
return resolve(event);
return applySecurityHeaders(await resolve(event), process.env.ORIGIN);
}
}
// Root path — allow through so +page.server.ts can handle redirect logic
if (pathname === '/') {
return resolve(event);
// Public landing also allowed without auth (renders guest-accessible boards
// or a "please log in" view).
if (pathname === '/' || pathname === '/status') {
return applySecurityHeaders(await resolve(event), process.env.ORIGIN);
}
throw redirect(302, '/login');
}
return resolve(event);
const response = await resolve(event);
return applySecurityHeaders(response, process.env.ORIGIN);
};
/**
* Centralized error handler — strips internal error details and logs the cause.
* Without this, SvelteKit will display the raw thrown message which can leak
* stack traces, file paths, or upstream IdP error_descriptions.
*/
export const handleError: HandleServerError = ({ error, event }) => {
console.error(`[error] ${event.request.method} ${event.url.pathname}:`, error);
return {
message:
process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: error instanceof Error
? error.message
: String(error)
};
};
+14 -14
View File
@@ -62,7 +62,7 @@
];
function applyFilters() {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams();
if (filterAction) params.set('action', filterAction);
if (filterEntityType) params.set('entityType', filterEntityType);
@@ -73,7 +73,7 @@
}
function changePage(delta: number) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams($page.url.searchParams);
params.set('page', String(Math.max(1, currentPage + delta)));
goto(`/admin/audit-log?${params.toString()}`, { replaceState: true });
@@ -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>
+172 -45
View File
@@ -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 performRestore(filename: string, allowSchemaMismatch: boolean): Promise<void> {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowSchemaMismatch })
});
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 {
const response = await fetch(`/api/admin/backups/${encodeURIComponent(filename)}/restore`, {
method: 'POST'
});
const result = await response.json();
await performRestore(filename, false);
} catch (err) {
statusMessage = err instanceof Error ? err.message : 'Failed to restore backup';
statusType = 'error';
} finally {
restoringFilename = null;
}
}
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to restore backup');
}
async function handleSchemaMismatchConfirm() {
const filename = pendingSchemaMismatchFile;
confirmSchemaMismatch = false;
pendingSchemaMismatchFile = null;
pendingSchemaMismatchMessage = '';
if (!filename) return;
statusMessage = $t('admin.backup_restore_success');
statusType = 'success';
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>
+19 -20
View File
@@ -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}
+9 -3
View File
@@ -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>
+36 -36
View File
@@ -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>
+10 -18
View File
@@ -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}
+1 -1
View File
@@ -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>
+27 -11
View File
@@ -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>
+68 -31
View File
@@ -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>;
@@ -42,7 +44,22 @@
})
.catch(() => {});
});
let availableIntegrations = $state<Array<{ id: string; name: string; icon: string; authConfigFields: any[]; extraConfigFields: any[] }>>([]);
interface IntegrationField {
name: string;
type: 'string' | 'number' | 'boolean';
required: boolean;
label: string;
description?: string;
}
let availableIntegrations = $state<
Array<{
id: string;
name: string;
icon: string;
authConfigFields: IntegrationField[];
extraConfigFields: IntegrationField[];
}>
>([]);
let integrationConfig = $state<Record<string, unknown>>({});
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
@@ -106,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}
@@ -123,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}
@@ -155,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>
@@ -171,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>
@@ -185,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>
@@ -204,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>
@@ -254,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"
/>
@@ -272,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"
@@ -292,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"
/>
@@ -305,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>
@@ -334,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)}
@@ -380,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}
@@ -394,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>
+16 -7
View File
@@ -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>
+1 -1
View File
@@ -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>
+7 -11
View File
@@ -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>
+2 -2
View File
@@ -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}"
+4 -4
View File
@@ -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>
+31 -10
View File
@@ -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}
<DynamicIcon name={board.icon} size={22} />
<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" />
+14 -9
View File
@@ -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,
@@ -148,13 +149,12 @@
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
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">
@@ -178,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" />
@@ -189,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 -->
@@ -211,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>
@@ -221,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"
@@ -239,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>
@@ -249,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>
+1 -1
View File
@@ -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}
@@ -1,42 +1,18 @@
<script lang="ts">
import { sanitizeCss, scopeCss } from '$lib/utils/cssSanitize.js';
interface Props {
css: string;
}
let { css }: Props = $props();
/**
* Sanitize CSS to prevent XSS vectors while keeping valid styling rules.
* All custom CSS is wrapped in .custom-css-scope to prevent breaking critical UI.
*/
const sanitizedCss = $derived.by(() => {
if (!css) return '';
let cleaned = css;
// Remove any HTML tags (including <script>)
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
// Remove javascript: URLs
cleaned = cleaned.replace(/javascript\s*:/gi, '');
// Remove expression() calls
cleaned = cleaned.replace(/expression\s*\(/gi, '');
// Remove url() with javascript:
cleaned = cleaned.replace(/url\s*\(\s*['"]?\s*javascript:/gi, 'url(');
// Remove @import rules
cleaned = cleaned.replace(/@import\s+[^;]+;?/gi, '');
// Remove behavior: (IE XSS)
cleaned = cleaned.replace(/behavior\s*:/gi, '');
// Remove -moz-binding (Firefox XSS)
cleaned = cleaned.replace(/-moz-binding\s*:/gi, '');
return cleaned;
});
// CSS is also sanitized server-side at SAVE time. This is defense-in-depth
// so legacy unsanitized rows (or any future client-rendered preview) cannot
// inject script-bearing CSS. We also prefix every selector with
// `.custom-css-scope` so a malicious admin's `body { display:none }` cannot
// hide the whole app — the rule only takes effect inside the wrapper div.
const sanitizedCss = $derived(scopeCss(sanitizeCss(css ?? '')));
</script>
{#if sanitizedCss}
@@ -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)}
>
+15 -17
View File
@@ -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'}
+74 -77
View File
@@ -24,19 +24,32 @@
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">
{#if !collapsed}
<a href="/" class="flex items-center gap-2 text-sidebar-foreground">
<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-6 w-6 text-sidebar-primary"
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
@@ -45,62 +58,49 @@
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" />
<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 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>
{/if}
</span>
{#if !collapsed}
<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
+1 -1
View File
@@ -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,5 +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;
@@ -28,6 +31,9 @@
let telegramChatId = $state('');
let httpUrl = $state('');
let httpMethod = $state('POST');
let httpSecret = $state('');
let httpSignatureHeader = $state('');
let showHttpSecret = $state(false);
// Parse existing config
if (channel?.config) {
@@ -47,6 +53,8 @@
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
httpSecret = parsed.secret ?? '';
httpSignatureHeader = parsed.signatureHeader ?? '';
break;
}
} catch {
@@ -62,8 +70,14 @@
return JSON.stringify({ webhookUrl: slackWebhookUrl });
case 'telegram':
return JSON.stringify({ botToken: telegramBotToken, chatId: telegramChatId });
case 'http':
return JSON.stringify({ url: httpUrl, method: httpMethod });
case 'http': {
// Only include secret/signatureHeader when set, to keep the stored
// config minimal and avoid encrypting empty strings.
const cfg: Record<string, string> = { url: httpUrl, method: httpMethod };
if (httpSecret) cfg.secret = httpSecret;
if (httpSignatureHeader) cfg.signatureHeader = httpSignatureHeader;
return JSON.stringify(cfg);
}
default:
return '{}';
}
@@ -100,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>
@@ -114,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>
@@ -134,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>
@@ -148,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>
@@ -162,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>
@@ -175,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>
@@ -189,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>
@@ -200,58 +214,90 @@
<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>
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label for="http-secret" class="mb-1 block text-sm font-medium text-foreground">
Webhook secret <span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<div class="relative">
<input
id="http-secret"
type={showHttpSecret ? 'text' : 'password'}
bind:value={httpSecret}
placeholder="Shared secret for HMAC-SHA256 signature"
autocomplete="off"
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"
onclick={() => (showHttpSecret = !showHttpSecret)}
aria-label={showHttpSecret ? 'Hide secret' : 'Show secret'}
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground"
>
{#if showHttpSecret}
<EyeOff class="h-4 w-4" />
{:else}
<Eye class="h-4 w-4" />
{/if}
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
When set, requests are signed with HMAC-SHA256 and sent as
<code class="rounded bg-muted/40 px-1">sha256=&lt;hex&gt;</code> in the signature header,
alongside an <code class="rounded bg-muted/40 px-1">X-Webhook-Timestamp</code>.
</p>
</div>
<div>
<label for="http-sig-header" class="mb-1 block text-sm font-medium text-foreground">
Signature header name
<span class="text-xs font-normal text-muted-foreground">(optional)</span>
</label>
<input
id="http-sig-header"
type="text"
bind:value={httpSignatureHeader}
placeholder="X-Signature-256"
autocomplete="off"
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>).
</p>
</div>
{/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-green-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>
@@ -25,7 +25,7 @@
async function loadNotifications(page: number = 1) {
loading = true;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({
limit: String(PAGE_SIZE),
offset: String((page - 1) * PAGE_SIZE)
@@ -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,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -22,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');
@@ -157,8 +157,9 @@
break;
}
// Redirect to login page
goto('/login');
// Auto-logged in via cookies. Use a full navigation (not goto) so
// hooks.server.ts re-runs and populates locals.user from the new cookies.
window.location.href = '/';
break;
}
}
@@ -170,6 +171,7 @@
}
const primaryColorOptions = [
{ label: 'Terracotta', value: '#e8754f' },
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Emerald', value: '#10b981' },
@@ -183,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">
@@ -228,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}
@@ -239,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>
@@ -249,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>
@@ -259,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>
@@ -299,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>
@@ -370,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>
@@ -414,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}
@@ -429,7 +426,7 @@
{:else}
Next
{/if}
</button>
</Button>
</div>
</div>
</div>
@@ -68,7 +68,6 @@
</script>
{#if search.open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
@@ -77,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>
+1 -1
View File
@@ -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>
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 3 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="skeleton h-8 w-8 rounded-md"></div>
<div class="min-w-0 flex-1">
<div class="skeleton mb-2 h-5 w-1/2 rounded"></div>
<div class="skeleton mb-1 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-20 rounded"></div>
</div>
</div>
</div>
{/each}
@@ -1,21 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 1 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-4">
<div class="mb-3 flex items-start justify-between">
<div class="skeleton h-10 w-10 rounded-lg"></div>
<div class="skeleton h-5 w-14 rounded-full"></div>
</div>
<div class="skeleton mb-2 h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-full rounded"></div>
<div class="skeleton mt-1 h-3 w-1/2 rounded"></div>
</div>
{/each}
@@ -1,32 +0,0 @@
<script lang="ts">
interface Props {
count?: number;
widgetsPerSection?: number;
}
let { count = 2, widgetsPerSection = 4 }: Props = $props();
const sections = $derived(Array.from({ length: count }, (_, i) => i));
const widgets = $derived(Array.from({ length: widgetsPerSection }, (_, i) => i));
</script>
{#each sections as s (s)}
<div class="rounded-lg border border-border bg-card/50">
<!-- Section header skeleton -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="skeleton h-4 w-4 rounded"></div>
<div class="skeleton h-4 w-32 rounded"></div>
</div>
<!-- Widget grid skeleton -->
<div class="grid grid-cols-2 gap-3 px-4 pb-4 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as w (w)}
<div class="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-4">
<div class="skeleton h-12 w-12 rounded-lg"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-4 w-12 rounded-full"></div>
</div>
{/each}
</div>
</div>
{/each}
@@ -91,8 +91,8 @@
/>
{#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">
{#each filtered as item, i}
<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"
onclick={() => selectItem(item)}
+81
View File
@@ -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>
+95
View File
@@ -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>
+6 -7
View File
@@ -39,32 +39,31 @@
transition:fade={{ duration: 120 }}
>
<!-- Dialog -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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 }}
role="alertdialog"
tabindex="-1"
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'}"
+2 -2
View File
@@ -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'}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
status: number;
title: string;
hint?: string;
/** Optional detail block (e.g. raw error message in a <details>). */
details?: Snippet;
/** Primary + secondary call-to-action snippets. */
actions?: Snippet;
/** When true, render the chrome (AmbientBackground, card surface). For
* boards/admin nested errors we want to inherit the parent layout. */
standalone?: boolean;
}
let { status, title, hint, details, actions, standalone = false }: Props = $props();
</script>
{#if standalone}
<main
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-[var(--shadow-lift)] backdrop-blur-sm"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</div>
</main>
{:else}
<main
class="mx-auto flex min-h-[60vh] max-w-lg flex-col items-center justify-center px-4 py-16 text-center"
>
<div class="mb-2 text-xs uppercase tracking-widest text-muted-foreground">
{status}
</div>
<h1 class="mb-2 text-2xl font-semibold">{title}</h1>
{#if hint}
<p class="mb-6 text-sm text-muted-foreground">{hint}</p>
{/if}
{#if details}
<div class="mb-6 w-full text-left">{@render details()}</div>
{/if}
{#if actions}
<div class="flex flex-wrap justify-center gap-2">{@render actions()}</div>
{/if}
</main>
{/if}
+39
View File
@@ -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>
+2 -2
View File
@@ -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>
@@ -138,7 +138,7 @@
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No matching icons'}</p>
{:else}
<div class="grid grid-cols-8 gap-0.5">
{#each filteredIcons as iconName}
{#each filteredIcons as iconName (iconName)}
<button
type="button"
onclick={() => selectIcon(iconName)}
@@ -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>
+31
View File
@@ -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"
>
@@ -120,14 +120,14 @@
</script>
{#if name}
{#each values as v}
{#each values as v (v)}
<input type="hidden" {name} value={v} />
{/each}
{/if}
<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'}
+37
View File
@@ -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>
+155
View File
@@ -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>
+86
View File
@@ -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>
+4 -4
View File
@@ -99,8 +99,8 @@
<!-- Tag pills -->
{#if tags.length > 0}
<div class="mb-1.5 flex flex-wrap gap-1">
{#each tags as tag}
<span class="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">
{#each tags as tag (tag)}
<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,8 +128,8 @@
/>
{#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">
{#each filtered as item, i}
<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"
onclick={() => selectItem(item)}
@@ -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');
});
});
+8 -8
View File
@@ -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>
@@ -24,10 +24,10 @@
function groupLabel(dateStr: string): string {
const date = new Date(dateStr);
/* eslint-disable svelte/prefer-svelte-reactivity */
const today = new Date();
const tomorrow = new Date();
/* eslint-enable svelte/prefer-svelte-reactivity */
tomorrow.setDate(today.getDate() + 1);
if (date.toDateString() === today.toDateString()) return 'Today';
@@ -52,7 +52,7 @@
}
const grouped = $derived.by((): GroupedEvents[] => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const groups: Map<string, CalendarEvent[]> = new Map();
for (const evt of events) {
const key = new Date(evt.start).toDateString();
@@ -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"

Some files were not shown because too many files have changed in this diff Show More