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
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
2 changes: 1 addition & 1 deletion apps/web/app/t/[tableId]/[viewId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ 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>

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>
);
}
83 changes: 83 additions & 0 deletions apps/web/components/DashboardWidget.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="flex h-24 items-center justify-center text-xs text-neutral-400">{children}</div>;
}

/** 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 <Empty>Couldn’t load: {result.error}</Empty>;

if (widget.type === "kpi") {
return <div className="py-2 text-3xl font-semibold tabular-nums text-neutral-900">{fmtNum(result?.rows?.[0]?.value ?? 0)}</div>;
}

if (widget.type === "line") {
const data = (result?.rows ?? []).map((r) => ({ date: label(r.groupValue), v: r.value }));
return data.length ? <div className="h-40"><MetricChart data={data} /></div> : <Empty>No data</Empty>;
}

if (widget.type === "bar") {
const data = (result?.rows ?? []).map((r) => ({ label: label(r.groupValue), value: r.value }));
return data.length ? <div className="h-40"><BarMini data={data} /></div> : <Empty>No data</Empty>;
}

// 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 <Empty>No rows</Empty>;
return (
<div className="max-h-44 overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-400">
{cols.map((c) => (
<th key={c} className="border-b border-surface-border px-2 py-1 font-medium">{fieldById.get(c)?.name ?? c}</th>
))}
</tr>
</thead>
<tbody>
{records.slice(0, 8).map((rec) => (
<tr key={rec.id} className="border-b border-surface-border/60">
{cols.map((c) => (
<td key={c} className="truncate px-2 py-1 text-neutral-700">{cellText(rec.fields[c])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
29 changes: 24 additions & 5 deletions apps/web/components/KanbanView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ export function KanbanView({

if (!stackField || !stackId) return <p className="text-neutral-500">This Kanban view has no stack field configured.</p>;

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" },
Expand Down Expand Up @@ -75,6 +81,7 @@ export function KanbanView({
href={`/t/${view.tableId}/${view.viewId}/${rec.id}`}
primaryId={primaryId}
previewFields={previewFields}
labels={labels}
/>
))}
</Column>
Expand All @@ -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, string>): 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<string, string> }) {
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 (
Expand All @@ -113,11 +132,11 @@ function Card({ rec, href, primaryId, previewFields }: { rec: RecordEnvelope; hr
{primaryId && rec.fields[primaryId] ? String(rec.fields[primaryId]) : "(untitled)"}
</Link>
{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 (
<div key={f.fieldId} className="truncate text-xs text-neutral-500">
<span className="text-neutral-400">{f.name}:</span> {String(v)}
<span className="text-neutral-400">{f.name}:</span> {text}
</div>
);
})}
Expand Down
27 changes: 27 additions & 0 deletions apps/web/components/NewDashboardButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button onClick={create} disabled={busy} className="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
+ New dashboard
</button>
);
}
Loading