Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -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**
14 changes: 14 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -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**
10 changes: 10 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## What & why

<!-- Brief description of the change and the motivation. -->

## 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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 <your name>
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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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**
Expand Down
23 changes: 23 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions apps/web/app/d/[dashboardId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-6">
<DashboardRenderer dashboard={dashboard} meta={meta} data={data} />
</div>
);
}
38 changes: 38 additions & 0 deletions apps/web/app/d/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-6">
<div className="mb-5 flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight text-neutral-900">Dashboards</h1>
<NewDashboardButton />
</div>

{dashboards.length === 0 ? (
<p className="text-sm text-neutral-400">No dashboards yet. Create one to compose KPIs, charts, and reports from any table.</p>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{dashboards.map((d) => {
const n = d.config.widgets?.length ?? 0;
return (
<li key={d.dashboardId}>
<Link href={`/d/${d.dashboardId}`} className="block rounded-lg border border-surface-border bg-white p-4 hover:border-blue-300">
<span className="text-sm font-medium text-neutral-900">{d.name}</span>
<span className="mt-1 block text-xs text-neutral-400">{n} widget{n === 1 ? "" : "s"}</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
Binary file added apps/web/app/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
7 changes: 5 additions & 2 deletions apps/web/app/t/[tableId]/[viewId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -55,17 +56,19 @@ export default async function ViewPage({
</div>
<div className="mt-3 flex items-center justify-between">
<ViewSwitcher tableId={tableId} currentViewId={viewId} views={allViews} currentConfig={view.config} />
{view.type !== "form" ? <ViewToolbar viewId={viewId} fields={fields} config={view.config} /> : null}
{view.type !== "form" ? <ViewToolbar viewId={viewId} fields={fields} config={view.config} meta={meta} /> : null}
</div>
</header>

<div className={`flex-1 p-5 ${view.type === "kanban" || view.type === "calendar" || view.type === "form" ? "overflow-auto" : "overflow-hidden"}`}>
<div className={`flex-1 p-5 ${view.type === "kanban" || view.type === "calendar" || view.type === "form" || view.type === "dashboard" ? "overflow-auto" : "overflow-hidden"}`}>
{view.type === "kanban" ? (
<KanbanView meta={meta} view={view} records={records} labels={labels} />
) : view.type === "calendar" ? (
<CalendarView meta={meta} view={view} records={records} />
) : view.type === "form" ? (
<FormRenderer tableId={tableId} fields={visibleFields(meta, view)} title={view.config.form?.title ?? view.name} />
) : view.type === "dashboard" ? (
<DashboardView meta={meta} view={view} records={records} />
) : (
<TableView meta={meta} view={view} records={records} labels={labels} />
)}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/components/BarMini.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 6, bottom: 0, left: 6 }}>
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#9ca3af" }} tickLine={false} axisLine={false} interval={0} />
<YAxis hide />
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8, border: "1px solid #e5e7eb", padding: "4px 8px" }} cursor={{ fill: "rgba(0,0,0,0.03)" }} />
<Bar dataKey="value" fill="#2563eb" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
);
}
83 changes: 83 additions & 0 deletions apps/web/components/DashboardRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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<string, WidgetResult> }) {
const router = useRouter();
const [editing, setEditing] = useState<null | { widget: Widget | null }>(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 (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight text-neutral-900">{dashboard.name}</h1>
<div className="flex items-center gap-2">
{busy ? <span className="text-xs text-neutral-400">Saving…</span> : null}
<button onClick={() => setEditing({ widget: null })} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
+ Add widget
</button>
</div>
</div>

{widgets.length === 0 ? (
<div className="rounded-lg border border-dashed border-surface-border p-10 text-center text-sm text-neutral-400">
No widgets yet — add a KPI, chart, or report from any table.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{widgets.map((w, i) => (
<div key={w.widgetId} className="rounded-lg border border-surface-border bg-white p-4">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="truncate text-sm font-medium text-neutral-700">{w.title || "(untitled)"}</span>
<span className="flex items-center gap-1.5 text-neutral-300">
<button title="Move left" onClick={() => move(i, -1)} className="hover:text-neutral-600">←</button>
<button title="Move right" onClick={() => move(i, 1)} className="hover:text-neutral-600">→</button>
<button title="Edit" onClick={() => setEditing({ widget: w })} className="hover:text-neutral-600">✎</button>
<button title="Remove" onClick={() => remove(w.widgetId)} className="hover:text-red-500">✕</button>
</span>
</div>
<DashboardWidget widget={w} meta={meta} result={data[w.widgetId]} />
</div>
))}
</div>
)}

{editing ? <WidgetBuilder meta={meta} initial={editing.widget} onSave={onSave} onClose={() => setEditing(null)} /> : null}
</div>
);
}
62 changes: 62 additions & 0 deletions apps/web/components/DashboardView.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="text-sm text-neutral-500">No metrics to chart yet — this dashboard fills in as rows accrue.</div>;
}

return (
<div className="h-full overflow-auto">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{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 (
<div key={m.fieldId} className="rounded-lg border border-surface-border bg-white p-4">
<div className="flex items-baseline justify-between">
<span className="truncate text-sm text-neutral-500">{m.name}</span>
<span className={`text-xs tabular-nums ${color}`}>
{arrow}
{d !== null && d !== 0 ? ` ${Math.abs(d)}` : ""}
</span>
</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-neutral-900">{fmt(m.current)}</div>
<div className="mt-3 h-16">
<MetricChart data={data} />
</div>
</div>
);
})}
</div>
<p className="mt-4 text-xs text-neutral-400">
Trends across {records.length} weekly snapshot{records.length === 1 ? "" : "s"}. The earliest week includes the initial data import.
</p>
</div>
);
}
Loading
Loading