feat(www): landing page (#25)
* design: landing page spec + app icon asset * chore(www): add anime.js, fonts, and landing page screenshot assets * feat(www): add design tokens, fonts, shared layout * feat(www): add landing page sections and styling * feat(www): add theme toggle and anime.js viewport animations * feat(www): feedback updates — no hero card, os-aware download, footer email, lowercase colored heading * feat(www): remove screenshot borders, move macos hint below ctas, mobile github fallback * feat(www): keep hero CTAs side by side * feat(www): set initial nav border to transparent * feat(www): add Rybbit prod-only, sitemap, SEO head, OG image, robots.txt, 404 page * docs(AGENTS): include www landing page version in release sync checklist * chore(www): add railpack.json for Dokploy deploy (force node provider, TZ) * chore(www): use bun for Railpack deploy (packageManager + standalone www/bun.lock) * docs(AGENTS): note www/bun.lock sync for Railpack deploys * chore(www): add vitest test tooling * feat(www): add FAQ data source * feat(www): add SEO JSON-LD builders * feat(www): add Seo JSON-LD component * feat(www): wire Seo component and social/meta tags into Layout * feat(www): add homepage JSON-LD, keyword H1, data-driven FAQ * fix(www): point Roadmap footer link to GitHub * feat(www): add lastmod to sitemap * perf(www): self-host anime.js * refactor(www): move nav and footer into shared Layout * feat(www): add changelog page * fix(www): make shared component styles global in Layout * perf(www): optimize screenshots with Astro Image (webp/avif + srcset) * feat(www): add web app manifest * feat(www): add helpful links to 404 page * fix(www): shorten homepage H1 copy * fix(www): OS-aware navbar download + footer layout * feat(www): build full changelog from v0.7.0 to v0.7.10
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
@@ -0,0 +1,15 @@
|
||||
---
|
||||
import type { JsonLd } from "../lib/seo";
|
||||
|
||||
interface Props {
|
||||
jsonLd?: JsonLd[];
|
||||
}
|
||||
|
||||
const { jsonLd = [] } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
jsonLd.map((ld) => (
|
||||
<script type="application/ld+json" set:html={JSON.stringify(ld)} />
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
highlights: string[];
|
||||
}
|
||||
|
||||
export const changelogEntries: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.7.10",
|
||||
date: "2026-08-13",
|
||||
highlights: [
|
||||
"Copy connection URL from the connection card menu — with or without the password (password fetched from your OS keychain on demand, percent-encoded, sslmode appended for PostgreSQL).",
|
||||
"First-launch documentation improvements for macOS (Gatekeeper / quarantine instructions).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.9",
|
||||
date: "2026-08-08",
|
||||
highlights: ["Fix macOS launch issues on Apple Silicon."],
|
||||
},
|
||||
{
|
||||
version: "0.7.8",
|
||||
date: "2026-08-08",
|
||||
highlights: [
|
||||
"MySQL & SQLite backup and restore — SQLite `.dump` portability plus mysqldump for MySQL, matching the pg_dump UX.",
|
||||
"DB-to-DB sync extended beyond PostgreSQL (MySQL & SQLite).",
|
||||
"Excel (.xlsx) export alongside JSON / CSV / SQL / Markdown.",
|
||||
"Cancel long-running queries from the toolbar (pg_cancel_backend / MySQL KILL / SQLite interruption).",
|
||||
"Settings export/import (theme, accent, editor options, page sizes, defaults).",
|
||||
"Windows/Linux title-bar fix — native title bar for dragging, macOS keeps the overlay drag strip.",
|
||||
"Visual Create Table / Edit Table extended to SQLite (SQLite-aware type mapping and ALTER TABLE limits).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.7",
|
||||
date: "2026-08-07",
|
||||
highlights: [
|
||||
"PostgreSQL roles & grants — create/edit/drop roles, per-role privilege explorer grouped by object class, and GRANT/REVOKE staging with WITH GRANT OPTION.",
|
||||
"Table maintenance — right-click any table for VACUUM / ANALYZE / REINDEX.",
|
||||
"Visual Create Table editor — columns grid with type dropdowns, drag-to-reorder, single & composite primary keys, live SQL preview.",
|
||||
"Edit Table as a robust column diff — ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL staged one per queue item, with a stale-write guard.",
|
||||
"Atomic column-reorder table rebuild — single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/partitioning.",
|
||||
"Foreign keys — multi-column FK composer, cross-schema references, ON DELETE / ON UPDATE actions, edit/remove from the form.",
|
||||
"Table options — tablespace picker and row-level security toggle.",
|
||||
"Fixes: rolconnlimit cast, aclexplode-based privilege queries (works on PG 15+), auto schema-tree refresh after schema-modifying commits.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.6",
|
||||
date: "2026-08-06",
|
||||
highlights: [
|
||||
"PostgreSQL object management CRUD — right-click / ⋮ create, edit, and drop every PG object type, opened as workspace tabs with a Visual ⇄ SQL toggle and staged through the changes queue with generated-SQL preview.",
|
||||
"Objects view now uses the shared tabbed workspace — object details open as tabs, plus an inline manual query tab.",
|
||||
"Keychain toggle (default ON) — opt out to keep DB passwords session-only and purge existing keychain entries; SSH secrets stay keychain-only.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.5",
|
||||
date: "2026-08-05",
|
||||
highlights: [
|
||||
"Bundled PostgreSQL client tools — pg_dump, pg_restore, and psql ship as Tauri resources with system-first, bundled-fallback resolution.",
|
||||
"Schema CRUD — create, rename, and drop schemas from the object tree with CASCADE-drop typed-name confirmation and a dependency warning.",
|
||||
"Global object search (Cmd+K) — current-schema scope across all object types; results open a table tab or jump to the Objects view.",
|
||||
"Copy as DDL for any object — CREATE DDL for every browsable type (tables via pg_dump, pg_get_*def passthrough, synthesized sequences/enums/extensions/views).",
|
||||
"Object dependencies — a pg_depend “what depends on this?” view shown before destructive drops (table, schema).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.0",
|
||||
date: "2026-08-04",
|
||||
highlights: [
|
||||
"Revamped New Connection screen — two-stage entry: paste a connection URI or pick from a 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB), then configure label, tags, environment, folder, and General | SSH·SSL tabs.",
|
||||
"MySQL DB viewer — connect (SSL + SSH tunnel), browse databases/tables/columns/FKs, query with pagination, inline cell editing with a changes queue, DDL copy, CSV/JSON import.",
|
||||
"SQLite connection via file-path picker instead of a URI field.",
|
||||
"DB viewer capability gating — per-database-type feature matrix; Redis browsing gated with a clean “not supported” state.",
|
||||
"Schema/database selector loading states, table toolbar renders during load, tag overflow scroll on connection cards.",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { faqItems } from "./faq";
|
||||
|
||||
describe("faqItems", () => {
|
||||
it("has exactly 5 items", () => {
|
||||
expect(faqItems).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("every item has a non-empty question and answer", () => {
|
||||
for (const item of faqItems) {
|
||||
expect(item.question.trim().length).toBeGreaterThan(0);
|
||||
expect(item.answer.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("questions are unique", () => {
|
||||
const questions = faqItems.map((i) => i.question);
|
||||
expect(new Set(questions).size).toBe(questions.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface FaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export const faqItems: FaqItem[] = [
|
||||
{
|
||||
question: "Is Gridline really free for commercial use?",
|
||||
answer:
|
||||
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.",
|
||||
},
|
||||
{
|
||||
question: "Which databases are supported?",
|
||||
answer:
|
||||
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
|
||||
},
|
||||
{
|
||||
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
||||
answer:
|
||||
"No paywalled features, no tab or connection limits, and DB-to-DB sync without intermediate files. We also expose deeper PostgreSQL objects like sequences, enums, extensions, roles, and grants.",
|
||||
},
|
||||
{
|
||||
question: "Do you store my database credentials?",
|
||||
answer:
|
||||
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
||||
},
|
||||
{
|
||||
question: "Can I self-host or contribute?",
|
||||
answer:
|
||||
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import Seo from "../components/Seo.astro";
|
||||
import type { JsonLd } from "../lib/seo";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
ogImage?: string;
|
||||
noIndex?: boolean;
|
||||
jsonLd?: JsonLd[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = "Gridline — the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
||||
ogImage = "/og.png",
|
||||
noIndex = false,
|
||||
jsonLd = [],
|
||||
} = Astro.props;
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
const ogImageURL = new URL(ogImage, Astro.site);
|
||||
const isProd = import.meta.env.PROD;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme-auto>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/gridline-icon.svg" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:image" content={ogImageURL} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Gridline — the open-source database GUI" />
|
||||
<meta property="og:site_name" content="Gridline" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL} />
|
||||
<meta name="twitter:site" content="@AdrianBonpin" />
|
||||
<meta name="twitter:creator" content="@AdrianBonpin" />
|
||||
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
{noIndex && <meta name="robots" content="noindex, nofollow" />}
|
||||
|
||||
<Seo jsonLd={jsonLd} />
|
||||
|
||||
{
|
||||
isProd && (
|
||||
<script
|
||||
src="https://stat.ranio.xyz/api/script.js"
|
||||
data-site-id="846d0ea9e3d0"
|
||||
defer
|
||||
/>
|
||||
)
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<nav id="nav" class="fixed top-0 inset-x-0 z-50 transition-all duration-300 border-b border-transparent">
|
||||
<div class="max-w-[1200px] mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2.5 group">
|
||||
<img src="/gridline-icon.svg" alt="Gridline" class="w-7 h-7" />
|
||||
<span class="text-lg font-semibold tracking-tight">Gridline</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center gap-6 text-sm">
|
||||
<a href="/#features" class="nav-link">Features</a>
|
||||
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="nav-link">GitHub</a>
|
||||
<a href="/#download" data-download data-download-label class="nav-link">Download</a>
|
||||
<button id="theme-toggle" class="p-2 rounded-full hover:bg-[var(--surface-elevated)] transition-colors" aria-label="Toggle theme">
|
||||
<span class="theme-icon-sun hidden">☀</span>
|
||||
<span class="theme-icon-moon">☾</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
|
||||
<footer class="border-t border-[var(--border)] mt-20 md:mt-28">
|
||||
<div class="max-w-[1200px] mx-auto px-6 py-16 md:py-20">
|
||||
<div class="grid md:grid-cols-4 gap-10 md:gap-8">
|
||||
<div class="md:col-span-1">
|
||||
<a href="/" class="flex items-center gap-2.5 mb-4">
|
||||
<img src="/gridline-icon.svg" alt="Gridline" class="w-6 h-6" />
|
||||
<span class="text-lg font-semibold">Gridline</span>
|
||||
</a>
|
||||
<p class="text-sm text-[var(--text-muted)] leading-relaxed">
|
||||
The open-source database GUI that doesn't hold features hostage.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-4">Product</h4>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
<li><a href="/#download" class="footer-link">Download</a></li>
|
||||
<li><a href="/#features" class="footer-link">Features</a></li>
|
||||
<li><a href="/changelog" class="footer-link">Changelog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-4">Project</h4>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
<li><a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="footer-link">GitHub</a></li>
|
||||
<li><a href="https://github.com/AdrianBonpin/gridline/issues" target="_blank" rel="noopener" class="footer-link">Issues</a></li>
|
||||
<li><a href="https://github.com/AdrianBonpin/gridline/blob/prod/ROADMAP.md" target="_blank" rel="noopener" class="footer-link">Roadmap</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-4">Connect</h4>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
||||
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
||||
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
||||
<span>Made with care for databases.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
.nav-link {
|
||||
position: relative;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.nav-link::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -2px;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: var(--primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.nav-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
.primary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.primary-btn:hover {
|
||||
transform: scale(1.02);
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 10px 30px -10px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.ghost-btn:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--surface-elevated);
|
||||
}
|
||||
.glass-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
.glass-card:hover {
|
||||
background: var(--surface-elevated);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.feature-card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.faq-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
.faq-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
list-style: none;
|
||||
}
|
||||
.faq-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.faq-chevron {
|
||||
transition: transform 0.3s;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.faq-item[open] .faq-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.faq-body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
.footer-link {
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
.footer-link:hover {
|
||||
color: var(--text);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSoftwareApplicationJsonLd,
|
||||
buildFaqJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
buildBreadcrumbJsonLd,
|
||||
buildTitle,
|
||||
} from "./seo";
|
||||
|
||||
describe("seo builders", () => {
|
||||
it("buildSoftwareApplicationJsonLd returns valid schema", () => {
|
||||
const ld = buildSoftwareApplicationJsonLd({
|
||||
name: "Gridline",
|
||||
url: "https://getgridline.app/",
|
||||
description: "Open-source database GUI",
|
||||
image: "https://getgridline.app/og.png",
|
||||
operatingSystem: "macOS, Windows, Linux",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
offersPrice: 0,
|
||||
offersCurrency: "USD",
|
||||
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
|
||||
});
|
||||
expect(ld["@type"]).toBe("SoftwareApplication");
|
||||
expect(ld.name).toBe("Gridline");
|
||||
expect((ld.offers as { price: number }).price).toBe(0);
|
||||
});
|
||||
|
||||
it("buildFaqJsonLd maps questions to mainEntity", () => {
|
||||
const ld = buildFaqJsonLd([
|
||||
{ question: "Q1", answer: "A1" },
|
||||
{ question: "Q2", answer: "A2" },
|
||||
]);
|
||||
expect(ld["@type"]).toBe("FAQPage");
|
||||
const main = ld.mainEntity as { name: string }[];
|
||||
expect(main).toHaveLength(2);
|
||||
expect(main[0].name).toBe("Q1");
|
||||
});
|
||||
|
||||
it("buildOrganizationJsonLd includes sameAs", () => {
|
||||
const ld = buildOrganizationJsonLd({
|
||||
name: "Gridline",
|
||||
url: "https://getgridline.app/",
|
||||
logo: "https://getgridline.app/gridline-icon.svg",
|
||||
sameAs: ["https://github.com/AdrianBonpin/gridline"],
|
||||
});
|
||||
expect(ld["@type"]).toBe("Organization");
|
||||
expect(ld.sameAs).toContain("https://github.com/AdrianBonpin/gridline");
|
||||
});
|
||||
|
||||
it("buildWebSiteJsonLd includes SearchAction", () => {
|
||||
const ld = buildWebSiteJsonLd({
|
||||
name: "Gridline",
|
||||
url: "https://getgridline.app/",
|
||||
searchUrl: "https://getgridline.app/?q={search_term_string}",
|
||||
});
|
||||
expect(ld["@type"]).toBe("WebSite");
|
||||
expect(ld.potentialAction).toBeDefined();
|
||||
});
|
||||
|
||||
it("buildBreadcrumbJsonLd builds itemListElement", () => {
|
||||
const ld = buildBreadcrumbJsonLd([
|
||||
{ name: "Home", url: "https://getgridline.app/" },
|
||||
{ name: "Changelog", url: "https://getgridline.app/changelog" },
|
||||
]);
|
||||
expect(ld["@type"]).toBe("BreadcrumbList");
|
||||
expect((ld.itemListElement as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it("buildTitle appends the brand", () => {
|
||||
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
export interface JsonLd {
|
||||
"@context": "https://schema.org";
|
||||
"@type": string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function buildSoftwareApplicationJsonLd(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
description: string;
|
||||
image: string;
|
||||
operatingSystem: string;
|
||||
applicationCategory: string;
|
||||
offersPrice: number;
|
||||
offersCurrency: string;
|
||||
downloadUrl: string;
|
||||
}): JsonLd {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
description: opts.description,
|
||||
image: opts.image,
|
||||
operatingSystem: opts.operatingSystem,
|
||||
applicationCategory: opts.applicationCategory,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: opts.offersPrice,
|
||||
priceCurrency: opts.offersCurrency,
|
||||
},
|
||||
downloadUrl: opts.downloadUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFaqJsonLd(
|
||||
questions: { question: string; answer: string }[],
|
||||
): JsonLd {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: questions.map((q) => ({
|
||||
"@type": "Question",
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: q.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrganizationJsonLd(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
sameAs: string[];
|
||||
}): JsonLd {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
logo: opts.logo,
|
||||
sameAs: opts.sameAs,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebSiteJsonLd(opts: {
|
||||
name: string;
|
||||
url: string;
|
||||
searchUrl?: string;
|
||||
}): JsonLd {
|
||||
const ld: JsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: opts.name,
|
||||
url: opts.url,
|
||||
};
|
||||
if (opts.searchUrl) {
|
||||
ld.potentialAction = {
|
||||
"@type": "SearchAction",
|
||||
target: {
|
||||
"@type": "EntryPoint",
|
||||
urlTemplate: opts.searchUrl,
|
||||
},
|
||||
"query-input": "required name=search_term_string",
|
||||
};
|
||||
}
|
||||
return ld;
|
||||
}
|
||||
|
||||
export function buildBreadcrumbJsonLd(
|
||||
items: { name: string; url: string }[],
|
||||
): JsonLd {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: items.map((item, i) => ({
|
||||
"@type": "ListItem",
|
||||
position: i + 1,
|
||||
name: item.name,
|
||||
item: item.url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTitle(primary: string, brand = "Gridline"): string {
|
||||
return `${primary} | ${brand}`;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
|
||||
const title = "Page not found — Gridline";
|
||||
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} noIndex>
|
||||
<main class="min-h-screen flex flex-col items-center justify-center px-6 text-center">
|
||||
<p class="font-mono text-xs tracking-[0.15em] uppercase text-[var(--accent)] mb-4">404</p>
|
||||
<h1 class="font-serif text-5xl md:text-7xl tracking-tight mb-4 lowercase">Lost in the schema.</h1>
|
||||
<p class="text-[var(--text-muted)] max-w-md mb-8">
|
||||
We couldn’t find that page. It may have been renamed, removed, or never existed.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/" class="primary-btn">Back home</a>
|
||||
<a href="/changelog" class="ghost-btn">Changelog</a>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { changelogEntries } from "../data/changelog";
|
||||
import { buildBreadcrumbJsonLd, buildTitle } from "../lib/seo";
|
||||
|
||||
const site = "https://getgridline.app";
|
||||
const jsonLd = [
|
||||
buildBreadcrumbJsonLd([
|
||||
{ name: "Home", url: `${site}/` },
|
||||
{ name: "Changelog", url: `${site}/changelog` },
|
||||
]),
|
||||
];
|
||||
---
|
||||
|
||||
<Layout
|
||||
title={buildTitle("Changelog")}
|
||||
description="Gridline release notes. Free, open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis."
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<main class="pt-32 pb-20 md:pt-44 md:pb-28">
|
||||
<div class="max-w-[800px] mx-auto px-6">
|
||||
<p class="font-mono text-xs tracking-[0.15em] uppercase text-[var(--accent)] mb-4">Changelog</p>
|
||||
<h1 class="font-serif text-4xl md:text-6xl leading-[1.05] tracking-tight mb-10">Release notes</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
{
|
||||
changelogEntries.map((entry) => (
|
||||
<article class="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6">
|
||||
<h2 class="text-xl font-semibold mb-1">v{entry.version}</h2>
|
||||
<p class="text-xs text-[var(--text-muted)] mb-3">{entry.date}</p>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
{entry.highlights.map((h) => <li>• {h}</li>)}
|
||||
</ul>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-sm text-[var(--text-muted)]">
|
||||
Full release history on{" "}
|
||||
<a href="https://github.com/AdrianBonpin/gridline/releases" target="_blank" rel="noopener" class="text-[var(--primary)] hover:text-[var(--primary-hover)]">
|
||||
GitHub Releases
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
+197
-14
@@ -1,17 +1,200 @@
|
||||
---
|
||||
import "../styles/global.css";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { faqItems } from "../data/faq";
|
||||
import {
|
||||
buildSoftwareApplicationJsonLd,
|
||||
buildFaqJsonLd,
|
||||
buildOrganizationJsonLd,
|
||||
buildWebSiteJsonLd,
|
||||
} from "../lib/seo";
|
||||
import { Image } from "astro:assets";
|
||||
import queryEditor from "../assets/screenshots/query-editor.png";
|
||||
import erDiagram from "../assets/screenshots/er-diagram.png";
|
||||
import home from "../assets/screenshots/home.png";
|
||||
import dataGrid from "../assets/screenshots/data-grid.png";
|
||||
const version = "0.7.10";
|
||||
const site = "https://getgridline.app";
|
||||
const jsonLd = [
|
||||
buildSoftwareApplicationJsonLd({
|
||||
name: "Gridline",
|
||||
url: `${site}/`,
|
||||
description:
|
||||
"The open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
||||
image: `${site}/og.png`,
|
||||
operatingSystem: "macOS, Windows, Linux",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
offersPrice: 0,
|
||||
offersCurrency: "USD",
|
||||
downloadUrl: "https://github.com/AdrianBonpin/gridline/releases",
|
||||
}),
|
||||
buildFaqJsonLd(faqItems),
|
||||
buildOrganizationJsonLd({
|
||||
name: "Gridline",
|
||||
url: `${site}/`,
|
||||
logo: `${site}/gridline-icon.svg`,
|
||||
sameAs: [
|
||||
"https://github.com/AdrianBonpin/gridline",
|
||||
"https://x.com/AdrianBonpin",
|
||||
],
|
||||
}),
|
||||
buildWebSiteJsonLd({
|
||||
name: "Gridline",
|
||||
url: `${site}/`,
|
||||
}),
|
||||
];
|
||||
---
|
||||
<Layout title="Gridline — Open-Source Database GUI" jsonLd={jsonLd}>
|
||||
<script src="/landing.js" defer></script>
|
||||
|
||||
<!-- Hero -->
|
||||
<header id="hero" class="relative pt-32 pb-20 md:pt-44 md:pb-28 overflow-hidden">
|
||||
<div class="max-w-[1200px] mx-auto px-6 text-center">
|
||||
<div class="hero-animate">
|
||||
<p class="font-mono text-xs tracking-[0.15em] uppercase text-[var(--accent)] mb-5">Open-Source Database GUI</p>
|
||||
<h1 class="font-serif text-5xl md:text-7xl lg:text-8xl leading-[1.05] tracking-tight max-w-4xl mx-auto lowercase">
|
||||
the open-source database <span class="text-[var(--accent)]">gui</span>
|
||||
</h1>
|
||||
<p class="mt-6 text-lg md:text-xl text-[var(--text-muted)] max-w-xl mx-auto">
|
||||
Query, explore, sync, and edit PostgreSQL, MySQL, SQLite, and Redis — with no paywalls, no tab limits, no account required.
|
||||
</p>
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a id="download" href="https://github.com/AdrianBonpin/gridline/releases" class="primary-btn" data-download>
|
||||
Download
|
||||
</a>
|
||||
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="macos-hint" class="hidden max-w-lg mx-auto w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] p-3 text-left mt-4">
|
||||
<p class="text-xs text-[var(--text-muted)] mb-2">First launch on macOS? Run this in Terminal:</p>
|
||||
<div class="flex items-center justify-between gap-3 rounded-lg bg-[var(--bg)] border border-[var(--border)] px-3 py-2">
|
||||
<code class="font-mono text-xs truncate">sudo xattr -dr com.apple.quarantine /Applications/Gridline.app</code>
|
||||
<button id="copy-quarantine" class="text-xs font-medium text-[var(--primary)] hover:text-[var(--primary-hover)] transition-colors shrink-0" aria-label="Copy command">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-screenshot mt-14 md:mt-20 mx-auto max-w-5xl">
|
||||
<Image
|
||||
src={queryEditor}
|
||||
alt="Gridline query editor and data grid"
|
||||
widths={[640, 1024, 1600]}
|
||||
sizes="(max-width: 1024px) 100vw, 1024px"
|
||||
formats={["webp", "avif"]}
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-xs md:text-sm text-[var(--text-muted)] font-mono">
|
||||
Free for personal and commercial use · Apache 2.0 · v{version}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Why Gridline -->
|
||||
<section id="why" class="section-animate py-20 md:py-28">
|
||||
<div class="max-w-[1200px] mx-auto px-6">
|
||||
<h2 class="font-serif text-3xl md:text-5xl text-center max-w-2xl mx-auto">
|
||||
Everything the paid apps lock behind a subscription.
|
||||
</h2>
|
||||
<div class="mt-12 grid md:grid-cols-3 gap-5">
|
||||
<article class="glass-card">
|
||||
<p class="font-mono text-xs text-[var(--accent)] mb-3">01</p>
|
||||
<h3 class="text-xl font-semibold mb-2">No limits</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm leading-relaxed">
|
||||
Unlimited connections, open tabs, saved queries, and exports. The free plan is the full plan.
|
||||
</p>
|
||||
</article>
|
||||
<article class="glass-card">
|
||||
<p class="font-mono text-xs text-[var(--accent)] mb-3">02</p>
|
||||
<h3 class="text-xl font-semibold mb-2">DB-to-DB sync</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm leading-relaxed">
|
||||
Pipe <code class="font-mono text-xs">pg_dump</code> directly into <code class="font-mono text-xs">pg_restore</code> between two live connections. No file round-trip needed.
|
||||
</p>
|
||||
</article>
|
||||
<article class="glass-card">
|
||||
<p class="font-mono text-xs text-[var(--accent)] mb-3">03</p>
|
||||
<h3 class="text-xl font-semibold mb-2">Deeper PG explorer</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm leading-relaxed">
|
||||
Full detail views for functions, triggers, sequences, enums, extensions, roles, and grants.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature highlights -->
|
||||
<section id="features" class="section-animate py-20 md:py-28">
|
||||
<div class="max-w-[1200px] mx-auto px-6">
|
||||
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] text-center mb-3">Features</p>
|
||||
<h2 class="font-serif text-3xl md:text-5xl text-center mb-14">Explore, query, and move data.</h2>
|
||||
<div class="grid md:grid-cols-2 gap-5">
|
||||
<article class="feature-card">
|
||||
<p class="font-mono text-xs text-[var(--text-muted)] mb-3">QUERY & EDIT</p>
|
||||
<h3 class="text-xl font-semibold mb-2">SQL editor + virtualized grid</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-4">Run queries with autocomplete, inline edit cells, and browse 100k+ rows without breaking a sweat.</p>
|
||||
<Image src={queryEditor} alt="SQL query editor" widths={[640, 1024]} sizes="(max-width: 1024px) 100vw, 640px" formats={["webp", "avif"]} loading="lazy" class="w-full h-auto" />
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<p class="font-mono text-xs text-[var(--text-muted)] mb-3">VISUALIZE</p>
|
||||
<h3 class="text-xl font-semibold mb-2">ER diagram</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-4">Auto-layout schema graphs with crow’s foot relationships across schemas, right inside the app.</p>
|
||||
<Image src={erDiagram} alt="ER diagram" widths={[640, 1024]} sizes="(max-width: 1024px) 100vw, 640px" formats={["webp", "avif"]} loading="lazy" class="w-full h-auto" />
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<p class="font-mono text-xs text-[var(--text-muted)] mb-3">MOVE DATA</p>
|
||||
<h3 class="text-xl font-semibold mb-2">Backup, restore, and sync</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-4">Point-and-click <code class="font-mono text-xs">pg_dump</code>/<code class="font-mono text-xs">pg_restore</code>, SQLite dumps, and live DB-to-DB sync.</p>
|
||||
<Image src={home} alt="Gridline home screen" widths={[640, 1024]} sizes="(max-width: 1024px) 100vw, 640px" formats={["webp", "avif"]} loading="lazy" class="w-full h-auto" />
|
||||
</article>
|
||||
<article class="feature-card">
|
||||
<p class="font-mono text-xs text-[var(--text-muted)] mb-3">MANAGE</p>
|
||||
<h3 class="text-xl font-semibold mb-2">Objects, roles, and grants</h3>
|
||||
<p class="text-[var(--text-muted)] text-sm mb-4">Create and edit tables, foreign keys, indexes, functions, triggers, sequences, roles, and privileges.</p>
|
||||
<Image src={dataGrid} alt="Data grid with foreign key preview" widths={[640, 1024]} sizes="(max-width: 1024px) 100vw, 640px" formats={["webp", "avif"]} loading="lazy" class="w-full h-auto" />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Open source -->
|
||||
<section id="oss" class="section-animate py-20 md:py-28 text-center">
|
||||
<div class="max-w-[800px] mx-auto px-6">
|
||||
<p class="font-mono text-xs text-[var(--accent)] uppercase tracking-[0.15em] mb-3">Open Source</p>
|
||||
<h2 class="font-serif text-3xl md:text-5xl mb-5">Built in public.<br />No VC roadmap, no rug pulls.</h2>
|
||||
<p class="text-[var(--text-muted)] mb-8 max-w-md mx-auto">
|
||||
Gridline is Apache 2.0 and developed openly. Contributions, issues, and feature requests are welcome.
|
||||
</p>
|
||||
<a href="https://github.com/AdrianBonpin/gridline" target="_blank" rel="noopener" class="ghost-btn">
|
||||
Star on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section id="faq" class="section-animate py-20 md:py-28">
|
||||
<div class="max-w-[800px] mx-auto px-6">
|
||||
<h2 class="font-serif text-3xl md:text-5xl text-center mb-12">Questions? Answered.</h2>
|
||||
<div class="faq-list space-y-3">
|
||||
{
|
||||
faqItems.map((item, i) => (
|
||||
<details class="faq-item" open={i === 0}>
|
||||
<summary class="faq-summary">
|
||||
<span>{item.question}</span>
|
||||
<span class="faq-chevron">⌄</span>
|
||||
</summary>
|
||||
<div class="faq-body">
|
||||
<p class="text-[var(--text-muted)] text-sm leading-relaxed">{item.answer}</p>
|
||||
</div>
|
||||
</details>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</Layout>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>Astro</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Astro</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const getRobotsTxt = (sitemapURL: URL) =>
|
||||
`User-agent: *\nAllow: /\n\nSitemap: ${sitemapURL.href}\n`;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL("sitemap-index.xml", site);
|
||||
return new Response(getRobotsTxt(sitemapURL), {
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
};
|
||||
@@ -1 +1,50 @@
|
||||
@import "tailwindcss";
|
||||
@import "@fontsource-variable/literata";
|
||||
@import "@fontsource-variable/outfit";
|
||||
@import "@fontsource/space-mono/400.css";
|
||||
@import "@fontsource/space-mono/700.css";
|
||||
|
||||
:root {
|
||||
--font-serif: "Literata Variable", Georgia, serif;
|
||||
--font-sans: "Outfit Variable", system-ui, sans-serif;
|
||||
--font-mono: "Space Mono", ui-monospace, monospace;
|
||||
|
||||
--bg: #0a0a0a;
|
||||
--surface: rgba(255, 255, 255, 0.04);
|
||||
--surface-elevated: rgba(255, 255, 255, 0.07);
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--text: rgba(255, 255, 255, 0.90);
|
||||
--text-muted: rgba(255, 255, 255, 0.55);
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #60a5fa;
|
||||
--accent: #f59e0b;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg: #fafafa;
|
||||
--surface: rgba(0, 0, 0, 0.03);
|
||||
--surface-elevated: rgba(0, 0, 0, 0.06);
|
||||
--border: rgba(0, 0, 0, 0.10);
|
||||
--text: #171717;
|
||||
--text-muted: #737373;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--accent: #d97706;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user