From 8c2fa1817edd80924cbb4ae3002e80659b78a80d Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 22:37:09 +0300 Subject: [PATCH 01/16] docs: design spec for multi-page studio (every section a URL; docker-compose in Explorer) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-23-multipage-studio-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/designs/2026-06-23-multipage-studio-design.md diff --git a/docs/designs/2026-06-23-multipage-studio-design.md b/docs/designs/2026-06-23-multipage-studio-design.md new file mode 100644 index 0000000..5b82d9d --- /dev/null +++ b/docs/designs/2026-06-23-multipage-studio-design.md @@ -0,0 +1,149 @@ +# Multi-Page Studio — every section is its own indexable URL + +> Builds on the deploy-unify work (PR #5): `StudioShell.astro` + standalone mode +> already exist and `/deploy` already renders a single section in the shell. +> This generalizes that pattern to the WHOLE site: each Explorer "table" becomes +> its own real, indexable page; the Explorer left-menu navigates by URL; the +> homepage `#hash` in-page swap is removed. Smooth client-side transitions via +> Astro View Transitions. + +## Decisions (locked with user, 2026-06-23) +- **Homepage model A**: `/` = home/hero only. Every other section is its own page. + No single-scroll landing. +- Explorer left-menu links are **real URLs** (`/features`, `/`, `/deploy`), not + `/#id`. Clicking navigates (with View Transitions for SPA-like smoothness). +- **In-page hash swap is removed entirely** (simpler `studio.ts`). +- **`/docker-compose-example` joins the studio** as the 9th Explorer table and a + real studio page (user: "this is important — it must be in the left menu too"). +- **View Transitions** (`astro:transitions` ``) added for smooth + navigation; chrome persists, the result pane animates. +- Out of scope (stay standalone, NOT Explorer tables): `/privacy-policy`, `404`. + They keep Header/Footer for now. + +## 1. Routing & slugs +| URL | Page source | Active section | +|---|---|---| +| `/` | `index.astro` | home (hero) | +| `/features` | `[section].astro` | features | +| `/databases` | `[section].astro` | databases | +| `/compare` | `[section].astro` | compare | +| `/tech-stack` | `[section].astro` | tech_stack | +| `/get-started` | `[section].astro` | get_started | +| `/faq` | `[section].astro` | faq | +| `/deploy` | `[section].astro` | deploy | +| `/docker-compose-example` | `[section].astro` | docker_compose | + +- **One dynamic route** `src/pages/[section].astro` with `getStaticPaths()` + emitting the 8 non-home slugs; it maps slug→Section component and renders + `` + ``. +- `index.astro` keeps `/` (home). `deploy.astro` and `docker-compose-example.astro` + are **deleted** (replaced by the dynamic route + section components). +- **Slug field** added to `SectionMeta`: `slug` (home→`''`→`/`; underscores→hyphens; + docker_compose→`docker-compose-example` to preserve the indexed URL). +- `getStaticPaths` returns `sections.filter(s => s.id !== 'home').map(s => ({ params: { section: s.slug }, props: { id: s.id } }))`. + +## 2. The 9-table Explorer manifest (`src/data/sections.ts`) +Existing 8 entries gain `slug`, `pageTitle`, `pageDescription`. A 9th entry is added: +``` +docker_compose: + table: 'docker_compose' slug: 'docker-compose-example' + query: 'SELECT variable, default, description FROM env_vars;' + columns: [variable VARCHAR, default VARCHAR, description TEXT] rows: 21 cols: 3 execMs: 6 + explain: 'A copy-paste docker-compose.yml: pulls the published image, with every + env var (auth, OIDC SSO, storage, AI/LLM, seed) — self-host in one command.' + pageTitle: 'LibreDB Studio Docker Compose Example — Self-Host in Minutes' + pageDescription: (existing description, verbatim) +``` +(Per-section `pageTitle`/`pageDescription` drive each page's ``/meta. Home's +page title stays the current homepage title.) + +Optional per-section structured data: a small map (in the manifest or a sibling +`section-seo.ts`) of `id → JSON-LD object[]`: +- `deploy` → the ItemList schema (moved out of the deleted deploy.astro). +- `docker_compose` → the HowTo + SourceCode schemas (moved out of the deleted page). +The dynamic route injects these into `<head>` via the Layout `head` slot. + +## 3. Shell, Explorer, and the removal of in-page swap +- **Explorer/MobileTopBar**: section links become real URLs `href={s.slug === '' ? '/' : '/' + s.slug}`. The `standalone` prop is replaced by this URL model (every studio page links by URL now). `active` (server-rendered per page) sets the highlight. +- **Every studio page renders ONE section**, server-marked active and always shown. + The `.studio.js .studio-section { display:none unless .is-active }` swap rule is + removed; a single section simply fills the pane (`flex-col h-full`, results scroll). +- **`StudioShell`**: keeps `active`; `standalone` becomes the default/only mode + (single-section). It renders the chrome + one section slot. `data-initial-active` + retained for `studio.ts`. + +## 4. `studio.ts` simplification +Remove: `setActive` section-swap toggling, hash routing (`hashchange`/`popstate`), +`onLinkClick` swap, the `is-active` juggling. Section links are plain `<a href>` +navigations. Keep + adapt for View Transitions (`astro:page-load`): +- Command palette (open/close/keyboard) — "Jump to {section}" → `location.href = '/'+slug` (with `''`→`/`). +- Explorer search filter + Enter → `location.href = '/'+firstMatch.slug`. +- Console toasts; RUN shimmer + toast; Copy deep link (now copies `origin + '/' + slug`); Export; Explain toggle; column expand; mobile drawer. +- Active-row highlight: set on `astro:page-load` from `location.pathname` (since chrome persists across transitions). +Net: the file shrinks substantially (no swap/hash engine). + +## 5. View Transitions +- Add `<ClientRouter />` (`astro:transitions`) in `Layout.astro` `<head>`. +- Mark persistent chrome with `transition:persist`: TopBar, the Explorer sidebar, + StatusBar, Console, CommandPalette (so they don't flicker; the result pane + cross-fades). The `<main class="studio-pane">` content transitions. +- Re-run interaction wiring on `astro:page-load` (studio.ts subscribes to it instead + of only `DOMContentLoaded`). Respect `prefers-reduced-motion` (Astro VT already does; + keep our shimmer guard). + +## 6. Content extraction (single source; mostly already components) +- home/features/databases/compare/tech_stack/get_started/faq → already section + components; reused by the dynamic route. No content rewrite. +- deploy → `DeploySection.astro` already the single rich source (PR #5). Reused. +- **`DockerComposeSection.astro` (NEW)** — single source of the docker-compose + content lifted from the deleted page: quick-start block + copy button, the full + `docker-compose.example.yml` via `<Code lang="yaml">`, the 5 env-var reference + tables, the links row, and the `.copy-btn` client `<script>` + the `.compose-code` + scoped style. Imports `composeYaml` from `../../data/docker-compose.example.yml?raw`. + +## 7. Internal links updated to real URLs +- Footer "Product" (`/#features` → `/features`, etc.; Docker Compose link → + `/docker-compose-example`), HomeSection CTAs, get_started pointer (`/#…` → `/…`), + Header nav (used by the remaining standalone pages) → `/features` etc. +- Hash anchors were never separately indexed, so no redirects needed. (Optional: + none required.) + +## 8. Files +``` +src/data/sections.ts MOD + slug/pageTitle/pageDescription on 8; + docker_compose (9th) +src/data/section-seo.ts NEW id → JSON-LD[] (deploy ItemList; docker_compose HowTo+SourceCode) +src/pages/index.astro MOD StudioShell active=home + HomeSection (home page) + home SEO +src/pages/[section].astro NEW dynamic route: getStaticPaths over non-home slugs; slug→component; per-section SEO + head-slot JSON-LD +src/pages/deploy.astro DEL (replaced by [section].astro) +src/pages/docker-compose-example.astro DEL (replaced by [section].astro + DockerComposeSection) +src/components/sections/DockerComposeSection.astro NEW single-source compose content (+ copy script + style) +src/components/studio/StudioShell.astro MOD single-section mode; real-URL link model +src/components/studio/Explorer.astro MOD links = real URLs by slug; active highlight +src/components/studio/MobileTopBar.astro MOD same link model +src/scripts/studio.ts MOD remove swap/hash engine; palette/search navigate by URL; astro:page-load lifecycle; active-row sync +src/styles/global.css MOD remove swap display rule; single-section always-shown +src/layouts/Layout.astro MOD add <ClientRouter/>; keep head slot +src/components/Footer.astro / Header.astro MOD internal links → real section URLs +``` + +## 9. SEO +- 9 indexable URLs, each with its own `<title>`/description/self-canonical → far + better coverage than today's `/` + `/deploy`. Astro sitemap auto-includes all routes. +- `/deploy` ItemList and `/docker-compose-example` HowTo+SourceCode JSON-LD preserved + via the per-section SEO map + head slot. Existing global JSON-LD stays on every page. +- `/deploy` and `/docker-compose-example` keep their exact current URLs → indexing continuity. + +## 10. Out of scope +`/privacy-policy`, `404` remain standalone Header/Footer pages (not Explorer tables). +A later pass could fold them into the shell, but they are not "tables". + +## 11. Success criteria +- 9 real pages build; Explorer shows 9 tables; clicking any navigates by URL. +- `/`, `/deploy`, `/docker-compose-example` keep their URLs + structured data. +- No in-page hash swap remains; `studio.ts` has no setActive/hashchange engine. +- View Transitions: navigating between sections animates; chrome doesn't flicker; + reduced-motion respected. Studio interactions (palette/search/run/copy/export/ + explain/toasts/drawer) work after every navigation. +- `bunx astro build` passes; `bun test` green; deploy/docker-compose content each + authored in exactly one source file. +``` From de925628da5bcb8cb96ddc0272e74f7b3d99940c Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 22:50:52 +0300 Subject: [PATCH 02/16] docs: implementation plan for multi-page studio (9 tasks) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../plans/2026-06-23-multipage-studio.md | 647 ++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-multipage-studio.md diff --git a/docs/superpowers/plans/2026-06-23-multipage-studio.md b/docs/superpowers/plans/2026-06-23-multipage-studio.md new file mode 100644 index 0000000..b59b9b5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-multipage-studio.md @@ -0,0 +1,647 @@ +# Multi-Page Studio Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn each Explorer "table" into its own real, indexable URL (incl. `/docker-compose-example` as a 9th table), navigated via Astro View Transitions, removing the in-page hash swap — while keeping the IDE shell and all interactions identical. + +**Architecture:** Builds on `StudioShell.astro` (single-section shell) from the deploy-unify branch. A single dynamic route `src/pages/[section].astro` generates every non-home section page from a slug manifest; `index.astro` is `/` (home). Explorer links become real URLs. `studio.ts` drops the swap/hash engine and re-wires per navigation via `astro:page-load`. `<ClientRouter/>` + `transition:persist` give SPA-smooth navigation with persistent chrome. + +**Tech Stack:** Astro 6 (`astro:transitions` ClientRouter, `getStaticPaths`, `astro:components` `Code`), Tailwind v4, TypeScript, `bun:test`. Build: `bunx astro build` (NOT `bun run build`). Dev: `bun run dev` → :4321. + +## Global Constraints +- Homepage model A: `/` = home/hero only; every other section is its own page. No single-scroll landing. +- Explorer/MobileTopBar section links are REAL URLs (`/`, `/features`, `/deploy`, `/docker-compose-example`), never `/#id`. +- In-page hash swap REMOVED: no `setActive` section-toggling, no `hashchange`/`popstate` engine in `studio.ts`. +- `/deploy` and `/docker-compose-example` keep their EXACT current URLs + their structured data (deploy ItemList; docker-compose HowTo + SourceCode) for SEO continuity. +- Single source: deploy content only in `DeploySection.astro`; docker-compose content only in `DockerComposeSection.astro`. +- Design tokens only (no raw hex). `text-white` on `bg-primary` buttons is an accepted project-wide convention. +- View Transitions: chrome (TopBar, Explorer, StatusBar, Console, CommandPalette) uses `transition:persist`; respect `prefers-reduced-motion`. +- Out of scope (stay standalone, keep Header/Footer): `/privacy-policy`, `404`. +- Slugs: home→`''`(`/`), features→`features`, databases→`databases`, compare→`compare`, tech_stack→`tech-stack`, get_started→`get-started`, faq→`faq`, deploy→`deploy`, docker_compose→`docker-compose-example`. +- Verify each task with `bunx astro build` (must pass) + stated checks. + +## File Structure +``` +src/data/sections.ts MOD + slug/pageTitle/pageDescription on 8; + docker_compose (9th) (Task 1) +src/data/sections.test.ts NEW slug invariants (Task 1) +src/data/section-seo.ts NEW id → JSON-LD[] (deploy, docker_compose) (Task 1) +src/components/sections/DockerComposeSection.astro NEW single-source compose content (Task 2) +src/pages/[section].astro NEW dynamic route for the 8 non-home sections (Task 3) +src/pages/deploy.astro DEL (Task 3, with route) +src/pages/docker-compose-example.astro DEL (Task 3, with route) +src/pages/index.astro MOD home-only single section + home SEO (Task 4) +src/components/studio/Explorer.astro MOD real-URL links (Task 5) +src/components/studio/MobileTopBar.astro MOD forward URL link model (Task 5) +src/scripts/studio.ts MOD drop swap/hash; URL nav; astro:page-load lifecycle; active sync (Task 6) +src/styles/global.css MOD remove swap display rule; single-section always shown (Task 6) +src/layouts/Layout.astro MOD add <ClientRouter/> (Task 7) +src/components/studio/{StudioShell,TopBar,StatusBar,Explorer,Console,CommandPalette}.astro MOD transition:persist (Task 7) +src/components/Footer.astro, Header.astro MOD internal links → real URLs (Task 8) +``` + +--- + +### Task 1: Slug + SEO manifest, docker_compose table, section-seo + +**Files:** +- Modify: `src/data/sections.ts` +- Create: `src/data/section-seo.ts` +- Test: `src/data/sections.test.ts` + +**Interfaces:** +- Produces: `SectionMeta` gains `slug: string`, `pageTitle: string`, `pageDescription: string`. A 9th section `docker_compose` is added. `sectionSeo: Record<string, object[]>` exports per-section JSON-LD arrays. + +- [ ] **Step 1: Write the failing test** + +```ts +// src/data/sections.test.ts +import { test, expect } from 'bun:test'; +import { sections, sectionById } from './sections'; + +test('every section has a unique slug; home is empty', () => { + const slugs = sections.map((s) => s.slug); + expect(new Set(slugs).size).toBe(slugs.length); + expect(sectionById['home'].slug).toBe(''); + expect(sectionById['tech_stack'].slug).toBe('tech-stack'); + expect(sectionById['get_started'].slug).toBe('get-started'); + expect(sectionById['docker_compose'].slug).toBe('docker-compose-example'); +}); + +test('docker_compose table exists with page SEO', () => { + const d = sectionById['docker_compose']; + expect(d).toBeDefined(); + expect(d.pageTitle.length).toBeGreaterThan(0); + expect(d.pageDescription.length).toBeGreaterThan(0); +}); + +test('every section has page SEO fields', () => { + for (const s of sections) { + expect(typeof s.slug).toBe('string'); + expect(s.pageTitle.length).toBeGreaterThan(0); + expect(s.pageDescription.length).toBeGreaterThan(0); + } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/data/sections.test.ts` +Expected: FAIL (slug/pageTitle undefined; docker_compose missing). + +- [ ] **Step 3: Extend `SectionMeta` and every section** + +In `src/data/sections.ts`, add to the `SectionMeta` interface: +```ts + slug: string; // URL slug ('' = home at '/') + pageTitle: string; // <title> when this section is its own page + pageDescription: string; +``` +Add these three fields to each existing section (verbatim values): +``` +home: slug: '', pageTitle: 'LibreDB Studio - AI-Powered Open-Source SQL IDE', + pageDescription: 'LibreDB Studio - The Modern, AI-Powered Open-Source SQL IDE for Cloud-Native Teams' +features: slug: 'features', pageTitle: 'Features — LibreDB Studio SQL IDE', + pageDescription: 'Everything you need to master your data: Monaco SQL editor, NL2SQL Copilot, AI query safety, 7+ databases, pro data grid, visual EXPLAIN, ER diagrams, data masking, SSO and more.' +databases: slug: 'databases', pageTitle: 'Supported Databases — PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Redis', + pageDescription: 'One tool, all your databases. Connect to PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB and Redis through one unified browser-based SQL IDE.' +compare: slug: 'compare', pageTitle: 'How LibreDB Studio Compares — vs DataGrip, DBeaver, pgAdmin, TablePlus', + pageDescription: 'See why teams switch to LibreDB Studio: zero-install, mobile, AI-native, SSO and free — compared against DataGrip, DBeaver, pgAdmin and TablePlus.' +tech_stack: slug: 'tech-stack', pageTitle: 'Tech Stack — LibreDB Studio', + pageDescription: 'Built with a modern, production-ready stack: Next.js 16, React 19, TypeScript, Tailwind 4, Monaco, TanStack Table, ReactFlow, Gemini/OIDC, Docker and Bun.' +get_started: slug: 'get-started', pageTitle: 'Get Started in Minutes — LibreDB Studio', + pageDescription: 'Run LibreDB Studio locally in three steps — clone & install, configure, launch — or one-command Docker. Self-host the open-source AI SQL IDE.' +faq: slug: 'faq', pageTitle: 'FAQ — LibreDB Studio', + pageDescription: 'Frequently asked questions about LibreDB Studio: pricing, self-hosting, AI providers, security & SSO, supported databases, and how it compares to legacy tools.' +deploy: slug: 'deploy', pageTitle: 'Deploy LibreDB Studio Anywhere — One-Click Apps, Helm, Docker & Cloud', + pageDescription: 'Run the open-source LibreDB Studio SQL IDE anywhere: official Railway and CapRover one-click apps, Docker Hub & GHCR images, a Helm chart on Artifact Hub, npm, and every major open-source PaaS, managed PaaS, and cloud.' +``` +Add the 9th section to the `sections` array (after `deploy`): +```ts + { + id: 'docker_compose', + table: 'docker_compose', + slug: 'docker-compose-example', + query: 'SELECT variable, default, description FROM env_vars;', + rows: 21, + cols: 3, + execMs: 6, + columns: [ + { name: 'variable', type: 'VARCHAR' }, + { name: 'default', type: 'VARCHAR' }, + { name: 'description', type: 'TEXT' }, + ], + explain: 'A copy-paste docker-compose.yml: pulls the published ghcr.io image with every environment variable (auth, OIDC SSO, storage, AI/LLM, seed) — self-host in one command.', + pageTitle: 'LibreDB Studio Docker Compose Example — Self-Host in Minutes', + pageDescription: 'Copy-paste docker-compose.example.yml for LibreDB Studio. Run the open-source SQL IDE with one command using the ghcr.io/libredb/libredb-studio image. Includes every environment variable, SQLite/PostgreSQL storage, and OIDC SSO options.', + }, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/data/sections.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create `section-seo.ts`** + +```ts +// src/data/section-seo.ts +// Per-section structured data injected into <head> by the dynamic route. +import { deployTargets } from './deploy-targets'; + +const SITE = 'https://libredb.org'; +const REPO = 'https://github.com/libredb/libredb-studio'; +const rawFileURL = `${SITE}/docker-compose.example.yml`; + +export const sectionSeo: Record<string, object[]> = { + deploy: [ + { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: 'LibreDB Studio deployment targets', + about: { '@id': 'https://libredb.org/#application' }, + itemListElement: deployTargets.map((t, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: t.name, + url: t.deployUrl ?? t.docsUrl ?? t.url, + })), + }, + ], + docker_compose: [ + { + '@context': 'https://schema.org', + '@type': 'HowTo', + name: 'Run LibreDB Studio with Docker Compose', + description: 'Self-host the open-source LibreDB Studio SQL IDE using Docker Compose.', + totalTime: 'PT5M', + tool: [{ '@type': 'HowToTool', name: 'Docker' }, { '@type': 'HowToTool', name: 'Docker Compose' }], + step: [ + { '@type': 'HowToStep', position: 1, name: 'Download the compose file', text: 'Download docker-compose.example.yml and rename it to docker-compose.yml.', url: `${SITE}/docker-compose-example/` }, + { '@type': 'HowToStep', position: 2, name: 'Configure environment', text: 'Set JWT_SECRET (min 32 chars), ADMIN_PASSWORD and USER_PASSWORD in your .env file.', url: `${SITE}/docker-compose-example/` }, + { '@type': 'HowToStep', position: 3, name: 'Start the container', text: 'Run "docker compose up -d" and open http://localhost:3000.', url: `${SITE}/docker-compose-example/` }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'SoftwareSourceCode', + name: 'docker-compose.example.yml', + description: 'Ready-to-use Docker Compose configuration for LibreDB Studio.', + programmingLanguage: 'YAML', + codeRepository: REPO, + url: rawFileURL, + license: 'https://opensource.org/licenses/MIT', + about: { '@id': 'https://libredb.org/#application' }, + }, + ], +}; +``` + +- [ ] **Step 6: Build + commit** + +Run: `bunx astro build` → PASS (5 pages). `bun test src/data/` → all pass. +```bash +git add src/data/sections.ts src/data/sections.test.ts src/data/section-seo.ts +git commit -m "feat: section slugs + page SEO, docker_compose table, per-section JSON-LD" +``` + +--- + +### Task 2: `DockerComposeSection.astro` (single source) + +**Files:** +- Create: `src/components/sections/DockerComposeSection.astro` + +**Interfaces:** +- Produces: a self-contained section component rendering the docker-compose content (quick-start + copy, full yml via `<Code>`, 5 env-var tables, links row, copy `<script>`, `.compose-code` style). No props. + +- [ ] **Step 1: Create the component by lifting the existing page body** + +Create `src/components/sections/DockerComposeSection.astro`. Move the content from the current `src/pages/docker-compose-example.astro` into it, with these exact adaptations: +- Frontmatter imports: keep `import { Code } from 'astro:components';` and change the raw import path to `import composeYaml from '../../data/docker-compose.example.yml?raw';` (one level deeper than the page). Keep the `siteURL`, `rawFileURL`, `repoURL`, `exampleFileURL`, `issueURL`, `quickStart`, and `envGroups` consts EXACTLY as in the current page. DROP the `title`, `description`, `howToSchema`, `sourceCodeSchema` consts (these move to `section-seo.ts`/`[section].astro`, Task 1/3) and DROP the `Layout`/`Header`/`Footer` imports. +- Body: take everything currently inside `<main class="pt-24 pb-16"><div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> … </div></main>` EXCEPT the breadcrumb `<nav>` and the `<h1>`/intro `<p>` (the page `<h1>` is replaced by a `SectionHeader`). Wrap the remaining sections (Quick start, full file, env reference, links) in `<div class="mx-auto max-w-4xl">` and precede them with: + ```astro + import SectionHeader from './SectionHeader.astro'; + ... + <SectionHeader title="Docker Compose example" subtitle="One command to self-host LibreDB Studio — pulls the published ghcr.io image, with every environment variable." /> + ``` +- Keep the `.copy-btn` client `<script>` and the `<style>` block (`.compose-code :global(pre) {…}`) VERBATIM at the end of the component. +- Result: the component renders identically to the old page's content area, minus the page chrome (Layout/Header/Footer/breadcrumb/H1), which the shell + SectionHeader now provide. + +- [ ] **Step 2: Build verify** + +Run: `bunx astro build` → PASS (component unused yet, just compiles). Confirm via grep: `grep -c "Code code={composeYaml}" src/components/sections/DockerComposeSection.astro` → 1; `grep -c "copy-btn" src/components/sections/DockerComposeSection.astro` → present. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/sections/DockerComposeSection.astro +git commit -m "feat: DockerComposeSection single-source content (lifted from the page)" +``` + +--- + +### Task 3: Dynamic `[section].astro` route + delete old pages + +**Files:** +- Create: `src/pages/[section].astro` +- Delete: `src/pages/deploy.astro`, `src/pages/docker-compose-example.astro` + +**Interfaces:** +- Consumes: `sections`/`sectionById` (Task 1), `sectionSeo` (Task 1), all section components, `StudioShell`, `SectionShell`. +- Produces: static pages at every non-home slug. + +- [ ] **Step 1: Create the dynamic route** + +```astro +--- +// src/pages/[section].astro +import Layout from '../layouts/Layout.astro'; +import StudioShell from '../components/studio/StudioShell.astro'; +import SectionShell from '../components/studio/SectionShell.astro'; +import { sections, sectionById } from '../data/sections'; +import { sectionSeo } from '../data/section-seo'; + +import FeaturesSection from '../components/sections/FeaturesSection.astro'; +import DatabasesSection from '../components/sections/DatabasesSection.astro'; +import CompareSection from '../components/sections/CompareSection.astro'; +import TechStackSection from '../components/sections/TechStackSection.astro'; +import GetStartedSection from '../components/sections/GetStartedSection.astro'; +import FaqSection from '../components/sections/FaqSection.astro'; +import DeploySection from '../components/sections/DeploySection.astro'; +import DockerComposeSection from '../components/sections/DockerComposeSection.astro'; + +export function getStaticPaths() { + return sections + .filter((s) => s.id !== 'home') + .map((s) => ({ params: { section: s.slug }, props: { id: s.id } })); +} + +const COMPONENTS: Record<string, any> = { + features: FeaturesSection, + databases: DatabasesSection, + compare: CompareSection, + tech_stack: TechStackSection, + get_started: GetStartedSection, + faq: FaqSection, + deploy: DeploySection, + docker_compose: DockerComposeSection, +}; + +const { id } = Astro.props as { id: string }; +const meta = sectionById[id]; +const Body = COMPONENTS[id]; +const jsonLd = sectionSeo[id] ?? []; +--- +<Layout title={meta.pageTitle} description={meta.pageDescription}> + {jsonLd.map((schema) => ( + <script slot="head" is:inline type="application/ld+json" set:html={JSON.stringify(schema)} /> + ))} + <StudioShell active={id} standalone> + <SectionShell section={meta} active><Body /></SectionShell> + </StudioShell> +</Layout> +``` + +- [ ] **Step 2: Delete the old standalone pages** + +```bash +git rm src/pages/deploy.astro src/pages/docker-compose-example.astro +``` +(They are replaced by the dynamic route, which generates `/deploy` and `/docker-compose-example` from their slugs — avoids a duplicate-route build error.) + +- [ ] **Step 3: Build verify (all section pages generated)** + +Run: `bunx astro build` +Expected: PASS. Confirm all section pages emit: +```bash +for p in features databases compare tech-stack get-started faq deploy docker-compose-example; do test -f "dist/$p/index.html" && echo "ok $p" || echo "MISSING $p"; done +``` +Expected: all `ok`. Confirm `/docker-compose-example` still carries its JSON-LD: `grep -o 'application/ld+json' dist/docker-compose-example/index.html | wc -l` → 5 (3 global + HowTo + SourceCode); `/deploy` → 4. + +- [ ] **Step 4: Commit** + +```bash +git add src/pages/[section].astro +git commit -m "feat: dynamic [section] route generates every section page; drop deploy.astro & docker-compose-example.astro" +``` + +--- + +### Task 4: `index.astro` = home-only single section + +**Files:** +- Modify: `src/pages/index.astro` + +**Interfaces:** +- Consumes: `StudioShell`, `SectionShell`, `HomeSection`, `sectionById`. + +- [ ] **Step 1: Rewrite index.astro to render only the home section** + +Replace the entire body of `src/pages/index.astro`: +```astro +--- +import Layout from '../layouts/Layout.astro'; +import StudioShell from '../components/studio/StudioShell.astro'; +import SectionShell from '../components/studio/SectionShell.astro'; +import HomeSection from '../components/sections/HomeSection.astro'; +import { sectionById } from '../data/sections'; +const home = sectionById['home']; +--- +<Layout title={home.pageTitle} description={home.pageDescription}> + <StudioShell active="home" standalone> + <SectionShell section={home} active><HomeSection /></SectionShell> + </StudioShell> +</Layout> +``` +(`/` now renders only home/hero inside the shell — the other 7 SectionShells are gone; they live at their own URLs.) + +- [ ] **Step 2: Build + browser verify** + +Run: `bunx astro build` → PASS. +In dev, `/` shows the shell with home active and ONLY the home section in the pane (no features/compare/etc. stacked). Explorer still lists all 9 tables. Confirm: +```js +document.querySelectorAll('[data-section]').length // 1 (only home) +document.querySelector('.studio-section')?.id // "home" +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/pages/index.astro +git commit -m "feat: / renders home-only in the studio shell" +``` + +--- + +### Task 5: Explorer + MobileTopBar real-URL links + +**Files:** +- Modify: `src/components/studio/Explorer.astro` +- Modify: `src/components/studio/MobileTopBar.astro` + +**Interfaces:** +- Consumes: `sections` (each has `slug`). +- Produces: Explorer section links are real URLs (`/` or `/{slug}`); `data-section-link={s.id}` retained for studio.ts (search/active). + +- [ ] **Step 1: Explorer links → real URLs** + +In `src/components/studio/Explorer.astro`, the section row currently builds `href={`${linkBase}#${s.id}`}`. Replace the link href with a slug-based real URL and drop the `linkBase`/`standalone` hash logic: +```astro + href={s.slug === '' ? '/' : `/${s.slug}`} +``` +Remove the now-unused `standalone`/`linkBase` const lines from the frontmatter (the `standalone` prop may remain accepted but is no longer used for href; if present, leave the prop declaration but delete the `linkBase` derivation and hash usage). Keep `data-section-link={s.id}` and the active-highlight markup. + +- [ ] **Step 2: MobileTopBar — no hash forwarding needed** + +In `src/components/studio/MobileTopBar.astro`, the drawer `<Explorer>` call no longer needs `standalone` for links (Explorer now always uses real URLs). Leave `active={active}` and `idPrefix="drawer"`; the `standalone` prop pass-through can stay or be removed (no effect on links now). + +- [ ] **Step 3: Build + browser verify** + +Run: `bunx astro build` → PASS. +In dev on `/`, Explorer links are real URLs: +```js +document.querySelector('[data-section-link="compare"]').getAttribute('href') // "/compare" +document.querySelector('[data-section-link="home"]').getAttribute('href') // "/" +document.querySelector('[data-section-link="docker_compose"]').getAttribute('href') // "/docker-compose-example" +``` +Clicking `compare` navigates to `/compare` (full nav for now; View Transitions added in Task 7). + +- [ ] **Step 4: Commit** + +```bash +git add src/components/studio/Explorer.astro src/components/studio/MobileTopBar.astro +git commit -m "feat: Explorer navigates by real section URLs" +``` + +--- + +### Task 6: `studio.ts` — drop swap engine, URL nav, page-load lifecycle + +**Files:** +- Modify: `src/scripts/studio.ts` +- Modify: `src/styles/global.css` + +**Interfaces:** +- Consumes: `sections` (slug↔id), DOM with one section per page. +- Produces: interaction layer that works per page and across View-Transition navigations. + +- [ ] **Step 1: Remove the swap/hash engine and switch to URL navigation** + +Edit `src/scripts/studio.ts`: +- DELETE `setActive`'s section-toggling responsibility, the `hashchange`/`popstate` listeners, the `onLinkClick` swap path, and `currentHash`-based activation. Section links are now plain `<a href="/slug">`; do not intercept them (no `data-section-link` click handler that preventDefaults). +- Build a slug↔id map from `sections`: `const slugToId = Object.fromEntries(sections.map(s => [s.slug, s.id]));` +- Add `currentId()`: derive the active id from the URL path: + ```ts + function currentId(): string { + const seg = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] ?? ''; + return slugToId[seg] ?? 'home'; + } + ``` +- Add `syncActive()`: highlight the active Explorer row + update the StatusBar from `currentId()`: + ```ts + function syncActive() { + const id = currentId(); + const meta = sectionById[id] ?? sectionById['home']; + document.querySelectorAll<HTMLElement>('[data-section-link]').forEach((a) => { + const on = a.dataset.sectionLink === id; + a.closest<HTMLElement>('.exp-row')?.classList.toggle('active', on); + a.setAttribute('aria-current', on ? 'true' : 'false'); + }); + const t = document.querySelector('[data-statusbar-table]'); + const r = document.querySelector('[data-statusbar-rows]'); + if (t) t.textContent = meta.table; + if (r) r.textContent = String(meta.rows); + } + ``` +- Palette "Jump to {section}" and Explorer search Enter now NAVIGATE by URL. Replace the palette jump run body and the search-Enter handler to use: + ```ts + const href = (slug: string) => (slug === '' ? '/' : `/${slug}`); + // palette jump: run: () => { location.href = href(s.slug); } + // search Enter: location.href = href(firstMatch.slug); // firstMatch from the [data-explorer-item] dataset/section + ``` + (For search, map the first visible `[data-explorer-item]` id → its section slug via `sectionById[id].slug`.) +- `copyLink()` now copies the section's real URL: `const url = location.origin + href(sectionById[currentId()].slug);` +- `runQuery()`/`exportSection()`/`toggleExplain()` use `currentId()` instead of `currentHash()`. + +- [ ] **Step 2: Lifecycle — wire once + per-navigation** + +Restructure init so document/window listeners attach ONCE and per-page sync runs on every navigation: +```ts +let wiredOnce = false; +function wireOnce() { + if (wiredOnce) return; + wiredOnce = true; + studio?.classList.add('js'); + // single delegated [data-action] click handler (palette/run/copy-link/export/explain/notice) + document.addEventListener('click', onActionClick); + // ⌘K + palette keyboard + window.addEventListener('keydown', onKeydown); + // drawer + palette close + explorer search + column toggles are on persisted chrome — bind here + bindChrome(); +} +function onPage() { + syncActive(); +} +function start() { wireOnce(); onPage(); } +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); +else start(); +document.addEventListener('astro:page-load', () => { wireOnce(); onPage(); }); +``` +(Factor the existing delegated click handler into `onActionClick`, the keydown into `onKeydown`, and the explorer-search/column-toggle/drawer wiring into `bindChrome()`. Because the chrome is `transition:persist`ed, `bindChrome()` only needs to run once; guard re-binds if needed. `syncActive()` runs on every `astro:page-load`.) + +- [ ] **Step 3: global.css — single section always shown** + +In `src/styles/global.css`, the swap rule block: +```css +@media (min-width: 1024px) { + .studio.js { height: 100vh; height: 100dvh; overflow: hidden; } + .studio.js .studio-pane { min-height: 0; overflow: hidden; } + .studio.js .studio-section { display: none; height: 100%; min-height: 0; } + .studio.js .studio-section.is-active { display: flex; flex-direction: column; } + .studio.js .studio-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; } +} +``` +Replace the two `.studio-section` lines so the (single) section is always shown: +```css + .studio.js .studio-section { display: flex; flex-direction: column; height: 100%; min-height: 0; } + .studio.js .studio-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; } +``` +(Remove the `display:none` + `.is-active` gate — every page has exactly one section.) + +- [ ] **Step 4: Build + browser verify** + +Run: `bunx astro build` → PASS. `bun test src/` → green. +In dev: `/` shows home; clicking Explorer `compare` navigates to `/compare` (full reload until Task 7), where compare is shown, its Explorer row is active, StatusBar shows `compare`. Palette ⌘K → "Jump to deploy" navigates to `/deploy`. Explorer search "faq" + Enter → `/faq`. Copy on `/compare` copies `…/compare`. No console errors; no leftover hashchange behavior. + +- [ ] **Step 5: Commit** + +```bash +git add src/scripts/studio.ts src/styles/global.css +git commit -m "feat: studio.ts navigates by URL; drop in-page swap; page-load lifecycle" +``` + +--- + +### Task 7: View Transitions (ClientRouter + persist) + +**Files:** +- Modify: `src/layouts/Layout.astro` +- Modify: `src/components/studio/StudioShell.astro` (+ persisted chrome components) + +**Interfaces:** +- Consumes: `astro:transitions` `ClientRouter`. + +- [ ] **Step 1: Add ClientRouter to Layout** + +In `src/layouts/Layout.astro` frontmatter add `import { ClientRouter } from 'astro:transitions';`. In `<head>` (near the other head tags, before the `<slot name="head" />`) add: +```astro + <ClientRouter /> +``` + +- [ ] **Step 2: Persist the chrome** + +In `src/components/studio/StudioShell.astro`, add `transition:persist` to the persistent chrome wrappers so they don't re-render/flicker across navigations — and give each a stable `transition:persist` name where it's a distinct element: +- The desktop TopBar wrapper `<div class="hidden lg:block">` → add `transition:persist="topbar"`. +- The Explorer `<aside …>` → add `transition:persist="sidebar"`. +- `<MobileTopBar … />` → wrap or add `transition:persist="mobiletop"`. +- The StatusBar wrapper `<div class="hidden lg:block">` → `transition:persist="statusbar"`. +- `<Console />` → `transition:persist="console"`. +- `<CommandPalette />` → `transition:persist="palette"`. +Leave `<main class="studio-pane">` (the result content) NON-persisted so it transitions. + +- [ ] **Step 3: Build + browser verify (the key runtime check)** + +Run: `bunx astro build` → PASS. +In dev, navigate Explorer `home → compare → deploy → docker-compose-example`: +- Transitions are smooth/animated (no full white reload); the sidebar/topbar/statusbar do not flicker (persisted); the result pane cross-fades. +- After EACH navigation, interactions still work: ⌘K palette opens, a `[data-notice]` button shows a toast, `RUN` shimmers, Explain toggles, Explorer search filters. (This confirms `wireOnce`/`onPage` lifecycle.) +- Active Explorer row + StatusBar reflect the current page after each nav. +- With OS reduced-motion on, transitions don't animate jarringly. + +- [ ] **Step 4: Commit** + +```bash +git add src/layouts/Layout.astro src/components/studio/StudioShell.astro +git commit -m "feat: Astro View Transitions with persistent chrome" +``` + +--- + +### Task 8: Internal links → real section URLs + +**Files:** +- Modify: `src/components/Footer.astro`, `src/components/Header.astro` +- Modify: `src/components/sections/HomeSection.astro`, `src/components/sections/GetStartedSection.astro` (any `/#section` links) + +**Interfaces:** none (link hrefs only). + +- [ ] **Step 1: Replace hash-section links with real URLs** + +Search and update every internal link of the form `/#features`, `/#databases`, `/#tech-stack`, `/#get-started`, `/#deploy`, `/#faq` to the real path (`/features`, `/databases`, `/tech-stack`, `/get-started`, `/deploy`, `/faq`). Run to find them: +```bash +grep -rn 'href="/#' src/components src/pages +``` +Update each occurrence in `Footer.astro` (Product section + Docker Compose Example link → `/docker-compose-example`), `Header.astro` (nav links), `HomeSection.astro` (CTAs — note "Deploy in one click" → `/deploy` which may already be `/deploy`), and `GetStartedSection.astro` ("Explore all deploy options" → `/deploy`, "View the full docker-compose.example.yml" → `/docker-compose-example`). Leave external links and `app.libredb.org`/GitHub links unchanged. + +- [ ] **Step 2: Build + verify no stale hash-section links remain** + +Run: `bunx astro build` → PASS. +Run: `grep -rn 'href="/#' src/components src/pages` → expect NO matches (all converted). + +- [ ] **Step 3: Commit** + +```bash +git add src/components/Footer.astro src/components/Header.astro src/components/sections/HomeSection.astro src/components/sections/GetStartedSection.astro +git commit -m "feat: internal links point to real section URLs" +``` + +--- + +### Task 9: QA pass + +**Files:** none (verification + fixes only). + +- [ ] **Step 1: Full build + tests** + +Run: `bunx astro build` → PASS. Pages built should include `/`, the 8 section pages, `/privacy-policy`, `404` (≈11). `bun test src/` → green. + +- [ ] **Step 2: Single-source + route checks** + +```bash +grep -rl "Official one-click integrations" src/ # only DeploySection.astro +grep -rl "Environment variable reference" src/ # only DockerComposeSection.astro +test ! -f src/pages/deploy.astro && test ! -f src/pages/docker-compose-example.astro && echo "old pages removed" +grep -rn 'href="/#' src/components src/pages || echo "no stale hash links" +``` + +- [ ] **Step 3: SEO continuity** + +```bash +grep -o 'application/ld+json' dist/deploy/index.html | wc -l # 4 +grep -o 'application/ld+json' dist/docker-compose-example/index.html | wc -l # 5 +grep -o '<title>[^<]*' dist/compare/index.html # the compare pageTitle +``` +Confirm `dist/sitemap-0.xml` (or sitemap-index) lists the new section URLs. + +- [ ] **Step 4: Browser matrix (dev :4321), desktop 1440 + mobile 390** + +- Navigate `home → features → compare → deploy → docker-compose-example` via the Explorer: smooth View Transitions, persistent chrome, no flicker. +- After each navigation: ⌘K palette, a redirect toast, RUN shimmer, Copy (copies real URL), Export, Explain, Explorer search all work. +- `/docker-compose-example`: quick-start + copy button work, full yml renders, env tables present; the Explorer shows `docker_compose` highlighted. +- Mobile: drawer opens, links navigate by URL, one section per page. +- Reduced-motion: navigations don't animate harshly; RUN shimmer suppressed. +- Direct loads of `/compare`, `/deploy`, `/docker-compose-example` (no SPA history) render correctly with the right section active. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "test: QA pass for multi-page studio" +``` + +--- + +## Self-Review (completed by plan author) +- **Spec coverage:** routing/slugs + dynamic route (Task 3) ✓; 9th docker_compose table + Explorer presence (Task 1 manifest + Task 5 links + Task 3 route) ✓; DockerComposeSection single source (Task 2) ✓; home-only `/` (Task 4) ✓; real-URL Explorer (Task 5) ✓; studio.ts swap removal + URL nav + page-load lifecycle (Task 6) ✓; View Transitions + persist (Task 7) ✓; per-section SEO + deploy/docker-compose JSON-LD continuity (Task 1 section-seo + Task 3 head-slot) ✓; internal link updates (Task 8) ✓; out-of-scope privacy/404 untouched ✓. +- **Placeholder scan:** none — code/edits are concrete; the DockerComposeSection lift names exact source regions + import-path changes (the source file exists in-repo). +- **Type/name consistency:** `slug`/`pageTitle`/`pageDescription` on `SectionMeta` (Task 1) used by `[section].astro` (Task 3), `index.astro` (Task 4), Explorer (Task 5), studio.ts `slugToId`/`currentId`/`href` (Task 6); `sectionSeo` map (Task 1) consumed in Task 3; `transition:persist` chrome (Task 7) matches StudioShell elements from the deploy-unify branch. Consistent. From 00abcf9371f285c372c37b6b7262c0d0cbc12722 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 22:52:56 +0300 Subject: [PATCH 03/16] feat: section slugs + page SEO, docker_compose table, per-section JSON-LD Co-Authored-By: Claude Opus 4.8 (1M context) --- src/data/section-seo.ts | 50 +++++++++++++++++++++++++++++++++++++++ src/data/sections.test.ts | 26 ++++++++++++++++++++ src/data/sections.ts | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 src/data/section-seo.ts create mode 100644 src/data/sections.test.ts diff --git a/src/data/section-seo.ts b/src/data/section-seo.ts new file mode 100644 index 0000000..e9a88b2 --- /dev/null +++ b/src/data/section-seo.ts @@ -0,0 +1,50 @@ +// src/data/section-seo.ts +// Per-section structured data injected into by the dynamic route. +import { deployTargets } from './deploy-targets'; + +const SITE = 'https://libredb.org'; +const REPO = 'https://github.com/libredb/libredb-studio'; +const rawFileURL = `${SITE}/docker-compose.example.yml`; + +export const sectionSeo: Record = { + deploy: [ + { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: 'LibreDB Studio deployment targets', + about: { '@id': 'https://libredb.org/#application' }, + itemListElement: deployTargets.map((t, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: t.name, + url: t.deployUrl ?? t.docsUrl ?? t.url, + })), + }, + ], + docker_compose: [ + { + '@context': 'https://schema.org', + '@type': 'HowTo', + name: 'Run LibreDB Studio with Docker Compose', + description: 'Self-host the open-source LibreDB Studio SQL IDE using Docker Compose.', + totalTime: 'PT5M', + tool: [{ '@type': 'HowToTool', name: 'Docker' }, { '@type': 'HowToTool', name: 'Docker Compose' }], + step: [ + { '@type': 'HowToStep', position: 1, name: 'Download the compose file', text: 'Download docker-compose.example.yml and rename it to docker-compose.yml.', url: `${SITE}/docker-compose-example/` }, + { '@type': 'HowToStep', position: 2, name: 'Configure environment', text: 'Set JWT_SECRET (min 32 chars), ADMIN_PASSWORD and USER_PASSWORD in your .env file.', url: `${SITE}/docker-compose-example/` }, + { '@type': 'HowToStep', position: 3, name: 'Start the container', text: 'Run "docker compose up -d" and open http://localhost:3000.', url: `${SITE}/docker-compose-example/` }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'SoftwareSourceCode', + name: 'docker-compose.example.yml', + description: 'Ready-to-use Docker Compose configuration for LibreDB Studio.', + programmingLanguage: 'YAML', + codeRepository: REPO, + url: rawFileURL, + license: 'https://opensource.org/licenses/MIT', + about: { '@id': 'https://libredb.org/#application' }, + }, + ], +}; diff --git a/src/data/sections.test.ts b/src/data/sections.test.ts new file mode 100644 index 0000000..306de65 --- /dev/null +++ b/src/data/sections.test.ts @@ -0,0 +1,26 @@ +import { test, expect } from 'bun:test'; +import { sections, sectionById } from './sections'; + +test('every section has a unique slug; home is empty', () => { + const slugs = sections.map((s) => s.slug); + expect(new Set(slugs).size).toBe(slugs.length); + expect(sectionById['home'].slug).toBe(''); + expect(sectionById['tech_stack'].slug).toBe('tech-stack'); + expect(sectionById['get_started'].slug).toBe('get-started'); + expect(sectionById['docker_compose'].slug).toBe('docker-compose-example'); +}); + +test('docker_compose table exists with page SEO', () => { + const d = sectionById['docker_compose']; + expect(d).toBeDefined(); + expect(d.pageTitle.length).toBeGreaterThan(0); + expect(d.pageDescription.length).toBeGreaterThan(0); +}); + +test('every section has page SEO fields', () => { + for (const s of sections) { + expect(typeof s.slug).toBe('string'); + expect(s.pageTitle.length).toBeGreaterThan(0); + expect(s.pageDescription.length).toBeGreaterThan(0); + } +}); diff --git a/src/data/sections.ts b/src/data/sections.ts index b70b023..b9155a6 100644 --- a/src/data/sections.ts +++ b/src/data/sections.ts @@ -20,6 +20,9 @@ export interface SectionMeta { execMs: number; // fake exec time badge columns: SectionColumn[]; explain: string; // AI-generated explanation shown in the Explain panel + slug: string; // URL slug ('' = home at '/') + pageTitle: string; // when this section is its own page + pageDescription: string; } export const sections: SectionMeta[] = [ @@ -36,6 +39,9 @@ export const sections: SectionMeta[] = [ { name: 'stats', type: 'JSONB' }, ], explain: 'Returns the LibreDB Studio overview: a modern, AI-powered, browser-based SQL IDE with SSO across 7+ engines — free and open source under MIT.', + slug: '', + pageTitle: 'LibreDB Studio - AI-Powered Open-Source SQL IDE', + pageDescription: 'LibreDB Studio - The Modern, AI-Powered Open-Source SQL IDE for Cloud-Native Teams', }, { id: 'features', @@ -50,6 +56,9 @@ export const sections: SectionMeta[] = [ { name: 'summary', type: 'TEXT' }, ], explain: 'Lists 17 capabilities grouped by area — from the Monaco SQL editor and NL2SQL Copilot to data masking and the DBA toolkit.', + slug: 'features', + pageTitle: 'Features — LibreDB Studio SQL IDE', + pageDescription: 'Everything you need to master your data: Monaco SQL editor, NL2SQL Copilot, AI query safety, 7+ databases, pro data grid, visual EXPLAIN, ER diagrams, data masking, SSO and more.', }, { id: 'databases', @@ -64,6 +73,9 @@ export const sections: SectionMeta[] = [ { name: 'driver', type: 'VARCHAR' }, ], explain: 'The 7 supported engines and their drivers — PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB, Redis — behind one unified interface.', + slug: 'databases', + pageTitle: 'Supported Databases — PostgreSQL, MySQL, Oracle, SQL Server, MongoDB, Redis', + pageDescription: 'One tool, all your databases. Connect to PostgreSQL, MySQL, Oracle, SQL Server, SQLite, MongoDB and Redis through one unified browser-based SQL IDE.', }, { id: 'compare', @@ -78,6 +90,9 @@ export const sections: SectionMeta[] = [ { name: 'price', type: 'VARCHAR' }, ], explain: 'Scores LibreDB Studio against DataGrip, DBeaver, pgAdmin and TablePlus on zero-install, mobile, AI, SSO and price — ordered by how free and open each is.', + slug: 'compare', + pageTitle: 'How LibreDB Studio Compares — vs DataGrip, DBeaver, pgAdmin, TablePlus', + pageDescription: 'See why teams switch to LibreDB Studio: zero-install, mobile, AI-native, SSO and free — compared against DataGrip, DBeaver, pgAdmin and TablePlus.', }, { id: 'tech_stack', @@ -91,6 +106,9 @@ export const sections: SectionMeta[] = [ { name: 'tools', type: 'TEXT[]' }, ], explain: 'The production stack in four layers: frontend (Next.js 16, React 19), editor & data (Monaco, TanStack, ReactFlow), AI & auth (Gemini, OIDC), and devops (Docker, Bun).', + slug: 'tech-stack', + pageTitle: 'Tech Stack — LibreDB Studio', + pageDescription: 'Built with a modern, production-ready stack: Next.js 16, React 19, TypeScript, Tailwind 4, Monaco, TanStack Table, ReactFlow, Gemini/OIDC, Docker and Bun.', }, { id: 'get_started', @@ -105,6 +123,9 @@ export const sections: SectionMeta[] = [ { name: 'command', type: 'TEXT' }, ], explain: 'Three steps to run locally — clone & install, configure env, launch — plus a one-command Docker alternative.', + slug: 'get-started', + pageTitle: 'Get Started in Minutes — LibreDB Studio', + pageDescription: 'Run LibreDB Studio locally in three steps — clone & install, configure, launch — or one-command Docker. Self-host the open-source AI SQL IDE.', }, { id: 'faq', @@ -118,6 +139,9 @@ export const sections: SectionMeta[] = [ { name: 'answer', type: 'TEXT' }, ], explain: 'The nine most common questions: pricing, self-hosting, AI providers, security & SSO, supported databases, and how it compares to legacy tools.', + slug: 'faq', + pageTitle: 'FAQ — LibreDB Studio', + pageDescription: 'Frequently asked questions about LibreDB Studio: pricing, self-hosting, AI providers, security & SSO, supported databases, and how it compares to legacy tools.', }, { id: 'deploy', @@ -132,6 +156,26 @@ export const sections: SectionMeta[] = [ { name: 'method', type: 'VARCHAR' }, ], explain: 'Every place LibreDB Studio runs — 39 targets across registries, self-hosted PaaS, Kubernetes, managed PaaS and cloud — from one open-source image.', + slug: 'deploy', + pageTitle: 'Deploy LibreDB Studio Anywhere — One-Click Apps, Helm, Docker & Cloud', + pageDescription: 'Run the open-source LibreDB Studio SQL IDE anywhere: official Railway and CapRover one-click apps, Docker Hub & GHCR images, a Helm chart on Artifact Hub, npm, and every major open-source PaaS, managed PaaS, and cloud.', + }, + { + id: 'docker_compose', + table: 'docker_compose', + slug: 'docker-compose-example', + query: 'SELECT variable, default, description FROM env_vars;', + rows: 21, + cols: 3, + execMs: 6, + columns: [ + { name: 'variable', type: 'VARCHAR' }, + { name: 'default', type: 'VARCHAR' }, + { name: 'description', type: 'TEXT' }, + ], + explain: 'A copy-paste docker-compose.yml: pulls the published ghcr.io image with every environment variable (auth, OIDC SSO, storage, AI/LLM, seed) — self-host in one command.', + pageTitle: 'LibreDB Studio Docker Compose Example — Self-Host in Minutes', + pageDescription: 'Copy-paste docker-compose.example.yml for LibreDB Studio. Run the open-source SQL IDE with one command using the ghcr.io/libredb/libredb-studio image. Includes every environment variable, SQLite/PostgreSQL storage, and OIDC SSO options.', }, ]; From c8c3f68ba59fed41de16f6fb927d6f99a791341c Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 22:56:46 +0300 Subject: [PATCH 04/16] feat: DockerComposeSection single-source content (lifted from the page) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../sections/DockerComposeSection.astro | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 src/components/sections/DockerComposeSection.astro diff --git a/src/components/sections/DockerComposeSection.astro b/src/components/sections/DockerComposeSection.astro new file mode 100644 index 0000000..4a5143b --- /dev/null +++ b/src/components/sections/DockerComposeSection.astro @@ -0,0 +1,209 @@ +--- +import { Code } from 'astro:components'; +import SectionHeader from './SectionHeader.astro'; +import composeYaml from '../../data/docker-compose.example.yml?raw'; + +const siteURL = 'https://libredb.org'; +const rawFileURL = `${siteURL}/docker-compose.example.yml`; +const repoURL = 'https://github.com/libredb/libredb-studio'; +const exampleFileURL = `${repoURL}/blob/main/docker-compose.example.yml`; +const issueURL = `${repoURL}/issues/54`; + +const quickStart = `curl -O ${rawFileURL} +mv docker-compose.example.yml docker-compose.yml +# set JWT_SECRET, ADMIN_PASSWORD and USER_PASSWORD in .env +docker compose up -d +# open http://localhost:3000`; + +// Environment variable reference — grouped exactly as in the compose file. +const envGroups = [ + { + title: 'Authentication (required)', + vars: [ + { name: 'ADMIN_EMAIL', def: 'admin@libredb.org', desc: 'Admin login — full access plus maintenance tools.' }, + { name: 'ADMIN_PASSWORD', def: '— (required)', desc: 'Admin password. Set this in your .env file.' }, + { name: 'USER_EMAIL', def: 'user@libredb.org', desc: 'Standard user login — query execution only.' }, + { name: 'USER_PASSWORD', def: '— (required)', desc: 'User password. Set this in your .env file.' }, + { name: 'JWT_SECRET', def: '— (required)', desc: 'JWT signing secret, min 32 chars. Generate: openssl rand -base64 32' }, + { name: 'NEXT_PUBLIC_AUTH_PROVIDER', def: 'local', desc: 'Auth mode: "local" (email/password) or "oidc" (SSO).' }, + ], + }, + { + title: 'OIDC SSO (when NEXT_PUBLIC_AUTH_PROVIDER=oidc)', + vars: [ + { name: 'OIDC_ISSUER', def: '—', desc: 'Issuer URL serving /.well-known/openid-configuration.' }, + { name: 'OIDC_CLIENT_ID', def: '—', desc: 'OIDC client ID from your identity provider.' }, + { name: 'OIDC_CLIENT_SECRET', def: '—', desc: 'OIDC client secret.' }, + { name: 'OIDC_SCOPE', def: 'openid profile email', desc: 'Requested OIDC scopes.' }, + { name: 'OIDC_ROLE_CLAIM', def: '—', desc: 'Claim holding roles, e.g. realm_access.roles (Keycloak), groups (Okta).' }, + { name: 'OIDC_ADMIN_ROLES', def: 'admin', desc: 'Roles mapped to admin access. Works with Auth0, Keycloak, Okta, Azure AD, Zitadel.' }, + ], + }, + { + title: 'Storage — where connections & config persist', + vars: [ + { name: 'STORAGE_PROVIDER', def: 'local', desc: '"local" (browser localStorage, zero config), "sqlite" (single-node file), or "postgres" (multi-node).' }, + { name: 'STORAGE_SQLITE_PATH', def: '/app/data/libredb-storage.db', desc: 'SQLite file path. Mount a volume to persist it.' }, + { name: 'STORAGE_POSTGRES_URL', def: 'postgresql://…@libredb-postgres:5432/libredb_storage', desc: 'PostgreSQL connection string for multi-node persistence.' }, + ], + }, + { + title: 'AI / LLM (optional)', + vars: [ + { name: 'LLM_PROVIDER', def: 'gemini', desc: 'Provider: gemini | openai | ollama | custom.' }, + { name: 'LLM_API_KEY', def: '—', desc: 'API key — required for gemini/openai.' }, + { name: 'LLM_MODEL', def: 'gemini-2.5-flash', desc: 'Model name to use for NL2SQL.' }, + { name: 'LLM_API_URL', def: '—', desc: 'Base URL for ollama/custom, e.g. http://host:11434/v1' }, + ], + }, + { + title: 'Seed connections (optional)', + vars: [ + { name: 'SEED_CONFIG_PATH', def: '/app/config/seed-connections.yaml', desc: 'Pre-configure databases on boot from a mounted YAML file.' }, + { name: 'SEED_CACHE_TTL_MS', def: '60000', desc: 'Cache TTL for seeded connections, in milliseconds.' }, + ], + }, +]; +--- + +<SectionHeader title="Docker Compose example" subtitle="One command to self-host LibreDB Studio — pulls the published ghcr.io image, with every environment variable." /> + +<div class="mx-auto max-w-4xl"> + <!-- Quick start --> + <section class="mb-10 mt-8"> + <h2 class="text-xl font-semibold text-bright mb-4">Quick start</h2> + <div class="relative group"> + <pre class="p-4 pr-12 bg-canvas border border-edge text-xs md:text-sm text-fg font-mono overflow-x-auto"><code>{quickStart}</code></pre> + <button + class="copy-btn absolute top-3 right-3 p-1.5 rounded-md bg-raised hover:bg-edge-strong text-dim hover:text-fg opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all duration-200" + data-code={quickStart} + aria-label="Copy quick start commands" + > + <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> + <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> + </button> + </div> + <p class="text-sm text-muted mt-3"> + That's it — LibreDB Studio is now running at + <code class="text-primary">http://localhost:3000</code>. Log in with the admin credentials from your + <code class="text-primary">.env</code> file. + </p> + </section> + + <!-- Full file --> + <section class="mb-10"> + <div class="flex flex-wrap items-center justify-between gap-3 mb-4"> + <h2 class="text-xl font-semibold text-bright">The full <code class="text-primary">docker-compose.example.yml</code></h2> + <div class="flex items-center gap-2"> + <a + href="/docker-compose.example.yml" + download="docker-compose.yml" + class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs md:text-sm rounded-md border border-edge-strong text-fg hover:bg-raised transition-colors" + > + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg> + Download raw + </a> + <button + class="copy-btn inline-flex items-center gap-1.5 px-3 py-1.5 text-xs md:text-sm rounded-md border border-edge-strong text-fg hover:bg-raised transition-colors" + data-code={composeYaml} + aria-label="Copy the full docker-compose file" + > + <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> + <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> + <span class="copy-label">Copy</span> + </button> + </div> + </div> + <div class="compose-code border border-edge overflow-hidden text-xs md:text-sm"> + <Code code={composeYaml} lang="yaml" theme="github-dark" /> + </div> + </section> + + <!-- Environment variable reference --> + <section class="mb-10"> + <h2 class="text-xl font-semibold text-bright mb-2">Environment variable reference</h2> + <p class="text-sm text-muted mb-6"> + Every supported variable, grouped as in the file. Secrets are read from your + <code class="text-primary">.env</code> file via <code class="text-primary">${'{VAR}'}</code> interpolation — + never hardcoded in the compose file. + </p> + {envGroups.map((group) => ( + <div class="mb-8"> + <h3 class="text-base font-medium text-bright mb-3">{group.title}</h3> + <div class="overflow-x-auto border border-edge"> + <table class="w-full text-left text-sm"> + <thead class="bg-panel text-fg"> + <tr> + <th class="px-4 py-2.5 font-semibold whitespace-nowrap">Variable</th> + <th class="px-4 py-2.5 font-semibold whitespace-nowrap">Default</th> + <th class="px-4 py-2.5 font-semibold">Description</th> + </tr> + </thead> + <tbody class="divide-y divide-edge"> + {group.vars.map((v) => ( + <tr class="align-top"> + <td class="px-4 py-2.5"><code class="text-primary whitespace-nowrap">{v.name}</code></td> + <td class="px-4 py-2.5 text-muted font-mono text-xs whitespace-nowrap">{v.def}</td> + <td class="px-4 py-2.5 text-fg">{v.desc}</td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + ))} + </section> + + <!-- Links --> + <section class="border-t border-edge pt-8"> + <div class="flex flex-wrap gap-3"> + <a href={exampleFileURL} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> + View on GitHub + </a> + <a href={issueURL} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> + Related issue #54 + </a> + <a href="/#get-started" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> + All install options + </a> + </div> + </section> +</div> + +<style> + /* Let Astro's Shiki block scroll horizontally and sit flush in its frame. */ + .compose-code :global(pre) { + margin: 0; + padding: 1rem; + max-height: 32rem; + overflow: auto; + } +</style> + +<script> + document.querySelectorAll('.copy-btn').forEach((button) => { + button.addEventListener('click', async () => { + const code = button.getAttribute('data-code'); + const copyIcon = button.querySelector('.copy-icon'); + const checkIcon = button.querySelector('.check-icon'); + const label = button.querySelector('.copy-label'); + + try { + await navigator.clipboard.writeText(code || ''); + copyIcon?.classList.add('hidden'); + checkIcon?.classList.remove('hidden'); + button.classList.add('text-ok'); + if (label) label.textContent = 'Copied'; + + setTimeout(() => { + copyIcon?.classList.remove('hidden'); + checkIcon?.classList.add('hidden'); + button.classList.remove('text-ok'); + if (label) label.textContent = 'Copy'; + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }); + }); +</script> From 8ca883e45d33c5b19c0853edfe78ce267e95aa67 Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 23:00:10 +0300 Subject: [PATCH 05/16] feat: dynamic [section] route generates every section page; drop deploy.astro & docker-compose-example.astro Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/pages/[section].astro | 47 ++++ src/pages/deploy.astro | 31 --- src/pages/docker-compose-example.astro | 285 ------------------------- 3 files changed, 47 insertions(+), 316 deletions(-) create mode 100644 src/pages/[section].astro delete mode 100644 src/pages/deploy.astro delete mode 100644 src/pages/docker-compose-example.astro diff --git a/src/pages/[section].astro b/src/pages/[section].astro new file mode 100644 index 0000000..fe980bc --- /dev/null +++ b/src/pages/[section].astro @@ -0,0 +1,47 @@ +--- +// src/pages/[section].astro +import Layout from '../layouts/Layout.astro'; +import StudioShell from '../components/studio/StudioShell.astro'; +import SectionShell from '../components/studio/SectionShell.astro'; +import { sections, sectionById } from '../data/sections'; +import { sectionSeo } from '../data/section-seo'; + +import FeaturesSection from '../components/sections/FeaturesSection.astro'; +import DatabasesSection from '../components/sections/DatabasesSection.astro'; +import CompareSection from '../components/sections/CompareSection.astro'; +import TechStackSection from '../components/sections/TechStackSection.astro'; +import GetStartedSection from '../components/sections/GetStartedSection.astro'; +import FaqSection from '../components/sections/FaqSection.astro'; +import DeploySection from '../components/sections/DeploySection.astro'; +import DockerComposeSection from '../components/sections/DockerComposeSection.astro'; + +export function getStaticPaths() { + return sections + .filter((s) => s.id !== 'home') + .map((s) => ({ params: { section: s.slug }, props: { id: s.id } })); +} + +const COMPONENTS: Record<string, any> = { + features: FeaturesSection, + databases: DatabasesSection, + compare: CompareSection, + tech_stack: TechStackSection, + get_started: GetStartedSection, + faq: FaqSection, + deploy: DeploySection, + docker_compose: DockerComposeSection, +}; + +const { id } = Astro.props as { id: string }; +const meta = sectionById[id]; +const Body = COMPONENTS[id]; +const jsonLd = sectionSeo[id] ?? []; +--- +<Layout title={meta.pageTitle} description={meta.pageDescription}> + {jsonLd.map((schema) => ( + <script slot="head" is:inline type="application/ld+json" set:html={JSON.stringify(schema)} /> + ))} + <StudioShell active={id} standalone> + <SectionShell section={meta} active><Body /></SectionShell> + </StudioShell> +</Layout> diff --git a/src/pages/deploy.astro b/src/pages/deploy.astro deleted file mode 100644 index 93ae385..0000000 --- a/src/pages/deploy.astro +++ /dev/null @@ -1,31 +0,0 @@ ---- -import Layout from '../layouts/Layout.astro'; -import StudioShell from '../components/studio/StudioShell.astro'; -import SectionShell from '../components/studio/SectionShell.astro'; -import DeploySection from '../components/sections/DeploySection.astro'; -import { sectionById } from '../data/sections'; -import { deployTargets } from '../data/deploy-targets'; - -const title = 'Deploy LibreDB Studio Anywhere — One-Click Apps, Helm, Docker & Cloud'; -const description = - 'Run the open-source LibreDB Studio SQL IDE anywhere: official Railway and CapRover one-click apps, Docker Hub & GHCR images, a Helm chart on Artifact Hub, npm, and every major open-source PaaS, managed PaaS, and cloud.'; - -const itemListSchema = { - '@context': 'https://schema.org', - '@type': 'ItemList', - name: 'LibreDB Studio deployment targets', - about: { '@id': 'https://libredb.org/#application' }, - itemListElement: deployTargets.map((t, i) => ({ - '@type': 'ListItem', - position: i + 1, - name: t.name, - url: t.deployUrl ?? t.docsUrl ?? t.url, - })), -}; ---- -<Layout title={title} description={description}> - <script slot="head" is:inline type="application/ld+json" set:html={JSON.stringify(itemListSchema)} /> - <StudioShell active="deploy" standalone> - <SectionShell section={sectionById['deploy']} active><DeploySection /></SectionShell> - </StudioShell> -</Layout> diff --git a/src/pages/docker-compose-example.astro b/src/pages/docker-compose-example.astro deleted file mode 100644 index cb81bc9..0000000 --- a/src/pages/docker-compose-example.astro +++ /dev/null @@ -1,285 +0,0 @@ ---- -import { Code } from 'astro:components'; -import Layout from '../layouts/Layout.astro'; -import Header from '../components/Header.astro'; -import Footer from '../components/Footer.astro'; -import composeYaml from '../data/docker-compose.example.yml?raw'; - -const siteURL = 'https://libredb.org'; -const rawFileURL = `${siteURL}/docker-compose.example.yml`; -const repoURL = 'https://github.com/libredb/libredb-studio'; -const exampleFileURL = `${repoURL}/blob/main/docker-compose.example.yml`; -const issueURL = `${repoURL}/issues/54`; - -const quickStart = `curl -O ${rawFileURL} -mv docker-compose.example.yml docker-compose.yml -# set JWT_SECRET, ADMIN_PASSWORD and USER_PASSWORD in .env -docker compose up -d -# open http://localhost:3000`; - -// Environment variable reference — grouped exactly as in the compose file. -const envGroups = [ - { - title: 'Authentication (required)', - vars: [ - { name: 'ADMIN_EMAIL', def: 'admin@libredb.org', desc: 'Admin login — full access plus maintenance tools.' }, - { name: 'ADMIN_PASSWORD', def: '— (required)', desc: 'Admin password. Set this in your .env file.' }, - { name: 'USER_EMAIL', def: 'user@libredb.org', desc: 'Standard user login — query execution only.' }, - { name: 'USER_PASSWORD', def: '— (required)', desc: 'User password. Set this in your .env file.' }, - { name: 'JWT_SECRET', def: '— (required)', desc: 'JWT signing secret, min 32 chars. Generate: openssl rand -base64 32' }, - { name: 'NEXT_PUBLIC_AUTH_PROVIDER', def: 'local', desc: 'Auth mode: "local" (email/password) or "oidc" (SSO).' }, - ], - }, - { - title: 'OIDC SSO (when NEXT_PUBLIC_AUTH_PROVIDER=oidc)', - vars: [ - { name: 'OIDC_ISSUER', def: '—', desc: 'Issuer URL serving /.well-known/openid-configuration.' }, - { name: 'OIDC_CLIENT_ID', def: '—', desc: 'OIDC client ID from your identity provider.' }, - { name: 'OIDC_CLIENT_SECRET', def: '—', desc: 'OIDC client secret.' }, - { name: 'OIDC_SCOPE', def: 'openid profile email', desc: 'Requested OIDC scopes.' }, - { name: 'OIDC_ROLE_CLAIM', def: '—', desc: 'Claim holding roles, e.g. realm_access.roles (Keycloak), groups (Okta).' }, - { name: 'OIDC_ADMIN_ROLES', def: 'admin', desc: 'Roles mapped to admin access. Works with Auth0, Keycloak, Okta, Azure AD, Zitadel.' }, - ], - }, - { - title: 'Storage — where connections & config persist', - vars: [ - { name: 'STORAGE_PROVIDER', def: 'local', desc: '"local" (browser localStorage, zero config), "sqlite" (single-node file), or "postgres" (multi-node).' }, - { name: 'STORAGE_SQLITE_PATH', def: '/app/data/libredb-storage.db', desc: 'SQLite file path. Mount a volume to persist it.' }, - { name: 'STORAGE_POSTGRES_URL', def: 'postgresql://…@libredb-postgres:5432/libredb_storage', desc: 'PostgreSQL connection string for multi-node persistence.' }, - ], - }, - { - title: 'AI / LLM (optional)', - vars: [ - { name: 'LLM_PROVIDER', def: 'gemini', desc: 'Provider: gemini | openai | ollama | custom.' }, - { name: 'LLM_API_KEY', def: '—', desc: 'API key — required for gemini/openai.' }, - { name: 'LLM_MODEL', def: 'gemini-2.5-flash', desc: 'Model name to use for NL2SQL.' }, - { name: 'LLM_API_URL', def: '—', desc: 'Base URL for ollama/custom, e.g. http://host:11434/v1' }, - ], - }, - { - title: 'Seed connections (optional)', - vars: [ - { name: 'SEED_CONFIG_PATH', def: '/app/config/seed-connections.yaml', desc: 'Pre-configure databases on boot from a mounted YAML file.' }, - { name: 'SEED_CACHE_TTL_MS', def: '60000', desc: 'Cache TTL for seeded connections, in milliseconds.' }, - ], - }, -]; - -const title = 'LibreDB Studio Docker Compose Example — Self-Host in Minutes'; -const description = - 'Copy-paste docker-compose.example.yml for LibreDB Studio. Run the open-source SQL IDE with one command using the ghcr.io/libredb/libredb-studio image. Includes every environment variable, SQLite/PostgreSQL storage, and OIDC SSO options.'; - -// Page-specific structured data (valid in body; recognised by Google). -const howToSchema = { - '@context': 'https://schema.org', - '@type': 'HowTo', - name: 'Run LibreDB Studio with Docker Compose', - description: 'Self-host the open-source LibreDB Studio SQL IDE using Docker Compose.', - totalTime: 'PT5M', - tool: [{ '@type': 'HowToTool', name: 'Docker' }, { '@type': 'HowToTool', name: 'Docker Compose' }], - step: [ - { - '@type': 'HowToStep', - position: 1, - name: 'Download the compose file', - text: `Download docker-compose.example.yml and rename it to docker-compose.yml.`, - url: `${siteURL}/docker-compose-example/`, - }, - { - '@type': 'HowToStep', - position: 2, - name: 'Configure environment', - text: 'Set JWT_SECRET (min 32 chars), ADMIN_PASSWORD and USER_PASSWORD in your .env file.', - url: `${siteURL}/docker-compose-example/`, - }, - { - '@type': 'HowToStep', - position: 3, - name: 'Start the container', - text: 'Run "docker compose up -d" and open http://localhost:3000.', - url: `${siteURL}/docker-compose-example/`, - }, - ], -}; - -const sourceCodeSchema = { - '@context': 'https://schema.org', - '@type': 'SoftwareSourceCode', - name: 'docker-compose.example.yml', - description: 'Ready-to-use Docker Compose configuration for LibreDB Studio.', - programmingLanguage: 'YAML', - codeRepository: repoURL, - url: rawFileURL, - license: 'https://opensource.org/licenses/MIT', - about: { '@id': 'https://libredb.org/#application' }, -}; ---- - -<Layout title={title} description={description}> - <Header /> - <main class="pt-24 pb-16"> - <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> - <!-- Header --> - <nav class="text-xs text-faint mb-4" aria-label="Breadcrumb"> - <a href="/" class="hover:text-muted transition-colors">Home</a> - <span class="mx-1.5">/</span> - <span class="text-muted">Docker Compose Example</span> - </nav> - <h1 class="text-3xl sm:text-4xl font-bold text-bright mb-4"> - LibreDB Studio <span class="text-primary">Docker Compose</span> Example - </h1> - <p class="text-base md:text-lg text-fg leading-relaxed mb-4"> - A ready-to-use <code class="text-primary">docker-compose.yml</code> for self-hosting - <a href={repoURL} target="_blank" rel="noopener noreferrer" class="text-primary hover:text-primary-bright underline underline-offset-2">LibreDB Studio</a>, - the open-source, AI-powered SQL IDE. It pulls the published image - <code class="text-primary">ghcr.io/libredb/libredb-studio:latest</code> — no source build required — and runs - on any Docker host or PaaS that consumes a plain compose file (Dokploy, Coolify, Portainer, and more). - </p> - - <!-- Quick start --> - <section class="mb-10 mt-8"> - <h2 class="text-xl font-semibold text-bright mb-4">Quick start</h2> - <div class="relative group"> - <pre class="p-4 pr-12 bg-canvas border border-edge text-xs md:text-sm text-fg font-mono overflow-x-auto"><code>{quickStart}</code></pre> - <button - class="copy-btn absolute top-3 right-3 p-1.5 rounded-md bg-raised hover:bg-edge-strong text-dim hover:text-fg opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-all duration-200" - data-code={quickStart} - aria-label="Copy quick start commands" - > - <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> - <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> - </button> - </div> - <p class="text-sm text-muted mt-3"> - That's it — LibreDB Studio is now running at - <code class="text-primary">http://localhost:3000</code>. Log in with the admin credentials from your - <code class="text-primary">.env</code> file. - </p> - </section> - - <!-- Full file --> - <section class="mb-10"> - <div class="flex flex-wrap items-center justify-between gap-3 mb-4"> - <h2 class="text-xl font-semibold text-bright">The full <code class="text-primary">docker-compose.example.yml</code></h2> - <div class="flex items-center gap-2"> - <a - href="/docker-compose.example.yml" - download="docker-compose.yml" - class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs md:text-sm rounded-md border border-edge-strong text-fg hover:bg-raised transition-colors" - > - <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg> - Download raw - </a> - <button - class="copy-btn inline-flex items-center gap-1.5 px-3 py-1.5 text-xs md:text-sm rounded-md border border-edge-strong text-fg hover:bg-raised transition-colors" - data-code={composeYaml} - aria-label="Copy the full docker-compose file" - > - <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> - <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> - <span class="copy-label">Copy</span> - </button> - </div> - </div> - <div class="compose-code border border-edge overflow-hidden text-xs md:text-sm"> - <Code code={composeYaml} lang="yaml" theme="github-dark" /> - </div> - </section> - - <!-- Environment variable reference --> - <section class="mb-10"> - <h2 class="text-xl font-semibold text-bright mb-2">Environment variable reference</h2> - <p class="text-sm text-muted mb-6"> - Every supported variable, grouped as in the file. Secrets are read from your - <code class="text-primary">.env</code> file via <code class="text-primary">${'{VAR}'}</code> interpolation — - never hardcoded in the compose file. - </p> - {envGroups.map((group) => ( - <div class="mb-8"> - <h3 class="text-base font-medium text-bright mb-3">{group.title}</h3> - <div class="overflow-x-auto border border-edge"> - <table class="w-full text-left text-sm"> - <thead class="bg-panel text-fg"> - <tr> - <th class="px-4 py-2.5 font-semibold whitespace-nowrap">Variable</th> - <th class="px-4 py-2.5 font-semibold whitespace-nowrap">Default</th> - <th class="px-4 py-2.5 font-semibold">Description</th> - </tr> - </thead> - <tbody class="divide-y divide-edge"> - {group.vars.map((v) => ( - <tr class="align-top"> - <td class="px-4 py-2.5"><code class="text-primary whitespace-nowrap">{v.name}</code></td> - <td class="px-4 py-2.5 text-muted font-mono text-xs whitespace-nowrap">{v.def}</td> - <td class="px-4 py-2.5 text-fg">{v.desc}</td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - ))} - </section> - - <!-- Links --> - <section class="border-t border-edge pt-8"> - <div class="flex flex-wrap gap-3"> - <a href={exampleFileURL} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> - View on GitHub - </a> - <a href={issueURL} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> - Related issue #54 - </a> - <a href="/#get-started" class="inline-flex items-center gap-2 px-4 py-2 border border-edge-strong text-fg hover:bg-raised text-sm font-medium rounded-md transition-colors"> - All install options - </a> - </div> - </section> - </div> - </main> - <Footer /> - - <!-- Structured data --> - <script is:inline type="application/ld+json" set:html={JSON.stringify(howToSchema)} /> - <script is:inline type="application/ld+json" set:html={JSON.stringify(sourceCodeSchema)} /> -</Layout> - -<style> - /* Let Astro's Shiki block scroll horizontally and sit flush in its frame. */ - .compose-code :global(pre) { - margin: 0; - padding: 1rem; - max-height: 32rem; - overflow: auto; - } -</style> - -<script> - document.querySelectorAll('.copy-btn').forEach((button) => { - button.addEventListener('click', async () => { - const code = button.getAttribute('data-code'); - const copyIcon = button.querySelector('.copy-icon'); - const checkIcon = button.querySelector('.check-icon'); - const label = button.querySelector('.copy-label'); - - try { - await navigator.clipboard.writeText(code || ''); - copyIcon?.classList.add('hidden'); - checkIcon?.classList.remove('hidden'); - button.classList.add('text-ok'); - if (label) label.textContent = 'Copied'; - - setTimeout(() => { - copyIcon?.classList.remove('hidden'); - checkIcon?.classList.add('hidden'); - button.classList.remove('text-ok'); - if (label) label.textContent = 'Copy'; - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }); - }); -</script> From e7ba898e6eea043cc76d982cad73d516751b920f Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 23:03:09 +0300 Subject: [PATCH 06/16] feat: / renders home-only in the studio shell --- src/pages/index.astro | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/src/pages/index.astro b/src/pages/index.astro index fb022c5..ade53dc 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -2,28 +2,12 @@ import Layout from '../layouts/Layout.astro'; import StudioShell from '../components/studio/StudioShell.astro'; import SectionShell from '../components/studio/SectionShell.astro'; -import { sectionById } from '../data/sections'; - import HomeSection from '../components/sections/HomeSection.astro'; -import FeaturesSection from '../components/sections/FeaturesSection.astro'; -import DatabasesSection from '../components/sections/DatabasesSection.astro'; -import CompareSection from '../components/sections/CompareSection.astro'; -import TechStackSection from '../components/sections/TechStackSection.astro'; -import GetStartedSection from '../components/sections/GetStartedSection.astro'; -import FaqSection from '../components/sections/FaqSection.astro'; -import DeploySection from '../components/sections/DeploySection.astro'; - -const s = sectionById; +import { sectionById } from '../data/sections'; +const home = sectionById['home']; --- -<Layout title="LibreDB Studio - AI-Powered Open-Source SQL IDE"> - <StudioShell active="home"> - <SectionShell section={s.home} active><HomeSection /></SectionShell> - <SectionShell section={s.features}><FeaturesSection /></SectionShell> - <SectionShell section={s.databases}><DatabasesSection /></SectionShell> - <SectionShell section={s.compare}><CompareSection /></SectionShell> - <SectionShell section={s.tech_stack}><TechStackSection /></SectionShell> - <SectionShell section={s.get_started}><GetStartedSection /></SectionShell> - <SectionShell section={s.faq}><FaqSection /></SectionShell> - <SectionShell section={s.deploy}><DeploySection /></SectionShell> +<Layout title={home.pageTitle} description={home.pageDescription}> + <StudioShell active="home" standalone> + <SectionShell section={home} active><HomeSection /></SectionShell> </StudioShell> </Layout> From 20eb8df6d948c7391f092bc40d622ee649455697 Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 23:05:15 +0300 Subject: [PATCH 07/16] feat: Explorer navigates by real section URLs --- src/components/studio/Explorer.astro | 3 +-- src/components/studio/MobileTopBar.astro | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/studio/Explorer.astro b/src/components/studio/Explorer.astro index bab7ef9..72ddd14 100644 --- a/src/components/studio/Explorer.astro +++ b/src/components/studio/Explorer.astro @@ -8,7 +8,6 @@ interface Props { standalone?: boolean; } const { active = 'home', idPrefix = 'exp', showConnectionsLabel = true, standalone = false } = Astro.props; -const linkBase = standalone ? '/' : ''; --- <div data-explorer-root class="flex h-full flex-col text-[13px]"> <!-- Connections --> @@ -58,7 +57,7 @@ const linkBase = standalone ? '/' : ''; <span class="caret-icon" aria-hidden="true">▸</span> </button> <a - href={`${linkBase}#${s.id}`} + href={s.slug === '' ? '/' : `/${s.slug}`} class="flex flex-1 items-center justify-between gap-2 py-1.5 pl-0.5 pr-1" data-section-link={s.id} > diff --git a/src/components/studio/MobileTopBar.astro b/src/components/studio/MobileTopBar.astro index 8ae0b1f..6ac85ef 100644 --- a/src/components/studio/MobileTopBar.astro +++ b/src/components/studio/MobileTopBar.astro @@ -45,7 +45,7 @@ const DEMO = 'https://app.libredb.org'; <button type="button" class="text-faint hover:text-fg" aria-label="Close explorer" data-drawer-close>✕</button> </div> <div class="h-[calc(100%-49px)]"> - <Explorer active={active} idPrefix="drawer" showConnectionsLabel={false} standalone={standalone} /> + <Explorer active={active} idPrefix="drawer" showConnectionsLabel={false} /> </div> </aside> </div> From 4da5cc32ba8be683ec8f06222dfc93c07cc5ec7c Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 23:09:34 +0300 Subject: [PATCH 08/16] feat: studio.ts navigates by URL; drop in-page swap; page-load lifecycle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/scripts/studio.ts | 253 ++++++++++++++++++------------------------ src/styles/global.css | 5 +- 2 files changed, 111 insertions(+), 147 deletions(-) diff --git a/src/scripts/studio.ts b/src/scripts/studio.ts index 0746973..00295b2 100644 --- a/src/scripts/studio.ts +++ b/src/scripts/studio.ts @@ -7,68 +7,34 @@ import { filterItems, fuzzyMatch } from './lib/filter'; /** * Studio interaction layer (progressive enhancement). * - Adds `.js` to the shell → desktop switches to viewport-locked single-view. - * - Explorer selection swaps the active section (desktop) / scrolls (mobile). - * - URL hash routing keeps deep links + back/forward working. - * - Mobile drawer open/close; explorer column (schema) expand/collapse. + * - Explorer links are real URLs; navigating renders exactly one section per page. + * - `syncActive()` highlights the active Explorer row + updates StatusBar from the URL. + * - `astro:page-load` lifecycle re-runs `syncActive()` on every navigation. */ const studio = document.querySelector<HTMLElement>('[data-studio]'); -const standalone = !!studio && studio.hasAttribute('data-standalone'); -const initialActive = studio?.dataset.initialActive || 'home'; -const isDesktop = () => window.matchMedia('(min-width: 1024px)').matches; -const ids = new Set(sections.map((s) => s.id)); - -function setActive(id: string, opts: { scroll?: boolean } = {}) { - if (!ids.has(id)) id = initialActive; - // Focused/standalone pages render a single section; if the requested one - // isn't in the DOM, keep the current view instead of blanking it. - if (!document.querySelector(`[data-section="${id}"]`)) return; - const meta = sectionById[id]; - - // Toggle sections (desktop locked mode uses .is-active; CSS ignores it on mobile) - document.querySelectorAll<HTMLElement>('[data-section]').forEach((el) => { - el.classList.toggle('is-active', el.dataset.section === id); - }); - // Reset the active result pane scroll to top on desktop swap - if (isDesktop()) { - const pane = document.querySelector<HTMLElement>(`[data-section="${id}"] .studio-results`); - pane?.scrollTo({ top: 0 }); - } +/* ---- Slug ↔ id helpers ---- */ +const slugToId = Object.fromEntries(sections.map((s) => [s.slug, s.id])); +const href = (slug: string) => (slug === '' ? '/' : `/${slug}`); - // Explorer active highlight (side + drawer) — single .active class, styled in CSS +function currentId(): string { + const seg = location.pathname.replace(/^\/+|\/+$/g, '').split('/')[0] ?? ''; + return slugToId[seg] ?? 'home'; +} + +function syncActive() { + const id = currentId(); + const meta = sectionById[id] ?? sectionById['home']; document.querySelectorAll<HTMLElement>('[data-section-link]').forEach((a) => { const on = a.dataset.sectionLink === id; a.closest<HTMLElement>('.exp-row')?.classList.toggle('active', on); a.setAttribute('aria-current', on ? 'true' : 'false'); }); - - // Status bar - const tableEl = document.querySelector('[data-statusbar-table]'); - const rowsEl = document.querySelector('[data-statusbar-rows]'); - if (tableEl) tableEl.textContent = meta.table; - if (rowsEl) rowsEl.textContent = String(meta.rows); - - if (opts.scroll && !isDesktop()) { - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } -} - -function currentHash(): string { - return (location.hash || `#${initialActive}`).slice(1); -} - -function onLinkClick(e: Event, id: string) { - if (standalone) { closeDrawer(); return; } // links are /#id → let the browser navigate home - if (isDesktop()) { - e.preventDefault(); - if (location.hash !== `#${id}`) history.pushState(null, '', `#${id}`); - setActive(id); - } else { - // mobile: let the anchor scroll naturally, just sync state + close drawer - setActive(id); - closeDrawer(); - } + const t = document.querySelector('[data-statusbar-table]'); + const r = document.querySelector('[data-statusbar-rows]'); + if (t) t.textContent = meta.table; + if (r) r.textContent = String(meta.rows); } /* ---- Mobile drawer ---- */ @@ -94,7 +60,7 @@ function closeDrawer() { /* ---- Explain panel toggle ---- */ function toggleExplain(trigger: HTMLElement) { const section = trigger.closest<HTMLElement>('[data-section]'); - const id = section?.dataset.section ?? currentHash(); + const id = section?.dataset.section ?? currentId(); const panel = document.querySelector<HTMLElement>(`[data-explain="${id}"]`); if (!panel) return; const open = panel.hasAttribute('hidden'); @@ -194,7 +160,7 @@ const studioConsole = { }; function runQuery() { - const id = currentHash(); + const id = currentId(); const meta = sectionById[id] ?? sectionById['home']; const pane = document.querySelector<HTMLElement>(`[data-section="${id}"] .studio-results`); const allowMotion = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -208,11 +174,11 @@ function runQuery() { } async function copyLink() { - const id = currentHash(); - const url = `${location.origin}${location.pathname}#${id}`; + const id = currentId(); + const url = location.origin + href(sectionById[id]?.slug ?? ''); try { await navigator.clipboard.writeText(url); - studioConsole.ok(`copied link to #${id}`); + studioConsole.ok(`copied link to ${url}`); } catch { studioConsole.notice(`copy this: ${url}`); } @@ -231,7 +197,7 @@ function downloadBlob(content: string, filename: string, mime: string) { } function exportSection() { - const id = currentHash(); + const id = currentId(); const payloadEl = document.querySelector<HTMLElement>(`[data-export-payload="${id}"]`); if (!payloadEl?.textContent) { studioConsole.notice('nothing to export from this view'); return; } let rows: Row[]; @@ -248,30 +214,6 @@ function exportSection() { studioConsole.ok(`exported ${filename} (${rows.length} rows)`); } -/* ---- Explorer search live-filter ---- */ -function wireExplorerSearch() { - document.querySelectorAll<HTMLInputElement>('[data-explorer-search]').forEach((input) => { - const scope = input.closest<HTMLElement>('[data-explorer-root]'); - if (!scope) return; - const items = [...scope.querySelectorAll<HTMLElement>('[data-explorer-item]')]; - const apply = () => { - const q = input.value; - items.forEach((li) => { - const name = li.dataset.explorerItem ?? ''; - li.hidden = fuzzyMatch(q, name) === null; - }); - }; - input.addEventListener('input', apply); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const first = items.find((li) => !li.hidden); - const id = first?.dataset.explorerItem; - if (id) { location.hash = `#${id}`; input.blur(); } - } - }); - }); -} - /* ---- Command palette ---- */ interface PaletteItem { label: string; hint: string; run: () => void; } @@ -279,10 +221,7 @@ function paletteItems(): PaletteItem[] { const jumps: PaletteItem[] = sections.map((s) => ({ label: `Jump to ${s.table}`, hint: `${s.rows} rows`, - run: () => { - if (standalone) location.href = `/#${s.id}`; - else location.hash = `#${s.id}`; - }, + run: () => { location.href = href(s.slug); }, })); const actions: PaletteItem[] = [ { label: 'Copy link to current section', hint: 'clipboard', run: () => copyLink() }, @@ -362,14 +301,47 @@ function closePalette() { paletteLastFocus?.focus(); } -function init() { - if (studio) studio.classList.add('js'); +/* ---- Delegated action click handler ---- */ +function onActionClick(e: Event) { + const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); + if (!el) return; + const action = el.dataset.action; + if (action === 'notice') { + e.preventDefault(); + const msg = NOTICES[el.dataset.notice ?? '']; + if (msg) studioConsole.push(msg); + } + if (action === 'explain') { e.preventDefault(); toggleExplain(el); } + if (action === 'run') { e.preventDefault(); runQuery(); } + if (action === 'copy-link') { e.preventDefault(); copyLink(); } + if (action === 'export') { e.preventDefault(); exportSection(); } + if (action === 'palette') { e.preventDefault(); openPalette(); } + if (action === 'results') { + e.preventDefault(); + const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + document.querySelector(`[data-section="${currentId()}"] .studio-results`)?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); + } +} + +/* ---- Keyboard handler ---- */ +function onKeydown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); return; } + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); openPalette(); return; } + const root = document.querySelector('[data-palette-root]'); + if (!root || root.hasAttribute('hidden')) return; + if (e.key === 'Escape') { e.preventDefault(); closePalette(); } + else if (e.key === 'Tab') { e.preventDefault(); (document.querySelector('[data-palette-input]') as HTMLElement | null)?.focus(); } + else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (paletteFiltered.length === 0) { e.preventDefault(); return; } + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(Math.min(paletteHighlight + 1, paletteFiltered.length - 1)); } + else { e.preventDefault(); setHighlight(Math.max(paletteHighlight - 1, 0)); } + } + else if (e.key === 'Enter') { e.preventDefault(); paletteFiltered[paletteHighlight]?.run(); closePalette(); } +} - // Wire explorer links - document.querySelectorAll<HTMLElement>('[data-section-link]').forEach((a) => { - a.addEventListener('click', (e) => onLinkClick(e, a.dataset.sectionLink!)); - }); - // Wire column toggles +/* ---- Chrome bindings (persisted across navigations) ---- */ +function bindChrome() { + // Column toggles document.querySelectorAll<HTMLElement>('[data-explorer-toggle]').forEach((btn) => { btn.addEventListener('click', () => toggleColumns(btn.dataset.explorerToggle!, btn)); }); @@ -378,63 +350,56 @@ function init() { document.querySelectorAll<HTMLElement>('[data-drawer-close]').forEach((el) => el.addEventListener('click', closeDrawer), ); - - // Single delegated handler for all chrome controls. - document.addEventListener('click', (e) => { - const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); - if (!el) return; - const action = el.dataset.action; - if (action === 'notice') { - e.preventDefault(); - const msg = NOTICES[el.dataset.notice ?? '']; - if (msg) studioConsole.push(msg); - } - if (action === 'explain') { e.preventDefault(); toggleExplain(el); } - if (action === 'run') { e.preventDefault(); runQuery(); } - if (action === 'copy-link') { e.preventDefault(); copyLink(); } - if (action === 'export') { e.preventDefault(); exportSection(); } - if (action === 'palette') { e.preventDefault(); openPalette(); } - if (action === 'results') { - e.preventDefault(); - const smooth = !window.matchMedia('(prefers-reduced-motion: reduce)').matches; - document.querySelector(`[data-section="${currentHash()}"] .studio-results`)?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); - } - }); - - // Hash routing — hashchange covers in-page link nav; popstate covers - // browser back/forward after our pushState() desktop swaps. - window.addEventListener('hashchange', () => setActive(currentHash())); - window.addEventListener('popstate', () => setActive(currentHash())); - - window.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); runQuery(); } - }); - - // Palette open/close + keyboard + // Palette close + input document.querySelector('[data-palette-close]')?.addEventListener('click', closePalette); const paletteInput = document.querySelector<HTMLInputElement>('[data-palette-input]'); paletteInput?.addEventListener('input', () => renderPalette(paletteInput.value)); - window.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); openPalette(); return; } - const root = document.querySelector('[data-palette-root]'); - if (!root || root.hasAttribute('hidden')) return; - if (e.key === 'Escape') { e.preventDefault(); closePalette(); } - else if (e.key === 'Tab') { e.preventDefault(); (document.querySelector('[data-palette-input]') as HTMLElement | null)?.focus(); } - else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - if (paletteFiltered.length === 0) { e.preventDefault(); return; } - if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(Math.min(paletteHighlight + 1, paletteFiltered.length - 1)); } - else { e.preventDefault(); setHighlight(Math.max(paletteHighlight - 1, 0)); } - } - else if (e.key === 'Enter') { e.preventDefault(); paletteFiltered[paletteHighlight]?.run(); closePalette(); } + // Explorer search + document.querySelectorAll<HTMLInputElement>('[data-explorer-search]').forEach((input) => { + const scope = input.closest<HTMLElement>('[data-explorer-root]'); + if (!scope) return; + const items = [...scope.querySelectorAll<HTMLElement>('[data-explorer-item]')]; + const apply = () => { + const q = input.value; + items.forEach((li) => { + const name = li.dataset.explorerItem ?? ''; + li.hidden = fuzzyMatch(q, name) === null; + }); + }; + input.addEventListener('input', apply); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const first = items.find((li) => !li.hidden); + const id = first?.dataset.explorerItem; + if (id) { + const slug = sectionById[id]?.slug ?? id; + location.href = href(slug); + input.blur(); + } + } + }); }); +} - wireExplorerSearch(); +/* ---- Lifecycle ---- */ +let wiredOnce = false; - setActive(currentHash()); +function wireOnce() { + if (wiredOnce) return; + wiredOnce = true; + studio?.classList.add('js'); + document.addEventListener('click', onActionClick); + window.addEventListener('keydown', onKeydown); + bindChrome(); } -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); -} else { - init(); +function onPage() { + syncActive(); } + +function start() { wireOnce(); onPage(); } + +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); +else start(); + +document.addEventListener('astro:page-load', () => { wireOnce(); onPage(); }); diff --git a/src/styles/global.css b/src/styles/global.css index 628807e..2990791 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -97,7 +97,7 @@ Studio layout — progressive enhancement • Default (no-JS, any width): sections stack & the page scrolls. • JS + desktop (.studio.js @ lg): viewport-locked IDE, single - active section swaps in the result pane. + section per page is always shown (no swap). ============================================================ */ .studio-section { display: block; } .caret-icon { display: inline-block; transition: transform 0.15s ease; } @@ -128,8 +128,7 @@ details[open] > summary .faq-sign::before { content: '−'; } @media (min-width: 1024px) { .studio.js { height: 100vh; height: 100dvh; overflow: hidden; } .studio.js .studio-pane { min-height: 0; overflow: hidden; } - .studio.js .studio-section { display: none; height: 100%; min-height: 0; } - .studio.js .studio-section.is-active { display: flex; flex-direction: column; } + .studio.js .studio-section { display: flex; flex-direction: column; height: 100%; min-height: 0; } .studio.js .studio-results { flex: 1 1 auto; min-height: 0; overflow-y: auto; } } From 690d1398cd476da349dca2a75876d803c9dcb3cb Mon Sep 17 00:00:00 2001 From: cevheri <cevheribozoglan@gmail.com> Date: Tue, 23 Jun 2026 23:13:21 +0300 Subject: [PATCH 09/16] feat: Astro View Transitions with persistent chrome --- src/components/studio/StudioShell.astro | 12 ++++++------ src/layouts/Layout.astro | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/studio/StudioShell.astro b/src/components/studio/StudioShell.astro index b328a2d..8536594 100644 --- a/src/components/studio/StudioShell.astro +++ b/src/components/studio/StudioShell.astro @@ -20,11 +20,11 @@ const { active = 'home', standalone = false } = Astro.props; data-initial-active={active} data-standalone={standalone ? '' : undefined} > - <div class="hidden lg:block"><TopBar /></div> - <MobileTopBar active={active} standalone={standalone} /> + <div class="hidden lg:block" transition:persist="topbar"><TopBar /></div> + <MobileTopBar active={active} standalone={standalone} transition:persist="mobiletop" /> <div class="studio-workbench flex min-h-0 flex-1"> - <aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-edge lg:block"> + <aside class="hidden w-64 shrink-0 overflow-y-auto border-r border-edge lg:block" transition:persist="sidebar"> <Explorer active={active} idPrefix="side" standalone={standalone} /> </aside> @@ -33,11 +33,11 @@ const { active = 'home', standalone = false } = Astro.props; </main> </div> - <div class="hidden lg:block"><StatusBar active={active} /></div> - <Console /> + <div class="hidden lg:block" transition:persist="statusbar"><StatusBar active={active} /></div> + <Console transition:persist="console" /> </div> -<CommandPalette /> +<CommandPalette transition:persist="palette" /> <script> import '../../scripts/studio.ts'; diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index e21732f..1b2eb4a 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- import '../styles/global.css'; import CookieConsent from '../components/CookieConsent.astro'; +import { ClientRouter } from 'astro:transitions'; interface Props { title: string; @@ -140,6 +141,7 @@ const webSiteSchema = { <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&display=swap" rel="stylesheet" /> <title>{title} + From 5bc484597dd9d9183f1fb14626ddb292e5e631c1 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 23:19:03 +0300 Subject: [PATCH 10/16] fix: delegate chrome interactions so palette-close/search/drawer/toggles survive View Transitions Co-Authored-By: Claude Sonnet 4.6 --- src/scripts/studio.ts | 101 ++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/src/scripts/studio.ts b/src/scripts/studio.ts index 00295b2..51ea3a9 100644 --- a/src/scripts/studio.ts +++ b/src/scripts/studio.ts @@ -38,21 +38,21 @@ function syncActive() { } /* ---- Mobile drawer ---- */ -const drawerRoot = document.querySelector('[data-drawer-root]'); -const drawerPanel = document.querySelector('[data-drawer-panel]'); -const drawerOpenBtn = document.querySelector('[data-drawer-open]'); - function openDrawer() { + const drawerRoot = document.querySelector('[data-drawer-root]'); + const drawerPanel = document.querySelector('[data-drawer-panel]'); if (!drawerRoot || !drawerPanel) return; drawerRoot.classList.remove('hidden'); requestAnimationFrame(() => drawerPanel.classList.remove('-translate-x-full')); - drawerOpenBtn?.setAttribute('aria-expanded', 'true'); + document.querySelector('[data-drawer-open]')?.setAttribute('aria-expanded', 'true'); document.body.style.overflow = 'hidden'; } function closeDrawer() { + const drawerRoot = document.querySelector('[data-drawer-root]'); + const drawerPanel = document.querySelector('[data-drawer-panel]'); if (!drawerRoot || !drawerPanel) return; drawerPanel.classList.add('-translate-x-full'); - drawerOpenBtn?.setAttribute('aria-expanded', 'false'); + document.querySelector('[data-drawer-open]')?.setAttribute('aria-expanded', 'false'); document.body.style.overflow = ''; setTimeout(() => drawerRoot.classList.add('hidden'), 200); } @@ -303,7 +303,21 @@ function closePalette() { /* ---- Delegated action click handler ---- */ function onActionClick(e: Event) { - const el = (e.target as HTMLElement).closest('[data-action]'); + const target = e.target as HTMLElement; + + // Chrome delegations: palette-close, drawer open/close, explorer column toggles + if (target.closest('[data-palette-close]')) { e.preventDefault(); closePalette(); return; } + if (target.closest('[data-drawer-open]')) { e.preventDefault(); openDrawer(); return; } + if (target.closest('[data-drawer-close]')) { e.preventDefault(); closeDrawer(); return; } + const toggleBtn = target.closest('[data-explorer-toggle]'); + if (toggleBtn) { + e.preventDefault(); + const id = toggleBtn.dataset.explorerToggle!; + toggleColumns(id, toggleBtn); + return; + } + + const el = target.closest('[data-action]'); if (!el) return; const action = el.dataset.action; if (action === 'notice') { @@ -339,45 +353,14 @@ function onKeydown(e: KeyboardEvent) { else if (e.key === 'Enter') { e.preventDefault(); paletteFiltered[paletteHighlight]?.run(); closePalette(); } } -/* ---- Chrome bindings (persisted across navigations) ---- */ -function bindChrome() { - // Column toggles - document.querySelectorAll('[data-explorer-toggle]').forEach((btn) => { - btn.addEventListener('click', () => toggleColumns(btn.dataset.explorerToggle!, btn)); - }); - // Drawer - drawerOpenBtn?.addEventListener('click', openDrawer); - document.querySelectorAll('[data-drawer-close]').forEach((el) => - el.addEventListener('click', closeDrawer), - ); - // Palette close + input - document.querySelector('[data-palette-close]')?.addEventListener('click', closePalette); - const paletteInput = document.querySelector('[data-palette-input]'); - paletteInput?.addEventListener('input', () => renderPalette(paletteInput.value)); - // Explorer search - document.querySelectorAll('[data-explorer-search]').forEach((input) => { - const scope = input.closest('[data-explorer-root]'); - if (!scope) return; - const items = [...scope.querySelectorAll('[data-explorer-item]')]; - const apply = () => { - const q = input.value; - items.forEach((li) => { - const name = li.dataset.explorerItem ?? ''; - li.hidden = fuzzyMatch(q, name) === null; - }); - }; - input.addEventListener('input', apply); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - const first = items.find((li) => !li.hidden); - const id = first?.dataset.explorerItem; - if (id) { - const slug = sectionById[id]?.slug ?? id; - location.href = href(slug); - input.blur(); - } - } - }); +/* ---- Explorer search filter helper ---- */ +function filterExplorer(input: HTMLInputElement) { + const scope = input.closest('[data-explorer-root]'); + if (!scope) return; + const q = input.value; + scope.querySelectorAll('[data-explorer-item]').forEach((li) => { + const name = li.dataset.explorerItem ?? ''; + li.hidden = fuzzyMatch(q, name) === null; }); } @@ -390,7 +373,31 @@ function wireOnce() { studio?.classList.add('js'); document.addEventListener('click', onActionClick); window.addEventListener('keydown', onKeydown); - bindChrome(); + + // Delegated input: explorer search filter + palette input + document.addEventListener('input', (e) => { + const target = e.target as HTMLElement; + const searchInput = target.closest('[data-explorer-search]'); + if (searchInput) { filterExplorer(searchInput); return; } + const paletteInput = target.closest('[data-palette-input]'); + if (paletteInput) { renderPalette(paletteInput.value); } + }); + + // Delegated keydown: explorer search Enter-to-jump + document.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + const searchInput = (e.target as HTMLElement).closest('[data-explorer-search]'); + if (!searchInput) return; + const scope = searchInput.closest('[data-explorer-root]'); + if (!scope) return; + const first = scope.querySelector('[data-explorer-item]:not([hidden])'); + const id = first?.dataset.explorerItem; + if (id) { + const slug = sectionById[id]?.slug ?? id; + location.href = href(slug); + searchInput.blur(); + } + }); } function onPage() { From db1d4fe3f58d12fdca0065e8b82f14f9056dca07 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 23:21:10 +0300 Subject: [PATCH 11/16] feat: internal links point to real section URLs --- src/components/Footer.astro | 6 +++--- src/components/Header.astro | 8 ++++---- src/components/sections/DockerComposeSection.astro | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 5e64c8a..2ba8ceb 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -6,9 +6,9 @@ const sections = [ id: "product", title: "Product", links: [ - { label: "Features", href: "/#features" }, - { label: "Databases", href: "/#databases" }, - { label: "Tech Stack", href: "/#tech-stack" }, + { label: "Features", href: "/features" }, + { label: "Databases", href: "/databases" }, + { label: "Tech Stack", href: "/tech-stack" }, { label: "Deploy", href: "/deploy" }, { label: "Live Demo", href: "https://app.libredb.org", external: true }, ], diff --git a/src/components/Header.astro b/src/components/Header.astro index bc4b5fa..b84c56d 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -13,10 +13,10 @@ diff --git a/src/components/sections/DockerComposeSection.astro b/src/components/sections/DockerComposeSection.astro index 4a5143b..3ec2fd9 100644 --- a/src/components/sections/DockerComposeSection.astro +++ b/src/components/sections/DockerComposeSection.astro @@ -163,7 +163,7 @@ const envGroups = [ Related issue #54 - + All install options From cd9d56bf28282ad82dea7c64206bff8644e19958 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 23:28:02 +0300 Subject: [PATCH 12/16] chore: bump version to 0.4.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a8ac924..6e30b69 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libredb-studio-website", "type": "module", - "version": "0.4.1", + "version": "0.4.2", "scripts": { "dev": "astro dev", "build": "node scripts/sync-docker-compose.mjs && astro build", From 47e65e3ba243bc0edfe4fb0f2361c8243e131321 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 23:36:37 +0300 Subject: [PATCH 13/16] fix: re-apply .studio.js on each VT navigation; guard delegated targets; clearer [section] error; drop unused standalone prop Co-Authored-By: Claude Sonnet 4.6 --- src/components/studio/Explorer.astro | 3 +-- src/components/studio/MobileTopBar.astro | 4 ++-- src/components/studio/StudioShell.astro | 6 +++--- src/pages/[section].astro | 1 + src/scripts/studio.ts | 11 ++++++++--- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/components/studio/Explorer.astro b/src/components/studio/Explorer.astro index 72ddd14..c27de96 100644 --- a/src/components/studio/Explorer.astro +++ b/src/components/studio/Explorer.astro @@ -5,9 +5,8 @@ interface Props { active?: string; idPrefix?: string; // to keep ids unique between desktop sidebar & mobile drawer showConnectionsLabel?: boolean; - standalone?: boolean; } -const { active = 'home', idPrefix = 'exp', showConnectionsLabel = true, standalone = false } = Astro.props; +const { active = 'home', idPrefix = 'exp', showConnectionsLabel = true } = Astro.props; ---
diff --git a/src/components/studio/MobileTopBar.astro b/src/components/studio/MobileTopBar.astro index 6ac85ef..7b10c32 100644 --- a/src/components/studio/MobileTopBar.astro +++ b/src/components/studio/MobileTopBar.astro @@ -1,8 +1,8 @@ --- import Explorer from './Explorer.astro'; -interface Props { active?: string; standalone?: boolean; } -const { active = 'home', standalone = false } = Astro.props; +interface Props { active?: string; } +const { active = 'home' } = Astro.props; const DEMO = 'https://app.libredb.org'; ---
diff --git a/src/components/studio/StudioShell.astro b/src/components/studio/StudioShell.astro index 8536594..efecfad 100644 --- a/src/components/studio/StudioShell.astro +++ b/src/components/studio/StudioShell.astro @@ -1,6 +1,6 @@ --- // src/components/studio/StudioShell.astro -// The shared IDE chrome. index.astro and deploy.astro both render through this. +// Shared IDE chrome for the home page and every [section] page. import TopBar from './TopBar.astro'; import MobileTopBar from './MobileTopBar.astro'; import Explorer from './Explorer.astro'; @@ -21,11 +21,11 @@ const { active = 'home', standalone = false } = Astro.props; data-standalone={standalone ? '' : undefined} > - +
diff --git a/src/pages/[section].astro b/src/pages/[section].astro index fe980bc..e58b223 100644 --- a/src/pages/[section].astro +++ b/src/pages/[section].astro @@ -35,6 +35,7 @@ const COMPONENTS: Record = { const { id } = Astro.props as { id: string }; const meta = sectionById[id]; const Body = COMPONENTS[id]; +if (!Body) throw new Error(`[section].astro: no component mapped for section id "${id}"`); const jsonLd = sectionSeo[id] ?? []; --- diff --git a/src/scripts/studio.ts b/src/scripts/studio.ts index 51ea3a9..33eeacd 100644 --- a/src/scripts/studio.ts +++ b/src/scripts/studio.ts @@ -303,7 +303,8 @@ function closePalette() { /* ---- Delegated action click handler ---- */ function onActionClick(e: Event) { - const target = e.target as HTMLElement; + const target = e.target; + if (!(target instanceof Element)) return; // Chrome delegations: palette-close, drawer open/close, explorer column toggles if (target.closest('[data-palette-close]')) { e.preventDefault(); closePalette(); return; } @@ -376,7 +377,8 @@ function wireOnce() { // Delegated input: explorer search filter + palette input document.addEventListener('input', (e) => { - const target = e.target as HTMLElement; + const target = e.target; + if (!(target instanceof Element)) return; const searchInput = target.closest('[data-explorer-search]'); if (searchInput) { filterExplorer(searchInput); return; } const paletteInput = target.closest('[data-palette-input]'); @@ -386,7 +388,9 @@ function wireOnce() { // Delegated keydown: explorer search Enter-to-jump document.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; - const searchInput = (e.target as HTMLElement).closest('[data-explorer-search]'); + const target = e.target; + if (!(target instanceof Element)) return; + const searchInput = target.closest('[data-explorer-search]'); if (!searchInput) return; const scope = searchInput.closest('[data-explorer-root]'); if (!scope) return; @@ -401,6 +405,7 @@ function wireOnce() { } function onPage() { + document.querySelector('[data-studio]')?.classList.add('js'); syncActive(); } From 116748d973ffb0b2137eb88c3b41010cc408fec1 Mon Sep 17 00:00:00 2001 From: cevheri Date: Tue, 23 Jun 2026 23:47:46 +0300 Subject: [PATCH 14/16] fix: close mobile drawer on VT navigation; re-run page scripts (copy buttons, stars) after transitions Co-Authored-By: Claude Sonnet 4.6 --- src/components/sections/DeploySection.astro | 2 +- src/components/sections/DockerComposeSection.astro | 2 +- src/scripts/studio.ts | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/sections/DeploySection.astro b/src/components/sections/DeploySection.astro index b635162..b7454e6 100644 --- a/src/components/sections/DeploySection.astro +++ b/src/components/sections/DeploySection.astro @@ -108,7 +108,7 @@ const summary = sortedCategories.map((c, i) => ({
- diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 2ba8ceb..d993d65 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -212,7 +212,7 @@ const sections = [
-