diff --git a/.env.example b/.env.example index e4890287..7eba1ec5 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,14 @@ STACKABLE_COCKPIT_SESSION_SECRET=change-me-to-a-long-random-secret-min-32-chars # The publicly accessible base URL of this application STACKABLE_COCKPIT_BASE_URL=http://localhost:5173 -# OIDC discovery URL (Keycloak, Entra ID, or any compliant OIDC provider) +# OIDC discovery URL (dex, Keycloak, Entra ID, or any compliant OIDC provider). +# +# Recommended: point this at a dex instance (https://dexidp.io) that fronts the +# customer's OIDC provider. Stackable controls dex, so external services (e.g. +# Airflow) can be embedded via dex without loosening the upstream provider's +# frame/CSP headers, and one dex session can be shared between the cockpit and +# those services (SSO). The dev stack uses `http://localhost:5556` (see +# ~/airflow-kc). STACKABLE_COCKPIT_OIDC_DISCOVERY_URL=https://your-idp.example.com/realms/your-realm/.well-known/openid-configuration # The client ID and secret registered in your OIDC provider @@ -14,6 +21,36 @@ STACKABLE_COCKPIT_OIDC_CLIENT_SECRET=your-client-secret # OIDC claim used as the username for Trino impersonation (default: preferred_username) # STACKABLE_COCKPIT_OIDC_USERNAME_CLAIM=preferred_username +# Optional embedded external services. Any bookmark product id can be embedded +# through the same-origin service proxy (`/api/services//`) by configuring +# its upstream URL with flat environment variables: +# +# STACKABLE_COCKPIT__URL upstream base URL (presence enables the service) +# STACKABLE_COCKPIT__AUTH_MODE "none" (default) | "all-admins" | "bearer" | "sso" | "simple-users" +# STACKABLE_COCKPIT__BEARER_TOKEN required when AUTH_MODE=bearer +# STACKABLE_COCKPIT__SIMPLE_USERS "user:password,..." required when AUTH_MODE=simple-users +# +# Auth modes: +# - "none": plain same-origin reverse proxy (works for most web UIs). +# - "simple-users": per-user login on stock Airflow. The proxy exchanges the +# cockpit session identity (from dex/Keycloak) for a real Airflow token via +# SimpleAuthManager's /auth/token, using the matching entry from +# _SIMPLE_USERS. The embedded UI is logged in as that user, not anonymous. +# - "all-admins": fetch an anonymous admin token from Airflow's all-admins +# endpoint (no identity). +# - "bearer": sends a dedicated static token upstream. +# - "sso": forwards the cockpit session identity as X-Forwarded-* headers; +# requires an upstream auth layer that trusts those headers. +# +# Example — local dev stack (~airflow-proxy defines the same users): +# STACKABLE_COCKPIT_AIRFLOW_URL=http://127.0.0.1:8089 +# STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=simple-users +# STACKABLE_COCKPIT_AIRFLOW_SIMPLE_USERS=alice:pipeline-alice,bob:pipeline-bob +# +# Example — any other product UI (Superset, Spark, NiFi, ...): +# STACKABLE_COCKPIT_SUPERSET_URL=http://superset.example.com +# STACKABLE_COCKPIT_SPARK_URL=http://spark-history.example.com + # Logging # LOG_LEVEL=debug # Minimum log level (debug, info, warn, error). Default: debug in dev, info in prod. diff --git a/.env.test b/.env.test index c1b50cfe..ce1d47d2 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,6 @@ PUBLIC_STACKABLE_COCKPIT_MAX_RECENT_FILES=15 PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 GARAGE_ADMIN_URL=http://localhost:30902 GARAGE_ADMIN_TOKEN=stackable-cockpit-e2e-admin-token +STACKABLE_COCKPIT_AIRFLOW_URL=http://localhost:8089 +STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=all-admins +STACKABLE_COCKPIT_SUPERSET_URL=http://localhost:8088 diff --git a/README.md b/README.md index 3a8ef45d..0ecb2d78 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,54 @@ The application is configured via environment variables. Create a `.env` file at | `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES` | `integer` (bytes) | `26214400` (25 MiB) | Maximum bytes fetched when previewing PDF files. | `STACKABLE_COCKPIT_PDF_PREVIEW_BYTES=52428800` | | `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS` | `integer` (rows) | `250` | Maximum number of rows included in a tabular file preview (e.g. Parquet converted to CSV). | `STACKABLE_COCKPIT_FILE_PREVIEW_ROWS=500` | +### Embedded Services + +Embedded services are routed through authenticated, same-origin SvelteKit routes under +`/api/services//`. This removes browser CORS and iframe-cookie concerns without +modifying the service or deploying a separate reverse proxy. Service credentials are used +only by the Cockpit server and never reach the browser. + +Any bookmark product id can be embedded through the proxy by configuring its upstream URL +with flat environment variables (`STACKABLE_COCKPIT__URL`, `_AUTH_MODE`, +`_BEARER_TOKEN`). A bookmark for a configured product is then served from +`/api/services//` rather than its raw URL; unconfigured products keep using the raw URL. + +| Variable | Description | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STACKABLE_COCKPIT__URL` | Direct HTTP(S) URL of the service's web UI or API server. Setting it enables the proxy for that product. | +| `STACKABLE_COCKPIT__AUTH_MODE` | `none` (default, plain reverse proxy), `simple-users` (per-user Airflow login: the proxy exchanges the dex-sourced cockpit session identity for a real Airflow token via stock `SimpleAuthManager`), `all-admins` (anonymous admin token, stock dev fallback), `bearer` (static token), `sso` (forwards the cockpit session identity as `X-Forwarded-*` headers to an upstream that trusts them). | +| `STACKABLE_COCKPIT__BEARER_TOKEN` | Required when `AUTH_MODE=bearer`; remains server-side. | +| `STACKABLE_COCKPIT__SIMPLE_USERS` | Required when `AUTH_MODE=simple-users`; comma-separated `user:password` pairs matching the upstream's named users. | + +For example, to embed Superset and Spark alongside Airflow: + +```text +STACKABLE_COCKPIT_AIRFLOW_URL=http://127.0.0.1:8089 +STACKABLE_COCKPIT_AIRFLOW_AUTH_MODE=simple-users +STACKABLE_COCKPIT_AIRFLOW_SIMPLE_USERS=alice:pipeline-alice,bob:pipeline-bob +STACKABLE_COCKPIT_SUPERSET_URL=http://superset.example.com +STACKABLE_COCKPIT_SPARK_URL=http://spark-history.example.com +``` + +`~/airflow-proxy` contains the default **stock** Airflow 3.3.0 development stack — no image +patches, no SSO services inside Airflow. It defines the dev realm's users (alice/bob) as +stock `SimpleAuthManager` named users so the Cockpit proxy can log the embedded UI in as the +actual session user; production deployments must use a real OIDC auth manager or `bearer` +with a least-privilege service token. + +## dex (development SSO broker) + +`dev/docker-compose.dex.yml` runs a shared [dex](https://dexidp.io) instance on +`http://localhost:5556` (`dev/dex.yaml`). It fronts the kind-cluster Keycloak deployed by +`dev/setup.sh` (realm `stackable`) and serves as the single OIDC issuer for the cockpit, so +one browser session covers the cockpit login and any embedded services. Start the cluster +first, then: + +```bash +docker compose -f dev/docker-compose.dex.yml up -d +./dev/setup.sh # auto-detects dex on :5556 and configures .env.development accordingly +``` + ## Contributing 1. Make your changes diff --git a/TECH_DEBT.md b/TECH_DEBT.md index 63591e87..52a0a2b9 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -70,6 +70,22 @@ All server-side query state (progress, rows, status) is held in a module-level ` --- +### Bookmark tools embedded via unsandboxed iframe + +**File:** `src/routes/(app)/bookmark/[id]/+page.svelte` + +Bookmarks are embedded as full-page iframes without a `sandbox` attribute, so the embedded tool can run scripts, navigate the top frame, and read cookies in its own origin context. A `sandbox` attribute would break legitimate tools that need scripts/forms, and most external services will refuse framing anyway via `X-Frame-Options`/CSP. Acceptable for the current stage; long-term, consider a configurable sandbox policy per bookmark and validation of the URL scheme (http/https only). + +--- + +### Airflow's startup version check misses the proxy prefix + +**File:** `src/lib/server/embedded-services.ts` + +Airflow's OpenAPI client derives its base URL from the document `` tag at `queryClient` initialisation (`OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href")`). One call, `VersionService.getVersion()` in the i18n bootstrap (`src/i18n/config.ts`), fires at module import time — before `queryClient.ts` sets `OpenAPI.BASE` from the rewritten `` — so it requests the origin-rooted `/api/v2/version` and 404s through the proxy. The failure is swallowed by the UI (`.catch(() => fut(''))`), so only the version badge in the top bar is affected; nothing else breaks. Acceptable while embedding the stock UI; the robust fix would be rewriting the response to inject the service prefix into the version call or patching the upstream bundle. + +--- + ### Single-file download limit **File:** `src/lib/storage/download.ts`, `src/lib/components/storage/FileExplorer.svelte` @@ -149,3 +165,11 @@ The upload endpoint imposes no maximum file size. S3's 5 TB single-object limit **File:** `src/routes/healthz/+server.ts`, `deploy/helm/cockpit/values.yaml` Both `livenessProbe` and `readinessProbe` point at `/healthz`, which always returns 200. There is currently nothing meaningful to gate readiness on (better-auth uses an in-memory session store, OIDC discovery is fetched lazily on first auth call), so a separate `/readyz` would just be a placeholder. Once one of these lands — a real session store / DB, eager OIDC discovery, or a startup-time cache warm — split into `/healthz` (liveness, trivial) and `/readyz` (readiness, checking the new dependency), and update the helm probes accordingly. + +--- + +### Embedded-service `sso` auth mode trusts forwarded identity headers + +**File:** `src/lib/server/embedded-services.ts` + +In `AUTH_MODE=sso` the proxy derives `X-Forwarded-Preferred-Username`/`X-Forwarded-Email` from the cockpit session and forwards them upstream, where an identity-trusting auth layer turns them into a per-user session without verifying them again. Inbound `X-Forwarded-*` headers from the browser are always stripped by the proxy, so the trust boundary is the cockpit server itself. This is safe for loopback-only dev stacks; a production deployment should instead terminate at an authenticating proxy (e.g. oauth2-proxy) on the service side or use a real OIDC auth manager, and treat the forwarded headers as untrusted input. diff --git a/dev/dex.yaml b/dev/dex.yaml new file mode 100644 index 00000000..c081f1ae --- /dev/null +++ b/dev/dex.yaml @@ -0,0 +1,67 @@ +# dex — the OIDC identity broker for the Stackable Cockpit dev stack. +# +# dex fronts the "customer" OIDC provider — the Keycloak deployed by +# dev/setup.sh into the kind/k3s cluster (realm `stackable`, NodePort :30080) — +# and presents itself as the single OIDC issuer for the cockpit and any +# embedded external services. Because Stackable controls this dex instance, +# external service login flows can run inside iframe embeddings without +# loosening the upstream provider's frame/CSP headers, and one dex session is +# shared across the whole surface (SSO). +# +# It is deliberately served on `http://localhost:5556` — the SAME site as the +# cockpit (`http://localhost:5173`). That keeps dex's session cookie at +# SameSite=Lax and still lets Chrome send it on cross-site iframe navigations +# (SameSite is evaluated against the top-level site), so embedded services can +# silently reuse the SSO session. +# +# dev/setup.sh creates the matching `dex` client in the Keycloak realm and +# points the cockpit at this broker automatically when it detects +# http://localhost:5556/.well-known/openid-configuration. +# +# NOTE: Keycloak must be up before dex starts — dex opens its connector at +# startup and exits if discovery fails. +issuer: http://localhost:5556 + +storage: + type: memory + +web: + http: 0.0.0.0:5556 + +expiry: + idTokens: '24h' + signingKeys: '6h' + +oauth2: + # Never show dex's consent/approval screen — login implies consent, which + # would otherwise render inside cockpit iframe embeddings. + skipApprovalScreen: true + # Single connector, so dex redirects straight to Keycloak (no connector + # selection page) unless a session already exists. + alwaysShowLoginScreen: false + +staticClients: + # The Stackable Cockpit (better-auth genericOAuth callback). + - id: stackable-cockpit + name: Stackable Cockpit + secret: lY7rCsg4Ae0Gj1L119CRt1sGw2Z2yEBT + redirectURIs: + - 'http://localhost:5173/api/auth/oauth2/callback/oidc' + +connectors: + # The real "customer" OIDC provider — the kind-cluster Keycloak, `stackable` + # realm. Keycloak keeps its strict security headers (frame-ancestors 'self', + # X-Frame-Options: SAMEORIGIN) — the browser never needs to frame Keycloak + # because dex (same-site with the cockpit) brokers the login and reuses the + # Keycloak SSO session silently. + - type: oidc + id: keycloak + name: Keycloak + config: + issuer: http://localhost:30080/realms/stackable + clientID: dex + clientSecret: dex-secret + redirectURI: http://localhost:5556/callback + insecureSkipEmailVerified: true + +enablePasswordDB: false diff --git a/dev/docker-compose.dex.yml b/dev/docker-compose.dex.yml new file mode 100644 index 00000000..00163795 --- /dev/null +++ b/dev/docker-compose.dex.yml @@ -0,0 +1,30 @@ +# Runs the shared dex OIDC identity broker for the Stackable Cockpit dev stack. +# +# docker compose -f dev/docker-compose.dex.yml up -d +# +# Start the Keycloak/Trino cluster (dev/setup.sh) FIRST — dex opens its +# Keycloak connector at startup and exits if discovery fails. dev/setup.sh +# auto-detects dex on localhost:5556 and uses it as the cockpit's OIDC provider, +# so one dex session covers the cockpit login and any embedded services (SSO). +name: stackable-cockpit-dex + +services: + dex: + image: ghcr.io/dexidp/dex:v2.45.1 + command: ['dex', 'serve', '/etc/dex/config.yaml'] + volumes: + - ./dex.yaml:/etc/dex/config.yaml:ro + ports: + - '5556:5556' + healthcheck: + test: + ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:5556/.well-known/openid-configuration'] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + extra_hosts: + # dex reaches Keycloak (cluster NodePort on the host) server-to-server via + # the host gateway; Go's resolver falls through the container loopback to + # this mapping when Keycloak is reachable there. + - 'localhost:host-gateway' diff --git a/dev/setup.sh b/dev/setup.sh index 96ac2a1f..70c7e544 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -122,9 +122,32 @@ kcadm() { kubectl exec "$POD" -- /opt/keycloak/bin/kcadm.sh "$@" } +# The shared dex broker (airflow-kc stack, http://localhost:5556) uses this +# client to reach the stackable realm as its "customer" OIDC provider. Create it +# idempotently — the realm may already exist from a previous run. +create_dex_client() { + if kcadm get clients -r stackable --fields clientId 2>/dev/null | grep -q '"dex"'; then + echo "Client 'dex' already exists, skipping." + return + fi + echo "Creating client 'dex' (for the shared dex SSO broker)..." + kcadm create clients \ + -r stackable \ + -s clientId=dex \ + -s enabled=true \ + -s protocol=openid-connect \ + -s publicClient=false \ + -s standardFlowEnabled=true \ + -s directAccessGrantsEnabled=false \ + -s secret=dex-secret \ + -s 'redirectUris=["http://localhost:5556/callback"]' \ + -s 'webOrigins=["*"]' +} + # Check if realm already exists if kcadm get realms/stackable --fields realm 2>/dev/null | grep -q '"stackable"'; then echo "Realm 'stackable' already exists, skipping Keycloak configuration." + create_dex_client # Still need to fetch the client secret CLIENT_UUID=$(kcadm get clients -r stackable --fields id,clientId \ | grep -B1 '"stackable-cockpit"' | grep '"id"' | sed 's/.*: *"\(.*\)".*/\1/') @@ -169,6 +192,8 @@ else -s 'redirectUris=["*"]' \ -s 'webOrigins=["*"]' + create_dex_client + create_user() { local username=$1 password=$2 first=$3 last=$4 echo "Creating user '$username'..." @@ -176,6 +201,7 @@ else -r stackable \ -s username="$username" \ -s email="$username@example.com" \ + -s emailVerified=true \ -s firstName="$first" \ -s lastName="$last" \ -s enabled=true @@ -240,6 +266,26 @@ fi echo "" SESSION_SECRET=$(openssl rand -hex 32) +# When the shared dex broker (airflow-kc stack, http://localhost:5556) is +# reachable, use it as the cockpit's OIDC provider so the cockpit and Airflow +# share one identity broker (SSO). Otherwise fall back to the kind-cluster +# Keycloak deployed above. +DEX_DISCOVERY_URL="" +if curl -sf --max-time 2 "http://localhost:5556/.well-known/openid-configuration" >/dev/null 2>&1; then + DEX_DISCOVERY_URL="http://localhost:5556/.well-known/openid-configuration" +fi + +if [ -n "$DEX_DISCOVERY_URL" ]; then + OIDC_DISCOVERY_URL="$DEX_DISCOVERY_URL" + OIDC_CLIENT_SECRET="lY7rCsg4Ae0Gj1L119CRt1sGw2Z2yEBT" + echo "Using shared dex broker (http://localhost:5556) as OIDC provider." + echo " (airflow-kc docker-compose must be running; users live in the 'stackable' realm: alice/bob)" +else + OIDC_DISCOVERY_URL="${KEYCLOAK_BASE_URL}/realms/stackable/.well-known/openid-configuration" + OIDC_CLIENT_SECRET="${SECRET}" + echo "Shared dex broker not reachable at http://localhost:5556; falling back to kind-cluster Keycloak." +fi + if [ -f "$ENV_FILE" ]; then echo "Backing up existing .env.development to .env.development.bak" cp "$ENV_FILE" "$ENV_FILE.bak" @@ -262,11 +308,14 @@ fi if [[ "$SKIP_TRINO" == false ]]; then cat > "$ENV_FILE" < "$ENV_FILE" < { + test.use({ locale: 'en-US' }); + + const addButton = 'Add Bookmark'; + + test.beforeEach(async ({ page }) => { + // Reset bookmarks before each test, but only while navigating the dashboard: + // the bookmark embed view itself must still see the stored bookmark. + await page.addInitScript(() => { + if (!location.pathname.startsWith('/bookmark/')) { + localStorage.removeItem('dashboard_bookmarks'); + } + }); + }); + + test('shows Add Bookmark button on dashboard', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await expect(page.getByRole('button', { name: addButton })).toBeVisible(); + }); + + test('opens modal when Add Bookmark is clicked', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + + await expect(page.locator('dialog[open]')).toBeVisible(); + }); + + test('modal shows product selection and form fields', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Product buttons are visible (names are inside buttons with logos/initials) + await expect(page.locator('button[aria-pressed]').filter({ hasText: 'Trino' })).toBeVisible(); + await expect( + page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }) + ).toBeVisible(); + + // Open in options + await expect(page.getByText('Inside Cockpit')).toBeVisible(); + await expect(page.getByText('New Tab')).toBeVisible(); + + // Form fields + await expect(page.getByLabel('Name')).toBeVisible(); + await expect(page.getByLabel(/Environment/)).toBeVisible(); + await expect(page.getByLabel('URL')).toBeVisible(); + + // Pinned checkbox label + await expect(page.getByText('Pin this bookmark for all users?')).toBeVisible(); + + // Preview section + await expect(page.getByText('Preview')).toBeVisible(); + }); + + test('selecting a product fills the default name', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + }); + + test('preview updates as user fills the form', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('My Dashboard'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await expect(page.getByText('superset.example.com')).toBeVisible(); + }); + + test('adds a bookmark and shows it on the dashboard', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + // Bookmark section is visible + await expect(page.getByRole('heading', { name: 'Bookmarks' })).toBeVisible(); + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('superset.example.com')).toBeVisible(); + }); + + test('embeds Airflow through the same-origin service proxy', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await page.locator('button[aria-pressed]').filter({ hasText: 'Airflow' }).click(); + await page.getByLabel('Name').fill('Pipelines'); + await page.getByLabel('URL').fill('http://airflow.example.test'); + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await page.goto( + '/bookmark/' + (await page.evaluate(() => JSON.parse(localStorage.dashboard_bookmarks)[0].id)) + ); + await expect(page.locator('iframe')).toHaveAttribute('src', '/api/services/airflow/'); + }); + + test('embeds any configured product through the same-origin service proxy', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('http://superset.example.test'); + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await page.goto( + '/bookmark/' + (await page.evaluate(() => JSON.parse(localStorage.dashboard_bookmarks)[0].id)) + ); + await expect(page.locator('iframe')).toHaveAttribute('src', '/api/services/superset/'); + }); + + test('bookmark persists in localStorage', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + expect(stored).toBeTruthy(); + + const bookmarks = JSON.parse(stored!); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].name).toBe('Dashboards'); + }); + + test('pins a bookmark from the dashboard with the star button', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + // Bookmark appears in the regular section without a pinned heading + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('Pinned')).not.toBeVisible(); + + await page.getByRole('button', { name: 'Pin bookmark' }).click(); + + // Pinned section appears above and contains the bookmark + await expect(page.getByText('Pinned')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unpin bookmark' })).toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks[0].pinned).toBe(true); + + // Unpinning moves the bookmark back to the regular section + await page.getByRole('button', { name: 'Unpin bookmark' }).click(); + await expect(page.getByText('Pinned')).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Pin bookmark' })).toBeVisible(); + + const storedAfter = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarksAfter = JSON.parse(storedAfter!); + expect(bookmarksAfter[0].pinned).toBe(false); + }); + + test('pins a bookmark via the checkbox in the modal', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + await page.getByText('Pin this bookmark for all users?').click(); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await expect(page.getByText('Pinned')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unpin bookmark' })).toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks[0].pinned).toBe(true); + }); + + test('pinned state is preserved when editing a bookmark', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: addButton }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + await page.getByText('Pin this bookmark for all users?').click(); + + await page.locator('dialog[open]').getByRole('button', { name: addButton }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await expect( + page + .locator('dialog[open]') + .getByRole('checkbox', { name: 'Pin this bookmark for all users?' }) + ).toBeChecked(); + }); + + test('edits a bookmark', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Modal opens prefilled and shows edit title + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Edit Bookmark' }) + ).toBeVisible(); + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + await expect(page.getByLabel('URL')).toHaveValue('https://superset.example.com'); + + await page.getByLabel('Name').fill('Renamed Dashboard'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Save changes' }).click(); + + await expect(page.getByText('Renamed Dashboard')).toBeVisible(); + await expect(page.getByText('Dashboards')).not.toBeVisible(); + + const stored = await page.evaluate(() => localStorage.getItem('dashboard_bookmarks')); + const bookmarks = JSON.parse(stored!); + expect(bookmarks).toHaveLength(1); + expect(bookmarks[0].name).toBe('Renamed Dashboard'); + }); + + test('cancel in edit dialog closes without changes', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.getByLabel('Name').fill('Not Saved'); + await page.locator('dialog[open]').getByRole('button', { name: 'Cancel' }).click(); + + await expect(page.locator('dialog[open]')).not.toBeVisible(); + await expect(page.getByText('Dashboards')).toBeVisible(); + await expect(page.getByText('Not Saved')).not.toBeVisible(); + + // Reopening the edit modal prefills the bookmark values again + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + await expect(page.getByLabel('Name')).toHaveValue('Dashboards'); + await expect(page.getByLabel('URL')).toHaveValue('https://superset.example.com'); + }); + + test('deletes a bookmark from the edit modal with confirmation', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('button', { name: 'Add Bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + await page.locator('button[aria-pressed]').filter({ hasText: 'Superset' }).click(); + await page.getByLabel('Name').fill('Dashboards'); + await page.getByLabel('URL').fill('https://superset.example.com'); + + await page.locator('dialog[open]').getByRole('button', { name: 'Add Bookmark' }).click(); + + await page.getByRole('button', { name: 'Edit bookmark' }).click(); + await expect(page.locator('dialog[open]')).toBeVisible(); + + // Delete opens a confirmation dialog + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Delete bookmark?' }) + ).toBeVisible(); + + // Cancelling the confirmation returns to the edit dialog + await page.locator('dialog[open]').getByRole('button', { name: 'Cancel' }).click(); + await expect( + page.locator('dialog[open]').getByRole('heading', { name: 'Edit Bookmark' }) + ).toBeVisible(); + + // Delete again and confirm + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + await page.locator('dialog[open]').getByRole('button', { name: 'Delete' }).click(); + + await expect(page.getByText('Dashboards')).not.toBeVisible(); + }); +}); diff --git a/e2e/sidebar-bookmarks.spec.ts b/e2e/sidebar-bookmarks.spec.ts new file mode 100644 index 00000000..e633c951 --- /dev/null +++ b/e2e/sidebar-bookmarks.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import { waitForHydration } from './support/helpers'; + +test.describe('Sidebar bookmarks', () => { + test.use({ locale: 'en-US' }); + + const BOOKMARK_ID = 'b6d6c7b6-8f24-4c4a-9c64-4f3b7c19e6a1'; + const EMBED_URL = 'data:text/html,%3Ch1%3EEmbedded%20Tool%3C/h1%3E'; + + test.skip( + ({ viewport }) => (viewport?.width ?? 1280) < 1024, + 'Sidebar is off-canvas on small screens' + ); + + test.beforeEach(async ({ page }) => { + await page.addInitScript( + ({ bookmarkId, embedUrl }) => { + localStorage.removeItem('dashboard_bookmarks'); + localStorage.removeItem('sidebar_tools_open'); + localStorage.setItem( + 'dashboard_bookmarks', + JSON.stringify([ + { + id: bookmarkId, + productId: 'superset', + name: 'Dashboards', + environment: '', + url: embedUrl, + openIn: 'cockpit', + pinned: false, + createdAt: new Date().toISOString() + }, + { + id: 'e7d8c7b6-9f35-4d4b-8d75-5f4c8d2fa7b2', + productId: 'airflow', + name: 'Pipelines', + environment: '', + url: 'https://airflow.example.com', + openIn: 'cockpit', + pinned: true, + createdAt: new Date().toISOString() + } + ]) + ); + }, + { bookmarkId: BOOKMARK_ID, embedUrl: EMBED_URL } + ); + }); + + test('shows pinned bookmarks in Favourites and the rest in Tools', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await expect(page.getByText('Favourites')).toBeVisible(); + await expect(page.getByRole('link', { name: 'Pipelines' })).toBeVisible(); + + await expect(page.getByRole('button', { name: 'Collapse tools section' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Dashboards' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).toBeVisible(); + }); + + test('opens a bookmark inside the application as an iframe', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('link', { name: 'Dashboards' }).click(); + + await expect(page).toHaveURL(new RegExp(`/bookmark/${BOOKMARK_ID}`)); + + const frame = page.frameLocator('iframe'); + await expect(frame.getByRole('heading', { name: 'Embedded Tool' })).toBeVisible(); + }); + + test('external link button opens the bookmark URL in a new tab', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const externalLinks = page.getByRole('link', { name: 'Open in new tab' }); + await expect(externalLinks).toHaveCount(2); + + const dashboardsItem = page.getByRole('link', { name: 'Dashboards' }).locator('..'); + const dashboardsExternal = dashboardsItem.getByRole('link', { name: 'Open in new tab' }); + await expect(dashboardsExternal).toHaveAttribute('href', EMBED_URL); + await expect(dashboardsExternal).toHaveAttribute('target', '_blank'); + await expect(dashboardsExternal).toHaveAttribute('rel', /noopener/); + }); + + test('inbuilt tools have no external link button', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const trinoLink = page.getByRole('link', { name: 'Trino' }); + await expect(trinoLink).toBeVisible(); + const trinoItem = trinoLink.locator('..'); + await expect(trinoItem.getByRole('link', { name: 'Open in new tab' })).toHaveCount(0); + }); + + test('tools section is collapsible', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + const collapseButton = page.getByRole('button', { name: 'Collapse tools section' }); + await expect(collapseButton).toHaveAttribute('aria-expanded', 'true'); + + await collapseButton.click(); + await expect(page.getByRole('link', { name: 'Dashboards' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Pipelines' })).toBeVisible(); + + await page.getByRole('button', { name: 'Expand tools section' }).click(); + await expect(page.getByRole('link', { name: 'Dashboards' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Trino' })).toBeVisible(); + }); + + test('active bookmark is highlighted in the sidebar', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + await page.getByRole('link', { name: 'Dashboards' }).click(); + await expect(page).toHaveURL(new RegExp(`/bookmark/${BOOKMARK_ID}`)); + + await expect(page.getByRole('link', { name: 'Dashboards' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); +}); diff --git a/messages/de.json b/messages/de.json index c54247fe..6769b610 100644 --- a/messages/de.json +++ b/messages/de.json @@ -16,7 +16,6 @@ "dashboard_step_browse": "Kataloge durchsuchen und abfragen", "nav_platform": "Plattform", "nav_dashboard": "Dashboard", - "nav_data_tools": "Datenwerkzeuge", "nav_trino": "Trino", "nav_badge_soon": "Bald", "sidebar_label": "Seitenleiste", @@ -25,6 +24,11 @@ "sidebar_expand": "Seitenleiste ausklappen", "sidebar_collapse": "Seitenleiste einklappen", "sidebar_collapse_label": "Einklappen", + "sidebar_favourites": "Favoriten", + "sidebar_tools": "Werkzeuge", + "sidebar_tools_expand": "Werkzeugbereich ausklappen", + "sidebar_tools_collapse": "Werkzeugbereich einklappen", + "sidebar_bookmark_open_external": "In neuem Tab öffnen", "header_open_nav": "Navigation öffnen", "header_close_nav": "Navigation schließen", "header_user_menu": "Benutzermenü", @@ -369,6 +373,33 @@ "storage_upload_rename_confirm": "Mit diesem Namen hochladen", "storage_upload_retry": "Erneut versuchen", "storage_upload_close": "Schlie\u00dfen", + "bookmark_add_title": "Lesezeichen hinzufügen", + "bookmark_edit_title": "Lesezeichen bearbeiten", + "bookmark_save_changes": "Änderungen speichern", + "bookmark_product_label": "Produkt", + "bookmark_open_in_label": "Öffnen in", + "bookmark_open_in_cockpit": "Im Cockpit", + "bookmark_open_in_cockpit_desc": "Lesezeichen innerhalb der Anwendung öffnen", + "bookmark_open_in_new_tab": "Neuem Tab", + "bookmark_open_in_new_tab_desc": "Lesezeichen in einem neuen Browser-Tab öffnen", + "bookmark_name_label": "Name", + "bookmark_env_label": "Umgebung (optional)", + "bookmark_env_placeholder": "z.B. dev, prod", + "bookmark_url_label": "URL", + "bookmark_pinned_label": "Angeheftet", + "bookmark_pin_checkbox_label": "Dieses Lesezeichen für alle Benutzer anheften?", + "bookmark_pin_label": "Lesezeichen anheften", + "bookmark_unpin_label": "Lesezeichen lösen", + "bookmark_preview_label": "Vorschau", + "bookmark_section_title": "Lesezeichen", + "bookmark_edit_label": "Lesezeichen bearbeiten", + "bookmark_not_found_title": "Lesezeichen nicht gefunden", + "bookmark_not_found_message": "Dieses Lesezeichen existiert nicht mehr. Fügen Sie es über das Dashboard wieder hinzu, um es hier einzubetten.", + "bookmark_delete_confirm_title": "Lesezeichen löschen?", + "bookmark_delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.", + "button_delete": "Löschen", + "button_cancel": "Abbrechen", + "button_close": "Schließen", "timestamp_just_now": "gerade eben", "timestamp_minutes_ago": [ { diff --git a/messages/en.json b/messages/en.json index 55907765..7025602e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -16,7 +16,6 @@ "dashboard_step_browse": "Browse catalogs and query", "nav_platform": "Platform", "nav_dashboard": "Dashboard", - "nav_data_tools": "Data Tools", "nav_trino": "Trino", "nav_badge_soon": "Soon", "sidebar_label": "Sidebar", @@ -25,6 +24,11 @@ "sidebar_expand": "Expand sidebar", "sidebar_collapse": "Collapse sidebar", "sidebar_collapse_label": "Collapse", + "sidebar_favourites": "Favourites", + "sidebar_tools": "Tools", + "sidebar_tools_expand": "Expand tools section", + "sidebar_tools_collapse": "Collapse tools section", + "sidebar_bookmark_open_external": "Open in new tab", "header_open_nav": "Open navigation", "header_close_nav": "Close navigation", "header_user_menu": "User menu", @@ -369,6 +373,33 @@ "storage_upload_rename_confirm": "Upload with this name", "storage_upload_retry": "Retry", "storage_upload_close": "Close", + "bookmark_add_title": "Add Bookmark", + "bookmark_edit_title": "Edit Bookmark", + "bookmark_save_changes": "Save changes", + "bookmark_product_label": "Product", + "bookmark_open_in_label": "Open in", + "bookmark_open_in_cockpit": "Inside Cockpit", + "bookmark_open_in_cockpit_desc": "Open the bookmark within the application interface", + "bookmark_open_in_new_tab": "New Tab", + "bookmark_open_in_new_tab_desc": "Open the bookmark in a new browser tab", + "bookmark_name_label": "Name", + "bookmark_env_label": "Environment (optional)", + "bookmark_env_placeholder": "e.g. dev, prod", + "bookmark_url_label": "URL", + "bookmark_pinned_label": "Pinned", + "bookmark_pin_checkbox_label": "Pin this bookmark for all users?", + "bookmark_pin_label": "Pin bookmark", + "bookmark_unpin_label": "Unpin bookmark", + "bookmark_preview_label": "Preview", + "bookmark_section_title": "Bookmarks", + "bookmark_edit_label": "Edit bookmark", + "bookmark_not_found_title": "Bookmark not found", + "bookmark_not_found_message": "This bookmark no longer exists. Add it again from the dashboard to embed it here.", + "bookmark_delete_confirm_title": "Delete bookmark?", + "bookmark_delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.", + "button_delete": "Delete", + "button_cancel": "Cancel", + "button_close": "Close", "timestamp_just_now": "just now", "timestamp_minutes_ago": [ { diff --git a/package-lock.json b/package-lock.json index abeb948d..a94489bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", @@ -1682,6 +1683,544 @@ "import-meta-resolve": "^4.2.0" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inlang/paraglide-js": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.12.0.tgz", @@ -2917,6 +3456,25 @@ "@sveltejs/kit": "^2.4.0" } }, + "node_modules/@sveltejs/enhanced-img": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.11.0.tgz", + "integrity": "sha512-TN7VzGoqwFqvA4Faj1brUPHLtcEkyFa08nrn9bfm98yVrzr0g4bOZHjlBoPYHHX+sZNGE+cxznhTVgarECPQsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "sharp": "^0.34.1", + "svelte-parse-markup": "^0.1.5", + "vite-imagetools": "^9.0.3", + "zimmerframe": "^1.1.2" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0 || ^7.0.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || >=7.0.0" + } + }, "node_modules/@sveltejs/kit": { "version": "2.53.2", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.2.tgz", @@ -6695,6 +7253,16 @@ "node": ">= 4" } }, + "node_modules/imagetools-core": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/imagetools-core/-/imagetools-core-9.1.0.tgz", + "integrity": "sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -9679,6 +10247,51 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10373,6 +10986,19 @@ } } }, + "node_modules/svelte-parse-markup": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/svelte-parse-markup/-/svelte-parse-markup-0.1.5.tgz", + "integrity": "sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://bjornlu.com/sponsor" + }, + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, "node_modules/svelte/node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -11177,6 +11803,21 @@ } } }, + "node_modules/vite-imagetools": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/vite-imagetools/-/vite-imagetools-9.0.3.tgz", + "integrity": "sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.5", + "imagetools-core": "^9.1.0", + "sharp": "^0.34.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/vite/node_modules/@esbuild/linux-x64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", diff --git a/package.json b/package.json index f9393ffe..2a30d22c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@inlang/paraglide-js": "^2.12.0", "@playwright/test": "^1.58.2", "@sveltejs/adapter-node": "^5.5.3", + "@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/kit": "^2.53.0", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.2.1", diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte b/src/lib/components/dashboard/AddBookmarkModal.svelte new file mode 100644 index 00000000..22c1fc41 --- /dev/null +++ b/src/lib/components/dashboard/AddBookmarkModal.svelte @@ -0,0 +1,400 @@ + + + + + + + + + diff --git a/src/lib/components/layout/sidebar/Sidebar.svelte b/src/lib/components/layout/sidebar/Sidebar.svelte index 2ae05a16..b29548c9 100644 --- a/src/lib/components/layout/sidebar/Sidebar.svelte +++ b/src/lib/components/layout/sidebar/Sidebar.svelte @@ -1,11 +1,17 @@ @@ -45,6 +83,31 @@