7 Commits

Author SHA1 Message Date
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
241 changed files with 11792 additions and 3045 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
+34 -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,19 @@ 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"
# 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
+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'
}
},
{
+6 -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,13 @@
"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",
"zod": "^3.24.0"
},
"prisma": {
@@ -51,6 +50,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])
}
+198 -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 ===== */
@@ -138,27 +223,27 @@
.status-online {
animation: status-pulse 2s ease-in-out infinite;
color: hsl(142 71% 45%);
color: var(--status-online);
}
/* ===== 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 +255,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 +279,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 +314,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 +329,40 @@
background-position: 0% 50%;
}
}
/* ===== 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 {
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 () {
+105 -85
View File
@@ -1,53 +1,71 @@
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 { 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
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 +73,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 +124,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>
+13 -13
View File
@@ -211,7 +211,7 @@
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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? $t('admin.backup_creating') : $t('admin.backup_create')}
</button>
@@ -253,7 +253,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
? '...'
@@ -282,7 +282,7 @@
<!-- Restore Confirmation Dialog -->
{#if 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>
@@ -301,7 +301,7 @@
<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>
@@ -313,7 +313,7 @@
<!-- 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>
@@ -354,7 +354,7 @@
<input
type="checkbox"
bind:checked={schedule.backupEnabled}
class="h-4 w-4 rounded border-border text-primary focus:ring-ring"
class="h-4 w-4 rounded border-border text-primary focus-visible:ring-primary/30"
/>
<span class="text-sm text-foreground">{$t('admin.backup_schedule_enabled')}</span>
</label>
@@ -368,7 +368,7 @@
<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"
class="w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground 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>
@@ -383,7 +383,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,7 +399,7 @@
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>
{/if}
@@ -408,7 +408,7 @@
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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{savingSchedule ? $t('admin.backup_schedule_saving') : $t('admin.backup_schedule_save')}
</button>
@@ -418,9 +418,9 @@
<!-- 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>
@@ -147,7 +147,7 @@
type="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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{scanning ? $t('admin.discovery_scanning') : $t('admin.discovery_scan')}
</button>
@@ -155,7 +155,7 @@
<!-- 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}
@@ -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>
@@ -231,7 +231,7 @@
type="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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{approving ? $t('admin.discovery_approving') : $t('admin.discovery_approve')} ({selected.size})
</button>
@@ -241,7 +241,7 @@
<!-- Status Message -->
{#if statusMessage}
<div class="mt-4 rounded-md p-3 text-sm {statusType === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'}">
<div class="mt-4 rounded-xl border p-3 text-sm {statusType === 'success' ? 'border-status-online/30 bg-status-online/10 text-status-online-ink' : 'border-destructive/30 bg-destructive/10 text-destructive'}">
{statusMessage}
</div>
{/if}
+1 -1
View File
@@ -27,7 +27,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>
@@ -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>
+13 -13
View File
@@ -55,7 +55,7 @@
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"
class="w-full rounded-xl 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>
@@ -92,7 +92,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 +103,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,7 +114,7 @@
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}
@@ -124,12 +124,12 @@
type="button"
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"
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-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{oauthTesting ? $t('admin.oauth_testing') : $t('admin.oauth_test')}
</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}
@@ -147,7 +147,7 @@
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"
class="w-full rounded-xl 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>
@@ -161,8 +161,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}
@@ -188,7 +188,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}
@@ -206,7 +206,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 +217,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>
@@ -244,7 +244,7 @@
<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"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
disabled={$delayed}
>
{$delayed ? $t('admin.saving_settings') : $t('admin.save_settings')}
+7 -7
View File
@@ -14,13 +14,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 +56,7 @@
});
if (res.ok) {
newName = '';
newColor = '#6366f1';
newColor = '#e8754f';
showCreateForm = false;
await loadTags();
} else {
@@ -71,7 +71,7 @@
function startEdit(tag: Tag) {
editingTag = tag;
editName = tag.name;
editColor = tag.color ?? '#6366f1';
editColor = tag.color ?? '#e8754f';
}
async function saveEdit() {
@@ -118,7 +118,7 @@
<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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{showCreateForm ? 'Cancel' : 'New Tag'}
</button>
@@ -141,7 +141,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>
@@ -159,7 +159,7 @@
</div>
<button
type="submit"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Create Tag
</button>
+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>
+27 -12
View File
@@ -42,7 +42,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 +121,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 +138,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 +170,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 +186,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 +200,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>
@@ -254,7 +269,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 +287,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 +307,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"
/>
@@ -334,7 +349,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 +395,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}
@@ -397,7 +412,7 @@
<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"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
{$t('app.saving')}
+13 -7
View File
@@ -10,18 +10,24 @@
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: '', 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: '', 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);"
>
<span
class="inline-block h-2 w-2 rounded-full {config.cssClass}"
style="background: {config.color};"
></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>
+5 -5
View File
@@ -164,13 +164,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 +178,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>
@@ -196,7 +196,7 @@
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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Links'}
</button>
+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';
});
@@ -10,7 +10,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">
+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>
@@ -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">
@@ -82,7 +82,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 +107,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,7 +121,7 @@
<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 Hue -->
@@ -144,7 +144,7 @@
<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">
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">
<option value="none">None</option>
<option value="mesh">Mesh Gradient</option>
<option value="particles">Particles</option>
@@ -159,7 +159,7 @@
<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>
@@ -176,7 +176,7 @@
<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">
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">
<option value="compact">Compact</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
@@ -187,7 +187,7 @@
<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>
@@ -204,7 +204,7 @@
<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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('common.apply') ?? 'Apply'}
</button>
@@ -148,13 +148,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 +177,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" />
@@ -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)}
>
+14 -12
View File
@@ -21,6 +21,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 +30,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 +65,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 +85,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 +132,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 +146,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 +156,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 +176,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 +199,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
@@ -223,7 +225,7 @@
{: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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-[var(--shadow-soft)] transition-colors hover:bg-primary/90"
>
{$t('auth.login')}
</a>
@@ -67,10 +67,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>
@@ -86,7 +86,7 @@
<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"
class="shrink-0 rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{$t('install.button')}
</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,4 +1,5 @@
<script lang="ts">
import { Eye, EyeOff } from 'lucide-svelte';
import { NotificationType } from '$lib/utils/constants.js';
interface ChannelData {
@@ -28,6 +29,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 +51,8 @@
case 'http':
httpUrl = parsed.url ?? '';
httpMethod = parsed.method ?? 'POST';
httpSecret = parsed.secret ?? '';
httpSignatureHeader = parsed.signatureHeader ?? '';
break;
}
} catch {
@@ -62,8 +68,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 '{}';
}
@@ -114,7 +126,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 +146,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 +160,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 +174,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 +187,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 +201,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,13 +212,62 @@
<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 -->
@@ -222,7 +283,7 @@
<!-- 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}
@@ -231,7 +292,7 @@
<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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{channel ? 'Update' : 'Create'} Channel
</button>
@@ -240,7 +301,7 @@
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"
class="rounded-xl border border-input px-4 py-2 text-sm font-medium text-foreground hover:bg-accent disabled:opacity-50"
>
{testing ? 'Sending...' : 'Send Test'}
</button>
@@ -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,6 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
const STEPS = ['welcome', 'admin', 'authMode', 'theme', 'complete'] as const;
type Step = (typeof STEPS)[number];
@@ -22,7 +20,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 +155,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 +169,7 @@
}
const primaryColorOptions = [
{ label: 'Terracotta', value: '#e8754f' },
{ label: 'Indigo', value: '#6366f1' },
{ label: 'Blue', value: '#3b82f6' },
{ label: 'Emerald', value: '#10b981' },
@@ -183,7 +183,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 +228,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 +239,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 +249,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 +259,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 +299,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 +370,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>
@@ -418,7 +418,7 @@
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"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{#if loading}
Processing...
@@ -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}
@@ -21,7 +21,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>
@@ -34,7 +34,7 @@
<select
id="token-scope"
name="scope"
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="read">Read — View apps, boards, and status</option>
<option value="write">Write — Modify apps, boards, and settings</option>
@@ -50,7 +50,7 @@
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>
@@ -58,7 +58,7 @@
<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"
class="rounded-xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate Token
</button>
@@ -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>
@@ -88,7 +88,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>
@@ -128,7 +128,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 +167,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 +188,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 +204,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 +222,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 +240,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}
@@ -255,12 +255,12 @@
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"
class="rounded-xl bg-primary px-6 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{saving ? $t('settings.saving') : $t('settings.save')}
</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)}
+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}
+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>
@@ -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'}
+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)}
+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"
@@ -111,7 +111,7 @@
}
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
{#if clockStyle === 'analog'}
<!-- Analog clock face -->
<svg viewBox="0 0 100 100" class="h-32 w-32">
@@ -154,7 +154,7 @@
<p class="mt-2 text-xs text-muted-foreground">{dateStr}</p>
{:else}
<!-- Digital clock -->
<p class="font-mono text-4xl font-bold tabular-nums text-foreground">{timeStr}</p>
<p class="font-display text-4xl font-semibold tabular-nums text-foreground">{timeStr}</p>
<p class="mt-1 text-sm text-muted-foreground">{dateStr}</p>
{#if config.timezone}
<p class="mt-0.5 text-xs text-muted-foreground/70">{config.timezone.replace(/_/g, ' ')}</p>
+1 -1
View File
@@ -36,7 +36,7 @@
}
</script>
<div class="flex flex-col rounded-xl border border-border bg-card">
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
<div class="relative" style="height: {iframeHeight}px;">
{#if !safeUrl}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -15,7 +15,7 @@
const links = $derived(config.links ?? []);
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<!-- Header -->
{#if isCollapsible}
<button
@@ -53,7 +53,7 @@
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)]">
<!-- Toolbar -->
<div class="flex items-center justify-end border-b border-border px-3 py-1.5">
<button
@@ -30,7 +30,7 @@
async function fetchMetric() {
error = false;
try {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const params = new URLSearchParams({ source: config.source });
if (config.value) params.set('value', config.value);
if (config.url) params.set('url', config.url);
@@ -64,13 +64,13 @@
});
const trendColor = $derived.by(() => {
if (trend === 'up') return 'text-green-500';
if (trend === 'down') return 'text-red-500';
return 'text-muted-foreground';
if (trend === 'up') return 'var(--status-online-ink)';
if (trend === 'down') return 'var(--status-offline-ink)';
return 'var(--muted-foreground)';
});
</script>
<div class="flex h-full flex-col items-center justify-center rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col items-center justify-center rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
{#if loading}
<div class="flex flex-col items-center gap-2">
<div class="h-10 w-24 animate-pulse rounded bg-muted"></div>
@@ -80,7 +80,7 @@
<span class="text-xs text-muted-foreground">Failed to load metric</span>
{:else if currentValue !== null}
<!-- Trend arrow -->
<div class="mb-1 {trendColor}">
<div class="mb-1" style="color: {trendColor};">
{#if trend === 'up'}
<TrendingUp class="h-5 w-5" />
{:else if trend === 'down'}
@@ -92,7 +92,7 @@
<!-- Big number -->
<div class="flex items-baseline gap-1">
<span class="text-4xl font-bold tabular-nums text-foreground">
<span class="font-display text-4xl font-semibold tabular-nums text-foreground">
{formatNumber(currentValue)}
</span>
{#if config.unit}
+1 -1
View File
@@ -36,7 +36,7 @@
});
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
{@html renderedContent}
@@ -80,7 +80,7 @@
}
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<div class="mb-3 flex items-center gap-2">
<Rss class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium text-foreground">RSS Feed</span>
+13 -13
View File
@@ -46,7 +46,7 @@
let expanded = $state(false);
</script>
<div class="flex flex-col rounded-xl border border-border bg-card p-4">
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<!-- Header -->
<button
type="button"
@@ -63,28 +63,28 @@
<div class="mt-3 flex gap-1">
{#if statusCounts.online > 0}
<div
class="h-2 rounded-full bg-green-500"
class="h-2 rounded-full bg-status-online"
style="flex: {statusCounts.online}"
title="{statusCounts.online} online"
></div>
{/if}
{#if statusCounts.degraded > 0}
<div
class="h-2 rounded-full bg-yellow-500"
class="h-2 rounded-full bg-status-degraded"
style="flex: {statusCounts.degraded}"
title="{statusCounts.degraded} degraded"
></div>
{/if}
{#if statusCounts.offline > 0}
<div
class="h-2 rounded-full bg-red-500"
class="h-2 rounded-full bg-status-offline"
style="flex: {statusCounts.offline}"
title="{statusCounts.offline} offline"
></div>
{/if}
{#if statusCounts.unknown > 0}
<div
class="h-2 rounded-full bg-gray-500"
class="h-2 rounded-full bg-status-unknown"
style="flex: {statusCounts.unknown}"
title="{statusCounts.unknown} unknown"
></div>
@@ -95,25 +95,25 @@
<div class="mt-2 flex flex-wrap gap-3 text-xs">
{#if statusCounts.online > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
{statusCounts.online} online
</span>
{/if}
{#if statusCounts.degraded > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-yellow-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
{statusCounts.degraded} degraded
</span>
{/if}
{#if statusCounts.offline > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
{statusCounts.offline} offline
</span>
{/if}
{#if statusCounts.unknown > 0}
<span class="flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-gray-500"></span>
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
{statusCounts.unknown} unknown
</span>
{/if}
@@ -126,12 +126,12 @@
{@const status = app.statuses[0]?.status ?? 'unknown'}
{@const statusColor =
status === 'online'
? 'bg-green-500'
? 'bg-status-online'
: status === 'offline'
? 'bg-red-500'
? 'bg-status-offline'
: status === 'degraded'
? 'bg-yellow-500'
: 'bg-gray-500'}
? 'bg-status-degraded'
: 'bg-status-unknown'}
<div class="flex items-center justify-between text-xs">
<span class="text-foreground">{app.name}</span>
<span class="flex items-center gap-1">
@@ -21,15 +21,15 @@
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
function thresholdColor(value: number): string {
if (value >= 85) return 'text-red-500';
if (value >= 60) return 'text-yellow-500';
return 'text-green-500';
if (value >= 85) return 'text-status-offline-ink';
if (value >= 60) return 'text-status-degraded-ink';
return 'text-status-online-ink';
}
function thresholdStroke(value: number): string {
if (value >= 85) return 'stroke-red-500';
if (value >= 60) return 'stroke-yellow-500';
return 'stroke-green-500';
if (value >= 85) return 'stroke-status-offline';
if (value >= 60) return 'stroke-status-degraded';
return 'stroke-status-online';
}
function thresholdTrack(_value: number): string {
@@ -80,7 +80,7 @@
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
</script>
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
{#if loading}
@@ -72,7 +72,7 @@
let rssShowSummary = $state((initialConfig.showSummary as boolean) ?? true);
// Calendar
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#6366f1', label: '' }]);
let calendarUrlsRaw = $state((initialConfig.icalUrls as Array<{ url: string; color: string; label: string }>) ?? [{ url: '', color: '#e8754f', label: '' }]);
let calendarDaysAhead = $state((initialConfig.daysAhead as number) ?? 7);
// Markdown
@@ -155,7 +155,7 @@
}
function addCalendarUrl() {
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#6366f1', label: '' }];
calendarUrlsRaw = [...calendarUrlsRaw, { url: '', color: '#e8754f', label: '' }];
}
function removeCalendarUrl(index: number) {
@@ -163,7 +163,7 @@
}
// Helper for input styling
const inputClass = 'w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30';
const inputClass = 'w-full rounded-xl border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30';
const labelClass = 'mb-1 block text-sm font-medium text-foreground';
let firstInput: HTMLElement | undefined = $state();
@@ -171,7 +171,7 @@
</script>
<div
class="rounded-xl border border-border bg-card p-4 shadow-lg"
class="rounded-xl border border-border bg-card p-4 shadow-[var(--shadow-soft)]"
transition:fade={{ duration: 100 }}
onkeydown={handleKeydown}
role="dialog"
@@ -190,8 +190,7 @@
<div class="max-h-80 space-y-3 overflow-y-auto">
{#if widgetType === 'app'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.app') ?? 'App'}</label>
<div class={labelClass}>{$t('widget.app') ?? 'App'}</div>
<!-- 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">
@@ -202,16 +201,16 @@
bind:value={appSearchQuery}
bind:this={firstInput}
placeholder={$t('common.search') ?? 'Search apps...'}
class="w-full rounded-lg border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-ring/30"
class="w-full rounded-xl border border-input bg-background py-1.5 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
/>
</div>
<!-- App grid -->
<div class="max-h-48 overflow-y-auto rounded-lg border border-input bg-background p-1">
<div class="max-h-48 overflow-y-auto rounded-xl border border-input bg-background p-1">
{#if filteredApps.length === 0}
<p class="py-4 text-center text-xs text-muted-foreground">{$t('common.no_results') ?? 'No apps found'}</p>
{:else}
<div class="grid grid-cols-2 gap-1">
{#each filteredApps as app}
{#each filteredApps as app (app.id)}
<button
type="button"
onclick={() => { appId = app.id; }}
@@ -303,8 +302,7 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>{$t('widget.apps') ?? 'Apps'}</label>
<div class={labelClass}>{$t('widget.apps') ?? 'Apps'}</div>
<MultiEntityPicker
items={apps.map((a) => ({ value: a.id, label: a.name, icon: a.icon, iconType: a.iconType }))}
bind:values={statusAppIds}
@@ -349,13 +347,11 @@
{:else if widgetType === 'system_stats'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source URL
<input type="url" bind:value={sysStatsSourceUrl} placeholder="http://localhost:61208/api/3" class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Source Type
<select bind:value={sysStatsSourceType} class={inputClass}>
<option value="glances">Glances</option>
@@ -365,7 +361,6 @@
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Refresh ({sysStatsRefreshInterval}s)
<input type="range" min="5" max="300" bind:value={sysStatsRefreshInterval} class="w-full accent-primary" />
</label>
@@ -373,13 +368,11 @@
{:else if widgetType === 'rss'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Feed URL
<input type="url" bind:value={rssFeedUrl} placeholder="https://..." class={inputClass} bind:this={firstInput} />
</label>
</div>
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Max Items ({rssMaxItems})
<input type="range" min="1" max="50" bind:value={rssMaxItems} class="w-full accent-primary" />
</label>
@@ -391,9 +384,8 @@
{:else if widgetType === 'calendar'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>iCal URLs</label>
{#each calendarUrlsRaw as cal, i}
<div class={labelClass}>iCal URLs</div>
{#each calendarUrlsRaw as cal, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="url" bind:value={cal.url} placeholder="https://..." class="{inputClass} flex-1" />
<input type="text" bind:value={cal.label} placeholder="Label" class="{inputClass} w-20" />
@@ -477,9 +469,8 @@
{:else if widgetType === 'link_group'}
<div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class={labelClass}>Links</label>
{#each linkGroupLinks as link, i}
<div class={labelClass}>Links</div>
{#each linkGroupLinks as link, i (i)}
<div class="mb-1 flex items-center gap-1">
<input type="text" bind:value={link.label} placeholder="Label" class="{inputClass} w-24" />
<input type="url" bind:value={link.url} placeholder="URL" class="{inputClass} flex-1" />
@@ -531,7 +522,7 @@
<label class={labelClass}>{$t('widget.app') ?? 'App'}
<select bind:value={integrationAppId} class={inputClass} bind:this={firstInput}>
<option value="">Select app...</option>
{#each apps as app}
{#each apps as app (app.id)}
<option value={app.id}>{app.name}</option>
{/each}
</select>
@@ -557,7 +548,7 @@
{$t('common.cancel') ?? 'Cancel'}
</button>
<button type="button" onclick={handleSave}
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
class="rounded-xl bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90">
{mode === 'create' ? ($t('common.add') ?? 'Add') : ($t('common.save') ?? 'Save')}
</button>
</div>

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