Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a13b6b58c | |||
| 5dcadd1c20 | |||
| f1cfb61d13 | |||
| 38335e925b | |||
| 9cab7262e6 | |||
| b9f3a2ca0b | |||
| 3fa30f72a3 |
+17
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Command Deck</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Saira:wght@400;500;600;700&family=Saira+Condensed:wght@500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #070a0d;
|
||||
--panel: #0d1217;
|
||||
--panel-2: #10171e;
|
||||
--line: #1d2730;
|
||||
--line-bright: #2b3946;
|
||||
--ink: #e7eef3;
|
||||
--ink-dim: #7c8b97;
|
||||
--ink-faint: #4a5763;
|
||||
--accent: #36e0a4; /* tactical green */
|
||||
--accent-2: #ffb020; /* amber */
|
||||
--danger: #ff4d5e;
|
||||
--warn: #ffb020;
|
||||
--grid: rgba(54, 224, 164, 0.04);
|
||||
--radius: 4px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Saira', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* subtle scanline grid backdrop */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 70% 0%, #000 30%, transparent 90%);
|
||||
}
|
||||
.app {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 74px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.cond {
|
||||
font-family: 'Saira Condensed', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Rail ===== */
|
||||
.rail {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel), #080c10);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
margin-bottom: 18px;
|
||||
box-shadow:
|
||||
0 0 0 1px #0a1f18,
|
||||
0 0 18px -4px var(--accent);
|
||||
position: relative;
|
||||
}
|
||||
.logo svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.rail-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.rail-btn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.rail-btn.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.rail-btn.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
}
|
||||
.rail-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.rail-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #1a2a22, #0f1a14);
|
||||
border: 1px solid var(--line-bright);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== Main ===== */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 60px;
|
||||
padding: 0 26px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(8, 12, 16, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.crumbs {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.crumbs b {
|
||||
color: var(--ink-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 340px;
|
||||
max-width: 38vw;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 12px;
|
||||
color: var(--ink-dim);
|
||||
font-size: 13px;
|
||||
cursor: text;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.search:hover {
|
||||
border-color: var(--line-bright);
|
||||
}
|
||||
.search svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.search .kbd {
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.ico-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
transition: 0.15s;
|
||||
background: var(--panel);
|
||||
}
|
||||
.ico-btn:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--line-bright);
|
||||
}
|
||||
.ico-btn svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 26px;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* status bar */
|
||||
.statline {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
background: var(--line);
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
background: var(--panel);
|
||||
padding: 16px 18px;
|
||||
position: relative;
|
||||
}
|
||||
.stat .lbl {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.stat .val {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.stat .val.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
.stat .val.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stat .val.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
.stat .sub {
|
||||
font-size: 12px;
|
||||
color: var(--ink-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.stat::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), transparent);
|
||||
}
|
||||
.stat.s2::after {
|
||||
background: linear-gradient(90deg, var(--accent-2), transparent);
|
||||
}
|
||||
.stat.s3::after {
|
||||
background: linear-gradient(90deg, #3aa0ff, transparent);
|
||||
}
|
||||
.stat.s4::after {
|
||||
background: linear-gradient(90deg, var(--danger), transparent);
|
||||
}
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 30px 0 16px;
|
||||
}
|
||||
.sec-head h2 {
|
||||
font-family: 'Saira Condensed';
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.sec-head .rule {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--line-bright), transparent);
|
||||
}
|
||||
.sec-head .count {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* app grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(248px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.node {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.node::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 100% 0%, rgba(54, 224, 164, 0.08), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.node:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--line-bright);
|
||||
box-shadow: 0 10px 30px -12px #000;
|
||||
}
|
||||
.node:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.node-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.node-ico {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius);
|
||||
background: #0a0f13;
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.node-cat {
|
||||
font-family: 'Saira Condensed';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.led {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.led.ok {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
}
|
||||
.led.ok::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--accent);
|
||||
opacity: 0.4;
|
||||
animation: ping 2s ease-out infinite;
|
||||
}
|
||||
.led.warn {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 10px var(--warn);
|
||||
}
|
||||
.led.bad {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 10px var(--danger);
|
||||
}
|
||||
@keyframes ping {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.node-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.node-foot .up {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.node-foot .up b {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.node-foot .up.bad b {
|
||||
color: var(--danger);
|
||||
}
|
||||
.spark {
|
||||
height: 22px;
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono';
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* entrance */
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.node,
|
||||
.stat {
|
||||
animation: rise 0.5s both;
|
||||
}
|
||||
.stat:nth-child(2) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
.stat:nth-child(3) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.stat:nth-child(4) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.grid .node:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.grid .node:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.grid .node:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.grid .node:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.grid .node:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.grid .node:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.grid .node:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.grid .node:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<!-- Rail -->
|
||||
<nav class="rail">
|
||||
<div class="logo" title="Launcher">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<a class="rail-btn active" title="Overview"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="9" y1="21" x2="9" y2="9" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Apps"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Status"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" /></svg
|
||||
></a>
|
||||
<a class="rail-btn" title="Admin"
|
||||
><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 0 0-1.7-1l-.4-2.6h-4l-.4 2.6a7 7 0 0 0-1.7 1l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 1.7 1l.4 2.6h4l.4-2.6a7 7 0 0 0 1.7-1l2.4 1 2-3.4-2-1.6a7 7 0 0 0 .1-1z"
|
||||
/></svg
|
||||
></a>
|
||||
<div class="rail-spacer"></div>
|
||||
<div class="rail-avatar">AD</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="crumbs">SYSTEMS / <b>OVERVIEW</b></div>
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search apps, boards, commands…
|
||||
<span class="kbd">⌘K</span>
|
||||
</div>
|
||||
<div class="ico-btn" title="Notifications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ico-btn" title="Theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<!-- Status line -->
|
||||
<div class="statline">
|
||||
<div class="stat">
|
||||
<div class="lbl">Services Online</div>
|
||||
<div class="val ok">08 / 10</div>
|
||||
<div class="sub">2 require attention</div>
|
||||
</div>
|
||||
<div class="stat s2">
|
||||
<div class="lbl">Avg Response</div>
|
||||
<div class="val warn">
|
||||
142<span style="font-size: 14px; color: var(--ink-faint)"> ms</span>
|
||||
</div>
|
||||
<div class="sub">p95 over 24h</div>
|
||||
</div>
|
||||
<div class="stat s3">
|
||||
<div class="lbl">Fleet Uptime</div>
|
||||
<div class="val" style="color: #3aa0ff">99.4%</div>
|
||||
<div class="sub">rolling 30 days</div>
|
||||
</div>
|
||||
<div class="stat s4">
|
||||
<div class="lbl">UPS Load</div>
|
||||
<div class="val bad">61%</div>
|
||||
<div class="sub">est. 38 min on battery</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Favorites / pinned -->
|
||||
<div class="sec-head">
|
||||
<h2>Pinned Services</h2>
|
||||
<span class="rule"></span><span class="count">8 ACTIVE</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<!-- 1 Jellyfin -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🎬</div>
|
||||
<div>
|
||||
<div class="node-name">Jellyfin</div>
|
||||
<div class="node-cat">Media</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,16 12,14 24,17 36,9 48,12 60,7 72,10 84,5 96,8"
|
||||
/></svg
|
||||
><span class="up"><b>99.9%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2 Immich -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">📷</div>
|
||||
<div>
|
||||
<div class="node-name">Immich</div>
|
||||
<div class="node-cat">Photos</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,12 12,13 24,11 36,12 48,10 60,11 72,9 84,11 96,10"
|
||||
/></svg
|
||||
><span class="up"><b>100%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 3 Gitea -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🌿</div>
|
||||
<div>
|
||||
<div class="node-name">Gitea</div>
|
||||
<div class="node-cat">Git</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,14 12,10 24,12 36,8 48,11 60,9 72,13 84,8 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>99.8%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 4 Portainer -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🐳</div>
|
||||
<div>
|
||||
<div class="node-name">Portainer</div>
|
||||
<div class="node-cat">Containers</div>
|
||||
</div>
|
||||
<div class="led warn"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ffb020"
|
||||
stroke-width="1.5"
|
||||
points="0,10 12,12 24,9 36,15 48,11 60,18 72,12 84,16 96,13"
|
||||
/></svg
|
||||
><span class="up"><b style="color: var(--warn)">98.1%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5 Pi-hole -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🛡️</div>
|
||||
<div>
|
||||
<div class="node-name">Pi-hole</div>
|
||||
<div class="node-cat">DNS</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,13 12,11 24,12 36,10 48,11 60,9 72,10 84,8 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>100%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 6 Planka -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">📋</div>
|
||||
<div>
|
||||
<div class="node-name">Planka</div>
|
||||
<div class="node-cat">Kanban</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,15 12,12 24,14 36,11 48,12 60,10 72,12 84,9 96,11"
|
||||
/></svg
|
||||
><span class="up"><b>99.5%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 7 Deluge -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">⬇️</div>
|
||||
<div>
|
||||
<div class="node-name">Deluge</div>
|
||||
<div class="node-cat">Downloads</div>
|
||||
</div>
|
||||
<div class="led bad"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ff4d5e"
|
||||
stroke-width="1.5"
|
||||
points="0,9 12,11 24,14 36,12 48,18 60,16 72,20 84,19 96,21"
|
||||
/></svg
|
||||
><span class="up bad"><b>OFFLINE</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 8 Pi-hole / NPM -->
|
||||
<div class="node">
|
||||
<div class="node-top">
|
||||
<div class="node-ico">🔀</div>
|
||||
<div>
|
||||
<div class="node-name">Nginx Proxy Mgr</div>
|
||||
<div class="node-cat">Network</div>
|
||||
</div>
|
||||
<div class="led ok"></div>
|
||||
</div>
|
||||
<div class="node-foot">
|
||||
<svg class="spark" viewBox="0 0 96 22" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#36e0a4"
|
||||
stroke-width="1.5"
|
||||
points="0,12 12,11 24,12 36,10 48,11 60,11 72,9 84,10 96,9"
|
||||
/></svg
|
||||
><span class="up"><b>99.9%</b> 24h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-note">
|
||||
// COMMAND DECK — Saira + JetBrains Mono · tactical dark · LED telemetry · monospace
|
||||
data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,915 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Aurora Glass</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a14;
|
||||
--ink: #f3f2fb;
|
||||
--ink-dim: #a7a6c4;
|
||||
--ink-faint: #6f6e90;
|
||||
--accent-h: 265; /* user-tunable hue → this is the killer feature */
|
||||
--accent: hsl(var(--accent-h) 90% 66%);
|
||||
--accent-2: hsl(calc(var(--accent-h) + 60) 85% 64%);
|
||||
--accent-soft: hsl(var(--accent-h) 90% 66% / 0.14);
|
||||
--glass: rgba(255, 255, 255, 0.05);
|
||||
--glass-2: rgba(255, 255, 255, 0.07);
|
||||
--glass-line: rgba(255, 255, 255, 0.1);
|
||||
--ok: #34e0a1;
|
||||
--warn: #ffc24b;
|
||||
--bad: #ff5d73;
|
||||
--radius: 18px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Manrope', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* aurora mesh */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(60px);
|
||||
opacity: 0.9;
|
||||
background:
|
||||
radial-gradient(40% 40% at 18% 22%, hsl(var(--accent-h) 90% 60% / 0.55), transparent 70%),
|
||||
radial-gradient(
|
||||
38% 38% at 82% 18%,
|
||||
hsl(calc(var(--accent-h) + 70) 90% 60% / 0.42),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
45% 45% at 70% 85%,
|
||||
hsl(calc(var(--accent-h) - 40) 90% 58% / 0.4),
|
||||
transparent 72%
|
||||
),
|
||||
radial-gradient(
|
||||
40% 40% at 25% 90%,
|
||||
hsl(calc(var(--accent-h) + 120) 80% 55% / 0.3),
|
||||
transparent 72%
|
||||
);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-3%, 2%) scale(1.08);
|
||||
}
|
||||
}
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
120% 120% at 50% -10%,
|
||||
transparent 40%,
|
||||
rgba(10, 10, 20, 0.6) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* sidebar (glass) */
|
||||
.side {
|
||||
margin: 16px 0 16px 16px;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(26px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(160%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
margin-bottom: 26px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.brand .mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px -6px var(--accent);
|
||||
}
|
||||
.brand .mark svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.brand .name {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.brand .name span {
|
||||
display: block;
|
||||
font-family: 'Manrope';
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.nav-grp {
|
||||
font-family: 'Outfit';
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.13em;
|
||||
font-size: 10px;
|
||||
color: var(--ink-faint);
|
||||
margin: 14px 8px 8px;
|
||||
}
|
||||
.nav-i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
color: var(--ink-dim);
|
||||
font-weight: 500;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-i svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.nav-i:hover {
|
||||
color: var(--ink);
|
||||
background: var(--glass-2);
|
||||
}
|
||||
.nav-i.on {
|
||||
color: var(--ink);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--accent-h) 90% 66% / 0.35);
|
||||
}
|
||||
.nav-i.on::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.nav-i .dot {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-foot {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
}
|
||||
.av {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.side-foot .who {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.side-foot .who span {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* main */
|
||||
.main {
|
||||
padding: 30px 34px;
|
||||
min-width: 0;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.hello {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.hello em {
|
||||
font-style: normal;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.sub {
|
||||
color: var(--ink-dim);
|
||||
font-size: 14px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.searchwrap {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 300px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 14px;
|
||||
padding: 11px 14px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 13.5px;
|
||||
cursor: text;
|
||||
}
|
||||
.search svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.search .k {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
background: var(--glass-2);
|
||||
border-radius: 6px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.gbtn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(20px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-dim);
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
}
|
||||
.gbtn:hover {
|
||||
color: var(--ink);
|
||||
background: var(--glass-2);
|
||||
}
|
||||
.gbtn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* metric row */
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.metric {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric .ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.metric .ic svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.metric .v {
|
||||
font-family: 'Outfit';
|
||||
font-size: 27px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.metric .l {
|
||||
color: var(--ink-dim);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.metric .trend {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ok);
|
||||
background: rgba(52, 224, 161, 0.12);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.metric .trend.dn {
|
||||
color: var(--bad);
|
||||
background: rgba(255, 93, 115, 0.12);
|
||||
}
|
||||
|
||||
.sectitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
.sectitle h2 {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sectitle a {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-line);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
box-shadow 0.22s,
|
||||
border-color 0.22s;
|
||||
}
|
||||
.card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: hsl(var(--accent-h) 90% 66% / 0.5);
|
||||
box-shadow: 0 24px 50px -20px hsl(var(--accent-h) 90% 50% / 0.55);
|
||||
}
|
||||
.card .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
.ico {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 13px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Outfit';
|
||||
font-weight: 600;
|
||||
font-size: 15.5px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ct {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.pill {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.pill.ok {
|
||||
color: var(--ok);
|
||||
background: rgba(52, 224, 161, 0.13);
|
||||
}
|
||||
.pill.warn {
|
||||
color: var(--warn);
|
||||
background: rgba(255, 194, 75, 0.13);
|
||||
}
|
||||
.pill.bad {
|
||||
color: var(--bad);
|
||||
background: rgba(255, 93, 115, 0.13);
|
||||
}
|
||||
.pill .b {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.meta .up {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
.meta .up b {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.spark {
|
||||
height: 24px;
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.metric,
|
||||
.card {
|
||||
animation: rise 0.55s both;
|
||||
}
|
||||
.metric:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
.metric:nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.metric:nth-child(4) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.apps .card:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.apps .card:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.apps .card:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.apps .card:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.apps .card:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.apps .card:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.apps .card:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.apps .card:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 30px;
|
||||
justify-content: center;
|
||||
color: var(--ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
.sw {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--glass-line);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<!-- Sidebar -->
|
||||
<aside class="side">
|
||||
<div class="brand">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="name">Launcher<span>home cloud</span></div>
|
||||
</div>
|
||||
<div class="nav-grp">Workspace</div>
|
||||
<div class="nav-i on">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
<path d="M3 9h18M9 21V9" />
|
||||
</svg>
|
||||
Overview
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
|
||||
</svg>
|
||||
All Apps <span class="dot">10</span>
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
Status
|
||||
</div>
|
||||
<div class="nav-grp">Boards</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
</svg>
|
||||
Media Center
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||
</svg>
|
||||
Infrastructure
|
||||
</div>
|
||||
<div class="nav-i">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
New board…
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
<div class="av">AD</div>
|
||||
<div class="who">Alexei<span>Administrator</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="hello">Good evening, <em>Alexei</em></div>
|
||||
<div class="sub">All systems nominal — 8 of 10 services responding</div>
|
||||
</div>
|
||||
<div class="searchwrap">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search… <span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="gbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="gbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metrics -->
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">8<span style="color: var(--ink-faint); font-size: 18px">/10</span></div>
|
||||
<div class="l">Services online</div>
|
||||
<span class="trend">+2</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">142<span style="color: var(--ink-faint); font-size: 16px">ms</span></div>
|
||||
<div class="l">Avg response</div>
|
||||
<span class="trend dn">+18ms</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h4l3 8 4-16 3 8h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">99.4%</div>
|
||||
<div class="l">Uptime · 30d</div>
|
||||
<span class="trend">+0.2</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v">61%</div>
|
||||
<div class="l">UPS load · 38m</div>
|
||||
<span class="trend dn">batt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sectitle">
|
||||
<h2>Favorites</h2>
|
||||
<a href="#">View all apps →</a>
|
||||
</div>
|
||||
<div class="apps">
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🎬</div>
|
||||
<div>
|
||||
<div class="nm">Jellyfin</div>
|
||||
<div class="ct">Media</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.9%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,18 11,15 22,17 33,9 44,12 55,7 66,11 76,5 84,8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">📷</div>
|
||||
<div>
|
||||
<div class="nm">Immich</div>
|
||||
<div class="ct">Photos</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>100%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,13 11,14 22,12 33,13 44,11 55,12 66,10 76,12 84,11"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🌿</div>
|
||||
<div>
|
||||
<div class="nm">Gitea</div>
|
||||
<div class="ct">Git server</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.8%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,15 11,10 22,13 33,8 44,12 55,9 66,14 76,8 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🐳</div>
|
||||
<div>
|
||||
<div class="nm">Portainer</div>
|
||||
<div class="ct">Containers</div>
|
||||
</div>
|
||||
<div class="pill warn"><span class="b"></span>Slow</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>98.1%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--warn)"
|
||||
stroke-width="2"
|
||||
points="0,11 11,13 22,9 33,16 44,11 55,19 66,12 76,17 84,13"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🛡️</div>
|
||||
<div>
|
||||
<div class="nm">Pi-hole</div>
|
||||
<div class="ct">DNS · Ads</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>100%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,14 11,12 22,13 33,11 44,12 55,10 66,11 76,9 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">📋</div>
|
||||
<div>
|
||||
<div class="nm">Planka</div>
|
||||
<div class="ct">Kanban</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.5%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,16 11,13 22,15 33,12 44,13 55,11 66,13 76,10 84,12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">⬇️</div>
|
||||
<div>
|
||||
<div class="nm">Deluge</div>
|
||||
<div class="ct">Downloads</div>
|
||||
</div>
|
||||
<div class="pill bad"><span class="b"></span>Down</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up" style="color: var(--bad)"
|
||||
><b style="color: var(--bad)">offline</b> · 4m</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--bad)"
|
||||
stroke-width="2"
|
||||
points="0,10 11,12 22,15 33,13 44,19 55,17 66,21 76,20 84,22"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="ico">🔀</div>
|
||||
<div>
|
||||
<div class="nm">Proxy Mgr</div>
|
||||
<div class="ct">Network</div>
|
||||
</div>
|
||||
<div class="pill ok"><span class="b"></span>Up</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span class="up"><b>99.9%</b> uptime</span
|
||||
><svg class="spark" viewBox="0 0 84 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
stroke-width="2"
|
||||
points="0,13 11,12 22,13 33,11 44,12 55,12 66,10 76,11 84,10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="swatches">
|
||||
Accent (user-tunable):
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(265 90% 66%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '265')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(210 90% 60%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '210')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(150 80% 55%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '150')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(20 90% 62%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '20')"
|
||||
></span>
|
||||
<span
|
||||
class="sw"
|
||||
style="background: hsl(330 85% 65%)"
|
||||
onclick="document.documentElement.style.setProperty('--accent-h', '330')"
|
||||
></span>
|
||||
— try clicking; the whole UI + aurora retints live
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,643 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Editorial</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Instrument+Serif:ital@0;1&family=Hanken+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f4f1ea; /* warm paper */
|
||||
--paper-2: #ece7db;
|
||||
--card: #fbfaf6;
|
||||
--ink: #191712;
|
||||
--ink-2: #5a554a;
|
||||
--ink-faint: #9b9484;
|
||||
--line: #1a1712;
|
||||
--line-soft: #d8d2c4;
|
||||
--accent: #ff5436; /* vermilion */
|
||||
--accent-ink: #cf3a1f;
|
||||
--blue: #1f4ae0;
|
||||
--ok: #1f8a4c;
|
||||
--warn: #b8730a;
|
||||
--bad: #cf2020;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: 'Hanken Grotesk', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background-image: radial-gradient(rgba(0, 0, 0, 0.022) 1px, transparent 1px);
|
||||
background-size: 5px 5px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 0 26px;
|
||||
}
|
||||
|
||||
/* top bar */
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 22px 0 18px;
|
||||
border-bottom: 2.5px solid var(--line);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.logo .glyph {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.logo .glyph svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.logo .tt {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 23px;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 0.9;
|
||||
}
|
||||
.logo .tt small {
|
||||
display: block;
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 18px;
|
||||
}
|
||||
.nav a {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 2px;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.nav a:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.nav a.on {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.tools {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 2px;
|
||||
padding: 9px 13px;
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
cursor: text;
|
||||
background: var(--card);
|
||||
}
|
||||
.search svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.search .k {
|
||||
margin-left: 8px;
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
border: 1.5px solid var(--line-soft);
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.ib {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--card);
|
||||
transition: 0.15s;
|
||||
}
|
||||
.ib:hover {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.ib svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
/* hero */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.45fr 1fr;
|
||||
gap: 0;
|
||||
border-bottom: 2.5px solid var(--line);
|
||||
}
|
||||
.hero-l {
|
||||
padding: 46px 40px 46px 0;
|
||||
border-right: 2.5px solid var(--line);
|
||||
}
|
||||
.kicker {
|
||||
font-family: 'Hanken Grotesk';
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.22em;
|
||||
font-size: 12px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.hero h1 {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 62px;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.035em;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.hero h1 em {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
color: var(--accent);
|
||||
}
|
||||
.hero p {
|
||||
font-size: 16px;
|
||||
color: var(--ink-2);
|
||||
max-width: 30ch;
|
||||
margin-top: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.btn {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 2px;
|
||||
border: 2px solid var(--line);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.solid {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.btn.solid:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
.hero-r {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr 1fr;
|
||||
}
|
||||
.figure {
|
||||
padding: 20px 0 20px 36px;
|
||||
border-bottom: 1.5px solid var(--line-soft);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
}
|
||||
.figure:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.figure .num {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 800;
|
||||
font-size: 46px;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.85;
|
||||
min-width: 120px;
|
||||
}
|
||||
.figure .num.acc {
|
||||
color: var(--accent);
|
||||
}
|
||||
.figure .num.bl {
|
||||
color: var(--blue);
|
||||
}
|
||||
.figure .desc {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.figure .desc b {
|
||||
display: block;
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* section label */
|
||||
.slab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 36px 0 20px;
|
||||
}
|
||||
.slab h2 {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.slab .ln {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
background: var(--line);
|
||||
}
|
||||
.slab .meta {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 16px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
/* apps — asymmetric editorial grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
.tile {
|
||||
grid-column: span 3;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
position: relative;
|
||||
box-shadow: 4px 4px 0 var(--line);
|
||||
}
|
||||
.tile:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 7px 7px 0 var(--accent);
|
||||
}
|
||||
.tile.wide {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.tile.tall {
|
||||
grid-column: span 3;
|
||||
}
|
||||
.t-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.t-ico {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
background: var(--paper);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-name {
|
||||
font-family: 'Bricolage Grotesque';
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
.t-cat {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tag {
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 4px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1.5px solid currentColor;
|
||||
}
|
||||
.tag.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.tag.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.tag.bad {
|
||||
color: var(--bad);
|
||||
background: var(--bad);
|
||||
color: #fff;
|
||||
border-color: var(--bad);
|
||||
}
|
||||
.t-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
padding-top: 13px;
|
||||
border-top: 1.5px solid var(--line-soft);
|
||||
}
|
||||
.t-foot .up {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.t-foot .up small {
|
||||
font-weight: 500;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.spark {
|
||||
height: 24px;
|
||||
width: 90px;
|
||||
}
|
||||
.tile.wide .blurb {
|
||||
font-size: 14px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
margin-top: 14px;
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.tile,
|
||||
.figure {
|
||||
animation: pop 0.5s both;
|
||||
}
|
||||
.grid .tile:nth-child(1) {
|
||||
animation-delay: 0.05s;
|
||||
}
|
||||
.grid .tile:nth-child(2) {
|
||||
animation-delay: 0.11s;
|
||||
}
|
||||
.grid .tile:nth-child(3) {
|
||||
animation-delay: 0.17s;
|
||||
}
|
||||
.grid .tile:nth-child(4) {
|
||||
animation-delay: 0.23s;
|
||||
}
|
||||
.grid .tile:nth-child(5) {
|
||||
animation-delay: 0.29s;
|
||||
}
|
||||
.grid .tile:nth-child(6) {
|
||||
animation-delay: 0.35s;
|
||||
}
|
||||
.grid .tile:nth-child(7) {
|
||||
animation-delay: 0.41s;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
margin: 46px 0 30px;
|
||||
padding-top: 18px;
|
||||
border-top: 2.5px solid var(--line);
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: italic;
|
||||
font-size: 15px;
|
||||
color: var(--ink-2);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<!-- Masthead -->
|
||||
<div class="masthead">
|
||||
<div class="logo">
|
||||
<div class="glyph">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
<rect x="3" y="14" width="7" height="7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="tt">LAUNCHER<small>the home cloud edition</small></div>
|
||||
</div>
|
||||
<nav class="nav"><a class="on">Overview</a><a>Apps</a><a>Boards</a><a>Status</a></nav>
|
||||
<div class="tools">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search<span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="ib">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="hero-l">
|
||||
<div class="kicker">Tuesday · 27 May · 19:42</div>
|
||||
<h1>Your stack,<br />all in <em>one place.</em></h1>
|
||||
<p>
|
||||
Ten services under one roof. Eight humming, two asking for attention. Everything
|
||||
launches from here.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn solid">Open a board →</a><a class="btn ghost">Add an app</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-r">
|
||||
<div class="figure">
|
||||
<div class="num acc">8/10</div>
|
||||
<div class="desc"><b>Services online</b>Deluge offline · Portainer slow to respond</div>
|
||||
</div>
|
||||
<div class="figure">
|
||||
<div class="num bl">99.4%</div>
|
||||
<div class="desc"><b>Fleet uptime</b>Rolling 30-day average across all monitors</div>
|
||||
</div>
|
||||
<div class="figure">
|
||||
<div class="num">142<span style="font-size: 20px">ms</span></div>
|
||||
<div class="desc"><b>Median response</b>p95 latency over the last 24 hours</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Apps -->
|
||||
<div class="slab">
|
||||
<h2>Favorites</h2>
|
||||
<div class="ln"></div>
|
||||
<div class="meta">eight pinned</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="tile wide">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🎬</div>
|
||||
<div>
|
||||
<div class="t-name">Jellyfin</div>
|
||||
<div class="t-cat">Media server · the crown jewel</div>
|
||||
</div>
|
||||
<span class="tag ok">Online</span>
|
||||
</div>
|
||||
<p class="blurb">
|
||||
Streaming to 3 devices right now. Library scan completed 2 hours ago — 4,212 movies, 318
|
||||
shows indexed and healthy.
|
||||
</p>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.9% <small>uptime · 24h</small></div>
|
||||
<svg class="spark" viewBox="0 0 90 24" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#ff5436"
|
||||
stroke-width="2.2"
|
||||
points="0,18 12,15 24,17 36,9 48,12 60,7 72,11 82,5 90,8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">📷</div>
|
||||
<div>
|
||||
<div class="t-name">Immich</div>
|
||||
<div class="t-cat">Photos</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">100% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🌿</div>
|
||||
<div>
|
||||
<div class="t-name">Gitea</div>
|
||||
<div class="t-cat">Git</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.8% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🐳</div>
|
||||
<div>
|
||||
<div class="t-name">Portainer</div>
|
||||
<div class="t-cat">Containers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">98.1% <small>24h</small></div>
|
||||
<span class="tag warn">Slow</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">🛡️</div>
|
||||
<div>
|
||||
<div class="t-name">Pi-hole</div>
|
||||
<div class="t-cat">DNS</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">100% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">📋</div>
|
||||
<div>
|
||||
<div class="t-name">Planka</div>
|
||||
<div class="t-cat">Kanban</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up">99.5% <small>24h</small></div>
|
||||
<span class="tag ok">Up</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="t-top">
|
||||
<div class="t-ico">⬇️</div>
|
||||
<div>
|
||||
<div class="t-name">Deluge</div>
|
||||
<div class="t-cat">Downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="t-foot">
|
||||
<div class="up" style="color: var(--bad)">—</div>
|
||||
<span class="tag bad">Down</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colophon">
|
||||
<span>Editorial — Bricolage Grotesque + Instrument Serif</span
|
||||
><span>warm paper · ink rules · hard shadows · asymmetric grid</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,723 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Web App Launcher — Cozy Home</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fdf8f2; /* warm cream */
|
||||
--bg-2: #f6efe4;
|
||||
--card: #fffdfa;
|
||||
--ink: #3a322b;
|
||||
--ink-2: #857a6d;
|
||||
--ink-faint: #b3a899;
|
||||
--line: #ece2d3;
|
||||
--peach: #ff9a76;
|
||||
--terra: #e8754f;
|
||||
--sage: #7fb069;
|
||||
--sky: #6ca9d6;
|
||||
--butter: #f3c969;
|
||||
--lav: #b09fd6;
|
||||
--ok: #5fa86c;
|
||||
--warn: #d99a2b;
|
||||
--bad: #e0685f;
|
||||
--radius: 22px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Figtree', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(50% 40% at 12% 0%, rgba(255, 154, 118, 0.16), transparent 70%),
|
||||
radial-gradient(45% 40% at 95% 8%, rgba(108, 169, 214, 0.14), transparent 70%),
|
||||
radial-gradient(50% 45% at 85% 100%, rgba(127, 176, 105, 0.12), transparent 70%);
|
||||
}
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 236px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* sidebar */
|
||||
.side {
|
||||
padding: 24px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand .m {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--peach), var(--terra));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px -8px var(--terra);
|
||||
}
|
||||
.brand .m svg {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
.brand .t {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.brand .t span {
|
||||
display: block;
|
||||
font-family: 'Figtree';
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.nlabel {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 10.5px;
|
||||
color: var(--ink-faint);
|
||||
margin: 16px 10px 8px;
|
||||
}
|
||||
.ni {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
color: var(--ink-2);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: 0.16s;
|
||||
}
|
||||
.ni svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.ni:hover {
|
||||
background: var(--bg-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.ni.on {
|
||||
background: var(--card);
|
||||
color: var(--terra);
|
||||
box-shadow:
|
||||
0 6px 16px -8px rgba(0, 0, 0, 0.18),
|
||||
inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.ni .c {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
background: var(--bg-2);
|
||||
color: var(--ink-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.side-card {
|
||||
margin-top: auto;
|
||||
background: linear-gradient(135deg, rgba(127, 176, 105, 0.16), rgba(108, 169, 214, 0.14));
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.side-card p {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.side-card .who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.side-card .av {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--lav), var(--sky));
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* main */
|
||||
.main {
|
||||
padding: 30px 36px 40px;
|
||||
min-width: 0;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.top .search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: var(--ink-faint);
|
||||
font-size: 14px;
|
||||
width: 320px;
|
||||
cursor: text;
|
||||
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.top .search svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.top .search .k {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: var(--bg-2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rbtn {
|
||||
margin-left: auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 15px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 14px -10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.rbtn + .rbtn {
|
||||
margin-left: 0;
|
||||
}
|
||||
.rbtn:hover {
|
||||
color: var(--terra);
|
||||
}
|
||||
.rbtn svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.greet {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 18px 0 4px;
|
||||
}
|
||||
.greet .wave {
|
||||
display: inline-block;
|
||||
animation: wave 2.4s ease-in-out infinite;
|
||||
transform-origin: 70% 70%;
|
||||
}
|
||||
@keyframes wave {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
10% {
|
||||
transform: rotate(16deg);
|
||||
}
|
||||
20% {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
40% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
}
|
||||
.gsub {
|
||||
color: var(--ink-2);
|
||||
font-size: 15px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.gsub b {
|
||||
color: var(--sage);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* summary chips */
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
padding: 14px 18px;
|
||||
box-shadow: 0 8px 20px -16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.chip .ic {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chip .ic svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: #fff;
|
||||
}
|
||||
.chip .v {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chip .l {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 6px 0 18px;
|
||||
}
|
||||
.sec h2 {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sec .more {
|
||||
margin-left: auto;
|
||||
font-size: 13.5px;
|
||||
color: var(--terra);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.apps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 26px -20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-6px) rotate(-0.4deg);
|
||||
box-shadow: 0 22px 40px -22px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.blob {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
opacity: 0.5;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
}
|
||||
.ico {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 26px;
|
||||
position: relative;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
margin-top: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.ct {
|
||||
font-size: 13px;
|
||||
color: var(--ink-2);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dot .b {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.dot.ok .b {
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 0 4px rgba(95, 168, 108, 0.18);
|
||||
}
|
||||
.dot.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.dot.warn .b {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 4px rgba(217, 154, 43, 0.18);
|
||||
}
|
||||
.dot.bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
.dot.bad .b {
|
||||
background: var(--bad);
|
||||
box-shadow: 0 0 0 4px rgba(224, 104, 95, 0.18);
|
||||
}
|
||||
.up {
|
||||
font-size: 12px;
|
||||
color: var(--ink-faint);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.card,
|
||||
.chip {
|
||||
animation: rise 0.55s both;
|
||||
}
|
||||
.chip:nth-child(2) {
|
||||
animation-delay: 0.06s;
|
||||
}
|
||||
.chip:nth-child(3) {
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
.chip:nth-child(4) {
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
.apps .card:nth-child(1) {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
.apps .card:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
.apps .card:nth-child(3) {
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
.apps .card:nth-child(4) {
|
||||
animation-delay: 0.28s;
|
||||
}
|
||||
.apps .card:nth-child(5) {
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
.apps .card:nth-child(6) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.apps .card:nth-child(7) {
|
||||
animation-delay: 0.46s;
|
||||
}
|
||||
.apps .card:nth-child(8) {
|
||||
animation-delay: 0.52s;
|
||||
}
|
||||
|
||||
.note {
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
font-family: 'Fraunces';
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
margin-top: 36px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<!-- Sidebar -->
|
||||
<aside class="side">
|
||||
<div class="brand">
|
||||
<div class="m">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="2" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="2" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="t">Launcher<span>our home cloud</span></div>
|
||||
</div>
|
||||
<div class="nlabel">Menu</div>
|
||||
<div class="ni on">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M3 11l9-8 9 8M5 10v10h14V10" />
|
||||
</svg>
|
||||
Home
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18" />
|
||||
</svg>
|
||||
All apps <span class="c">10</span>
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
Status
|
||||
</div>
|
||||
<div class="nlabel">Rooms</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
</svg>
|
||||
Movie night
|
||||
</div>
|
||||
<div class="ni">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" />
|
||||
</svg>
|
||||
The basement rack
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<p>“Everything’s running smoothly today. ☕ Two apps want a peek when you get a sec.”</p>
|
||||
<div class="who">
|
||||
<div class="av">AD</div>
|
||||
<div>
|
||||
<div style="font-weight: 700; font-size: 13px">Alexei</div>
|
||||
<div style="font-size: 11px; color: var(--ink-faint)">Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<div class="top">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
Search your apps… <span class="k">⌘K</span>
|
||||
</div>
|
||||
<div class="rbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rbtn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="greet">Hi Alexei <span class="wave">👋</span></h1>
|
||||
<p class="gsub">It’s a calm evening — <b>8 of your 10 apps</b> are happy and online.</p>
|
||||
|
||||
<div class="chips">
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--sage)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">8/10</div>
|
||||
<div class="l">Apps online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--sky)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">142ms</div>
|
||||
<div class="l">Avg speed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--butter)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M3 12h4l3 8 4-16 3 8h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">99.4%</div>
|
||||
<div class="l">Uptime · 30d</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chip">
|
||||
<div class="ic" style="background: var(--lav)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M13 2 3 14h7l-1 8 10-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="v">38 min</div>
|
||||
<div class="l">Battery left</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec">
|
||||
<h2>Your favorites</h2>
|
||||
<a class="more" href="#">See all →</a>
|
||||
</div>
|
||||
<div class="apps">
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--terra)"></div>
|
||||
<div class="ico" style="background: rgba(232, 117, 79, 0.16)">🎬</div>
|
||||
<div class="nm">Jellyfin</div>
|
||||
<div class="ct">Movies & shows</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sky)"></div>
|
||||
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">📷</div>
|
||||
<div class="nm">Immich</div>
|
||||
<div class="ct">Photos</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sage)"></div>
|
||||
<div class="ico" style="background: rgba(127, 176, 105, 0.18)">🌿</div>
|
||||
<div class="nm">Gitea</div>
|
||||
<div class="ct">Code</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.8%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--butter)"></div>
|
||||
<div class="ico" style="background: rgba(243, 201, 105, 0.22)">🐳</div>
|
||||
<div class="nm">Portainer</div>
|
||||
<div class="ct">Containers</div>
|
||||
<div class="foot">
|
||||
<span class="dot warn"><span class="b"></span>A bit slow</span
|
||||
><span class="up">98.1%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--lav)"></div>
|
||||
<div class="ico" style="background: rgba(176, 159, 214, 0.2)">🛡️</div>
|
||||
<div class="nm">Pi-hole</div>
|
||||
<div class="ct">Ad blocker</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--peach)"></div>
|
||||
<div class="ico" style="background: rgba(255, 154, 118, 0.2)">📋</div>
|
||||
<div class="nm">Planka</div>
|
||||
<div class="ct">To-dos</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.5%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--bad)"></div>
|
||||
<div class="ico" style="background: rgba(224, 104, 95, 0.16)">⬇️</div>
|
||||
<div class="nm">Deluge</div>
|
||||
<div class="ct">Downloads</div>
|
||||
<div class="foot">
|
||||
<span class="dot bad"><span class="b"></span>Asleep</span><span class="up">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--sky)"></div>
|
||||
<div class="ico" style="background: rgba(108, 169, 214, 0.18)">🔀</div>
|
||||
<div class="nm">Proxy</div>
|
||||
<div class="ct">Networking</div>
|
||||
<div class="foot">
|
||||
<span class="dot ok"><span class="b"></span>Online</span><span class="up">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
Cozy Home — Fraunces + Figtree · warm cream · soft pastel rooms · gentle motion
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,639 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Cozy Home — Design System Reference</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
/* === The exact tokens now living in src/app.css (light/cream) === */
|
||||
:root {
|
||||
--background: hsl(35 56% 97%);
|
||||
--foreground: hsl(33 18% 18%);
|
||||
--muted: hsl(36 42% 93%);
|
||||
--muted-foreground: hsl(34 12% 47%);
|
||||
--card: hsl(40 60% 99%);
|
||||
--border: hsl(36 35% 88%);
|
||||
--primary: hsl(16 72% 56%);
|
||||
--primary-foreground: hsl(40 60% 99%);
|
||||
--accent: hsl(34 44% 90%);
|
||||
--status-online: #5fa86c;
|
||||
--status-offline: #e0685f;
|
||||
--status-degraded: #d99a2b;
|
||||
--status-unknown: #b3a899;
|
||||
--room-sage: #7fb069;
|
||||
--room-sky: #6ca9d6;
|
||||
--room-butter: #f3c969;
|
||||
--room-lav: #b09fd6;
|
||||
--room-peach: #ff9a76;
|
||||
--room-terra: #e8754f;
|
||||
--radius: 1rem;
|
||||
--shadow-soft: 0 10px 26px -20px rgba(80, 50, 20, 0.45);
|
||||
--shadow-lift: 0 22px 40px -22px rgba(80, 50, 20, 0.4);
|
||||
--font-sans: 'Figtree', system-ui, sans-serif;
|
||||
--font-display: 'Fraunces', serif;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
padding: 40px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lede {
|
||||
color: var(--muted-foreground);
|
||||
margin: 6px 0 8px;
|
||||
max-width: 64ch;
|
||||
}
|
||||
.path {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--room-terra);
|
||||
background: color-mix(in srgb, var(--room-terra) 12%, transparent);
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
section {
|
||||
margin-top: 42px;
|
||||
}
|
||||
.h {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* tokens */
|
||||
.swatch {
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.swatch .c {
|
||||
height: 56px;
|
||||
}
|
||||
.swatch .n {
|
||||
padding: 9px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.swatch .n b {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
.swatch .n span {
|
||||
color: var(--muted-foreground);
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 11px 18px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: 0.18s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, transparent);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
.btn-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* inputs */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
max-width: 340px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.input {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: 0.16s;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* badges / status pills */
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pill .b {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.pill.ok {
|
||||
color: var(--status-online);
|
||||
background: color-mix(in srgb, var(--status-online) 14%, transparent);
|
||||
}
|
||||
.pill.warn {
|
||||
color: var(--status-degraded);
|
||||
background: color-mix(in srgb, var(--status-degraded) 14%, transparent);
|
||||
}
|
||||
.pill.bad {
|
||||
color: var(--status-offline);
|
||||
background: color-mix(in srgb, var(--status-offline) 14%, transparent);
|
||||
}
|
||||
.pill .b {
|
||||
background: currentColor;
|
||||
}
|
||||
.room-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
background: var(--muted);
|
||||
padding: 5px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.tab {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.tab.on {
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
/* card */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
max-width: 300px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card .blob {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
filter: blur(30px);
|
||||
opacity: 0.45;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
}
|
||||
.card .ic {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 25px;
|
||||
position: relative;
|
||||
}
|
||||
.card h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.card .ct {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.card .f {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* dialog */
|
||||
.dialog {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-lift);
|
||||
max-width: 380px;
|
||||
}
|
||||
.dialog h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin: 8px 0 20px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.2rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted-foreground);
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
td {
|
||||
padding: 14px 18px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
tr:hover td {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* empty state */
|
||||
.empty {
|
||||
text-align: center;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 1.4rem;
|
||||
padding: 48px 24px;
|
||||
background: color-mix(in srgb, var(--card) 50%, transparent);
|
||||
}
|
||||
.empty .e-ic {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto 16px;
|
||||
background: color-mix(in srgb, var(--room-peach) 20%, transparent);
|
||||
color: var(--room-terra);
|
||||
}
|
||||
.empty h3 {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty p {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 14px;
|
||||
margin: 6px auto 18px;
|
||||
max-width: 36ch;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1 class="title">Cozy Home — Design System</h1>
|
||||
<p class="lede">
|
||||
The component pattern sheet for the migration. Every phase styles its components to match
|
||||
these primitives. Tokens here mirror what now lives in
|
||||
<span class="path">src/app.css</span> — change them there and the whole app follows.
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<div class="h">Color tokens</div>
|
||||
<div class="grid">
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--background)"></div>
|
||||
<div class="n"><b>background</b><span>cream #fdf8f2</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--card)"></div>
|
||||
<div class="n"><b>card</b><span>#fffdfa</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--primary)"></div>
|
||||
<div class="n"><b>primary</b><span>terracotta · tunable</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--foreground)"></div>
|
||||
<div class="n"><b>foreground</b><span>warm ink</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--muted)"></div>
|
||||
<div class="n"><b>muted</b><span>#f3ecde</span></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--border)"></div>
|
||||
<div class="n"><b>border</b><span>#ece2d3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 14px" class="grid">
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-terra)"></div>
|
||||
<div class="n"><b>room · terra</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-peach)"></div>
|
||||
<div class="n"><b>room · peach</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-butter)"></div>
|
||||
<div class="n"><b>room · butter</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-sage)"></div>
|
||||
<div class="n"><b>room · sage</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-sky)"></div>
|
||||
<div class="n"><b>room · sky</b></div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="c" style="background: var(--room-lav)"></div>
|
||||
<div class="n"><b>room · lav</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Typography — Fraunces (display) · Figtree (body)</div>
|
||||
<h1 style="font-size: 46px; font-weight: 600">Good evening, Alexei</h1>
|
||||
<h2 style="font-size: 28px; font-weight: 600; margin-top: 10px">Your favorites</h2>
|
||||
<h3 style="font-size: 19px; font-weight: 600; margin-top: 10px">Jellyfin</h3>
|
||||
<p style="margin-top: 8px; max-width: 60ch">
|
||||
Body copy is Figtree — friendly, legible, rounded. It carries descriptions, hints, and
|
||||
plain-language status like “a bit slow” or “asleep”.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Buttons</div>
|
||||
<div class="row">
|
||||
<button class="btn btn-primary">Open a board</button>
|
||||
<button class="btn btn-secondary">Add an app</button>
|
||||
<button class="btn btn-ghost">Cancel</button>
|
||||
<button class="btn btn-secondary btn-icon" title="icon">🔔</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Form fields</div>
|
||||
<div class="row" style="align-items: flex-start">
|
||||
<div class="field">
|
||||
<label>App name</label><input class="input" value="Jellyfin" /><span class="hint"
|
||||
>Shown on the card and in search.</span
|
||||
>
|
||||
</div>
|
||||
<div class="field"><label>URL</label><input class="input" placeholder="https://…" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Status pills & room tags</div>
|
||||
<div class="row">
|
||||
<span class="pill ok"><span class="b"></span>Online</span>
|
||||
<span class="pill warn"><span class="b"></span>A bit slow</span>
|
||||
<span class="pill bad"><span class="b"></span>Asleep</span>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-terra);
|
||||
background: color-mix(in srgb, var(--room-terra) 16%, transparent);
|
||||
"
|
||||
>Media</span
|
||||
>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-sky);
|
||||
background: color-mix(in srgb, var(--room-sky) 16%, transparent);
|
||||
"
|
||||
>Network</span
|
||||
>
|
||||
<span
|
||||
class="room-pill"
|
||||
style="
|
||||
color: var(--room-sage);
|
||||
background: color-mix(in srgb, var(--room-sage) 16%, transparent);
|
||||
"
|
||||
>Git</span
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Tabs</div>
|
||||
<div class="tabs">
|
||||
<div class="tab on">Overview</div>
|
||||
<div class="tab">Activity</div>
|
||||
<div class="tab">Settings</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">App card</div>
|
||||
<div class="card">
|
||||
<div class="blob" style="background: var(--room-terra)"></div>
|
||||
<div
|
||||
class="ic"
|
||||
style="
|
||||
background: color-mix(in srgb, var(--room-terra) 18%, transparent);
|
||||
color: var(--room-terra);
|
||||
"
|
||||
>
|
||||
🎬
|
||||
</div>
|
||||
<h3>Jellyfin</h3>
|
||||
<div class="ct">Movies & shows</div>
|
||||
<div class="f">
|
||||
<span class="pill ok"><span class="b"></span>Online</span
|
||||
><span class="hint">99.9%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Dialog</div>
|
||||
<div class="dialog">
|
||||
<h3>Remove Deluge?</h3>
|
||||
<p>This deletes the app and its uptime history. This can’t be undone.</p>
|
||||
<div class="row" style="justify-content: flex-end">
|
||||
<button class="btn btn-ghost">Keep it</button
|
||||
><button class="btn btn-primary" style="background: var(--status-offline)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Table (admin)</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Alexei</td>
|
||||
<td>Admin</td>
|
||||
<td>
|
||||
<span class="pill ok"><span class="b"></span>Active</span>
|
||||
</td>
|
||||
<td>just now</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Guest</td>
|
||||
<td>Viewer</td>
|
||||
<td>
|
||||
<span class="pill warn"><span class="b"></span>Idle</span>
|
||||
</td>
|
||||
<td>2h ago</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="h">Empty state</div>
|
||||
<div class="empty">
|
||||
<div class="e-ic">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>No apps yet</h3>
|
||||
<p>Add your first service and it’ll show up here with live status.</p>
|
||||
<button class="btn btn-primary">+ Add an app</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p
|
||||
style="
|
||||
margin: 48px 0 20px;
|
||||
text-align: center;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
color: var(--muted-foreground);
|
||||
"
|
||||
>
|
||||
Cozy Home design system · mirrors src/app.css · use as the pattern for every migrated
|
||||
component
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Launcher — Redesign Mockups</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Figtree:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: 'Figtree', system-ui, sans-serif;
|
||||
background: #0e0e12;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
p.sub {
|
||||
color: #9a99a6;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
max-width: 62ch;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 18px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
a.card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid #24242e;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
background: #15151c;
|
||||
transition: 0.2s;
|
||||
}
|
||||
a.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: #3a3a4a;
|
||||
background: #191920;
|
||||
}
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
.nm {
|
||||
font-family: 'Fraunces';
|
||||
font-weight: 600;
|
||||
font-size: 21px;
|
||||
margin: 14px 0 6px;
|
||||
}
|
||||
.ds {
|
||||
color: #9a99a6;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.swatch {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.swatch span {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Web App Launcher — Redesign Directions</h1>
|
||||
<p class="sub">
|
||||
Four aesthetic directions for the same launcher, built as theme presets of one modernized
|
||||
design system. Open each, resize, hover the cards, and try the live accent swatches in
|
||||
Aurora Glass. Pick the one that fits — or mix and match.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="01-command-deck.html">
|
||||
<span class="badge" style="background: #0d1f18; color: #36e0a4"
|
||||
>01 · Dark · Power-user</span
|
||||
>
|
||||
<div class="nm">Command Deck</div>
|
||||
<div class="ds">
|
||||
Mission-control / terminal. Dense, glanceable telemetry, LED status, monospace data.
|
||||
Saira + JetBrains Mono.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #070a0d"></span><span style="background: #36e0a4"></span
|
||||
><span style="background: #ffb020"></span><span style="background: #ff4d5e"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="02-aurora-glass.html">
|
||||
<span class="badge" style="background: #1d1340; color: #b69cff">02 · Dark · Premium</span>
|
||||
<div class="nm">Aurora Glass</div>
|
||||
<div class="ds">
|
||||
Frosted glass over a living gradient mesh. Soft glows, generous space, fully retintable
|
||||
accent. Outfit + Manrope.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #0a0a14"></span
|
||||
><span style="background: hsl(265 90% 66%)"></span
|
||||
><span style="background: hsl(325 85% 65%)"></span
|
||||
><span style="background: #34e0a1"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="03-editorial.html">
|
||||
<span class="badge" style="background: #2a1109; color: #ff5436">03 · Light · Bold</span>
|
||||
<div class="nm">Editorial</div>
|
||||
<div class="ds">
|
||||
Magazine masthead, big display type, ink rules, hard shadows, asymmetric grid. Bricolage
|
||||
Grotesque + Instrument Serif.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #f4f1ea"></span><span style="background: #191712"></span
|
||||
><span style="background: #ff5436"></span><span style="background: #1f4ae0"></span>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="04-cozy-home.html">
|
||||
<span class="badge" style="background: #2a1c12; color: #ff9a76"
|
||||
>04 · Light · Friendly</span
|
||||
>
|
||||
<div class="nm">Cozy Home</div>
|
||||
<div class="ds">
|
||||
Warm cream, soft rounded cards, pastel “rooms”, gentle motion. Friendly for the whole
|
||||
household. Fraunces + Figtree.
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<span style="background: #fdf8f2"></span><span style="background: #e8754f"></span
|
||||
><span style="background: #7fb069"></span><span style="background: #6ca9d6"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -12,6 +12,7 @@ declare global {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
role: 'admin' | 'user';
|
||||
} | null;
|
||||
session: {
|
||||
|
||||
+4
-1
@@ -5,11 +5,14 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/icon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="theme-color" content="#e8754f" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Launcher" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon.svg" />
|
||||
<!-- Cozy Home typography: Fraunces (display) + Figtree (body).
|
||||
Self-hosted from /static/fonts so offline/LAN installs work with no external calls. -->
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/fonts/fonts.css" />
|
||||
<script>
|
||||
// Inline script to prevent FOUC — set theme class before first paint
|
||||
(function () {
|
||||
|
||||
+105
-85
@@ -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)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
let selectedGroupId = $state('');
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-border">
|
||||
<div class="overflow-x-auto rounded-2xl border border-border bg-card shadow-[var(--shadow-soft)]">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-border bg-muted/50">
|
||||
<tr>
|
||||
|
||||
@@ -126,4 +126,13 @@
|
||||
.status-ring-unknown {
|
||||
animation: ring-rotate-dash 8s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-ring-online,
|
||||
.status-ring-offline,
|
||||
.status-ring-degraded,
|
||||
.status-ring-unknown {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Cozy "room" pastel tint — stable per app, derived from its name
|
||||
const roomTints = ['var(--room-terra)', 'var(--room-sky)', 'var(--room-sage)', 'var(--room-butter)', 'var(--room-lav)', 'var(--room-peach)'];
|
||||
const tint = $derived.by(() => {
|
||||
let h = 0;
|
||||
for (const ch of app.name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return roomTints[h % roomTints.length];
|
||||
});
|
||||
|
||||
const iconDisplay = $derived.by(() => {
|
||||
if (!app.icon) return null;
|
||||
|
||||
@@ -82,32 +90,39 @@
|
||||
tabindex="0"
|
||||
onclick={() => window.open(app.url, '_blank', 'noopener,noreferrer')}
|
||||
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') window.open(app.url, '_blank', 'noopener,noreferrer'); }}
|
||||
class="card-hover group flex cursor-pointer flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50"
|
||||
class="card-hover group relative flex cursor-pointer flex-col overflow-hidden rounded-[1.4rem] border border-border bg-card p-5 shadow-[var(--shadow-soft)]"
|
||||
title={app.description ?? app.name}
|
||||
>
|
||||
<!-- soft blob accent -->
|
||||
<span
|
||||
class="pointer-events-none absolute -right-10 -top-12 h-28 w-28 rounded-full opacity-40 blur-2xl transition-opacity duration-300 group-hover:opacity-70"
|
||||
style="background: {tint};"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground"
|
||||
class="flex h-12 w-12 items-center justify-center rounded-2xl text-lg"
|
||||
style="background: color-mix(in srgb, {tint} 18%, transparent); color: {tint};"
|
||||
>
|
||||
{#if iconDisplay?.kind === 'emoji'}
|
||||
<span class="text-xl">{iconDisplay.value}</span>
|
||||
<span class="text-2xl">{iconDisplay.value}</span>
|
||||
{:else if iconDisplay?.kind === 'image'}
|
||||
<img
|
||||
src={iconDisplay.src}
|
||||
alt="{app.name} icon"
|
||||
class="h-6 w-6 rounded object-contain"
|
||||
class="h-7 w-7 rounded-lg object-contain"
|
||||
/>
|
||||
{:else if iconDisplay?.kind === 'text'}
|
||||
<span class="text-xs font-medium">{iconDisplay.value}</span>
|
||||
<span class="text-sm font-bold">{iconDisplay.value}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-bold">{app.name.charAt(0).toUpperCase()}</span>
|
||||
<span class="text-sm font-bold">{app.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<a
|
||||
href="/apps/{app.id}/edit"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
class="rounded-md p-1 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
class="rounded-xl p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
title={$t('app.edit')}
|
||||
>
|
||||
<svg
|
||||
@@ -128,12 +143,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
|
||||
<h3 class="truncate font-display text-base font-semibold text-card-foreground transition-colors group-hover:text-primary">
|
||||
{app.name}
|
||||
</h3>
|
||||
|
||||
{#if app.description}
|
||||
<p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{app.description}</p>
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{app.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Sparkline -->
|
||||
@@ -143,14 +158,15 @@
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<SparklineChart data={historyData} />
|
||||
{#if uptimePercent !== null}
|
||||
<span class="text-[10px] text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
<span class="text-[11px] font-medium text-muted-foreground">{uptimePercent}% {$t('app.uptime')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.category}
|
||||
<span
|
||||
class="mt-2 inline-block self-start rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
||||
class="mt-3 inline-block self-start rounded-full px-2.5 py-0.5 text-[11px] font-semibold"
|
||||
style="background: color-mix(in srgb, {tint} 18%, transparent); color: color-mix(in srgb, {tint} 68%, var(--foreground));"
|
||||
>
|
||||
{app.category}
|
||||
</span>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<hex></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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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}
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<div class="prose prose-sm prose-invert max-w-none flex-1 overflow-auto text-foreground">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized with DOMPurify -->
|
||||
{@html renderedContent}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<Rss class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium text-foreground">RSS Feed</span>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<!-- Header -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -63,28 +63,28 @@
|
||||
<div class="mt-3 flex gap-1">
|
||||
{#if statusCounts.online > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-green-500"
|
||||
class="h-2 rounded-full bg-status-online"
|
||||
style="flex: {statusCounts.online}"
|
||||
title="{statusCounts.online} online"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.degraded > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-yellow-500"
|
||||
class="h-2 rounded-full bg-status-degraded"
|
||||
style="flex: {statusCounts.degraded}"
|
||||
title="{statusCounts.degraded} degraded"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.offline > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
class="h-2 rounded-full bg-status-offline"
|
||||
style="flex: {statusCounts.offline}"
|
||||
title="{statusCounts.offline} offline"
|
||||
></div>
|
||||
{/if}
|
||||
{#if statusCounts.unknown > 0}
|
||||
<div
|
||||
class="h-2 rounded-full bg-gray-500"
|
||||
class="h-2 rounded-full bg-status-unknown"
|
||||
style="flex: {statusCounts.unknown}"
|
||||
title="{statusCounts.unknown} unknown"
|
||||
></div>
|
||||
@@ -95,25 +95,25 @@
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{#if statusCounts.online > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-online"></span>
|
||||
{statusCounts.online} online
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.degraded > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-yellow-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-degraded"></span>
|
||||
{statusCounts.degraded} degraded
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.offline > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-red-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-offline"></span>
|
||||
{statusCounts.offline} offline
|
||||
</span>
|
||||
{/if}
|
||||
{#if statusCounts.unknown > 0}
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-gray-500"></span>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-status-unknown"></span>
|
||||
{statusCounts.unknown} unknown
|
||||
</span>
|
||||
{/if}
|
||||
@@ -126,12 +126,12 @@
|
||||
{@const status = app.statuses[0]?.status ?? 'unknown'}
|
||||
{@const statusColor =
|
||||
status === 'online'
|
||||
? 'bg-green-500'
|
||||
? 'bg-status-online'
|
||||
: status === 'offline'
|
||||
? 'bg-red-500'
|
||||
? 'bg-status-offline'
|
||||
: status === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-gray-500'}
|
||||
? 'bg-status-degraded'
|
||||
: 'bg-status-unknown'}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="text-foreground">{app.name}</span>
|
||||
<span class="flex items-center gap-1">
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
const refreshMs = $derived((config.refreshInterval ?? 30) * 1000);
|
||||
|
||||
function thresholdColor(value: number): string {
|
||||
if (value >= 85) return 'text-red-500';
|
||||
if (value >= 60) return 'text-yellow-500';
|
||||
return 'text-green-500';
|
||||
if (value >= 85) return 'text-status-offline-ink';
|
||||
if (value >= 60) return 'text-status-degraded-ink';
|
||||
return 'text-status-online-ink';
|
||||
}
|
||||
|
||||
function thresholdStroke(value: number): string {
|
||||
if (value >= 85) return 'stroke-red-500';
|
||||
if (value >= 60) return 'stroke-yellow-500';
|
||||
return 'stroke-green-500';
|
||||
if (value >= 85) return 'stroke-status-offline';
|
||||
if (value >= 60) return 'stroke-status-degraded';
|
||||
return 'stroke-status-online';
|
||||
}
|
||||
|
||||
function thresholdTrack(_value: number): string {
|
||||
@@ -80,7 +80,7 @@
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col rounded-xl border border-border bg-card p-4">
|
||||
<div class="flex h-full flex-col rounded-[1.4rem] border border-border bg-card shadow-[var(--shadow-soft)] p-4">
|
||||
<span class="mb-3 text-sm font-medium text-foreground">System Stats</span>
|
||||
|
||||
{#if loading}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user