diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ba9d8cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Something isn't working as expected +labels: bug +--- + +**What happened** +A clear description of the bug. + +**Expected** +What you expected to happen. + +**Repro** +Steps to reproduce (note the schema / view type / field types involved if relevant): +1. … + +**Environment** +- gridbase commit: +- Node / pnpm version: +- Running: local (`wrangler dev`) or deployed (Workers + D1) + +**Logs / screenshots** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..980bef4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea or improvement +labels: enhancement +--- + +**Problem** +What are you trying to do that's hard or impossible today? + +**Proposed solution** +What you'd like to see. (Check [docs/ROADMAP.md](../../docs/ROADMAP.md) first — it may +already be planned.) + +**Alternatives considered** diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6039eb7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## What & why + + + +## Checklist +- [ ] `pnpm -r typecheck` passes +- [ ] `pnpm test` passes +- [ ] `pnpm -C apps/web build` passes (if the web app changed) +- [ ] No secrets / real account ids / private identifiers committed +- [ ] Docs updated if behavior or setup changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c711202 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm -r typecheck + - run: pnpm test + - run: pnpm -C apps/web build diff --git a/.gitignore b/.gitignore index 7e8ce2f..cbbfd04 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ wrangler.toml .vercel *.tsbuildinfo next-env.d.ts + +# local-only extra patterns for scripts/sync-from-upstream.mjs (keeps private +# identifiers out of the committed scrubber) +.sync-scrub diff --git a/LICENSE b/LICENSE index a78ddc5..0683680 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 +Copyright (c) 2026 akim136 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1fb93ee..4a477ee 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # gridbase — an open-source Airtable that fronts your own database +[![CI](https://github.com/akim136/gridbase/actions/workflows/ci.yml/badge.svg)](https://github.com/akim136/gridbase/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + gridbase is a metadata-driven, database-backed workspace. It gives you Airtable's spreadsheet-meets-database experience — tables, multiple views, relations, formulas, lookups, filters, and inline editing — **over your own data source** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2555c63 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting a vulnerability + +Please report security issues **privately** — do not open a public issue. + +Use GitHub's [private vulnerability reporting](https://github.com/akim136/gridbase/security/advisories/new) +(the repo's **Security → Advisories → Report a vulnerability**) so the report stays +confidential until a fix is available. + +Include the affected version/commit, a description, reproduction steps, and the +impact. You'll get an acknowledgement, and a fix or mitigation timeline once triaged. + +## Scope + +gridbase is self-hosted — you run the Worker (API) and the web app on your own +Cloudflare account / host. The most relevant areas: + +- the Bearer-gated `/v1` API (`packages/api`) — fails closed when no secret is set; +- SQL identifier handling — allowlist-validated (`assertIdent`), values always bound; +- the BFF proxy (`apps/web/app/api/grid/[...path]`) — keeps the API secret server-side. + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the trust boundaries. diff --git a/apps/web/app/d/[dashboardId]/page.tsx b/apps/web/app/d/[dashboardId]/page.tsx new file mode 100644 index 0000000..ca1a466 --- /dev/null +++ b/apps/web/app/d/[dashboardId]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { DashboardRenderer } from "@/components/DashboardRenderer"; +import { computeWidgets, getMeta } from "@/lib/server/gridApi"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage({ params }: { params: Promise<{ dashboardId: string }> }) { + const { dashboardId } = await params; + const meta = await getMeta(); + const dashboard = (meta.dashboards ?? []).find((d) => d.dashboardId === dashboardId); + if (!dashboard) notFound(); + + // Every widget's data is computed server-side (the API secret stays on the server). + const data = await computeWidgets(dashboard.config.widgets ?? []); + + return ( +
+ +
+ ); +} diff --git a/apps/web/app/d/page.tsx b/apps/web/app/d/page.tsx new file mode 100644 index 0000000..f5ca8ff --- /dev/null +++ b/apps/web/app/d/page.tsx @@ -0,0 +1,38 @@ +import Link from "next/link"; +import { NewDashboardButton } from "@/components/NewDashboardButton"; +import { getMeta } from "@/lib/server/gridApi"; + +export const dynamic = "force-dynamic"; + +/** Dashboards index — the workspace's report pages. */ +export default async function DashboardsPage() { + const meta = await getMeta(); + const dashboards = (meta.dashboards ?? []).filter((d) => !d.isHidden); + + return ( +
+
+

Dashboards

+ +
+ + {dashboards.length === 0 ? ( +

No dashboards yet. Create one to compose KPIs, charts, and reports from any table.

+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png new file mode 100644 index 0000000..157728b Binary files /dev/null and b/apps/web/app/icon.png differ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e152ee9..95fbb49 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -4,8 +4,9 @@ import { Sidebar, type SidebarTable } from "@/components/Sidebar"; import { getMeta } from "@/lib/server/gridApi"; export const metadata: Metadata = { - title: "grid", - description: "Database-backed workspace (Airtable replacement)", + title: "gridbase", + description: "Database-backed workspace (open-source Airtable replacement)", + icons: { icon: "/icon.png" }, }; /** First non-hidden view for a table, by position. */ diff --git a/apps/web/app/t/[tableId]/[viewId]/page.tsx b/apps/web/app/t/[tableId]/[viewId]/page.tsx index ffa8459..6241391 100644 --- a/apps/web/app/t/[tableId]/[viewId]/page.tsx +++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from "next/navigation"; import { CalendarView } from "@/components/CalendarView"; +import { DashboardView } from "@/components/DashboardView"; import { FormRenderer } from "@/components/FormRenderer"; import { ImportButton } from "@/components/ImportDialog"; import { KanbanView } from "@/components/KanbanView"; @@ -55,17 +56,19 @@ export default async function ViewPage({
- {view.type !== "form" ? : null} + {view.type !== "form" ? : null}
-
+
{view.type === "kanban" ? ( ) : view.type === "calendar" ? ( ) : view.type === "form" ? ( + ) : view.type === "dashboard" ? ( + ) : ( )} diff --git a/apps/web/components/BarMini.tsx b/apps/web/components/BarMini.tsx new file mode 100644 index 0000000..8e077db --- /dev/null +++ b/apps/web/components/BarMini.tsx @@ -0,0 +1,16 @@ +"use client"; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +/** A small bar chart for a dashboard widget. Loaded client-only (recharts measures the DOM). */ +export function BarMini({ data }: { data: Array<{ label: string; value: number }> }) { + return ( + + + + + + + + + ); +} diff --git a/apps/web/components/DashboardRenderer.tsx b/apps/web/components/DashboardRenderer.tsx new file mode 100644 index 0000000..883e34b --- /dev/null +++ b/apps/web/components/DashboardRenderer.tsx @@ -0,0 +1,83 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { updateDashboard } from "@/lib/client"; +import type { DashboardMeta, Meta, Widget } from "@/lib/types"; +import { DashboardWidget, type WidgetResult } from "./DashboardWidget"; +import { WidgetBuilder } from "./WidgetBuilder"; + +/** A dashboard: a grid of widgets you can add/edit/remove/reorder. Each edit + * PATCHes the dashboard config, then refreshes so the server recomputes widget data. */ +export function DashboardRenderer({ dashboard, meta, data }: { dashboard: DashboardMeta; meta: Meta; data: Record }) { + const router = useRouter(); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const widgets = dashboard.config.widgets ?? []; + + async function persist(next: Widget[]) { + setBusy(true); + try { + await updateDashboard(dashboard.dashboardId, { config: { widgets: next } }); + router.refresh(); + } catch (e) { + alert(`Save failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(false); + } + } + + function onSave(w: Widget) { + const exists = widgets.some((x) => x.widgetId === w.widgetId); + setEditing(null); + void persist(exists ? widgets.map((x) => (x.widgetId === w.widgetId ? w : x)) : [...widgets, w]); + } + function remove(id: string) { + if (window.confirm("Remove this widget?")) void persist(widgets.filter((w) => w.widgetId !== id)); + } + function move(i: number, dir: -1 | 1) { + const j = i + dir; + if (j < 0 || j >= widgets.length) return; + const next = [...widgets]; + [next[i], next[j]] = [next[j]!, next[i]!]; + void persist(next); + } + + return ( +
+
+

{dashboard.name}

+
+ {busy ? Saving… : null} + +
+
+ + {widgets.length === 0 ? ( +
+ No widgets yet — add a KPI, chart, or report from any table. +
+ ) : ( +
+ {widgets.map((w, i) => ( +
+
+ {w.title || "(untitled)"} + + + + + + +
+ +
+ ))} +
+ )} + + {editing ? setEditing(null)} /> : null} +
+ ); +} diff --git a/apps/web/components/DashboardView.tsx b/apps/web/components/DashboardView.tsx new file mode 100644 index 0000000..34ec392 --- /dev/null +++ b/apps/web/components/DashboardView.tsx @@ -0,0 +1,62 @@ +"use client"; +import dynamic from "next/dynamic"; +import { buildDashboard } from "@/lib/dashboard"; +import type { Meta, RecordEnvelope, ViewMeta } from "@/lib/types"; + +// recharts is client-only (it measures the DOM); load it without SSR to avoid a +// 0-size first paint / window access on the server. +const MetricChart = dynamic(() => import("./MetricChart").then((m) => m.MetricChart), { ssr: false }); + +function fmt(v: number | null): string { + if (v === null) return "—"; + return Number.isInteger(v) ? String(v) : v.toFixed(2); +} + +/** MM-DD slice of an ISO date for compact axis labels. */ +function shortDate(d: string): string { + return d.length >= 10 ? d.slice(5, 10) : d; +} + +/** A dashboard of metric cards (current value + WoW delta + trend line) for a table's + * numeric/formula fields over its date field. Read-only. */ +export function DashboardView({ meta, view, records }: { meta: Meta; view: ViewMeta; records: RecordEnvelope[] }) { + const { points, metrics } = buildDashboard(meta, view, records); + + if (records.length === 0 || metrics.length === 0) { + return
No metrics to chart yet — this dashboard fills in as rows accrue.
; + } + + return ( +
+
+ {metrics.map((m) => { + const data = points.map((p) => ({ + date: shortDate(String(p.date)), + v: typeof p[m.fieldId] === "number" ? (p[m.fieldId] as number) : null, + })); + const d = m.prevDelta; + const arrow = d === null || d === 0 ? "→" : d > 0 ? "↑" : "↓"; + const color = d === null || d === 0 ? "text-neutral-400" : d > 0 ? "text-green-600" : "text-red-500"; + return ( +
+
+ {m.name} + + {arrow} + {d !== null && d !== 0 ? ` ${Math.abs(d)}` : ""} + +
+
{fmt(m.current)}
+
+ +
+
+ ); + })} +
+

+ Trends across {records.length} weekly snapshot{records.length === 1 ? "" : "s"}. The earliest week includes the initial data import. +

+
+ ); +} diff --git a/apps/web/components/DashboardWidget.tsx b/apps/web/components/DashboardWidget.tsx new file mode 100644 index 0000000..ac604ee --- /dev/null +++ b/apps/web/components/DashboardWidget.tsx @@ -0,0 +1,83 @@ +"use client"; +import dynamic from "next/dynamic"; +import type { AggregateRow, Meta, RecordEnvelope, Widget } from "@/lib/types"; + +// recharts is client-only — load the chart pieces without SSR. +const MetricChart = dynamic(() => import("./MetricChart").then((m) => m.MetricChart), { ssr: false }); +const BarMini = dynamic(() => import("./BarMini").then((m) => m.BarMini), { ssr: false }); + +export interface WidgetResult { + rows?: AggregateRow[]; + records?: RecordEnvelope[]; + error?: string; +} + +function fmtNum(v: number): string { + if (!Number.isFinite(v)) return "—"; + if (Math.abs(v) >= 1000) return v.toLocaleString(); + return Number.isInteger(v) ? String(v) : v.toFixed(2); +} +function label(s: string | null): string { + if (!s) return "—"; + return s.length > 14 ? `${s.slice(0, 13)}…` : s; +} +function cellText(v: unknown): string { + if (v == null) return ""; + if (Array.isArray(v)) return v.map((x) => (x && typeof x === "object" && "name" in x ? String((x as { name: unknown }).name) : String(x))).join(", "); + if (typeof v === "object") return "name" in (v as object) ? String((v as { name: unknown }).name) : JSON.stringify(v); + return String(v); +} + +function Empty({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +/** Render one widget from its server-computed result. Read-only. */ +export function DashboardWidget({ widget, meta, result }: { widget: Widget; meta: Meta; result?: WidgetResult }) { + if (result?.error) return Couldn’t load: {result.error}; + + if (widget.type === "kpi") { + return
{fmtNum(result?.rows?.[0]?.value ?? 0)}
; + } + + if (widget.type === "line") { + const data = (result?.rows ?? []).map((r) => ({ date: label(r.groupValue), v: r.value })); + return data.length ?
: No data; + } + + if (widget.type === "bar") { + const data = (result?.rows ?? []).map((r) => ({ label: label(r.groupValue), value: r.value })); + return data.length ?
: No data; + } + + // table + const records = result?.records ?? []; + const fieldById = new Map(meta.fields.map((f) => [f.fieldId, f])); + const cols = + widget.fieldIds && widget.fieldIds.length + ? widget.fieldIds + : meta.fields.filter((f) => f.tableId === widget.tableId).slice(0, 4).map((f) => f.fieldId); + if (!records.length) return No rows; + return ( +
+ + + + {cols.map((c) => ( + + ))} + + + + {records.slice(0, 8).map((rec) => ( + + {cols.map((c) => ( + + ))} + + ))} + +
{fieldById.get(c)?.name ?? c}
{cellText(rec.fields[c])}
+
+ ); +} diff --git a/apps/web/components/KanbanView.tsx b/apps/web/components/KanbanView.tsx index 039fca1..deed6aa 100644 --- a/apps/web/components/KanbanView.tsx +++ b/apps/web/components/KanbanView.tsx @@ -35,7 +35,13 @@ export function KanbanView({ if (!stackField || !stackId) return

This Kanban view has no stack field configured.

; - const previewFields = fields.filter((f) => f.fieldId !== primaryId && f.fieldId !== stackId && f.type !== "link").slice(0, 3); + // Card fields follow the view's FieldEditor (show/hide/reorder) — the single + // source of truth — minus the stack (it's the column) and the primary (the + // card title). A soft cap (default 8) keeps cards readable. + const maxPreview = view.config.kanban?.maxPreviewFields ?? 8; + const previewFields = fields + .filter((f) => f.fieldId !== primaryId && f.fieldId !== stackId) + .slice(0, maxPreview); const columns = [ ...(stackField.options?.choices?.map((c) => ({ id: c.name, label: c.name })) ?? []), { id: UNSET, label: "Uncategorized" }, @@ -75,6 +81,7 @@ export function KanbanView({ href={`/t/${view.tableId}/${view.viewId}/${rec.id}`} primaryId={primaryId} previewFields={previewFields} + labels={labels} /> ))} @@ -98,7 +105,19 @@ function Column({ id, label, count, children }: { id: string; label: string; cou ); } -function Card({ rec, href, primaryId, previewFields }: { rec: RecordEnvelope; href: string; primaryId?: string; previewFields: FieldMeta[] }) { +/** Render a card-preview cell value: link/lookup arrays become comma-joined + * labels (via the id→label map); scalars stringify. Returns "" when empty. */ +function previewValue(field: FieldMeta, value: unknown, labels: Map): string { + if (value == null || value === "") return ""; + if (field.type === "link") { + const ids = Array.isArray(value) ? (value as string[]) : [String(value)]; + return ids.map((id) => labels.get(id) ?? id).join(", "); + } + if (Array.isArray(value)) return value.filter((v) => v != null && v !== "").map((v) => String(v)).join(", "); + return String(value); +} + +function Card({ rec, href, primaryId, previewFields, labels }: { rec: RecordEnvelope; href: string; primaryId?: string; previewFields: FieldMeta[]; labels: Map }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: rec.id }); const style = transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 50 } : undefined; return ( @@ -113,11 +132,11 @@ function Card({ rec, href, primaryId, previewFields }: { rec: RecordEnvelope; hr {primaryId && rec.fields[primaryId] ? String(rec.fields[primaryId]) : "(untitled)"} {previewFields.map((f) => { - const v = rec.fields[f.fieldId]; - if (v == null || v === "") return null; + const text = previewValue(f, rec.fields[f.fieldId], labels); + if (!text) return null; return (
- {f.name}: {String(v)} + {f.name}: {text}
); })} diff --git a/apps/web/components/MetricChart.tsx b/apps/web/components/MetricChart.tsx new file mode 100644 index 0000000..28ff6f8 --- /dev/null +++ b/apps/web/components/MetricChart.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +/** A small trend line for one metric. Loaded client-only (recharts measures the DOM). */ +export function MetricChart({ data }: { data: Array<{ date: string; v: number | null }> }) { + return ( + + + + + [val, ""]} + /> + + + + ); +} diff --git a/apps/web/components/NewDashboardButton.tsx b/apps/web/components/NewDashboardButton.tsx new file mode 100644 index 0000000..056e052 --- /dev/null +++ b/apps/web/components/NewDashboardButton.tsx @@ -0,0 +1,27 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { createDashboard } from "@/lib/client"; + +/** Prompt for a name, create a dashboard, navigate to it. */ +export function NewDashboardButton() { + const router = useRouter(); + const [busy, setBusy] = useState(false); + async function create() { + const name = window.prompt("Dashboard name?", "Untitled dashboard"); + if (!name) return; + setBusy(true); + try { + const d = await createDashboard({ name }); + router.push(`/d/${d.dashboardId}`); + } catch (e) { + alert(`Create failed: ${e instanceof Error ? e.message : String(e)}`); + setBusy(false); + } + } + return ( + + ); +} diff --git a/apps/web/components/Sidebar.tsx b/apps/web/components/Sidebar.tsx index 7269883..38e5c59 100644 --- a/apps/web/components/Sidebar.tsx +++ b/apps/web/components/Sidebar.tsx @@ -1,4 +1,5 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; @@ -29,10 +30,24 @@ export function Sidebar({ tables }: { tables: SidebarTable[] }) { if (collapsed) { return (