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 b2e71f7..6241391 100644 --- a/apps/web/app/t/[tableId]/[viewId]/page.tsx +++ b/apps/web/app/t/[tableId]/[viewId]/page.tsx @@ -56,7 +56,7 @@ export default async function ViewPage({
- {view.type !== "form" ? : null} + {view.type !== "form" ? : null}
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/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/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 (