feat: ECHODO app shell, Coolify deploy, Authentik + Umami

Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-26 14:34:34 -05:00
parent a508ece6e7
commit 663bc77afe
108 changed files with 14142 additions and 1036 deletions

View file

@ -1,36 +1,82 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/tasks"
# =====================================================================
# ECHODO — Environment Variables
# =====================================================================
# Local dev: copy this file to .env and adjust values.
# Coolify (CT 107): paste these into the resource's "Environment Variables"
# tab. Coolify also accepts a .env file uploaded via the UI.
#
# See AGENT-DEPLOY.md for shared CT 102 service credentials and the
# CT 100 Traefik routing pattern.
# =====================================================================
# Auth (NextAuth)
# ----- Database (CT 102 shared Postgres) -----
# Local dev: postgresql://postgres:postgres@localhost:5432/tasks
# Production: dedicated `echodo` user/db on CT 102
DATABASE_URL="postgresql://echodo:CHANGE_ME@192.168.68.102:5432/echodo"
# ----- Redis (CT 102 shared Redis) -----
# Use a dedicated DB index per AGENT-DEPLOY.md (ECHODO uses /11)
# Local dev: redis://localhost:6379
REDIS_URL="redis://:CHANGE_ME@192.168.68.102:6379/11"
# ----- Auth (NextAuth v5) -----
# Local: http://localhost:3000 • Prod: https://echodo.stillwell.cloud
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-here-generate-with-openssl-rand-base64-32"
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32"
AUTH_SECRET="CHANGE_ME_RUN_openssl_rand_base64_32"
# OAuth Providers (optional)
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Optional dev-only credentials provider (looks up users by email,
# accepts this single password for everyone). Leave blank in prod.
AUTH_DEV_PASSWORD=""
# Redis
REDIS_URL="redis://localhost:6379"
# ----- OAuth Providers (all optional) -----
AUTH_GITHUB_ID=""
AUTH_GITHUB_SECRET=""
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
# Collaboration Server (browser / clients)
# Authentik SSO (CT 100 — auth.stillwell.cloud)
# In Authentik admin → Applications → Create OAuth2/OpenID Provider.
# Slug "echodo", redirect URI: <NEXTAUTH_URL>/api/auth/callback/authentik
AUTH_AUTHENTIK_ID=""
AUTH_AUTHENTIK_SECRET=""
AUTH_AUTHENTIK_ISSUER="https://auth.stillwell.cloud/application/o/echodo/"
# ----- Realtime collab server (Hocuspocus / Yjs) -----
# Server-side reference (used by Next.js server actions, if any)
COLLAB_SERVER_URL="ws://localhost:1234"
# Browser-facing — must be wss:// in production because the page is https
NEXT_PUBLIC_COLLAB_SERVER_URL="ws://localhost:1234"
# AI Providers
# ----- Umami Analytics (CT 107 — analytics.stillwell.cloud) -----
# In Umami: Settings → Websites → Add website. Copy the website UUID.
NEXT_PUBLIC_UMAMI_SCRIPT="https://analytics.stillwell.cloud/script.js"
NEXT_PUBLIC_UMAMI_WEBSITE_ID=""
# ----- Libredesk Support Widget (CT 105 — support.stillwell.cloud) -----
NEXT_PUBLIC_LIBREDESK_URL="https://support.stillwell.cloud"
NEXT_PUBLIC_LIBREDESK_WIDGET_ID=""
# ----- Directus Feedback (CT 107 — directus.stillwell.cloud) -----
# Static token scoped write-only on the `feedback` collection
NEXT_PUBLIC_DIRECTUS_URL="https://directus.stillwell.cloud"
DIRECTUS_FEEDBACK_TOKEN=""
# ----- AI Providers -----
# Use OPENAI_BASE_URL=http://192.168.68.108:11434/v1 to route through
# local Ollama (CT 108) instead of the OpenAI API.
OPENAI_API_KEY=""
OPENAI_BASE_URL=""
ANTHROPIC_API_KEY=""
# MCP Server (stdio transport today; port reserved for future HTTP/SSE)
# ----- MCP Server -----
MCP_SERVER_PORT=3001
# --- Docker Compose (optional; copy to .env and adjust) ---
# When all app services run in Docker, use service hostnames:
# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/tasks"
# REDIS_URL="redis://redis:6379"
# COLLAB_SERVER_URL="ws://localhost:1234"
# (Browsers still reach the collab service via the published host port.)
# =====================================================================
# Local Docker Compose only (docker/docker-compose.yml)
# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis).
# =====================================================================
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=tasks

18
.gitignore vendored
View file

@ -14,6 +14,24 @@ dist/
.env
.env.local
.env.*.local
.env.*
!.env.example
# homelab / deployment secrets
AGENT-DEPLOY.md
*.secrets
*.secret
secrets/
*-credentials.json
*-credentials.yaml
*-credentials.yml
credentials.json
credentials.yaml
credentials.yml
id_rsa
id_ed25519
*.key
*.crt
# debug
npm-debug.log*

View file

@ -185,6 +185,25 @@ function buildDatabaseExtension() {
});
}
const CURSOR_COLORS = [
"#958DF1",
"#F98181",
"#FBBC88",
"#FAF594",
"#70CFF8",
"#94FADB",
"#B9F18D",
"#E8A0BF",
];
function colorFromName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
}
async function main() {
const port = Number(process.env.PORT) || 1234;
@ -206,14 +225,46 @@ async function main() {
throw Forbidden;
}
// Try JWT decode for real user identity
const authSecret = process.env.AUTH_SECRET;
if (authSecret) {
try {
// Simple JWT decode (base64url decode the payload)
const parts = token.split(".");
if (parts.length === 3) {
const payload = JSON.parse(
Buffer.from(parts[1], "base64url").toString("utf-8"),
);
if (payload.sub || payload.name) {
return {
user: {
id: payload.sub ?? payload.email ?? token.slice(0, 16),
name: payload.name ?? payload.email ?? "Anonymous",
color: colorFromName(
payload.name ?? payload.email ?? "User",
),
},
};
}
}
} catch {
// Fall through to hash-based identity
}
}
const id = createHash("sha256").update(token).digest("hex");
return {
user: { id, name: `User ${id.slice(0, 8)}` },
user: {
id,
name: `User ${id.slice(0, 8)}`,
color: colorFromName(id),
},
};
},
async onConnect({ documentName }) {
console.log(`[collab] connect document=${documentName}`);
async onConnect({ documentName, connection }) {
const user = connection.readOnly ? "read-only" : "editor";
console.log(`[collab] connect document=${documentName} (${user})`);
},
async onDisconnect({ documentName }) {

View file

@ -1,13 +1,20 @@
"use client";
import type { ReactNode } from "react";
import { useParams } from "next/navigation";
import { ViewToolbar } from "@/components/views/config";
import { useViewStore } from "@/lib/stores/view-store";
import { useViewData } from "@/lib/hooks/use-view-data";
export default function ProjectLayout({ children }: { children: ReactNode }) {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const projectId =
typeof params?.projectId === "string" ? params.projectId : null;
const config = useViewStore((s) => s.config);
const { total } = useViewData(config);
const { total } = useViewData(config, workspaceId, projectId);
return (
<div className="flex h-full flex-col">

View file

@ -1,12 +1,22 @@
"use client";
import { useParams } from "next/navigation";
import { useViewStore } from "@/lib/stores/view-store";
import { ListView } from "@/components/views/list";
import { BoardView } from "@/components/views/board";
import { TableView } from "@/components/views/table";
import { EmbedView } from "@/components/views/embed";
import { OverviewView } from "@/components/views/overview/overview-view";
import { FormView } from "@/components/views/form/form-view";
export default function ProjectPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const projectId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const activeView = useViewStore((s) => s.activeView);
const config = useViewStore((s) => s.config);
@ -17,6 +27,12 @@ export default function ProjectPage() {
return <TableView config={config} />;
case "embed":
return <EmbedView config={config} />;
case "overview":
return (
<OverviewView workspaceId={workspaceSlug} spaceId={projectId} />
);
case "form":
return <FormView config={config} />;
case "list":
default:
return <ListView config={config} />;

View file

@ -0,0 +1,162 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useParams } from "next/navigation";
import { Loader2, Send, Sparkles, Bot, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Message = {
id: string;
role: "user" | "assistant";
content: string;
};
export default function AIPage() {
const params = useParams();
const workspaceSlug = params.workspaceSlug as string;
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = input.trim();
if (!trimmed || isLoading) return;
const userMsg: Message = { id: crypto.randomUUID(), role: "user", content: trimmed };
setMessages((prev) => [...prev, userMsg]);
setInput("");
setIsLoading(true);
// Placeholder AI response
setTimeout(() => {
const assistantMsg: Message = {
id: crypto.randomUUID(),
role: "assistant",
content:
"I'm your AI assistant. Full AI integration is coming soon! I'll be able to help you create tasks, documents, and manage your workspace.",
};
setMessages((prev) => [...prev, assistantMsg]);
setIsLoading(false);
}, 1000);
};
return (
<div className="flex h-full min-h-0 flex-col">
{/* Header */}
<div className="flex shrink-0 items-center gap-3 border-b px-6 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" />
</div>
<div>
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">Ask me anything about your workspace</p>
</div>
</div>
{/* Messages */}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
{messages.length === 0 ? (
<div className="flex h-full min-h-[12rem] flex-col items-center justify-center gap-4 text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="size-8 text-primary" />
</div>
<div className="text-center">
<p className="text-base font-medium">How can I help you today?</p>
<p className="mt-1 text-sm">
Ask me to create tasks, summarize documents, or manage your workspace.
</p>
</div>
<div className="mt-2 flex flex-wrap justify-center gap-2">
{["Create a new project", "Summarize my tasks", "Help me plan a sprint"].map(
(suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => setInput(suggestion)}
className="rounded-full border px-3 py-1.5 text-xs transition-colors hover:bg-muted"
>
{suggestion}
</button>
),
)}
</div>
</div>
) : (
<div className="mx-auto max-w-3xl space-y-4 px-4 py-6">
{messages.map((msg) => (
<div
key={msg.id}
className={cn("flex gap-3", msg.role === "user" && "flex-row-reverse")}
>
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted",
)}
>
{msg.role === "user" ? <User className="size-4" /> : <Bot className="size-4" />}
</div>
<div
className={cn(
"max-w-[80%] rounded-xl px-4 py-2.5 text-sm",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted",
)}
>
{msg.content}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Bot className="size-4" />
</div>
<div className="rounded-xl bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin" />
</div>
</div>
)}
</div>
)}
</div>
{/* Input */}
<div className="shrink-0 border-t bg-background p-4">
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSubmit(e);
}
}}
placeholder="Ask anything..."
className="flex-1 resize-none rounded-lg border bg-background px-4 py-2.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows={1}
/>
<Button type="submit" size="icon" disabled={!input.trim() || isLoading}>
<Send className="size-4" />
</Button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,161 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { ChevronRight } from "lucide-react";
import { CollaborativeBlockEditor } from "@/components/editor";
import { api } from "@/lib/trpc";
import { Input } from "@/components/ui/input";
function contentToHtml(content: unknown): string {
if (content == null) return "<p></p>";
if (typeof content === "string") return content || "<p></p>";
if (
typeof content === "object" &&
content !== null &&
"html" in content &&
typeof (content as { html: unknown }).html === "string"
) {
return (content as { html: string }).html || "<p></p>";
}
return "<p></p>";
}
export default function DocEditorPage() {
const params = useParams();
const { data: session } = useSession();
const utils = api.useUtils();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const docId = typeof params?.docId === "string" ? params.docId : undefined;
const docQuery = api.objects.getById.useQuery(
{ id: docId! },
{ enabled: Boolean(docId) },
);
const doc = docQuery.data as
| { id: string; title: string; content: unknown; type: string }
| undefined;
const [titleDraft, setTitleDraft] = React.useState("");
React.useEffect(() => {
if (doc?.title != null) setTitleDraft(doc.title);
}, [doc?.title]);
const saveContentTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
React.useEffect(
() => () => {
if (saveContentTimeoutRef.current) {
clearTimeout(saveContentTimeoutRef.current);
}
},
[],
);
const updateMutation = api.objects.update.useMutation({
onSuccess: async (_row, variables) => {
await utils.objects.getById.invalidate({ id: variables.id });
if (workspaceSlug) {
void utils.objects.list.invalidate({ workspaceId: workspaceSlug });
}
},
});
const scheduleContentSave = React.useCallback(
(html: string) => {
if (!docId) return;
if (saveContentTimeoutRef.current) {
clearTimeout(saveContentTimeoutRef.current);
}
saveContentTimeoutRef.current = setTimeout(() => {
saveContentTimeoutRef.current = null;
updateMutation.mutate({ id: docId, content: html });
}, 500);
},
[docId, updateMutation],
);
const handleTitleBlur = () => {
if (!docId || !doc) return;
const next = titleDraft.trim();
if (next.length === 0) {
setTitleDraft(doc.title);
return;
}
if (next === doc.title) return;
updateMutation.mutate({ id: docId, title: next });
};
if (!docId || !workspaceSlug) {
return (
<div className="mx-auto max-w-4xl px-8 py-10 text-sm text-muted-foreground">
Invalid document link.
</div>
);
}
if (docQuery.isPending) {
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<div className="animate-pulse space-y-4">
<div className="h-4 w-48 rounded bg-muted" />
<div className="h-10 w-full max-w-xl rounded-md bg-muted" />
<div className="h-[320px] w-full rounded-xl bg-muted" />
</div>
</div>
);
}
if (docQuery.isError || !doc) {
return (
<div className="mx-auto max-w-4xl px-8 py-10 text-sm text-muted-foreground">
Could not load this document.
</div>
);
}
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<nav
className="mb-6 flex flex-wrap items-center gap-1 text-sm text-muted-foreground"
aria-label="Breadcrumb"
>
<Link
href={`/${workspaceSlug}/docs`}
className="hover:text-foreground"
>
Documents
</Link>
<ChevronRight className="size-4 shrink-0 opacity-60" />
<span className="min-w-0 truncate text-foreground">{doc.title}</span>
</nav>
<Input
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={handleTitleBlur}
className="mb-6 border-0 border-b border-transparent bg-transparent px-0 text-3xl font-bold tracking-tight shadow-none focus-visible:border-border focus-visible:ring-0"
placeholder="Untitled"
aria-label="Document title"
/>
<CollaborativeBlockEditor
key={doc.id}
documentId={doc.id}
userName={session?.user?.name ?? undefined}
content={contentToHtml(doc.content)}
onChange={scheduleContentSave}
placeholder="Start typing, or use '/' for commands..."
/>
</div>
);
}

View file

@ -1,19 +1,101 @@
"use client";
import { BlockEditor } from "@/components/editor";
import { useMemo } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { FileText, Plus } from "lucide-react";
import { api } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
function formatUpdatedAt(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
export default function DocsPage() {
const params = useParams();
const router = useRouter();
const utils = api.useUtils();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: undefined, limit: 200 },
{ enabled: Boolean(workspaceId) },
);
const createMutation = api.objects.create.useMutation({
onSuccess: (created) => {
if (workspaceId) {
void utils.objects.list.invalidate({ workspaceId });
router.push(`/${workspaceId}/docs/${created.id}`);
}
},
});
const documents = useMemo(() => {
const rows = listQuery.data?.objects ?? [];
return rows
.filter((o) => o.type === "document")
.slice()
.sort((a, b) => {
const ta = new Date(a.updatedAt).getTime();
const tb = new Date(b.updatedAt).getTime();
return tb - ta;
});
}, [listQuery.data?.objects]);
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<h1 className="mb-6 text-3xl font-bold">Documents</h1>
<div className="rounded-lg border border-border bg-card p-1">
<BlockEditor
placeholder="Start typing, or use '/' for commands..."
onChange={(html) => {
// Will persist via tRPC in later phase
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-bold tracking-tight">Documents</h1>
<Button
type="button"
disabled={!workspaceId || createMutation.isPending}
onClick={() => {
if (!workspaceId) return;
createMutation.mutate({
type: "document",
title: "Untitled",
workspaceId,
parentId: null,
});
}}
/>
>
<Plus className="mr-2 size-4" />
New Document
</Button>
</div>
{listQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading documents</p>
) : documents.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
No documents yet. Create one to get started.
</div>
) : (
<ul className="divide-y divide-border rounded-lg border border-border bg-card shadow-sm">
{documents.map((doc) => (
<li key={doc.id}>
<Link
href={`/${workspaceId}/docs/${doc.id}`}
className="flex items-center justify-between gap-4 px-4 py-4 transition-colors hover:bg-muted/40"
>
<span className="flex min-w-0 items-center gap-3">
<FileText className="size-5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{doc.title}</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatUpdatedAt(doc.updatedAt)}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { FormBuilder } from "@/components/forms";
import { Button } from "@/components/ui/button";
export default function FormEditPage() {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string"
? params.workspaceSlug
: undefined;
const formId =
typeof params?.formId === "string" ? params.formId : undefined;
if (!workspaceId || !formId) {
return (
<div className="p-10 text-sm text-muted-foreground">
Missing workspace or form.
</div>
);
}
return (
<div className="mx-auto max-w-6xl px-6 py-8 sm:px-10">
<div className="mb-6 flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild className="gap-1 px-2">
<Link href={`/${workspaceId}/forms`}>
<ArrowLeft className="size-4" />
Forms
</Link>
</Button>
</div>
<FormBuilder formId={formId} workspaceId={workspaceId} />
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import { FormRenderer, type FormField } from "@/components/forms/form-renderer";
import { FormResponses } from "@/components/forms/form-responses";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
function parseFormFields(raw: unknown): FormField[] {
if (!Array.isArray(raw)) return [];
return raw as FormField[];
}
export default function FormDetailPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const formId = typeof params?.formId === "string" ? params.formId : undefined;
const formQuery = api.forms.getById.useQuery({ id: formId! }, { enabled: Boolean(formId) });
const fields = React.useMemo(
() => parseFormFields(formQuery.data?.fields),
[formQuery.data?.fields],
);
if (!workspaceSlug || !formId) {
return (
<div className="mx-auto max-w-3xl px-6 py-10 text-sm text-muted-foreground">
Invalid form link.
</div>
);
}
if (formQuery.isPending) {
return (
<div className="mx-auto max-w-3xl px-6 py-10">
<div className="animate-pulse space-y-4">
<div className="h-8 w-64 rounded bg-muted" />
<div className="h-10 w-full rounded-md bg-muted" />
<div className="h-48 w-full rounded-lg bg-muted" />
</div>
</div>
);
}
if (formQuery.isError || !formQuery.data) {
return (
<div className="mx-auto max-w-3xl px-6 py-10 text-sm text-muted-foreground">
Could not load this form.
</div>
);
}
const form = formQuery.data;
return (
<div className="mx-auto max-w-3xl px-6 py-8">
<header className="mb-8 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{form.title}</h1>
{form.isPublished === false ? (
<p className="mt-1 text-xs text-muted-foreground">Draft not published</p>
) : null}
</div>
<Link
href={`/${workspaceSlug}/forms/${formId}/edit`}
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
>
Edit
</Link>
</header>
<Tabs defaultValue="responses" className="flex flex-col gap-6">
<TabsList className="flex shrink-0 gap-0 border-b border-border">
{(
[
["responses", "Responses"],
["fill", "Fill form"],
] as const
).map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className={cn(
"relative px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"data-[state=active]:text-foreground",
"data-[state=active]:after:absolute data-[state=active]:after:inset-x-2 data-[state=active]:after:bottom-0 data-[state=active]:after:h-0.5 data-[state=active]:after:rounded-full data-[state=active]:after:bg-primary",
)}
>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="responses" className="outline-none data-[state=inactive]:hidden">
<FormResponses formId={formId} fields={fields} />
</TabsContent>
<TabsContent value="fill" className="outline-none data-[state=inactive]:hidden">
<FormRenderer formId={formId} />
</TabsContent>
</Tabs>
</div>
);
}

View file

@ -0,0 +1,118 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { ClipboardList, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
function formatUpdatedAt(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
export default function FormsListPage() {
const params = useParams();
const router = useRouter();
const utils = api.useUtils();
const workspaceId =
typeof params?.workspaceSlug === "string"
? params.workspaceSlug
: undefined;
const listQuery = api.forms.list.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const createMutation = api.forms.create.useMutation({
onSuccess: (created) => {
if (workspaceId) {
void utils.forms.list.invalidate({ workspaceId });
router.push(`/${workspaceId}/forms/${created.id}/edit`);
}
},
});
const forms = useMemo(
() => listQuery.data?.forms ?? [],
[listQuery.data?.forms],
);
return (
<div className="mx-auto max-w-5xl px-6 py-10 sm:px-10">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Forms</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build forms that create or update tasks in this workspace.
</p>
</div>
<Button
type="button"
disabled={!workspaceId || createMutation.isPending}
onClick={() => {
if (!workspaceId) return;
createMutation.mutate({
workspaceId,
title: "Untitled form",
});
}}
>
<Plus className="mr-2 size-4" />
New form
</Button>
</div>
{listQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading forms</p>
) : forms.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
No forms yet. Create one to open the form builder.
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{forms.map((form) => (
<li key={form.id}>
<Link
href={`/${workspaceId}/forms/${form.id}/edit`}
className="flex h-full flex-col rounded-xl border border-border bg-card p-5 shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/20"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ClipboardList className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-foreground">
{form.title}
</p>
{form.description ? (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{form.description}
</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge
variant={form.isPublished ? "default" : "secondary"}
>
{form.isPublished ? "Published" : "Draft"}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
Updated {formatUpdatedAt(form.updatedAt)}
</span>
</div>
</div>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,251 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import type { inferRouterOutputs } from "@trpc/server";
import { Calendar } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/lib/trpc";
import type { AppRouter } from "@/server/root";
import { cn } from "@/lib/utils";
type ListObject = inferRouterOutputs<AppRouter>["objects"]["list"]["objects"][number];
type PlannerTask = ListObject & {
dueDate?: unknown;
properties?: unknown;
};
function readDueDateString(task: PlannerTask): string | null {
if (typeof task.dueDate === "string" && task.dueDate.trim()) {
return task.dueDate.trim();
}
const props = task.properties;
if (props && typeof props === "object" && props !== null) {
const v = (props as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
const content = task.content;
if (content && typeof content === "object" && content !== null) {
const v = (content as Record<string, unknown>).dueDate;
if (typeof v === "string" && v.trim()) return v.trim();
}
return null;
}
function toLocalDateKey(iso: string): string | null {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function formatGroupHeading(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const date = new Date(y, mo - 1, da);
return date.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
}
function formatWeekRangeLabel(dateKey: string): string {
const [y, mo, da] = dateKey.split("-").map(Number);
const start = new Date(y, mo - 1, da);
const day = start.getDay();
const diff = start.getDate() - day + (day === 0 ? -6 : 1);
const weekStart = new Date(start);
weekStart.setDate(diff);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const opts: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
const a = weekStart.toLocaleDateString(undefined, opts);
const b = weekEnd.toLocaleDateString(undefined, {
...opts,
year: weekEnd.getFullYear() !== weekStart.getFullYear() ? "numeric" : undefined,
});
return `Week of ${a} ${b}`;
}
const NO_DATE_KEY = "__no_date__";
function statusLabel(status: string | null | undefined): string {
switch (status) {
case "in_progress":
return "In progress";
case "done":
return "Done";
case "closed":
return "Closed";
case "open":
default:
return "Open";
}
}
function StatusBadge({ status }: { status: string | null | undefined }) {
const s = status ?? "open";
if (s === "in_progress") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-blue-500/15 font-medium text-blue-700",
"hover:bg-blue-500/20 dark:text-blue-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
if (s === "done") {
return (
<Badge
className={cn(
"shrink-0 border-transparent bg-green-500/15 font-medium text-green-700",
"hover:bg-green-500/20 dark:text-green-300",
)}
>
{statusLabel(s)}
</Badge>
);
}
return (
<Badge variant="outline" className="shrink-0 font-medium">
{statusLabel(s)}
</Badge>
);
}
export default function PlannerPage() {
const params = useParams();
const workspaceSlug =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const listQuery = api.objects.list.useQuery(
{ workspaceId: workspaceSlug!, type: "task", limit: 200 },
{ enabled: Boolean(workspaceSlug) },
);
const grouped = useMemo(() => {
const rows = (listQuery.data?.objects ?? []) as PlannerTask[];
const map = new Map<string, PlannerTask[]>();
for (const task of rows) {
const raw = readDueDateString(task);
const key = raw ? toLocalDateKey(raw) : null;
const groupKey = key ?? NO_DATE_KEY;
const list = map.get(groupKey) ?? [];
list.push(task);
map.set(groupKey, list);
}
const keys = [...map.keys()].sort((a, b) => {
if (a === NO_DATE_KEY) return 1;
if (b === NO_DATE_KEY) return -1;
return a.localeCompare(b);
});
for (const k of keys) {
const list = map.get(k)!;
list.sort((a, b) => a.title.localeCompare(b.title));
}
return { keys, map };
}, [listQuery.data?.objects]);
const taskCount = listQuery.data?.objects?.length ?? 0;
const isEmpty =
Boolean(workspaceSlug) && !listQuery.isLoading && taskCount === 0;
if (!workspaceSlug) {
return (
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="text-sm text-muted-foreground">No workspace selected.</p>
</div>
);
}
return (
<div className="mx-auto flex h-full max-w-3xl flex-col px-8 py-10">
<header className="flex flex-wrap items-start gap-4 border-b border-border pb-6">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
"border border-border bg-muted/40 text-muted-foreground",
)}
>
<Calendar className="size-5" strokeWidth={1.75} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Planner</h1>
<Badge variant="secondary" className="font-normal text-muted-foreground">
Calendar view coming soon
</Badge>
</div>
</div>
</header>
{listQuery.isLoading ? (
<p className="mt-8 text-sm text-muted-foreground">Loading tasks</p>
) : isEmpty ? (
<div
className={cn(
"mt-8 rounded-lg border border-dashed border-border bg-muted/30",
"p-12 text-center text-sm text-muted-foreground",
)}
>
No tasks in this workspace yet. Create tasks in a space to see them here.
</div>
) : (
<ScrollArea className="mt-6 min-h-[min(480px,calc(100vh-14rem))] flex-1 pr-3">
<div className="space-y-8 pb-6">
{grouped.keys.map((key) => {
const tasks = grouped.map.get(key)!;
const isNoDate = key === NO_DATE_KEY;
return (
<section key={key} className="space-y-3">
<div>
<h2 className="text-sm font-semibold tracking-tight text-foreground">
{isNoDate ? "No date" : formatGroupHeading(key)}
</h2>
{!isNoDate ? (
<p className="text-xs text-muted-foreground">
{formatWeekRangeLabel(key)}
</p>
) : null}
</div>
<ul
className={cn(
"divide-y divide-border overflow-hidden rounded-lg border border-border",
"bg-card shadow-sm",
)}
>
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center gap-3 px-4 py-3 text-sm"
>
<span className="min-w-0 flex-1 truncate font-medium">
{task.title || "Untitled"}
</span>
<StatusBadge status={task.status} />
</li>
))}
</ul>
</section>
);
})}
</div>
</ScrollArea>
)}
</div>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import { useParams } from "next/navigation";
import { FileStack, Plus, Loader2 } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { TemplateEditor } from "@/components/templates";
export default function TemplatesSettingsPage() {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const [selectedId, setSelectedId] = useState<string | null>(null);
const utils = api.useUtils();
const { data, isLoading } = api.templates.list.useQuery(
{ workspaceId },
{ enabled: Boolean(workspaceId) },
);
const templates = data?.templates ?? [];
const createMutation = api.templates.create.useMutation({
onSuccess: (newTemplate) => {
setSelectedId(newTemplate.id);
void utils.templates.list.invalidate({ workspaceId });
},
});
const getByIdQuery = api.templates.getById.useQuery(
{ id: selectedId! },
{ enabled: Boolean(workspaceId && selectedId) },
);
if (!workspaceId) {
return (
<div className="p-8 text-sm text-muted-foreground">
No workspace selected.
</div>
);
}
const invalidateAfterSave = () => {
void utils.templates.list.invalidate({ workspaceId });
if (selectedId) void utils.templates.getById.invalidate({ id: selectedId });
};
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<FileStack className="size-5 text-primary" />
</div>
<h1 className="text-lg font-semibold">Templates</h1>
</div>
<Button
size="sm"
className="gap-1.5"
onClick={() =>
createMutation.mutate({
workspaceId,
name: "Untitled Template",
targetType: "task",
schema: { properties: [], defaultContent: "" },
})
}
disabled={createMutation.isPending}
>
<Plus className="size-4" />
New Template
</Button>
</div>
<div className="flex min-h-0 flex-1">
{/* Left: Template list */}
<div className="w-64 shrink-0 overflow-y-auto border-r bg-muted/30 p-3">
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
No templates yet
</p>
) : (
<div className="flex flex-col gap-1">
{templates.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setSelectedId(t.id)}
className={cn(
"rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent",
selectedId === t.id && "bg-accent font-medium",
)}
>
<p className="truncate">{t.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{t.targetType}
</p>
</button>
))}
</div>
)}
</div>
{/* Right: Template editor */}
<div className="flex-1 overflow-y-auto p-6">
{selectedId ? (
getByIdQuery.isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
) : getByIdQuery.data ? (
<TemplateEditor
key={selectedId}
template={getByIdQuery.data}
workspaceId={workspaceId}
onSave={invalidateAfterSave}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Template not found
</div>
)
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a template or create a new one
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
"use client";
import { useParams } from "next/navigation";
import { TypeManager } from "@/components/types";
export default function TypesSettingsPage() {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
return (
<div className="mx-auto max-w-4xl px-8 py-10">
<TypeManager workspaceId={workspaceId} />
</div>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import { useParams } from "next/navigation";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
function memberInitials(name: string | null | undefined, email: string) {
const n = name?.trim();
if (n) return n.slice(0, 1).toUpperCase();
return email.trim().slice(0, 1).toUpperCase();
}
function formatRoleLabel(role: string) {
const key = role.toLowerCase();
const labels: Record<string, string> = {
owner: "Owner",
admin: "Admin",
member: "Member",
};
return labels[key] ?? role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
function MemberCardSkeleton({ className }: { className?: string }) {
return (
<Card className={cn("overflow-hidden", className)}>
<CardContent className="flex items-center gap-4 p-6">
<div className="size-10 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-32 animate-pulse rounded-md bg-muted" />
<div className="h-3 w-48 max-w-full animate-pulse rounded-md bg-muted" />
</div>
<div className="h-5 w-16 shrink-0 animate-pulse rounded-full bg-muted" />
</CardContent>
</Card>
);
}
export default function TeamsPage() {
const params = useParams();
const workspaceSlug = params?.workspaceSlug;
const workspaceId = typeof workspaceSlug === "string" ? workspaceSlug : undefined;
const { data: members, isLoading } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId as string },
{ enabled: Boolean(workspaceId) },
);
return (
<div className="mx-auto max-w-5xl px-8 py-10">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-3xl font-bold tracking-tight">Teams</h1>
<Button type="button" onClick={() => window.alert("Invite coming soon")}>
Invite
</Button>
</div>
{!workspaceId ? (
<p className="text-sm text-muted-foreground">Missing workspace.</p>
) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
) : !members?.length ? (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-muted/30 p-12 text-center text-sm text-muted-foreground",
)}
>
No members in this workspace yet.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const displayName = m.name?.trim() || m.email;
return (
<Card key={m.id} className="overflow-hidden">
<CardContent className="flex items-center gap-4 p-6">
<Avatar className="size-10 shrink-0">
{m.avatarUrl ? (
<AvatarImage src={m.avatarUrl} alt="" />
) : null}
<AvatarFallback>{memberInitials(m.name, m.email)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{displayName}</p>
<p className="truncate text-sm text-muted-foreground">{m.email}</p>
</div>
<Badge variant="secondary" className="shrink-0 capitalize">
{formatRoleLabel(m.role)}
</Badge>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,43 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
const WhiteboardCanvas = dynamic(
() => import("@/components/whiteboard/canvas").then((m) => m.WhiteboardCanvas),
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground">Loading whiteboard...</div> },
);
export default function WhiteboardDetailPage() {
const params = useParams();
const router = useRouter();
const workspaceSlug = params.workspaceSlug as string;
const whiteboardId = params.whiteboardId as string;
const { data: wb } = api.objects.getById.useQuery(
{ id: whiteboardId },
{ enabled: Boolean(whiteboardId) },
);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-3 border-b px-4 py-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => router.push(`/${workspaceSlug}/whiteboards`)}
>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-sm font-medium">{(wb as { title?: string } | undefined)?.title ?? "Whiteboard"}</h1>
</div>
<div className="flex-1">
<WhiteboardCanvas documentId={whiteboardId} className="h-full" />
</div>
</div>
);
}

View file

@ -1,20 +1,99 @@
"use client";
import dynamic from "next/dynamic";
const WhiteboardCanvas = dynamic(
() => import("@/components/whiteboard/canvas").then((m) => m.WhiteboardCanvas),
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground">Loading whiteboard...</div> },
);
import { useParams, useRouter } from "next/navigation";
import { Plus, PenTool, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
export default function WhiteboardsPage() {
const params = useParams();
const router = useRouter();
const workspaceSlug = params.workspaceSlug as string;
const { data, isLoading } = api.objects.list.useQuery(
{ workspaceId: workspaceSlug, type: "whiteboard", limit: 200 },
{ enabled: Boolean(workspaceSlug) },
);
const createMutation = api.objects.create.useMutation({
onSuccess: (newObj) => {
router.push(`/${workspaceSlug}/whiteboards/${newObj.id}`);
},
});
const whiteboards = data?.objects ?? [];
return (
<div className="flex h-full flex-col">
<div className="shrink-0 border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Whiteboard</h1>
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<PenTool className="size-5 text-primary" />
</div>
<h1 className="text-lg font-semibold">Whiteboards</h1>
</div>
<Button
size="sm"
className="gap-1.5"
onClick={() =>
createMutation.mutate({
type: "whiteboard",
title: "Untitled Whiteboard",
workspaceId: workspaceSlug,
})
}
disabled={createMutation.isPending}
>
<Plus className="size-4" />
New Whiteboard
</Button>
</div>
<div className="flex-1">
<WhiteboardCanvas className="h-full" />
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : whiteboards.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-20 text-muted-foreground">
<PenTool className="size-10 opacity-40" />
<p className="text-sm">No whiteboards yet</p>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() =>
createMutation.mutate({
type: "whiteboard",
title: "Untitled Whiteboard",
workspaceId: workspaceSlug,
})
}
>
<Plus className="size-4" />
Create your first whiteboard
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{whiteboards.map((wb) => (
<button
key={wb.id}
type="button"
onClick={() => router.push(`/${workspaceSlug}/whiteboards/${wb.id}`)}
className="group flex flex-col gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/50"
>
<div className="flex h-32 w-full items-center justify-center rounded-md bg-muted">
<PenTool className="size-8 text-muted-foreground/40" />
</div>
<p className="truncate text-sm font-medium">{wb.title || "Untitled"}</p>
<p className="text-xs text-muted-foreground">
{new Date(wb.updatedAt ?? wb.createdAt).toLocaleDateString()}
</p>
</button>
))}
</div>
)}
</div>
</div>
);

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import "@/styles/globals.css";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { TRPCProvider } from "@/components/providers/trpc-provider";
@ -7,10 +8,14 @@ import { TRPCProvider } from "@/components/providers/trpc-provider";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "Tasks",
title: "ECHODO",
description: "Project management, docs, and whiteboards — all in one place.",
};
const umamiScript =
process.env.NEXT_PUBLIC_UMAMI_SCRIPT ?? "https://analytics.stillwell.cloud/script.js";
const umamiWebsiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
export default function RootLayout({
children,
}: {
@ -27,6 +32,14 @@ export default function RootLayout({
>
<TRPCProvider>{children}</TRPCProvider>
</ThemeProvider>
{umamiWebsiteId ? (
<Script
defer
src={umamiScript}
data-website-id={umamiWebsiteId}
strategy="afterInteractive"
/>
) : null}
</body>
</html>
);

View file

@ -1,5 +1,25 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@tasks/database/client";
import { objects, workspaceMembers } from "@tasks/database/schema";
import { eq, and } from "drizzle-orm";
export default async function HomePage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/sign-in");
}
const membership = await db
.select({ workspaceId: workspaceMembers.workspaceId })
.from(workspaceMembers)
.where(eq(workspaceMembers.userId, session.user.id))
.limit(1);
if (membership.length > 0) {
redirect(`/${membership[0].workspaceId}`);
}
export default function HomePage() {
redirect("/sign-in");
}

View file

@ -9,8 +9,8 @@ import Image from "@tiptap/extension-image";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import CodeBlock from "@tiptap/extension-code-block";
@ -130,6 +130,7 @@ export function BlockEditor({
multicolor: true,
}),
Typography,
Underline,
TextAlign.configure({
types: ["heading", "paragraph"],
}),

View file

@ -1,3 +1,4 @@
export { BlockEditor, type BlockEditorProps } from "./editor";
export { CollaborativeBlockEditor, type CollaborativeBlockEditorProps } from "./collaboration";
export { EditorToolbar } from "./toolbar";
export { SlashMenu } from "./slash-menu";

View file

@ -0,0 +1,378 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { FormFieldCard } from "./form-field-card";
import { FormFieldConfig } from "./form-field-config";
import { FormFieldTypePicker } from "./form-field-type-picker";
type FormField = {
id: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
helpText?: string;
options?: { label: string; value: string }[];
defaultValue?: unknown;
mappedProperty: string | null;
validation?: {
min?: number;
max?: number;
pattern?: string;
maxLength?: number;
};
conditionals?: {
fieldId: string;
operator: string;
value: unknown;
action: string;
}[];
};
type Draft = {
title: string;
description: string | null;
isPublished: boolean;
fields: FormField[];
};
function normalizeFields(raw: unknown): FormField[] {
if (!Array.isArray(raw)) return [];
return raw.map((item, i) => {
const o = item as Record<string, unknown>;
return {
id: typeof o.id === "string" ? o.id : `field_${i}`,
label: typeof o.label === "string" ? o.label : "Untitled",
type: typeof o.type === "string" ? o.type : "short_text",
required: Boolean(o.required),
placeholder:
typeof o.placeholder === "string" ? o.placeholder : undefined,
helpText: typeof o.helpText === "string" ? o.helpText : undefined,
options: Array.isArray(o.options) ? (o.options as FormField["options"]) : undefined,
defaultValue: o.defaultValue,
mappedProperty:
o.mappedProperty === null || typeof o.mappedProperty === "string"
? (o.mappedProperty as string | null)
: null,
validation:
o.validation && typeof o.validation === "object"
? (o.validation as FormField["validation"])
: undefined,
conditionals: Array.isArray(o.conditionals)
? (o.conditionals as FormField["conditionals"])
: undefined,
};
});
}
function createField(type: string): FormField {
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `fld_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const field: FormField = {
id,
label: "Untitled",
type,
required: false,
mappedProperty: null,
};
if (type === "select" || type === "multi_select" || type === "radio") {
field.options = [
{ label: "Option 1", value: "option_1" },
{ label: "Option 2", value: "option_2" },
];
}
return field;
}
export function FormBuilder({
formId,
workspaceId,
}: {
formId: string;
workspaceId: string;
}) {
const utils = api.useUtils();
const [draft, setDraft] = useState<Draft | null>(null);
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const hydratedRef = useRef(false);
const skipSaveRef = useRef(false);
const formQuery = api.forms.getById.useQuery(
{ id: formId },
{ enabled: Boolean(formId) },
);
const updateMutation = api.forms.update.useMutation({
onSuccess: () => {
void utils.forms.getById.invalidate({ id: formId });
},
});
useEffect(() => {
hydratedRef.current = false;
setDraft(null);
setSelectedFieldId(null);
}, [formId, workspaceId]);
useEffect(() => {
if (
!formQuery.isSuccess ||
!formQuery.data ||
formQuery.data.id !== formId
) {
return;
}
if (hydratedRef.current) return;
const row = formQuery.data;
setDraft({
title: row.title,
description: row.description ?? null,
isPublished: row.isPublished,
fields: normalizeFields(row.fields),
});
hydratedRef.current = true;
skipSaveRef.current = true;
}, [formQuery.isSuccess, formQuery.data, formId]);
useEffect(() => {
if (!draft || !hydratedRef.current) return;
if (skipSaveRef.current) {
skipSaveRef.current = false;
return;
}
const handle = setTimeout(() => {
updateMutation.mutate({
id: formId,
title: draft.title,
description: draft.description,
fields: draft.fields,
isPublished: draft.isPublished,
});
}, 550);
return () => clearTimeout(handle);
}, [draft, formId, updateMutation]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !draft) return;
const oldIndex = draft.fields.findIndex((f) => f.id === active.id);
const newIndex = draft.fields.findIndex((f) => f.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
setDraft({
...draft,
fields: arrayMove(draft.fields, oldIndex, newIndex),
});
};
const updateField = (id: string, patch: Partial<FormField>) => {
setDraft((d) => {
if (!d) return d;
return {
...d,
fields: d.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)),
};
});
};
const deleteField = (id: string) => {
setDraft((d) => {
if (!d) return d;
return {
...d,
fields: d.fields.filter((f) => f.id !== id),
};
});
setSelectedFieldId((cur) => (cur === id ? null : cur));
};
const selectedField = draft?.fields.find((f) => f.id === selectedFieldId);
if (formQuery.isLoading || draft === null) {
return (
<div className="rounded-lg border border-dashed border-border bg-muted/20 p-12 text-center text-sm text-muted-foreground">
Loading form
</div>
);
}
if (formQuery.isError) {
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
Could not load this form.
</div>
);
}
return (
<div className="flex flex-col gap-8 lg:flex-row lg:items-start">
<div className="min-w-0 flex-1 space-y-6">
<div className="space-y-3">
<Input
value={draft.title}
onChange={(e) =>
setDraft((d) => (d ? { ...d, title: e.target.value } : d))
}
className="text-2xl font-semibold tracking-tight"
placeholder="Form title"
/>
<textarea
value={draft.description ?? ""}
onChange={(e) =>
setDraft((d) =>
d
? {
...d,
description: e.target.value || null,
}
: d,
)
}
placeholder="Description (optional)"
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<label className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-sm font-medium shadow-sm">
<input
type="checkbox"
checked={draft.isPublished}
onChange={(e) =>
setDraft((d) =>
d ? { ...d, isPublished: e.target.checked } : d,
)
}
className="size-4 rounded border-input accent-primary"
/>
Published
<span className="text-xs font-normal text-muted-foreground">
When on, the form can accept responses (when wired).
</span>
</label>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-foreground">Fields</h2>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPickerOpen((o) => !o)}
>
{pickerOpen ? "Close picker" : "Add field"}
</Button>
</div>
{pickerOpen ? (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<p className="mb-2 text-xs text-muted-foreground">
Choose a field type
</p>
<FormFieldTypePicker
onSelect={(type) => {
const next = createField(type);
setDraft((d) =>
d ? { ...d, fields: [...d.fields, next] } : d,
);
setSelectedFieldId(next.id);
setPickerOpen(false);
}}
/>
</div>
) : null}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={draft.fields.map((f) => f.id)}
strategy={verticalListSortingStrategy}
>
{draft.fields.length === 0 ? (
<p className="rounded-lg border border-dashed border-border py-10 text-center text-sm text-muted-foreground">
No fields yet. Add a field to start building.
</p>
) : (
<ul className="flex flex-col gap-2">
{draft.fields.map((field) => (
<li key={field.id}>
<FormFieldCard
field={field}
isSelected={field.id === selectedFieldId}
onSelect={() => setSelectedFieldId(field.id)}
onChange={(patch) => updateField(field.id, patch)}
onDelete={() => deleteField(field.id)}
/>
</li>
))}
</ul>
)}
</SortableContext>
</DndContext>
</div>
{updateMutation.isError ? (
<p className="text-xs text-destructive">
Failed to save changes. Try again.
</p>
) : null}
</div>
<aside className="w-full shrink-0 lg:sticky lg:top-6 lg:w-96">
{selectedField ? (
<FormFieldConfig
key={selectedField.id}
field={selectedField}
allFields={draft.fields}
workspaceId={workspaceId}
onChange={(patch) => updateField(selectedField.id, patch)}
/>
) : (
<div className="rounded-lg border border-dashed border-border bg-muted/10 p-6 text-center text-sm text-muted-foreground">
Select a field to edit its settings, validation, mapping, and
conditional rules.
</div>
)}
</aside>
</div>
);
}

View file

@ -0,0 +1,182 @@
"use client";
import { useEffect, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type FormField = {
id: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
helpText?: string;
options?: { label: string; value: string }[];
defaultValue?: unknown;
mappedProperty: string | null;
validation?: {
min?: number;
max?: number;
pattern?: string;
maxLength?: number;
};
conditionals?: {
fieldId: string;
operator: string;
value: unknown;
action: string;
}[];
};
function formatTypeLabel(type: string) {
return type
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function FormFieldCard({
field,
isSelected,
onSelect,
onChange,
onDelete,
}: {
field: FormField;
isSelected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FormField>) => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: field.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState(field.label);
useEffect(() => {
setLabelDraft(field.label);
}, [field.label]);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-stretch gap-2 rounded-lg border bg-card p-2 shadow-sm transition-shadow",
isSelected && "ring-2 ring-ring ring-offset-2 ring-offset-background",
isDragging && "z-10 opacity-90 shadow-md",
)}
>
<button
type="button"
className="flex shrink-0 cursor-grab touch-none items-center rounded-md border border-transparent px-1 text-muted-foreground hover:bg-muted active:cursor-grabbing"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<div
role="button"
tabIndex={0}
className="min-w-0 flex-1 cursor-pointer text-left"
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
>
{editingLabel ? (
<Input
autoFocus
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onBlur={() => {
setEditingLabel(false);
if (labelDraft.trim() !== field.label) {
onChange({ label: labelDraft.trim() || "Untitled" });
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
e.stopPropagation();
}}
className="h-8"
onClick={(e) => e.stopPropagation()}
/>
) : (
<button
type="button"
className="block w-full truncate text-left font-medium text-foreground hover:underline"
onClick={(e) => {
e.stopPropagation();
setEditingLabel(true);
}}
>
{field.label || "Untitled"}
</button>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="font-normal">
{formatTypeLabel(field.type)}
</Badge>
{field.required ? (
<Badge variant="outline" className="text-xs">
Required
</Badge>
) : null}
</div>
</div>
<div className="flex shrink-0 flex-col items-end justify-center gap-1">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={field.required}
onChange={(e) => {
e.stopPropagation();
onChange({ required: e.target.checked });
}}
onClick={(e) => e.stopPropagation()}
className="size-3.5 rounded border-input accent-primary"
/>
Req.
</label>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-muted-foreground hover:text-destructive"
aria-label="Delete field"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,407 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { FormMappingPicker } from "./form-mapping-picker";
type FormField = {
id: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
helpText?: string;
options?: { label: string; value: string }[];
defaultValue?: unknown;
mappedProperty: string | null;
validation?: {
min?: number;
max?: number;
pattern?: string;
maxLength?: number;
};
conditionals?: {
fieldId: string;
operator: string;
value: unknown;
action: string;
}[];
};
const OPERATORS = ["eq", "neq", "contains", "isEmpty"] as const;
const ACTIONS = ["show", "hide"] as const;
const CHOICE_TYPES = new Set([
"select",
"multi_select",
"radio",
]);
export function FormFieldConfig({
field,
allFields,
workspaceId,
onChange,
}: {
field: FormField;
allFields: FormField[];
workspaceId: string;
onChange: (patch: Partial<FormField>) => void;
}) {
const otherFields = allFields.filter((f) => f.id !== field.id);
const v = field.validation ?? {};
const conditionals = field.conditionals ?? [];
const updateOption = (
index: number,
patch: Partial<{ label: string; value: string }>,
) => {
const options = [...(field.options ?? [])];
const current = options[index] ?? { label: "", value: "" };
options[index] = { ...current, ...patch };
onChange({ options });
};
const addOption = () => {
const n = (field.options?.length ?? 0) + 1;
onChange({
options: [
...(field.options ?? []),
{ label: `Option ${n}`, value: `option_${n}` },
],
});
};
const removeOption = (index: number) => {
const options = [...(field.options ?? [])];
options.splice(index, 1);
onChange({ options: options.length ? options : undefined });
};
return (
<div className="flex flex-col gap-4 rounded-lg border bg-card p-4 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-foreground">Field settings</h3>
<p className="text-xs text-muted-foreground">
{field.label} · {field.type.replaceAll("_", " ")}
</p>
</div>
<Separator />
<div className="space-y-2">
<label className="text-xs font-medium text-foreground" htmlFor="ff-label">
Label
</label>
<Input
id="ff-label"
value={field.label}
onChange={(e) => onChange({ label: e.target.value })}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-placeholder"
>
Placeholder
</label>
<Input
id="ff-placeholder"
value={field.placeholder ?? ""}
placeholder="Optional"
onChange={(e) =>
onChange({
placeholder: e.target.value || undefined,
})
}
/>
</div>
<div className="space-y-2">
<label
className="text-xs font-medium text-foreground"
htmlFor="ff-help"
>
Help text
</label>
<textarea
id="ff-help"
value={field.helpText ?? ""}
placeholder="Shown below the field"
rows={3}
onChange={(e) =>
onChange({
helpText: e.target.value || undefined,
})
}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={field.required}
onChange={(e) => onChange({ required: e.target.checked })}
className="size-4 rounded border-input accent-primary"
/>
Required
</label>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Validation</span>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder="Min"
value={v.min ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
min:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
<Input
type="number"
placeholder="Max"
value={v.max ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
max:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
<Input
placeholder="Pattern (regex)"
value={v.pattern ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
pattern: e.target.value || undefined,
},
})
}
/>
<Input
type="number"
placeholder="Max length"
value={v.maxLength ?? ""}
onChange={(e) =>
onChange({
validation: {
...v,
maxLength:
e.target.value === ""
? undefined
: Number(e.target.value),
},
})
}
/>
</div>
{CHOICE_TYPES.has(field.type) ? (
<>
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">Options</span>
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt, i) => (
<div key={i} className="flex gap-2">
<Input
placeholder="Label"
value={opt.label}
onChange={(e) => updateOption(i, { label: e.target.value })}
/>
<Input
placeholder="Value"
value={opt.value}
onChange={(e) => updateOption(i, { value: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="shrink-0"
onClick={() => removeOption(i)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1"
onClick={addOption}
>
<Plus className="size-4" />
Add option
</Button>
</div>
</>
) : null}
<Separator />
<div className="space-y-2">
<span className="text-xs font-medium text-foreground">
Map to task property
</span>
<FormMappingPicker
workspaceId={workspaceId}
value={field.mappedProperty}
onChange={(next) => onChange({ mappedProperty: next })}
/>
</div>
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">
Conditional rules
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() =>
onChange({
conditionals: [
...conditionals,
{
fieldId: otherFields[0]?.id ?? "",
operator: "eq",
value: "",
action: "show",
},
],
})
}
>
<Plus className="size-3.5" />
Add rule
</Button>
</div>
{conditionals.length === 0 ? (
<p className="text-xs text-muted-foreground">
No rules. Show or hide this field based on another field&apos;s value.
</p>
) : (
<ul className="flex flex-col gap-3">
{conditionals.map((rule, index) => (
<li
key={index}
className="space-y-2 rounded-md border border-border p-2"
>
<div className="flex justify-end">
<Button
type="button"
size="icon"
variant="ghost"
className="size-7"
onClick={() => {
const next = conditionals.filter((_, i) => i !== index);
onChange({
conditionals: next.length ? next : undefined,
});
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.fieldId}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, fieldId: e.target.value };
onChange({ conditionals: next });
}}
>
<option value="">Select field</option>
{otherFields.map((f) => (
<option key={f.id} value={f.id}>
{f.label}
</option>
))}
</select>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.operator}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, operator: e.target.value };
onChange({ conditionals: next });
}}
>
{OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
placeholder="Value"
value={
rule.value === undefined || rule.value === null
? ""
: String(rule.value)
}
disabled={rule.operator === "isEmpty"}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, value: e.target.value };
onChange({ conditionals: next });
}}
/>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
value={rule.action}
onChange={(e) => {
const next = [...conditionals];
next[index] = { ...rule, action: e.target.value };
onChange({ conditionals: next });
}}
>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import type { ComponentType } from "react";
import {
AlignLeft,
Calendar,
CheckSquare,
CircleDot,
Heading,
Hash,
Link,
List,
ListChecks,
Mail,
Minus,
Star,
Type,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const FIELD_TYPES: {
type: string;
label: string;
icon: ComponentType<{ className?: string }>;
}[] = [
{ type: "short_text", label: "Short text", icon: Type },
{ type: "long_text", label: "Long text", icon: AlignLeft },
{ type: "number", label: "Number", icon: Hash },
{ type: "email", label: "Email", icon: Mail },
{ type: "url", label: "URL", icon: Link },
{ type: "date", label: "Date", icon: Calendar },
{ type: "select", label: "Select", icon: List },
{ type: "multi_select", label: "Multi select", icon: ListChecks },
{ type: "checkbox", label: "Checkbox", icon: CheckSquare },
{ type: "radio", label: "Radio", icon: CircleDot },
{ type: "file_upload", label: "File upload", icon: Upload },
{ type: "rating", label: "Rating", icon: Star },
{ type: "section_header", label: "Section", icon: Heading },
{ type: "divider", label: "Divider", icon: Minus },
];
export function FormFieldTypePicker({
onSelect,
className,
}: {
onSelect: (type: string) => void;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4",
className,
)}
>
{FIELD_TYPES.map(({ type, label, icon: Icon }) => (
<Button
key={type}
type="button"
variant="outline"
className="h-auto flex-col gap-2 py-3 text-xs font-medium"
onClick={() => onSelect(type)}
>
<Icon className="size-5 text-muted-foreground" />
<span className="text-center leading-tight">{label}</span>
</Button>
))}
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
const BUILTIN = [
{ value: "title", label: "Task title" },
{ value: "description", label: "Description" },
{ value: "status", label: "Status" },
] as const;
export function FormMappingPicker({
workspaceId,
value,
onChange,
disabled,
className,
}: {
workspaceId: string;
value: string | null;
onChange: (next: string | null) => void;
disabled?: boolean;
className?: string;
}) {
const { data, isLoading } = api.properties.listDefinitions.useQuery(
{ workspaceId },
{ enabled: Boolean(workspaceId) },
);
const definitions = data?.definitions ?? [];
const labelFor = (v: string | null) => {
if (v === null || v === "") return "No mapping";
const builtin = BUILTIN.find((b) => b.value === v);
if (builtin) return builtin.label;
const def = definitions.find((d) => d.id === v);
return def?.name ?? v;
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled || isLoading}
className={cn("w-full justify-between font-normal", className)}
>
<span className="truncate">{labelFor(value)}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-64 w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto">
<DropdownMenuLabel>Task fields</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onChange(null)}>
No mapping
</DropdownMenuItem>
{BUILTIN.map((b) => (
<DropdownMenuItem key={b.value} onClick={() => onChange(b.value)}>
{b.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>Custom properties</DropdownMenuLabel>
{definitions.length === 0 ? (
<DropdownMenuItem disabled>No custom properties</DropdownMenuItem>
) : (
definitions.map((d) => (
<DropdownMenuItem key={d.id} onClick={() => onChange(d.id)}>
{d.name}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,587 @@
"use client";
import * as React from "react";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
export type FormField = {
id: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
helpText?: string;
options?: { label: string; value: string }[];
mappedProperty: string | null;
validation?: { min?: number; max?: number };
conditionals?: {
fieldId: string;
operator: string;
value: unknown;
action: string;
}[];
};
function matchesConditional(
fieldValue: unknown,
operator: string,
expected: unknown,
): boolean {
switch (operator) {
case "eq":
return fieldValue === expected;
case "neq":
return fieldValue !== expected;
case "contains": {
const a = String(fieldValue ?? "").toLowerCase();
const b = String(expected ?? "").toLowerCase();
return a.includes(b);
}
case "isEmpty":
return (
fieldValue === undefined ||
fieldValue === null ||
fieldValue === "" ||
(Array.isArray(fieldValue) && fieldValue.length === 0)
);
default:
return true;
}
}
/** Resolves whether a field should be shown given current answer values and its conditionals. */
export function isFieldVisible(
field: FormField,
values: Record<string, unknown>,
): boolean {
if (!field.conditionals?.length) return true;
let visible = true;
for (const c of field.conditionals) {
const other = values[c.fieldId];
const ok = matchesConditional(other, c.operator, c.value);
if (c.action === "hide" && ok) visible = false;
if (c.action === "show" && !ok) visible = false;
}
return visible;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateField(
field: FormField,
value: unknown,
visible: boolean,
): string | null {
if (!visible) return null;
const skip = new Set(["section_header", "divider"]);
if (skip.has(field.type)) return null;
const empty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0);
if (field.required && empty) {
return `${field.label || "This field"} is required`;
}
if (empty) return null;
if (field.type === "email" && typeof value === "string" && !EMAIL_RE.test(value)) {
return "Enter a valid email address";
}
if (field.type === "number" && typeof value === "number") {
const { min, max } = field.validation ?? {};
if (min !== undefined && value < min) return `Must be at least ${min}`;
if (max !== undefined && value > max) return `Must be at most ${max}`;
}
if (field.type === "rating" && typeof value === "number") {
const min = field.validation?.min ?? 1;
const max = field.validation?.max ?? 5;
if (value < min || value > max) return `Pick a rating between ${min} and ${max}`;
}
return null;
}
function parseFields(raw: unknown): FormField[] {
if (!Array.isArray(raw)) return [];
return raw as FormField[];
}
function defaultValueForField(field: FormField): unknown {
switch (field.type) {
case "checkbox":
return false;
case "multi_select":
return [];
case "rating": {
const min = field.validation?.min ?? 1;
return min;
}
default:
return "";
}
}
export interface FormRendererProps {
formId: string;
onSubmitted?: (objectId: string) => void;
className?: string;
}
export function FormRenderer({ formId, onSubmitted, className }: FormRendererProps) {
const formQuery = api.forms.getById.useQuery({ id: formId }, { enabled: Boolean(formId) });
const fields = React.useMemo(
() => parseFields(formQuery.data?.fields),
[formQuery.data?.fields],
);
const [values, setValues] = React.useState<Record<string, unknown>>({});
const [errors, setErrors] = React.useState<Record<string, string>>({});
const [submitted, setSubmitted] = React.useState(false);
React.useEffect(() => {
setSubmitted(false);
setErrors({});
setValues({});
}, [formId]);
React.useEffect(() => {
if (!fields.length) return;
setValues((prev) => {
const next = { ...prev };
for (const f of fields) {
if (!(f.id in next)) {
next[f.id] = defaultValueForField(f);
}
}
return next;
});
}, [fields]);
const confirmationMessage = React.useMemo(() => {
const s = formQuery.data?.settings;
if (s && typeof s === "object" && s !== null && "confirmationMessage" in s) {
const m = (s as { confirmationMessage?: unknown }).confirmationMessage;
if (typeof m === "string" && m.trim()) return m.trim();
}
return "Thank you — your response was recorded.";
}, [formQuery.data?.settings]);
const submitMutation = api.forms.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true);
onSubmitted?.(result.object.id);
},
});
const setField = (id: string, v: unknown) => {
setValues((p) => ({ ...p, [id]: v }));
setErrors((p) => {
const { [id]: _, ...rest } = p;
return rest;
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formQuery.data) return;
const nextErrors: Record<string, string> = {};
for (const f of fields) {
const vis = isFieldVisible(f, values);
const err = validateField(f, values[f.id], vis);
if (err) nextErrors[f.id] = err;
}
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
const data: Record<string, unknown> = {};
for (const f of fields) {
if (!isFieldVisible(f, values)) continue;
if (f.type === "section_header" || f.type === "divider") continue;
data[f.id] = values[f.id];
}
submitMutation.mutate({ formId, data });
};
if (formQuery.isPending) {
return (
<div className={cn("flex items-center justify-center py-16", className)}>
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Loading form</span>
</div>
);
}
if (formQuery.isError || !formQuery.data) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
Could not load this form.
</p>
);
}
if (submitted) {
return (
<div
className={cn(
"rounded-lg border border-border bg-muted/30 px-6 py-8 text-center",
className,
)}
role="status"
>
<p className="text-sm font-medium text-foreground">{confirmationMessage}</p>
<Badge variant="secondary" className="mt-4">
Submitted
</Badge>
</div>
);
}
const form = formQuery.data;
return (
<form onSubmit={handleSubmit} className={cn("space-y-6", className)}>
{form.description ? (
<p className="text-sm text-muted-foreground">{form.description}</p>
) : null}
{fields.map((field) => {
if (!isFieldVisible(field, values)) return null;
if (field.type === "section_header") {
return (
<h3
key={field.id}
className="border-b border-border pb-1 text-sm font-semibold tracking-tight"
>
{field.label}
</h3>
);
}
if (field.type === "divider") {
return <hr key={field.id} className="border-border" />;
}
const err = errors[field.id];
const v = values[field.id];
const inputTypes = new Set([
"short_text",
"text",
"long_text",
"textarea",
"number",
"email",
"url",
"date",
"datetime",
"datetime-local",
"select",
"multi_select",
"checkbox",
"radio",
"rating",
"file_upload",
]);
return (
<div key={field.id} className="space-y-1.5">
<label
htmlFor={field.type === "rating" ? undefined : field.id}
className="text-sm font-medium leading-none"
>
{field.label}
{field.required ? (
<span className="text-destructive" aria-hidden>
{" "}
*
</span>
) : null}
</label>
{field.helpText ? (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
) : null}
{field.type === "short_text" || field.type === "text" ? (
<Input
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder}
aria-invalid={!!err}
aria-describedby={err ? `${field.id}-err` : undefined}
/>
) : null}
{field.type === "long_text" || field.type === "textarea" ? (
<textarea
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder}
rows={4}
aria-invalid={!!err}
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
"ring-offset-background placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
) : null}
{field.type === "number" ? (
<Input
id={field.id}
type="number"
value={
typeof v === "number"
? String(v)
: v === "" || v === undefined
? ""
: String(v ?? "")
}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") setField(field.id, "");
else setField(field.id, Number(raw));
}}
placeholder={field.placeholder}
min={field.validation?.min}
max={field.validation?.max}
aria-invalid={!!err}
/>
) : null}
{field.type === "email" ? (
<Input
id={field.id}
type="email"
autoComplete="email"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder ?? "you@example.com"}
aria-invalid={!!err}
/>
) : null}
{field.type === "url" ? (
<Input
id={field.id}
type="url"
inputMode="url"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
placeholder={field.placeholder ?? "https://"}
aria-invalid={!!err}
/>
) : null}
{field.type === "date" ? (
<Input
id={field.id}
type="date"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
/>
) : null}
{field.type === "datetime" || field.type === "datetime-local" ? (
<Input
id={field.id}
type="datetime-local"
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
/>
) : null}
{field.type === "select" ? (
<select
id={field.id}
value={typeof v === "string" ? v : ""}
onChange={(e) => setField(field.id, e.target.value)}
aria-invalid={!!err}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<option value="">{field.placeholder ?? "Choose…"}</option>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : null}
{field.type === "multi_select" ? (
<div className="flex flex-col gap-2 rounded-md border border-input p-3">
{(field.options ?? []).map((opt) => {
const selected = Array.isArray(v) && v.includes(opt.value);
return (
<label key={opt.value} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={!!selected}
onChange={() => {
const cur = Array.isArray(v) ? [...v] : [];
if (selected) {
setField(
field.id,
cur.filter((x) => x !== opt.value),
);
} else {
setField(field.id, [...cur, opt.value]);
}
}}
/>
{opt.label}
</label>
);
})}
</div>
) : null}
{field.type === "checkbox" ? (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={v === true}
onChange={(e) => setField(field.id, e.target.checked)}
aria-invalid={!!err}
/>
{field.placeholder ?? "Yes"}
</label>
) : null}
{field.type === "radio" ? (
<div className="flex flex-col gap-2">
{(field.options ?? []).map((opt) => (
<label key={opt.value} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={field.id}
value={opt.value}
checked={v === opt.value}
onChange={() => setField(field.id, opt.value)}
/>
{opt.label}
</label>
))}
</div>
) : null}
{field.type === "rating" ? (
<RatingStars
id={field.id}
min={field.validation?.min ?? 1}
max={field.validation?.max ?? 5}
value={typeof v === "number" ? v : field.validation?.min ?? 1}
onChange={(n) => setField(field.id, n)}
/>
) : null}
{field.type === "file_upload" ? (
<Input
id={field.id}
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
setField(field.id, file ? file.name : "");
}}
aria-invalid={!!err}
/>
) : null}
{!inputTypes.has(field.type) ? (
<p className="text-xs text-muted-foreground">
Unsupported field type: {field.type}
</p>
) : null}
{err ? (
<p id={`${field.id}-err`} className="text-xs text-destructive" role="alert">
{err}
</p>
) : null}
</div>
);
})}
{submitMutation.isError ? (
<p className="text-sm text-destructive" role="alert">
Something went wrong while submitting. Please try again.
</p>
) : null}
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Submitting
</>
) : (
"Submit"
)}
</Button>
</form>
);
}
function RatingStars({
id,
min,
max,
value,
onChange,
}: {
id: string;
min: number;
max: number;
value: number;
onChange: (n: number) => void;
}) {
const stars = React.useMemo(
() => Array.from({ length: max - min + 1 }, (_, i) => min + i),
[min, max],
);
return (
<div id={id} className="flex flex-wrap items-center gap-1" role="group">
{stars.map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
className={cn(
"rounded p-0.5 text-2xl leading-none transition-colors",
n <= value ? "text-amber-500" : "text-muted-foreground/30 hover:text-muted-foreground/60",
)}
aria-label={`${n} stars`}
aria-pressed={n === value}
>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import * as React from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/trpc";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
import type { FormField } from "./form-renderer";
function formatCellValue(value: unknown): string {
if (value === undefined || value === null) return "—";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return value.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ");
}
try {
return JSON.stringify(value);
} catch {
return "—";
}
}
function tableColumns(fields: FormField[]): FormField[] {
return fields.filter((f) => f.type !== "section_header" && f.type !== "divider");
}
export interface FormResponsesProps {
formId: string;
fields: FormField[];
className?: string;
}
export function FormResponses({ formId, fields, className }: FormResponsesProps) {
const open = usePanelStore((s) => s.open);
const cols = React.useMemo(() => tableColumns(fields), [fields]);
const listQuery = api.forms.listResponses.useQuery(
{ formId },
{ enabled: Boolean(formId) },
);
if (listQuery.isPending) {
return (
<div className={cn("flex justify-center py-12", className)}>
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Loading responses</span>
</div>
);
}
if (listQuery.isError) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
Could not load responses.
</p>
);
}
const rows = listQuery.data?.responses ?? [];
if (rows.length === 0) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>No responses yet.</p>
);
}
return (
<div className={cn("w-full overflow-x-auto rounded-md border border-border", className)}>
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
{cols.map((f) => (
<th
key={f.id}
className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground"
>
{f.label}
</th>
))}
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
Submitted
</th>
<th className="whitespace-nowrap px-3 py-2 font-medium text-muted-foreground">
Task
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const data =
row.data && typeof row.data === "object" && row.data !== null
? (row.data as Record<string, unknown>)
: {};
const submitted =
row.submittedAt instanceof Date
? row.submittedAt.toLocaleString()
: String(row.submittedAt ?? "—");
return (
<tr key={row.id} className="border-b border-border last:border-0">
{cols.map((f) => (
<td key={f.id} className="max-w-[240px] truncate px-3 py-2 align-top">
{formatCellValue(data[f.id])}
</td>
))}
<td className="whitespace-nowrap px-3 py-2 align-top text-muted-foreground">
{submitted}
</td>
<td className="px-3 py-2 align-top">
{row.createdObjectId ? (
<Button
type="button"
variant="link"
className="inline-flex h-auto items-center gap-1 p-0 text-primary"
onClick={() => open("object-detail", row.createdObjectId)}
>
Open task
<ExternalLink className="size-3.5 opacity-70" aria-hidden />
</Button>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,7 @@
export { FormBuilder } from "./form-builder";
export { FormFieldCard } from "./form-field-card";
export { FormFieldConfig } from "./form-field-config";
export { FormFieldTypePicker } from "./form-field-type-picker";
export { FormMappingPicker } from "./form-mapping-picker";
export { FormRenderer, isFieldVisible, type FormField } from "./form-renderer";
export { FormResponses } from "./form-responses";

View file

@ -4,13 +4,18 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { IconRail } from "@/components/layout/icon-rail";
import { TopHeader } from "@/components/layout/top-header";
import { RightPanel } from "@/components/panels/right-panel";
import { Sidebar } from "@/components/sidebar/sidebar";
import { CommandPalette } from "@/components/ai/command-palette";
import { CreateObjectDialog } from "@/components/objects";
import { SearchDialog } from "@/components/search";
export function AppShell({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createType, setCreateType] = useState<string | undefined>();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@ -27,15 +32,36 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-[100dvh] w-full overflow-hidden bg-background">
<Sidebar onOpenSearch={() => setSearchOpen(true)} />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex-1 overflow-auto">{children}</main>
<div className="flex h-[100dvh] w-full flex-col overflow-hidden bg-background">
<TopHeader
onOpenSearch={() => setSearchOpen(true)}
onQuickAction={(type) => {
setCreateType(type);
setCreateOpen(true);
}}
/>
<div className="flex min-h-0 flex-1 overflow-hidden">
<IconRail />
<Sidebar
onOpenSearch={() => setSearchOpen(true)}
onQuickAction={(type) => {
setCreateType(type);
setCreateOpen(true);
}}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex-1 overflow-auto">{children}</main>
</div>
<RightPanel />
</div>
<RightPanel />
</div>
<CommandPalette />
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
<CreateObjectDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultType={createType}
/>
</TooltipProvider>
);
}

View file

@ -0,0 +1,200 @@
"use client";
import type { ComponentType } from "react";
import {
CalendarDays,
ClipboardList,
FileText,
Home,
LayoutGrid,
MoreHorizontal,
Presentation,
Sparkles,
UserPlus,
Users,
} from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
export interface IconRailProps {
className?: string;
}
type NavItem = {
id: string;
label: string;
segment: string;
icon: ComponentType<{ className?: string }>;
};
const NAV_ITEMS: NavItem[] = [
{ id: "home", label: "Home", segment: "", icon: Home },
{ id: "spaces", label: "Spaces", segment: "", icon: LayoutGrid },
{ id: "planner", label: "Planner", segment: "planner", icon: CalendarDays },
{ id: "ai", label: "AI", segment: "ai", icon: Sparkles },
{ id: "teams", label: "Teams", segment: "teams", icon: Users },
{ id: "docs", label: "Docs", segment: "docs", icon: FileText },
{ id: "whiteboards", label: "Whiteboards", segment: "whiteboards", icon: Presentation },
{ id: "forms", label: "Forms", segment: "forms", icon: ClipboardList },
];
function workspaceHref(workspaceSegment: string, segment: string) {
return segment ? `/${workspaceSegment}/${segment}` : `/${workspaceSegment}`;
}
const RESERVED_ROOT_SEGMENTS = new Set([
"planner",
"ai",
"teams",
"docs",
"whiteboards",
"forms",
]);
function isAtWorkspaceHome(pathname: string, prefix: string): boolean {
const rest = pathname.slice(prefix.length).replace(/^\//, "");
const first = rest.split("/")[0] ?? "";
if (!first) return true;
return !RESERVED_ROOT_SEGMENTS.has(first);
}
function isNavItemActive(
pathname: string,
workspaceSegment: string | undefined,
item: NavItem,
): boolean {
if (!workspaceSegment) return false;
const prefix = `/${workspaceSegment}`;
if (!pathname.startsWith(prefix)) return false;
if (item.segment) {
const next = pathname[prefix.length] === "/" ? pathname.slice(prefix.length + 1) : "";
const first = next.split("/")[0] ?? "";
return first === item.segment;
}
if (item.id === "home") {
return isAtWorkspaceHome(pathname, prefix);
}
// Spaces uses the same URL as home for now; keep inactive here to avoid double highlight
if (item.id === "spaces") {
return false;
}
return false;
}
export function IconRail({ className }: IconRailProps) {
const pathname = usePathname();
const router = useRouter();
const params = useParams();
const currentWorkspace = useWorkspaceStore((s) => s.currentWorkspace);
const slugParam =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : "";
const workspaceSegment =
currentWorkspace?.slug ||
currentWorkspace?.id ||
slugParam ||
undefined;
const workspaceName = currentWorkspace?.name ?? "";
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "—";
const push = (segment: string) => {
if (!workspaceSegment) return;
router.push(workspaceHref(workspaceSegment, segment));
};
return (
<aside
className={cn(
"flex h-full w-[60px] shrink-0 flex-col",
"bg-gradient-to-b from-[hsl(263,70%,25%)] to-[hsl(263,70%,18%)]",
"dark:from-[hsl(263,70%,20%)] dark:to-[hsl(263,70%,12%)]",
className,
)}
>
<nav
className="flex min-h-0 flex-1 flex-col items-stretch overflow-y-auto overflow-x-hidden px-1 py-2"
aria-label="Main"
>
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const active = isNavItemActive(pathname, workspaceSegment, item);
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
type="button"
disabled={!workspaceSegment}
onClick={() => push(item.segment)}
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5",
active
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10",
)}
>
<Icon className="h-5 w-5 shrink-0" aria-hidden />
<span
className={cn(
"max-w-[52px] truncate text-center text-[10px] font-medium leading-tight",
active ? "text-white" : "text-white/70",
)}
>
{item.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
})}
</nav>
<div className="flex shrink-0 flex-col items-stretch gap-0.5 px-1 pb-3">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/50 hover:bg-white/10 hover:text-white/70",
)}
>
<MoreHorizontal className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
More
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">More</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"w-full cursor-pointer rounded-lg px-1 py-2 transition-colors",
"flex flex-col items-center gap-0.5 text-white/70 hover:bg-white/10",
)}
>
<UserPlus className="h-5 w-5 shrink-0" aria-hidden />
<span className="max-w-[52px] truncate text-center text-[10px] font-medium leading-tight">
Invite
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">Invite</TooltipContent>
</Tooltip>
</div>
</aside>
);
}

View file

@ -0,0 +1,260 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import {
AppWindow,
Calendar,
CheckSquare,
ChevronDown,
FileText,
LayoutGrid,
LayoutTemplate,
LogOut,
Pencil,
Plus,
Presentation,
Search,
Settings,
User,
Users,
Zap,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
export interface TopHeaderProps {
onOpenSearch?: () => void;
onQuickAction?: (type: string) => void;
}
export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
const pathname = usePathname();
const router = useRouter();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceName = workspace?.name ?? "Workspace";
const workspaceId = workspace?.id;
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "WS";
return (
<header
className={cn(
"sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-4 border-b border-border bg-background px-4",
)}
>
{/* Workspace dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/15 text-[10px] font-bold text-primary">
{initials}
</div>
<span className="max-w-[180px] truncate">{workspaceName}</span>
<ChevronDown className="size-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 p-0">
{/* Header */}
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-sm font-bold text-primary">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{workspaceName}</p>
<p className="text-xs text-muted-foreground">1 member</p>
</div>
</div>
{/* Settings + People row */}
<div className="flex gap-2 px-4 pb-3">
<Button
variant="outline"
size="sm"
className="flex-1 gap-1.5 text-xs"
onClick={() => workspaceId && router.push(`/${workspaceId}/settings`)}
>
<Settings className="size-3.5" />
Settings
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 gap-1.5 text-xs"
>
<Users className="size-3.5" />
People
</Button>
</div>
<DropdownMenuSeparator className="my-0" />
{/* Manage section */}
<DropdownMenuLabel className="px-4 pb-1 pt-3 text-xs font-medium text-muted-foreground">
Manage
</DropdownMenuLabel>
<DropdownMenuItem className="gap-2.5 px-4">
<AppWindow className="size-4 text-muted-foreground" />
Apps
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/templates`)
}
>
<LayoutTemplate className="size-4 text-muted-foreground" />
Templates
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/types`)
}
>
<Pencil className="size-4 text-muted-foreground" />
Custom Fields
</DropdownMenuItem>
<DropdownMenuItem className="gap-2.5 px-4">
<Zap className="size-4 text-muted-foreground" />
Automations
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Create Workspace */}
<div className="px-3 py-2">
<Button
variant="outline"
size="sm"
className="w-full gap-1.5 text-xs"
onClick={() => window.alert("Create workspace (placeholder)")}
>
<Plus className="size-3.5" />
Create Workspace
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Center search bar */}
<div className="flex flex-1 justify-center px-2">
<div
role="button"
tabIndex={0}
className={cn(
"flex h-9 w-full max-w-[400px] cursor-pointer items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
onClick={() => onOpenSearch?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenSearch?.();
}
}}
>
<Search className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">Search...</span>
<kbd className="pointer-events-none shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium">
Cmd+/
</kbd>
</div>
</div>
{/* Right actions */}
<div className="flex shrink-0 items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Calendar"
>
<Calendar className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Calendar</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
aria-label="Quick actions"
>
<Plus className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={() => onQuickAction?.("task")}>
<CheckSquare className="size-4" />
New Task
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("doc")}>
<FileText className="size-4" />
New Doc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("whiteboard")}>
<Presentation className="size-4" />
New Whiteboard
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("space")}>
<LayoutGrid className="size-4" />
New Space
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="relative h-8 w-8 rounded-full p-0"
aria-label="Account menu"
>
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => {}}>
<User className="size-4" />
Account
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings`)
}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => signOut({ callbackUrl: "/sign-in" })}
>
<LogOut className="size-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View file

@ -4,14 +4,7 @@ import type { ReactNode } from "react";
import { useEffect } from "react";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
function formatNameFromSlug(slug: string) {
return slug
.split("-")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
import { api } from "@/lib/trpc";
export function WorkspaceSync({
workspaceSlug,
@ -22,14 +15,18 @@ export function WorkspaceSync({
}) {
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const { data } = api.workspaces.getById.useQuery({ id: workspaceSlug });
useEffect(() => {
setWorkspace({
id: workspaceSlug,
slug: workspaceSlug,
name: formatNameFromSlug(workspaceSlug),
});
if (data) {
setWorkspace({
id: data.id,
slug: data.id,
name: data.title,
});
}
return () => setWorkspace(null);
}, [workspaceSlug, setWorkspace]);
}, [workspaceSlug, data, setWorkspace]);
return <>{children}</>;
}

View file

@ -0,0 +1,425 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useRouter } from "next/navigation";
import {
CheckSquare,
ClipboardList,
FileStack,
FileText,
LayoutGrid,
Presentation,
X,
} from "lucide-react";
import { TemplatePicker, type PickerTemplate } from "@/components/templates";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
const CREATE_TYPES = ["task", "document", "space", "whiteboard", "form"] as const;
type CreateObjectType = (typeof CREATE_TYPES)[number];
const TYPE_OPTIONS: {
value: CreateObjectType;
label: string;
icon: React.ComponentType<{ className?: string }>;
}[] = [
{ value: "task", label: "Task", icon: CheckSquare },
{ value: "document", label: "Document", icon: FileText },
{ value: "space", label: "Space", icon: LayoutGrid },
{ value: "whiteboard", label: "Whiteboard", icon: Presentation },
{ value: "form", label: "Form", icon: ClipboardList },
];
const TASK_STATUSES = [
{ value: "open", label: "Open" },
{ value: "in_progress", label: "In Progress" },
{ value: "done", label: "Done" },
] as const;
const selectFieldClass =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
function normalizeDefaultType(raw?: string): CreateObjectType {
if (!raw) return "task";
const v = raw.toLowerCase().trim().replace(/\s+/g, "_");
if (v === "doc") return "document";
if (v === "white_board") return "whiteboard";
if (v === "list") return "task";
if (v === "form") return "form";
if ((CREATE_TYPES as readonly string[]).includes(v)) {
return v as CreateObjectType;
}
return "task";
}
export interface CreateObjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultType?: string;
defaultParentId?: string;
workspaceId?: string;
}
export function CreateObjectDialog({
open,
onOpenChange,
defaultType,
defaultParentId,
workspaceId: workspaceIdProp,
}: CreateObjectDialogProps) {
const router = useRouter();
const storeWorkspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id);
const resolvedWorkspaceId = workspaceIdProp ?? storeWorkspaceId ?? undefined;
const utils = api.useUtils();
const titleInputRef = React.useRef<HTMLInputElement>(null);
const selectedTemplateRef = React.useRef<PickerTemplate | null>(null);
const [objectType, setObjectType] = React.useState<CreateObjectType>(() =>
normalizeDefaultType(defaultType),
);
const [title, setTitle] = React.useState("");
const [titleError, setTitleError] = React.useState(false);
const [parentId, setParentId] = React.useState("");
const [taskStatus, setTaskStatus] = React.useState<
(typeof TASK_STATUSES)[number]["value"]
>("open");
const [selectedTemplate, setSelectedTemplate] = React.useState<PickerTemplate | null>(
null,
);
const [showTemplatePicker, setShowTemplatePicker] = React.useState(false);
React.useEffect(() => {
selectedTemplateRef.current = selectedTemplate;
}, [selectedTemplate]);
const showParentPicker =
objectType === "task" || objectType === "document";
const spacesQuery = api.objects.list.useQuery(
{
workspaceId: resolvedWorkspaceId!,
type: "space",
limit: 500,
},
{ enabled: Boolean(open && resolvedWorkspaceId && showParentPicker) },
);
const spaces = spacesQuery.data?.objects ?? [];
React.useEffect(() => {
if (!open) return;
setObjectType(normalizeDefaultType(defaultType));
setTitle("");
setTitleError(false);
setParentId(defaultParentId ?? "");
setTaskStatus("open");
setSelectedTemplate(null);
setShowTemplatePicker(false);
}, [open, defaultType, defaultParentId]);
React.useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => titleInputRef.current?.focus(), 0);
return () => window.clearTimeout(t);
}, [open]);
const applyTemplateMutation = api.templates.applyTemplate.useMutation();
const createMutation = api.objects.create.useMutation({
onSuccess: async (newObj) => {
const t = selectedTemplateRef.current;
if (t && newObj?.id) {
try {
await applyTemplateMutation.mutateAsync({
templateId: t.id,
objectId: newObj.id,
});
} catch {
// Template application failure shouldn't block creation
}
}
await utils.objects.list.invalidate();
await utils.objects.getTree.invalidate();
onOpenChange(false);
},
});
const createFormMutation = api.forms.create.useMutation({
onSuccess: (data) => {
utils.objects.getTree.invalidate();
const newId = (data as { id?: string }).id;
if (newId && resolvedWorkspaceId) {
router.push(`/${resolvedWorkspaceId}/forms/${newId}/edit`);
}
onOpenChange(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) {
setTitleError(true);
return;
}
if (!resolvedWorkspaceId) {
return;
}
setTitleError(false);
if (objectType === "form") {
createFormMutation.mutate({
workspaceId: resolvedWorkspaceId,
title: trimmed,
});
return;
}
const parentForCreate =
objectType === "task" || objectType === "document"
? parentId || null
: null;
createMutation.mutate({
type: objectType,
title: trimmed,
workspaceId: resolvedWorkspaceId,
parentId: parentForCreate,
...(objectType === "task" ? { status: taskStatus } : {}),
});
};
const isSubmitting =
createMutation.isPending ||
createFormMutation.isPending ||
applyTemplateMutation.isPending;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"rounded-lg",
)}
onOpenAutoFocus={(ev) => ev.preventDefault()}
>
<div className="flex flex-col gap-1.5 pr-8 text-left">
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
Create object
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Choose a type, enter a title, and optional parent space or task
status.
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{!resolvedWorkspaceId ? (
<p className="text-sm text-muted-foreground">
Select a workspace to create objects.
</p>
) : null}
<div className="space-y-2">
<span className="text-sm font-medium">Type</span>
<div
className="grid grid-cols-2 gap-2 sm:grid-cols-5"
role="radiogroup"
aria-label="Object type"
>
{TYPE_OPTIONS.map(({ value, label, icon: Icon }) => {
const selected = objectType === value;
return (
<button
key={value}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setObjectType(value)}
className={cn(
"flex flex-col items-center gap-2 rounded-md border p-3 text-center transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
selected
? "border-primary bg-accent"
: "border-input bg-background hover:bg-accent/50",
)}
>
<Badge
variant={selected ? "default" : "outline"}
className="gap-1 px-2 py-1 font-normal"
>
<Icon className="size-4" />
<span>{label}</span>
</Badge>
</button>
);
})}
</div>
</div>
{(objectType === "task" || objectType === "document") && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Template</span>
{selectedTemplate ? (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => setSelectedTemplate(null)}
>
Clear
</button>
) : null}
</div>
{selectedTemplate ? (
<div className="flex items-center gap-2 rounded-md border bg-accent/50 px-3 py-2 text-sm">
<Badge variant="secondary" className="gap-1">
<FileStack className="size-3" />
{selectedTemplate.name}
</Badge>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="w-full gap-1.5"
onClick={() => setShowTemplatePicker(true)}
>
<FileStack className="size-4" />
Use Template
</Button>
)}
</div>
)}
<div className="space-y-2">
<label htmlFor="create-object-title" className="text-sm font-medium">
Title
</label>
<Input
id="create-object-title"
ref={titleInputRef}
value={title}
onChange={(ev) => {
setTitle(ev.target.value);
if (titleError) setTitleError(false);
}}
placeholder="Name"
autoComplete="off"
aria-invalid={titleError}
/>
{titleError ? (
<p className="text-sm text-destructive" role="alert">
Title is required
</p>
) : null}
</div>
{showParentPicker ? (
<div className="space-y-2">
<label
htmlFor="create-object-parent"
className="text-sm font-medium"
>
Parent space
</label>
<select
id="create-object-parent"
className={selectFieldClass}
value={parentId}
onChange={(e) => setParentId(e.target.value)}
disabled={spacesQuery.isLoading}
>
<option value="">Workspace root</option>
{spaces.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</div>
) : null}
{objectType === "task" ? (
<div className="space-y-2">
<label
htmlFor="create-object-status"
className="text-sm font-medium"
>
Status
</label>
<select
id="create-object-status"
className={selectFieldClass}
value={taskStatus}
onChange={(e) =>
setTaskStatus(
e.target.value as (typeof TASK_STATUSES)[number]["value"],
)
}
>
{TASK_STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!resolvedWorkspaceId || isSubmitting}
>
{isSubmitting ? "Creating…" : "Create"}
</Button>
</div>
</form>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
{showTemplatePicker && resolvedWorkspaceId && (
<TemplatePicker
open={showTemplatePicker}
onOpenChange={setShowTemplatePicker}
workspaceId={resolvedWorkspaceId}
objectType={objectType}
onSelect={(template) => {
setSelectedTemplate(template);
setShowTemplatePicker(false);
}}
/>
)}
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1 @@
export * from "./create-object-dialog";

View file

@ -1,10 +1,12 @@
"use client";
import * as React from "react";
import { useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
Avatar,
AvatarFallback,
@ -18,8 +20,8 @@ export interface WorkspaceUser {
avatarUrl?: string | null;
}
/** Default mock list; override via `users` prop when wiring real data */
export const WORKSPACE_USERS: WorkspaceUser[] = [
/** Internal default for prop comparison only; list comes from `listMembers` when `workspaceId` is set */
const WORKSPACE_USERS: WorkspaceUser[] = [
{ id: "u1", name: "Alice", avatarUrl: null },
{ id: "u2", name: "Bob", avatarUrl: null },
{ id: "u3", name: "Charlie", avatarUrl: null },
@ -41,6 +43,7 @@ export interface AssigneePickerProps {
assignedIds: string[];
onToggle: (userId: string) => void;
users?: WorkspaceUser[];
workspaceId?: string;
children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
@ -52,16 +55,32 @@ export function AssigneePicker({
assignedIds,
onToggle,
users = WORKSPACE_USERS,
workspaceId,
children,
side = "bottom",
align = "start",
}: AssigneePickerProps) {
const { data: members } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const resolvedUsers = useMemo(() => {
if (users && users !== WORKSPACE_USERS) return users;
if (!members) return [];
return members.map((m) => ({
id: m.id,
name: m.name ?? m.email,
avatarUrl: m.avatarUrl,
}));
}, [users, members]);
const [q, setQ] = React.useState("");
const filtered = React.useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return users;
return users.filter((u) => u.name.toLowerCase().includes(s));
}, [q, users]);
if (!s) return resolvedUsers;
return resolvedUsers.filter((u) => u.name.toLowerCase().includes(s));
}, [q, resolvedUsers]);
React.useEffect(() => {
if (!open) setQ("");

View file

@ -2,7 +2,6 @@
import * as React from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
CheckSquare,
@ -15,13 +14,16 @@ import {
X,
} from "lucide-react";
import { isTRPCClientError } from "@trpc/client";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
usePanelStore,
type PanelDetailTab,
} from "@/lib/stores/panel-store";
import { AssigneePicker, WORKSPACE_USERS } from "@/components/panels/assignee-picker";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { AssigneePicker } from "@/components/panels/assignee-picker";
import {
PropertyEditor,
type PropertyFieldType,
@ -43,8 +45,6 @@ import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
const OBJECT_DETAIL_KEY = "object-detail";
type StatusValue = "open" | "in_progress" | "done" | "closed";
const STATUS_OPTIONS: {
@ -58,50 +58,6 @@ const STATUS_OPTIONS: {
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
];
const MOCK_OBJECT: ObjectDetailData = {
id: "demo",
title: "Design Landing Page",
type: "task",
status: "in_progress",
description: "Create wireframes and align with brand guidelines before handoff.",
assignees: [
{ user: { id: "u1", name: "Alice", avatarUrl: null } },
],
propertyValues: [
{
id: "pv1",
propertyDefinition: {
name: "Priority",
fieldType: "select",
config: {
options: [
{ label: "High", value: "High" },
{ label: "Medium", value: "Medium" },
{ label: "Low", value: "Low" },
],
},
},
value: "High",
},
{
id: "pv2",
propertyDefinition: {
name: "Due Date",
fieldType: "date",
config: {},
},
value: "2025-04-01",
},
],
children: [
{ id: "child-1", title: "Review copy", type: "task" },
],
relations: [
{ id: "rel-1", title: "Brand guidelines PDF", type: "document" },
{ id: "rel-2", title: "Q2 marketing plan", type: "task" },
],
};
export interface ObjectDetailData {
id: string;
title: string;
@ -114,6 +70,7 @@ export interface ObjectDetailData {
propertyValues: {
id?: string;
propertyDefinition: {
id: string;
name: string;
fieldType: string;
config?: Record<string, unknown> | null;
@ -166,24 +123,10 @@ function initials(name: string) {
}
function useObjectDetailQuery(objectId: string | null) {
const utils = api.useUtils();
return useQuery({
queryKey: [OBJECT_DETAIL_KEY, objectId],
queryFn: async (): Promise<ObjectDetailData | null> => {
if (!objectId) return null;
const fetcher = (
utils as unknown as {
objects?: { getById?: { fetch: (args: { id: string }) => Promise<ObjectDetailData> } };
}
).objects?.getById?.fetch;
if (typeof fetcher === "function") {
return fetcher({ id: objectId });
}
await new Promise((r) => setTimeout(r, 220));
return { ...MOCK_OBJECT, id: objectId };
},
enabled: Boolean(objectId),
});
return api.objects.getById.useQuery(
{ id: objectId as string },
{ enabled: Boolean(objectId) },
);
}
function DetailSkeleton() {
@ -210,10 +153,18 @@ export function ObjectDetail() {
const closePanel = usePanelStore((s) => s.close);
const openPanel = usePanelStore((s) => s.open);
const queryClient = useQueryClient();
const utils = api.useUtils();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceId = workspace?.id;
const { data, isPending, isError, error } = useObjectDetailQuery(objectId);
const { data: workspaceMembersList } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const objectDetailQuery = useObjectDetailQuery(objectId);
const data = objectDetailQuery.data as ObjectDetailData | undefined;
const { isPending, isError, error } = objectDetailQuery;
const [titleDraft, setTitleDraft] = React.useState("");
const [editingTitle, setEditingTitle] = React.useState(false);
@ -230,56 +181,50 @@ export function ObjectDetail() {
const mergeObjectCache = React.useCallback(
(id: string, patch: Partial<ObjectDetailData>) => {
queryClient.setQueryData<ObjectDetailData | null>(
[OBJECT_DETAIL_KEY, id],
(old) => (old ? { ...old, ...patch } : old),
);
utils.objects.getById.setData({ id }, (old) => {
if (!old) return old;
return {
...(old as ObjectDetailData),
...patch,
} as typeof old;
});
},
[queryClient],
[utils],
);
const updateObject = useMutation({
mutationFn: async (patch: Partial<ObjectDetailData> & { id: string }) => {
const u = utils as unknown as {
objects?: { update?: { mutate: (args: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.update?.mutate === "function") {
return u.objects.update.mutate(patch);
}
const updateObjectMutation = api.objects.update.useMutation({
onMutate: async (variables) => {
mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>);
},
onMutate: async (patch) => {
mergeObjectCache(patch.id, patch);
onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.id });
},
});
const setPropertyValue = useMutation({
mutationFn: async (args: {
objectId: string;
propertyValueId?: string;
propertyDefinitionId?: string;
value: unknown;
}) => {
const u = utils as unknown as {
properties?: { setValue?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.properties?.setValue?.mutate === "function") {
return u.properties.setValue.mutate(args);
}
const assignMutation = api.objects.assign.useMutation({
onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.objectId });
},
});
const setPropertyValueMutation = api.properties.setValue.useMutation({
onSuccess: async (_data, variables) => {
await utils.objects.getById.invalidate({ id: variables.objectId });
},
});
const handlePropertyChange = (
index: number,
next: unknown,
row: ObjectDetailData["propertyValues"][number],
next: unknown,
) => {
if (!data) return;
const nextRows = [...data.propertyValues];
nextRows[index] = { ...row, value: next };
mergeObjectCache(data.id, { propertyValues: nextRows });
setPropertyValue.mutate({
setPropertyValueMutation.mutate({
objectId: data.id,
propertyValueId: row.id,
propertyDefId: row.propertyDefinition.id,
value: next,
});
};
@ -293,29 +238,32 @@ export function ObjectDetail() {
const toggleAssignee = (userId: string) => {
if (!data) return;
const user = WORKSPACE_USERS.find((u) => u.id === userId);
if (!user) return;
const has = assignedIds.includes(userId);
const member = workspaceMembersList?.find((u) => u.id === userId);
const displayName = member
? (member.name ?? member.email)
: "User";
let nextAssignees: ObjectDetailData["assignees"];
if (has) {
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
} else {
nextAssignees = [
...data.assignees,
{ user: { id: user.id, name: user.name, avatarUrl: user.avatarUrl } },
{
user: {
id: userId,
name: displayName,
avatarUrl: member?.avatarUrl ?? null,
},
},
];
}
mergeObjectCache(data.id, { assignees: nextAssignees });
const u = utils as unknown as {
objects?: { assign?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.assign?.mutate === "function") {
u.objects.assign.mutate({
objectId: data.id,
userId,
assign: !has,
});
}
assignMutation.mutate({
objectId: data.id,
userId,
action: has ? "remove" : "add",
});
};
const commitTitle = () => {
@ -323,19 +271,19 @@ export function ObjectDetail() {
setEditingTitle(false);
return;
}
updateObject.mutate({ id: data.id, title: titleDraft.trim() });
updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() });
setEditingTitle(false);
};
const commitDescription = () => {
if (!data) return;
if (descriptionDraft === (data.description ?? "")) return;
updateObject.mutate({ id: data.id, description: descriptionDraft });
updateObjectMutation.mutate({ id: data.id, description: descriptionDraft });
};
const setStatus = (status: StatusValue) => {
if (!data) return;
updateObject.mutate({ id: data.id, status });
updateObjectMutation.mutate({ id: data.id, status });
};
const onTabChange = (v: string) => {
@ -358,13 +306,17 @@ export function ObjectDetail() {
}
if (isError || !data) {
const notFound =
isTRPCClientError(error) && error.data?.code === "NOT_FOUND";
return (
<div className="p-4 text-xs text-destructive">
{isError
? error instanceof Error
? error.message
: "Failed to load object."
: "Nothing to display."}
? notFound
? "Not found"
: error instanceof Error
? error.message
: "Failed to load object."
: "Not found"}
</div>
);
}
@ -520,6 +472,7 @@ export function ObjectDetail() {
onOpenChange={setAssigneeOpen}
assignedIds={assignedIds}
onToggle={toggleAssignee}
workspaceId={workspaceId}
>
<Button
variant="outline"
@ -572,7 +525,7 @@ export function ObjectDetail() {
| undefined,
}}
value={row.value}
onChange={(v) => handlePropertyChange(index, v, row)}
onChange={(v) => handlePropertyChange(index, row, v)}
/>
</div>
))

View file

@ -3,3 +3,5 @@ export * from "./sidebar-header";
export * from "./sidebar-item";
export * from "./sidebar-nav";
export * from "./sidebar-toggle";
export * from "./nav-tree";
export * from "./workspace-switcher";

View file

@ -3,14 +3,13 @@
import { useCallback, useMemo, type ReactNode } from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
ChevronRight,
CircleDot,
FileText,
Folder,
FolderOpen,
FolderPlus,
LayoutGrid,
List as ListIcon,
MoreHorizontal,
PenTool,
Plus,
@ -30,12 +29,9 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getBaseUrl } from "@/lib/trpc";
import {
isSidebarNodeExpanded,
isSidebarSectionExpanded,
useSidebarStore,
} from "@/lib/stores/sidebar-store";
import type { ObjectType } from "@tasks/shared";
import { api } from "@/lib/trpc";
import { isSidebarNodeExpanded, useSidebarStore } from "@/lib/stores/sidebar-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
@ -56,44 +52,12 @@ export type PartitionedTrees = {
whiteboards: TreeNodeData[];
};
function hasDescendantType(node: TreeNodeData, type: string): boolean {
if (node.type === type) return true;
return node.children.some((c) => hasDescendantType(c, type));
}
export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees {
const projects: TreeNodeData[] = [];
const documents: TreeNodeData[] = [];
const whiteboards: TreeNodeData[] = [];
for (const root of roots) {
if (root.type === "project") {
projects.push(root);
continue;
}
if (root.type === "whiteboard") {
whiteboards.push(root);
continue;
}
if (root.type === "document") {
documents.push(root);
continue;
}
if (root.type === "group") {
const hasWb = hasDescendantType(root, "whiteboard");
const hasDoc = hasDescendantType(root, "document");
if (hasWb && !hasDoc) {
whiteboards.push(root);
} else if (hasDoc) {
documents.push(root);
} else {
projects.push(root);
}
continue;
}
projects.push(root);
}
return { projects, documents, whiteboards };
return {
projects: roots,
documents: [],
whiteboards: [],
};
}
const EMPTY_PARTITIONED: PartitionedTrees = {
@ -102,170 +66,54 @@ const EMPTY_PARTITIONED: PartitionedTrees = {
whiteboards: [],
};
const MOCK_PARTITIONED: PartitionedTrees = {
projects: [
{
id: "10000000-0000-4000-8000-000000000001",
title: "Project Alpha",
type: "project",
icon: null,
parentId: null,
childCount: 2,
children: [
{
id: "10000000-0000-4000-8000-000000000002",
title: "Sprint 1",
type: "group",
icon: null,
parentId: "10000000-0000-4000-8000-000000000001",
childCount: 2,
children: [
{
id: "10000000-0000-4000-8000-000000000003",
title: "Task 1",
type: "task",
icon: null,
parentId: "10000000-0000-4000-8000-000000000002",
childCount: 0,
children: [],
},
{
id: "10000000-0000-4000-8000-000000000004",
title: "Task 2",
type: "task",
icon: null,
parentId: "10000000-0000-4000-8000-000000000002",
childCount: 0,
children: [],
},
],
},
{
id: "10000000-0000-4000-8000-000000000005",
title: "Sprint 2",
type: "group",
icon: null,
parentId: "10000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
{
id: "10000000-0000-4000-8000-000000000006",
title: "Project Beta",
type: "project",
icon: null,
parentId: null,
childCount: 0,
children: [],
},
],
documents: [
{
id: "20000000-0000-4000-8000-000000000001",
title: "Documents",
type: "group",
icon: null,
parentId: null,
childCount: 2,
children: [
{
id: "20000000-0000-4000-8000-000000000002",
title: "Meeting Notes",
type: "document",
icon: null,
parentId: "20000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
{
id: "20000000-0000-4000-8000-000000000003",
title: "Product Spec",
type: "document",
icon: null,
parentId: "20000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
],
whiteboards: [
{
id: "30000000-0000-4000-8000-000000000001",
title: "Whiteboards",
type: "group",
icon: null,
parentId: null,
childCount: 1,
children: [
{
id: "30000000-0000-4000-8000-000000000002",
title: "Brainstorm",
type: "whiteboard",
icon: null,
parentId: "30000000-0000-4000-8000-000000000001",
childCount: 0,
children: [],
},
],
},
],
};
const SPACE_COLORS = [
"bg-amber-500",
"bg-blue-500",
"bg-emerald-500",
"bg-purple-500",
"bg-pink-500",
"bg-red-500",
"bg-cyan-500",
"bg-orange-500",
];
function TypeIcon({ type }: { type: string }) {
function SpaceIcon({ node }: { node: TreeNodeData }) {
if (node.icon) {
return <span className="flex h-5 w-5 items-center justify-center text-sm">{node.icon}</span>;
}
const letter = (node.title || "S").charAt(0).toUpperCase();
const colorIndex = node.title.length % SPACE_COLORS.length;
const colorClass = SPACE_COLORS[colorIndex];
return (
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-[10px] font-bold text-white",
colorClass,
)}
>
{letter}
</span>
);
}
function TypeIcon({ type, node }: { type: string; node?: TreeNodeData }) {
switch (type) {
case "project":
return <Folder className="size-3.5 shrink-0 text-amber-600/90 dark:text-amber-400/90" />;
case "space":
return node ? <SpaceIcon node={node} /> : <LayoutGrid className="size-4 text-muted-foreground" />;
case "group":
return <FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />;
case "document":
return <FileText className="size-3.5 shrink-0 text-muted-foreground" />;
case "whiteboard":
return <PenTool className="size-3.5 shrink-0 text-muted-foreground" />;
return <Folder className="size-4 text-purple-400/80" />;
case "task":
return <CircleDot className="size-3.5 shrink-0 text-muted-foreground" />;
return <ListIcon className="size-4 text-muted-foreground" />;
case "document":
return <FileText className="size-4 text-blue-400/80" />;
case "whiteboard":
return <PenTool className="size-4 text-muted-foreground" />;
default:
return <Folder className="size-3.5 shrink-0 text-muted-foreground" />;
return <CircleDot className="size-4 text-muted-foreground" />;
}
}
async function fetchObjectsTree(workspaceId: string): Promise<{ tree: TreeNodeData[] }> {
const input = encodeURIComponent(JSON.stringify({ json: { workspaceId } }));
const res = await fetch(`${getBaseUrl()}/api/trpc/objects.getTree?input=${input}`, {
credentials: "include",
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`getTree failed: ${res.status}`);
}
const payload = (await res.json()) as unknown;
const tree = extractTreeFromTrpcPayload(payload);
if (!tree) {
throw new Error("getTree: unexpected response shape");
}
return { tree };
}
function extractTreeFromTrpcPayload(payload: unknown): TreeNodeData[] | null {
if (Array.isArray(payload)) {
const first = payload[0] as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } };
return first?.result?.data?.json?.tree ?? null;
}
const single = payload as { result?: { data?: { json?: { tree?: TreeNodeData[] } } } };
return single.result?.data?.json?.tree ?? null;
}
function useObjectsTreeQuery(workspaceId: string | undefined) {
return useQuery({
queryKey: ["objects", "getTree", workspaceId],
queryFn: () => fetchObjectsTree(workspaceId!),
enabled: Boolean(workspaceId),
retry: false,
});
}
function countNodes(roots: TreeNodeData[]): number {
let n = 0;
const walk = (nodes: TreeNodeData[]) => {
@ -297,28 +145,151 @@ function CollapsibleBody({
);
}
function showCreateChildActions(type: string): boolean {
return type === "project" || type === "space" || type === "group";
}
function hrefForNode(base: string, nodeId: string, nodeType: string): string {
switch (nodeType) {
case "document":
return `${base}/docs/${nodeId}`;
case "whiteboard":
return `${base}/whiteboards/${nodeId}`;
default:
return `${base}/${nodeId}`;
}
}
function MoreMenuItems({
node,
href,
workspaceId,
}: {
node: TreeNodeData;
href: string;
workspaceId: string;
}) {
const utils = api.useUtils();
const archiveObj = api.objects.archive.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const deleteObj = api.objects.delete.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const duplicateObj = api.objects.create.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const toggleFav = api.favorites.toggle.useMutation({
onSuccess: () => {
void utils.favorites.list.invalidate();
},
});
return (
<>
<DropdownMenuItem
onSelect={() => {
toggleFav.mutate({ objectId: node.id });
}}
>
Favorite
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { /* rename - complex, placeholder */ }}>Rename</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigator.clipboard.writeText(window.location.origin + href);
}}
>
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>Color & Icon</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
duplicateObj.mutate({
type: node.type as ObjectType,
title: `${node.title} (copy)`,
workspaceId,
parentId: node.parentId ?? undefined,
});
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
archiveObj.mutate({ id: node.id });
}}
>
Archive
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => {
if (window.confirm(`Delete "${node.title}"?`)) {
deleteObj.mutate({ id: node.id });
}
}}
>
Delete
</DropdownMenuItem>
</>
);
}
function CreateChildMenu({ parentId, workspaceId }: { parentId: string; workspaceId: string }) {
const utils = api.useUtils();
const create = api.objects.create.useMutation({
onSuccess: () => {
void utils.objects.getTree.invalidate();
},
});
const handleCreate = (type: ObjectType, title: string) => {
create.mutate({ type, title, workspaceId, parentId });
};
return (
<>
<DropdownMenuItem onSelect={() => handleCreate("task", "Untitled List")}>List</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("document", "Untitled Doc")}>Doc</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("group", "Untitled Folder")}>Folder</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleCreate("whiteboard", "Untitled Whiteboard")}>
Whiteboard
</DropdownMenuItem>
</>
);
}
export function TreeNode({
node,
level,
collapsed,
base,
pathname,
workspaceId,
}: {
node: TreeNodeData;
level: number;
collapsed: boolean;
base: string;
pathname: string | null;
workspaceId: string;
}) {
const expandedNodes = useSidebarStore((s) => s.expandedNodes);
const toggleNode = useSidebarStore((s) => s.toggleNode);
const hasChildren = node.children.length > 0;
const expanded = isSidebarNodeExpanded(expandedNodes, node.id);
const href = `${base}/o/${node.id}`;
const href = hrefForNode(base, node.id, node.type);
const active =
pathname === href ||
(pathname?.startsWith(`${base}/o/${node.id}/`) ?? false);
pathname === href || (pathname?.startsWith(href + "/") ?? false);
const showPlus = showCreateChildActions(node.type);
const onToggleExpand = useCallback(
(e: React.MouseEvent) => {
@ -340,7 +311,7 @@ export function TreeNode({
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
<TypeIcon type={node.type} node={node} />
</Link>
);
return (
@ -361,6 +332,7 @@ export function TreeNode({
collapsed={collapsed}
base={base}
pathname={pathname}
workspaceId={workspaceId}
/>
))}
</div>
@ -369,7 +341,7 @@ export function TreeNode({
);
}
const indentPx = 8 + level * 16;
const indentPx = level === 0 ? 0 : 8 + (level - 1) * 16;
return (
<div className="select-none">
@ -377,36 +349,39 @@ export function TreeNode({
className="group relative flex min-h-7 items-center gap-0.5 rounded-md pr-1 transition-colors duration-150"
style={{ paddingLeft: indentPx }}
>
<div className="flex min-h-7 min-w-0 flex-1 items-center gap-0.5">
{hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-expanded={expanded}
aria-label={expanded ? "Collapse" : "Expand"}
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</button>
) : (
<span className="size-6 shrink-0" aria-hidden />
)}
<div className="flex min-h-7 min-w-0 flex-1 items-center">
<Link
href={href}
className={cn(
"flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-sm py-1 pl-0.5 pr-2 text-sm text-sidebar-foreground transition-colors duration-150",
"flex min-h-7 min-w-0 flex-1 items-center gap-2 rounded-sm py-1 pl-1 pr-2 text-sm text-sidebar-foreground transition-colors duration-150",
"hover:bg-sidebar-accent/80 hover:text-sidebar-accent-foreground",
active &&
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
)}
>
<TypeIcon type={node.type} />
{hasChildren ? (
<button
type="button"
onClick={onToggleExpand}
className="relative flex size-5 shrink-0 items-center justify-center rounded-sm"
aria-expanded={expanded}
aria-label={expanded ? "Collapse" : "Expand"}
>
<span className="group-hover:hidden">
<TypeIcon type={node.type} node={node} />
</span>
<span className="hidden rounded bg-muted group-hover:flex group-hover:items-center group-hover:justify-center group-hover:size-5">
<ChevronRight
className={cn(
"size-3.5 text-muted-foreground transition-transform duration-200",
expanded && "rotate-90",
)}
/>
</span>
</button>
) : (
<TypeIcon type={node.type} node={node} />
)}
<span className="min-w-0 flex-1 truncate font-medium">{node.title}</span>
{node.childCount > 0 ? (
<span className="shrink-0 tabular-nums text-[10px] text-muted-foreground">
@ -422,23 +397,28 @@ export function TreeNode({
"group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Add child</TooltipContent>
</Tooltip>
{showPlus ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
title="Create"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Plus className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<CreateChildMenu parentId={node.id} workspaceId={workspaceId} />
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -455,11 +435,8 @@ export function TreeNode({
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem>Rename</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">Archive</DropdownMenuItem>
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
<MoreMenuItems node={node} href={href} workspaceId={workspaceId} />
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -476,6 +453,7 @@ export function TreeNode({
collapsed={collapsed}
base={base}
pathname={pathname}
workspaceId={workspaceId}
/>
))}
</div>
@ -485,57 +463,6 @@ export function TreeNode({
);
}
function SectionHeader({
title,
sectionKey,
collapsed,
}: {
title: string;
sectionKey: string;
collapsed: boolean;
}) {
const expandedSections = useSidebarStore((s) => s.expandedSections);
const toggleSection = useSidebarStore((s) => s.toggleSection);
const open = isSidebarSectionExpanded(expandedSections, sectionKey);
if (collapsed) {
return (
<div className="flex justify-center py-1">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:bg-sidebar-accent"
onClick={() => toggleSection(sectionKey)}
>
<ChevronRight
className={cn("size-3.5 transition-transform", open && "rotate-90")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
</div>
);
}
return (
<Button
type="button"
variant="ghost"
className="mb-0.5 flex h-7 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={() => toggleSection(sectionKey)}
>
<span>{title}</span>
<ChevronRight
className={cn("size-3.5 shrink-0 transition-transform duration-200", open && "rotate-90")}
/>
</Button>
);
}
export function NavTree({
collapsed,
trees: treesProp,
@ -554,36 +481,34 @@ export function NavTree({
? `/${slugParam}`
: "";
const workspaceId = workspace?.id;
const { data, isLoading, isError } = useObjectsTreeQuery(workspaceId);
const workspaceId = workspace?.id ?? "";
const favoritesQuery = api.favorites.list.useQuery(undefined, {
enabled: Boolean(workspaceId),
});
const favorites = favoritesQuery.data ?? [];
const { data, isLoading, isError } = api.objects.getTree.useQuery(
{ workspaceId: workspace?.id! },
{ enabled: Boolean(workspace?.id) },
);
const partitioned = useMemo(() => {
if (treesProp) return treesProp;
if (!workspaceId) return MOCK_PARTITIONED;
if (isLoading) return EMPTY_PARTITIONED;
if (isError || !data?.tree) return MOCK_PARTITIONED;
if (!workspace?.id || isLoading) return EMPTY_PARTITIONED;
if (isError || !data?.tree) return EMPTY_PARTITIONED;
return partitionRoots(data.tree);
}, [treesProp, workspaceId, isLoading, isError, data?.tree]);
}, [treesProp, workspace?.id, isLoading, isError, data?.tree]);
const totalCount =
countNodes(partitioned.projects) +
countNodes(partitioned.documents) +
countNodes(partitioned.whiteboards);
const totalCount = countNodes(partitioned.projects);
const liveEmpty =
Boolean(workspaceId) &&
Boolean(workspace?.id) &&
!isLoading &&
!isError &&
data?.tree &&
data.tree.length === 0;
const showLoading = Boolean(workspaceId) && isLoading && !treesProp;
const expandedSections = useSidebarStore((s) => s.expandedSections);
const projectsOpen = isSidebarSectionExpanded(expandedSections, "projects");
const documentsOpen = isSidebarSectionExpanded(expandedSections, "documents");
const whiteboardsOpen = isSidebarSectionExpanded(expandedSections, "whiteboards");
const showLoading = Boolean(workspace?.id) && isLoading && !treesProp;
if (showLoading) {
return (
@ -601,11 +526,69 @@ export function NavTree({
return (
<ScrollArea className="flex-1">
<div className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
{!collapsed ? (
<div className="mb-2">
<div className="flex items-center px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Favorites
</span>
</div>
{favorites.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground/60">No favorites yet</div>
) : (
<div className="flex flex-col gap-0.5 px-1">
{favorites.map((fav) => {
const favHref = hrefForNode(base, fav.objectId, fav.objectType);
return (
<Link
key={fav.id}
href={favHref}
className={cn(
"flex min-h-7 items-center gap-2 rounded-sm px-2 py-1 text-sm text-sidebar-foreground transition-colors",
"hover:bg-sidebar-accent/80",
pathname === favHref &&
"bg-sidebar-accent text-sidebar-accent-foreground",
)}
>
<TypeIcon type={fav.objectType} />
<span className="min-w-0 flex-1 truncate font-medium">{fav.objectTitle}</span>
</Link>
);
})}
</div>
)}
</div>
) : null}
{!collapsed ? (
<div className="flex items-center justify-between px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Spaces
</span>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-5 text-muted-foreground hover:text-sidebar-accent-foreground"
type="button"
onClick={(e) => {
e.preventDefault();
}}
>
<Plus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">New Space</TooltipContent>
</Tooltip>
</div>
) : null}
{liveEmpty ? (
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
No projects, documents, or whiteboards yet.
No spaces yet.
<br />
<span className="text-[10px]">Create a project to get started.</span>
<span className="text-[10px]">Create a space to get started.</span>
</div>
) : null}
@ -616,91 +599,19 @@ export function NavTree({
) : null}
{!liveEmpty && totalCount > 0 ? (
<>
<div>
<SectionHeader title="Projects" sectionKey="projects" collapsed={collapsed} />
<CollapsibleBody open={projectsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.projects.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
{!collapsed ? (
<Button
type="button"
variant="ghost"
className="mt-1 h-7 w-full justify-start gap-2 px-2 text-xs font-medium text-muted-foreground hover:text-sidebar-accent-foreground"
onClick={() => {
/* api.objects.create — wire when AppRouter includes objects */
}}
>
<FolderPlus className="size-3.5" />
New Project
</Button>
) : (
<div className="mt-1 flex justify-center">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => {}}
>
<FolderPlus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">New Project</TooltipContent>
</Tooltip>
</div>
)}
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Documents" sectionKey="documents" collapsed={collapsed} />
<CollapsibleBody open={documentsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.documents.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
<div>
<SectionHeader title="Whiteboards" sectionKey="whiteboards" collapsed={collapsed} />
<CollapsibleBody open={whiteboardsOpen || collapsed}>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.whiteboards.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
/>
))}
</div>
</CollapsibleBody>
</div>
</>
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
{partitioned.projects.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
collapsed={collapsed}
base={base}
pathname={pathname}
workspaceId={workspaceId}
/>
))}
</div>
) : null}
</div>
</ScrollArea>

View file

@ -1,126 +1,174 @@
"use client";
import { ChevronDown, LogOut, Settings2, User } from "lucide-react";
import {
ChevronsLeft,
ChevronsRight,
ChevronDown,
ClipboardList,
FileText,
List,
Presentation,
LayoutGrid,
Plus,
Search,
SlidersHorizontal,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
export function SidebarHeader({ collapsed }: { collapsed: boolean }) {
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
export interface SidebarHeaderProps {
collapsed: boolean;
onOpenSearch?: () => void;
onQuickAction?: (type: string) => void;
}
const title = workspace?.name ?? "Workspace";
export function SidebarHeader({
collapsed,
onOpenSearch,
onQuickAction,
}: SidebarHeaderProps) {
const toggle = useSidebarStore((s) => s.toggle);
const trigger = (
<Button
type="button"
variant="ghost"
className={cn(
"h-auto min-h-10 w-full justify-between gap-1 rounded-md px-2 py-1.5 text-left font-semibold text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
>
{!collapsed ? (
<>
<span className="min-w-0 flex-1 truncate text-sm">{title}</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</>
) : (
<span className="flex size-8 items-center justify-center rounded-md bg-sidebar-accent/80 text-xs font-bold text-primary">
{title.slice(0, 2).toUpperCase()}
</span>
)}
</Button>
);
if (collapsed) {
return (
<div className="flex shrink-0 justify-center border-b border-sidebar-border px-1 py-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 text-muted-foreground hover:text-sidebar-foreground"
onClick={toggle}
aria-label="Expand sidebar"
>
<ChevronsRight className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Expand</TooltipContent>
</Tooltip>
</div>
);
}
return (
<div
className={cn(
"flex shrink-0 items-center gap-1 border-b border-sidebar-border px-2 py-2",
collapsed && "flex-col px-1",
)}
>
<DropdownMenu>
{collapsed ? (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{title}</TooltipContent>
</Tooltip>
) : (
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
{trigger}
</DropdownMenuTrigger>
)}
<DropdownMenuContent className="w-56" align="start" side="bottom">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{title}</p>
<p className="text-xs leading-none text-muted-foreground">
{workspace?.slug ? `/${workspace.slug}` : ""}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Settings2 className="size-4" />
Workspace settings
</DropdownMenuItem>
<DropdownMenuItem>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex shrink-0 items-center gap-1 border-b border-sidebar-border px-2 py-2">
<span className="min-w-0 flex-1 truncate text-base font-semibold text-sidebar-foreground">
Home
</span>
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
aria-label="Search"
onClick={() => onOpenSearch?.()}
>
<Search className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Search</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 rounded-full text-sidebar-foreground hover:bg-sidebar-accent"
aria-label="Account menu"
>
<Avatar className="size-8 border border-sidebar-border">
<AvatarFallback className="bg-primary/20 text-xs font-semibold text-primary">
ME
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem>
<User className="size-4" />
Account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
aria-label="Filter"
>
<SlidersHorizontal className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Filter</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
onClick={toggle}
aria-label="Collapse sidebar"
>
<ChevronsLeft className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Collapse</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-0.5 px-1.5 text-muted-foreground hover:text-sidebar-foreground"
aria-label="Create"
>
<Plus className="size-4" />
<ChevronDown className="size-3 opacity-70" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Create</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
className="gap-2"
onClick={() => onQuickAction?.("list")}
>
<List className="size-4" />
List
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
onClick={() => onQuickAction?.("doc")}
>
<FileText className="size-4" />
Doc
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
onClick={() => onQuickAction?.("whiteboard")}
>
<Presentation className="size-4" />
Whiteboard
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
onClick={() => onQuickAction?.("form")}
>
<ClipboardList className="size-4" />
Form
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2"
onClick={() => onQuickAction?.("space")}
>
<LayoutGrid className="size-4" />
Space
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View file

@ -1,34 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import {
ChevronDown,
FileText,
FolderKanban,
Home,
LayoutGrid,
Presentation,
Search,
Settings,
Star,
} from "lucide-react";
import { FileText, Home, Presentation, Search, Settings } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { SidebarItem } from "./sidebar-item";
const PROJECT_DOTS = [
"hsl(var(--primary))",
"hsl(var(--teal))",
"hsl(38 92% 50%)",
"hsl(199 89% 48%)",
];
function NavSectionLabel({
children,
collapsed,
@ -62,166 +41,60 @@ export function SidebarNav({
? `/${slugParam}`
: "";
const [favoritesOpen, setFavoritesOpen] = useState(true);
const [projectsOpen, setProjectsOpen] = useState(true);
const homeActive = pathname === base || pathname === `${base}/`;
const favoriteItems = useMemo(
() => [
{ label: "Q1 Launch", href: `${base}/favorites/q1` },
{ label: "Design system", href: `${base}/favorites/design` },
],
[base],
);
const projectItems = useMemo(
() => [
{ label: "Product roadmap", slug: "product-roadmap" },
{ label: "Marketing", slug: "marketing" },
{ label: "Engineering", slug: "engineering" },
{ label: "Operations", slug: "operations" },
],
[base],
);
return (
<ScrollArea className="flex-1">
<nav className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
<SidebarItem
href={onOpenSearch ? "#" : `${base}/search`}
icon={<Search />}
label="Search"
collapsed={collapsed}
active={pathname.startsWith(`${base}/search`)}
onClick={
onOpenSearch
? (e) => {
e.preventDefault();
onOpenSearch();
}
: undefined
}
/>
<nav className="flex shrink-0 flex-col gap-0.5 px-2 pb-2 pt-1">
<SidebarItem
href={onOpenSearch ? "#" : `${base}/search`}
icon={<Search />}
label="Search"
collapsed={collapsed}
active={pathname.startsWith(`${base}/search`)}
onClick={
onOpenSearch
? (e) => {
e.preventDefault();
onOpenSearch();
}
: undefined
}
/>
<Separator className="my-2 bg-sidebar-border" />
<Separator className="my-2 bg-sidebar-border" />
<SidebarItem
href={base || "/"}
icon={<Home />}
label="Home"
collapsed={collapsed}
active={homeActive}
/>
<SidebarItem
href={base || "/"}
icon={<Home />}
label="Home"
collapsed={collapsed}
active={homeActive}
/>
<div>
<Button
type="button"
variant="ghost"
className={cn(
"mb-0.5 flex h-8 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
onClick={() => setFavoritesOpen((o) => !o)}
>
{!collapsed ? (
<>
<span className="flex items-center gap-1.5">
<Star className="size-3.5" />
Favorites
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
!favoritesOpen && "-rotate-90",
)}
/>
</>
) : (
<Star className="size-4 text-muted-foreground" />
)}
</Button>
{favoritesOpen || collapsed
? favoriteItems.map((fav) => (
<SidebarItem
key={fav.href}
href={fav.href}
icon={<Star className="size-[15px]" />}
label={fav.label}
collapsed={collapsed}
active={pathname === fav.href}
/>
))
: null}
</div>
<Separator className="my-2 bg-sidebar-border" />
<div>
<Button
type="button"
variant="ghost"
className={cn(
"mb-0.5 flex h-8 w-full items-center justify-between rounded-md px-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
collapsed && "justify-center px-0",
)}
onClick={() => setProjectsOpen((o) => !o)}
>
{!collapsed ? (
<>
<span className="flex items-center gap-1.5">
<FolderKanban className="size-3.5" />
Projects
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
!projectsOpen && "-rotate-90",
)}
/>
</>
) : (
<FolderKanban className="size-4 text-muted-foreground" />
)}
</Button>
{projectsOpen || collapsed
? projectItems.map((p, i) => (
<SidebarItem
key={p.slug}
href={`${base}/projects/${p.slug}`}
icon={<LayoutGrid className="size-[15px]" />}
label={p.label}
collapsed={collapsed}
active={pathname === `${base}/projects/${p.slug}`}
dotColor={PROJECT_DOTS[i % PROJECT_DOTS.length]}
/>
))
: null}
</div>
<NavSectionLabel collapsed={collapsed}>Content</NavSectionLabel>
<SidebarItem
href={`${base}/documents`}
icon={<FileText />}
label="Documents"
collapsed={collapsed}
active={pathname.startsWith(`${base}/documents`)}
/>
<SidebarItem
href={`${base}/whiteboards`}
icon={<Presentation />}
label="Whiteboards"
collapsed={collapsed}
active={pathname.startsWith(`${base}/whiteboards`)}
/>
<NavSectionLabel collapsed={collapsed}>Workspace</NavSectionLabel>
<SidebarItem
href={`${base}/settings`}
icon={<Settings />}
label="Settings"
collapsed={collapsed}
active={pathname.startsWith(`${base}/settings`)}
/>
</nav>
</ScrollArea>
<NavSectionLabel collapsed={collapsed}>Workspace</NavSectionLabel>
<SidebarItem
href={`${base}/documents`}
icon={<FileText />}
label="Documents"
collapsed={collapsed}
active={pathname.startsWith(`${base}/documents`)}
/>
<SidebarItem
href={`${base}/whiteboards`}
icon={<Presentation />}
label="Whiteboards"
collapsed={collapsed}
active={pathname.startsWith(`${base}/whiteboards`)}
/>
<SidebarItem
href={`${base}/settings`}
icon={<Settings />}
label="Settings"
collapsed={collapsed}
active={pathname.startsWith(`${base}/settings`)}
/>
</nav>
);
}

View file

@ -3,11 +3,16 @@
import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { NavTree } from "./nav-tree";
import { SidebarHeader } from "./sidebar-header";
import { SidebarNav } from "./sidebar-nav";
import { SidebarToggle } from "./sidebar-toggle";
export function Sidebar({ onOpenSearch }: { onOpenSearch?: () => void }) {
export function Sidebar({
onOpenSearch,
onQuickAction,
}: {
onOpenSearch?: () => void;
onQuickAction?: (type: string) => void;
}) {
const collapsed = useSidebarStore((s) => s.isCollapsed);
return (
@ -17,12 +22,13 @@ export function Sidebar({ onOpenSearch }: { onOpenSearch?: () => void }) {
collapsed ? "w-12" : "w-[240px]",
)}
>
<SidebarHeader collapsed={collapsed} />
<SidebarNav collapsed={collapsed} onOpenSearch={onOpenSearch} />
<div className="mt-auto shrink-0 border-t border-sidebar-border px-1 py-2">
<div className={cn("flex", collapsed ? "justify-center" : "justify-end")}>
<SidebarToggle />
</div>
<SidebarHeader
collapsed={collapsed}
onOpenSearch={onOpenSearch}
onQuickAction={onQuickAction}
/>
<div className="min-h-0 flex-1 overflow-hidden">
<NavTree collapsed={collapsed} />
</div>
</aside>
);

View file

@ -1,6 +1,7 @@
"use client";
import { Building2, Check, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@ -16,30 +17,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
useWorkspaceStore,
type WorkspaceInfo,
} from "@/lib/stores/workspace-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
const MOCK_WORKSPACES: WorkspaceInfo[] = [
{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
slug: "personal",
name: "Personal",
},
{
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
slug: "team-alpha",
name: "Team Alpha",
},
{
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
slug: "acme",
name: "Acme Corp",
},
];
export function WorkspaceSwitcher({
collapsed,
className,
@ -47,13 +28,11 @@ export function WorkspaceSwitcher({
collapsed?: boolean;
className?: string;
}) {
const router = useRouter();
const current = useWorkspaceStore((s) => s.currentWorkspace);
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const { data: workspaces } = api.workspaces.listForUser.useQuery();
const display =
current ??
MOCK_WORKSPACES[0] ??
({ id: "", slug: "", name: "Workspace" } satisfies WorkspaceInfo);
const displayName = current?.name ?? "Select workspace...";
const trigger = (
<Button
@ -67,7 +46,7 @@ export function WorkspaceSwitcher({
>
<Building2 className="size-4 shrink-0 text-primary" />
{!collapsed ? (
<span className="min-w-0 flex-1 truncate text-sm">{display.name}</span>
<span className="min-w-0 flex-1 truncate text-sm">{displayName}</span>
) : null}
</Button>
);
@ -80,7 +59,7 @@ export function WorkspaceSwitcher({
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">{display.name}</TooltipContent>
<TooltipContent side="right">{displayName}</TooltipContent>
</Tooltip>
) : (
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
@ -91,16 +70,18 @@ export function WorkspaceSwitcher({
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Workspaces
</DropdownMenuLabel>
{MOCK_WORKSPACES.map((ws) => {
const selected = display.id === ws.id;
{(workspaces ?? []).map((ws) => {
const selected = current?.id === ws.id;
return (
<DropdownMenuItem
key={ws.id}
className="gap-2"
onClick={() => setWorkspace(ws)}
onClick={() => {
router.push(`/${ws.id}`);
}}
>
<Building2 className="size-4 shrink-0 opacity-70" />
<span className="flex-1 truncate">{ws.name}</span>
<span className="flex-1 truncate">{ws.title}</span>
{selected ? (
<Check className="size-4 shrink-0 text-primary" />
) : null}

View file

@ -108,36 +108,47 @@ function propertyCount(schema: TemplateSchemaJson | null | undefined): number {
}
export type TemplatePickerProps = {
objectId: string;
objectType: string;
onSelect: (templateId: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
objectType: string;
/** List templates for this workspace when no object exists yet (e.g. create dialog). */
workspaceId?: string;
/** Resolve workspace from an existing object; ignored when `workspaceId` is set. */
objectId?: string;
onSelect: (template: PickerTemplate) => void;
/** Renders the footer; invoked when the user chooses “Create New Template”. */
onCreateNew?: () => void;
};
export function TemplatePicker({
objectId,
objectType,
onSelect,
open,
onOpenChange,
objectType,
workspaceId: workspaceIdProp,
objectId,
onSelect,
onCreateNew,
}: TemplatePickerProps) {
const [search, setSearch] = React.useState("");
const [showAllTypes, setShowAllTypes] = React.useState(false);
const objectQuery = api.objects.getById.useQuery(
{ id: objectId },
{ enabled: open && Boolean(objectId) },
{ id: objectId! },
{ enabled: open && Boolean(objectId) && !workspaceIdProp },
);
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
?.workspaceId;
const workspaceId =
const workspaceFromObject =
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
const resolvedWorkspaceId = workspaceIdProp ?? workspaceFromObject;
const listQuery = api.templates.list.useQuery(
{ workspaceId: workspaceId!, targetType: showAllTypes ? undefined : objectType },
{ enabled: open && Boolean(workspaceId) },
{
workspaceId: resolvedWorkspaceId!,
targetType: showAllTypes ? undefined : objectType,
},
{ enabled: open && Boolean(resolvedWorkspaceId) },
);
const merged = React.useMemo(() => {
@ -245,13 +256,13 @@ export function TemplatePicker({
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
<div className="space-y-4 pb-3 pr-3">
{listQuery.isPending && workspaceId ? (
{listQuery.isPending && resolvedWorkspaceId ? (
<p className="text-sm text-muted-foreground">Loading templates</p>
) : null}
{!workspaceId && objectQuery.isPending ? (
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading object</p>
) : null}
{!workspaceId && objectQuery.isError ? (
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isError ? (
<p className="text-sm text-destructive">Could not load workspace.</p>
) : null}
@ -299,7 +310,7 @@ export function TemplatePicker({
size="sm"
className="shrink-0"
onClick={() => {
onSelect(t.id);
onSelect(t);
onOpenChange(false);
}}
>
@ -315,19 +326,21 @@ export function TemplatePicker({
</div>
</ScrollArea>
<div className="border-t px-4 py-3">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
onSelect(CREATE_SENTINEL);
onOpenChange(false);
}}
>
<FileStack className="h-4 w-4" />
Create New Template
</Button>
</div>
{onCreateNew ? (
<div className="border-t px-4 py-3">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
onCreateNew();
onOpenChange(false);
}}
>
<FileStack className="h-4 w-4" />
Create New Template
</Button>
</div>
) : null}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

View file

@ -0,0 +1,3 @@
export * from "./type-picker";
export * from "./type-manager";
export * from "./type-editor";

View file

@ -0,0 +1,210 @@
"use client";
import * as React from "react";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const PRESET_COLORS = [
{ value: "", label: "None" },
{ value: "#ef4444", label: "Red" },
{ value: "#f97316", label: "Orange" },
{ value: "#eab308", label: "Yellow" },
{ value: "#22c55e", label: "Green" },
{ value: "#14b8a6", label: "Teal" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#8b5cf6", label: "Violet" },
{ value: "#ec4899", label: "Pink" },
{ value: "#64748b", label: "Slate" },
] as const;
const LAYOUT_OPTIONS = [
{ value: "task", label: "Task" },
{ value: "document", label: "Document" },
{ value: "board", label: "Board" },
{ value: "custom", label: "Custom" },
] as const;
function slugFromName(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
export interface TypeEditorProps {
workspaceId: string;
existingType?: {
id: string;
name: string;
slug: string;
icon: string | null;
color: string | null;
layout: string;
};
onSave: () => void;
onCancel: () => void;
}
const fieldClass =
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50";
export function TypeEditor({
workspaceId,
existingType,
onSave,
onCancel,
}: TypeEditorProps) {
const utils = api.useUtils();
const [name, setName] = React.useState(existingType?.name ?? "");
const [icon, setIcon] = React.useState(existingType?.icon ?? "");
const [color, setColor] = React.useState(existingType?.color ?? "");
const [layout, setLayout] = React.useState(
(existingType?.layout as (typeof LAYOUT_OPTIONS)[number]["value"]) ?? "task",
);
const presetValues = React.useMemo(
() => new Set<string>(PRESET_COLORS.map((c) => c.value)),
[],
);
const colorOptions = React.useMemo(() => {
const c = color.trim();
if (c && !presetValues.has(c)) {
return [...PRESET_COLORS, { value: c, label: `Custom (${c})` }];
}
return [...PRESET_COLORS];
}, [color, presetValues]);
React.useEffect(() => {
setName(existingType?.name ?? "");
setIcon(existingType?.icon ?? "");
setColor(existingType?.color ?? "");
setLayout((existingType?.layout as (typeof LAYOUT_OPTIONS)[number]["value"]) ?? "task");
}, [existingType]);
const createMutation = api.types.create.useMutation({
onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId });
onSave();
},
});
const updateMutation = api.types.update.useMutation({
onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId });
onSave();
},
});
const isPending = createMutation.isPending || updateMutation.isPending;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const slug = slugFromName(name);
if (!slug) return;
const iconTrim = icon.trim();
const colorTrim = color.trim();
if (existingType) {
await updateMutation.mutateAsync({
id: existingType.id,
name,
icon: iconTrim,
color: colorTrim,
layout,
});
} else {
await createMutation.mutateAsync({
workspaceId,
name,
slug,
icon: iconTrim || undefined,
color: colorTrim || undefined,
layout,
});
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="type-name" className="text-sm font-medium leading-none">
Name
</label>
<Input
id="type-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Initiative"
required
autoComplete="off"
/>
</div>
<div className="space-y-2">
<label htmlFor="type-icon" className="text-sm font-medium leading-none">
Icon
</label>
<Input
id="type-icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="Emoji or short label"
autoComplete="off"
/>
</div>
<div className="space-y-2">
<label htmlFor="type-color" className="text-sm font-medium leading-none">
Color
</label>
<select
id="type-color"
className={cn(fieldClass)}
value={color}
onChange={(e) => setColor(e.target.value)}
>
{colorOptions.map((c) => (
<option key={`${c.label}-${c.value}`} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="type-layout" className="text-sm font-medium leading-none">
Layout
</label>
<select
id="type-layout"
className={cn(fieldClass)}
value={layout}
onChange={(e) =>
setLayout(e.target.value as (typeof LAYOUT_OPTIONS)[number]["value"])
}
>
{LAYOUT_OPTIONS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={isPending}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{existingType ? "Save" : "Create"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,266 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import {
Box,
CheckSquare,
FileText,
LayoutGrid,
MoreHorizontal,
Pencil,
Presentation,
Trash2,
X,
} from "lucide-react";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TypeEditor } from "./type-editor";
const BUILTIN_TYPES = [
{ value: "task", label: "Task", Icon: CheckSquare },
{ value: "document", label: "Document", Icon: FileText },
{ value: "space", label: "Space", Icon: LayoutGrid },
{ value: "whiteboard", label: "Whiteboard", Icon: Presentation },
] as const;
function layoutLabel(layout: string): string {
const map: Record<string, string> = {
task: "Task",
document: "Document",
board: "Board",
custom: "Custom",
};
return map[layout] ?? layout;
}
function TypeIconDisplay({ icon }: { icon: string | null | undefined }) {
if (icon && icon.trim()) {
return (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-lg leading-none">
{icon.trim()}
</span>
);
}
return (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<Box className="size-4" />
</span>
);
}
export interface TypeManagerProps {
workspaceId: string;
}
export function TypeManager({ workspaceId }: TypeManagerProps) {
const utils = api.useUtils();
const listQuery = api.types.list.useQuery(
{ workspaceId },
{ enabled: Boolean(workspaceId) },
);
const deleteMutation = api.types.delete.useMutation({
onSuccess: async () => {
await utils.types.list.invalidate({ workspaceId });
},
});
const [dialogOpen, setDialogOpen] = React.useState(false);
const [editing, setEditing] = React.useState<
| {
id: string;
name: string;
slug: string;
icon: string | null;
color: string | null;
layout: string;
}
| undefined
>(undefined);
const customTypes = listQuery.data ?? [];
function openCreate() {
setEditing(undefined);
setDialogOpen(true);
}
function openEdit(row: (typeof customTypes)[number]) {
setEditing({
id: row.id,
name: row.name,
slug: row.slug,
icon: row.icon,
color: row.color,
layout: row.layout,
});
setDialogOpen(true);
}
function handleDelete(row: (typeof customTypes)[number]) {
const ok = window.confirm(`Delete type “${row.name}”? This cannot be undone.`);
if (!ok) return;
deleteMutation.mutate({ id: row.id });
}
return (
<div className="space-y-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Object Types</h1>
<Button type="button" onClick={openCreate} disabled={!workspaceId}>
Create Type
</Button>
</div>
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Built-in</h2>
<div className="grid gap-3 sm:grid-cols-2">
{BUILTIN_TYPES.map(({ value, label, Icon }) => (
<div
key={value}
className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 shadow-sm"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium leading-tight">{label}</p>
<p className="text-xs text-muted-foreground">System type · not editable</p>
</div>
<Badge variant="secondary">Built-in</Badge>
</div>
))}
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">Custom</h2>
{listQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading types</p>
) : customTypes.length === 0 ? (
<p className="text-sm text-muted-foreground">
No custom types yet. Create one to extend your workspace.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{customTypes.map((row) => (
<div
key={row.id}
className="flex items-start gap-3 rounded-lg border bg-card px-4 py-3 shadow-sm"
>
<TypeIconDisplay icon={row.icon} />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium leading-tight">{row.name}</p>
<div className="flex flex-wrap items-center gap-2">
{row.color ? (
<span
className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium"
title={row.color}
>
<span
className="size-2.5 rounded-full border border-border"
style={{ backgroundColor: row.color }}
/>
Color
</span>
) : (
<Badge variant="outline" className="font-normal text-muted-foreground">
No color
</Badge>
)}
<Badge variant="outline" className="font-normal">
{layoutLabel(row.layout)}
</Badge>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Type actions"
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem className="gap-2" onClick={() => openEdit(row)}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 text-destructive focus:text-destructive"
onClick={() => handleDelete(row)}
disabled={deleteMutation.isPending}
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</section>
<DialogPrimitive.Root open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-0 rounded-lg border bg-background p-0 shadow-lg duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
)}
>
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0 space-y-1">
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
{editing ? "Edit type" : "Create type"}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-muted-foreground">
{editing
? "Update name, icon, color, and default layout."
: "Define a new object type for this workspace."}
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label="Close">
<X className="h-4 w-4" />
</Button>
</DialogPrimitive.Close>
</div>
<div className="px-4 py-4">
{workspaceId ? (
<TypeEditor
key={editing?.id ?? "new"}
workspaceId={workspaceId}
existingType={editing}
onSave={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
/>
) : null}
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}

View file

@ -0,0 +1,128 @@
"use client";
import * as React from "react";
import {
Box,
CheckSquare,
ChevronDown,
FileText,
LayoutGrid,
Presentation,
} from "lucide-react";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const BUILTIN_OPTIONS = [
{ value: "task", label: "Task", Icon: CheckSquare },
{ value: "document", label: "Document", Icon: FileText },
{ value: "space", label: "Space", Icon: LayoutGrid },
{ value: "whiteboard", label: "Whiteboard", Icon: Presentation },
] as const;
export interface TypePickerProps {
workspaceId?: string;
value: string;
onChange: (type: string) => void;
}
function TypeOptionIcon({
kind,
icon,
}: {
kind: "builtin" | "custom";
icon: string | null | undefined;
}) {
if (kind === "custom") {
if (icon && icon.trim()) {
return (
<span className="flex size-4 shrink-0 items-center justify-center text-base leading-none">
{icon.trim()}
</span>
);
}
return <Box className="size-4 shrink-0 text-muted-foreground" aria-hidden />;
}
return null;
}
export function TypePicker({ workspaceId, value, onChange }: TypePickerProps) {
const listQuery = api.types.list.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const customTypes = listQuery.data ?? [];
const selectedLabel = React.useMemo(() => {
const builtin = BUILTIN_OPTIONS.find((o) => o.value === value);
if (builtin) return builtin.label;
const custom = customTypes.find((t) => t.slug === value);
return custom?.name ?? value;
}, [value, customTypes]);
const SelectedBuiltinIcon = BUILTIN_OPTIONS.find((o) => o.value === value)?.Icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn("min-w-[200px] justify-between gap-2 font-normal")}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
{SelectedBuiltinIcon ? (
<SelectedBuiltinIcon className="size-4 shrink-0 text-muted-foreground" />
) : (
<TypeOptionIcon
kind="custom"
icon={customTypes.find((t) => t.slug === value)?.icon}
/>
)}
<span className="truncate">{selectedLabel || "Select type"}</span>
</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-[220px]">
<DropdownMenuLabel>Built-in</DropdownMenuLabel>
{BUILTIN_OPTIONS.map(({ value: v, label, Icon }) => (
<DropdownMenuItem
key={v}
className="gap-2"
onSelect={() => onChange(v)}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span>{label}</span>
</DropdownMenuItem>
))}
{customTypes.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Custom</DropdownMenuLabel>
{customTypes.map((t) => (
<DropdownMenuItem
key={t.id}
className="gap-2"
onSelect={() => onChange(t.slug)}
>
<TypeOptionIcon kind="custom" icon={t.icon} />
<span className="truncate">{t.name}</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,61 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -5,20 +5,29 @@ import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ViewObject } from "@/lib/hooks/use-view-data";
import { cn } from "@/lib/utils";
import { BoardCard } from "./board-card";
export interface BoardColumnInlineCreate {
isCreating: boolean;
newTitle: string;
onNewTitleChange: (value: string) => void;
onOpen: () => void;
onSubmit: () => void;
onCancel: () => void;
isPending: boolean;
}
export interface BoardColumnProps {
columnId: string;
label: string;
items: ViewObject[];
dotClass: string;
borderTopClass: string;
onAddTask?: () => void;
inlineCreate: BoardColumnInlineCreate;
}
function formatColumnLabel(id: string): string {
@ -31,7 +40,7 @@ export function BoardColumn({
items,
dotClass,
borderTopClass,
onAddTask,
inlineCreate,
}: BoardColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: columnId,
@ -83,17 +92,44 @@ export function BoardColumn({
</SortableContext>
</ScrollArea>
<div className="shrink-0 border-t border-border/40 p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
onClick={onAddTask}
>
<Plus className="h-4 w-4" />
Add task
</Button>
<div className="shrink-0 border-t border-border/40">
{inlineCreate.isCreating ? (
<div className="flex items-center gap-2 px-2 py-2">
<input
autoFocus
className="min-w-0 flex-1 rounded-md border border-border/60 bg-background px-2 py-1.5 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Task title…"
value={inlineCreate.newTitle}
onChange={(e) => inlineCreate.onNewTitleChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && inlineCreate.newTitle.trim()) {
e.preventDefault();
inlineCreate.onSubmit();
}
if (e.key === "Escape") {
inlineCreate.onCancel();
}
}}
onBlur={() => {
if (inlineCreate.isPending) return;
if (inlineCreate.newTitle.trim()) {
inlineCreate.onSubmit();
} else {
inlineCreate.onCancel();
}
}}
/>
</div>
) : (
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={inlineCreate.onOpen}
>
<Plus className="size-4 shrink-0" />
Add task
</button>
)}
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
DndContext,
DragOverlay,
@ -16,6 +17,7 @@ import { arrayMove } from "@dnd-kit/sortable";
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { BoardCardPreview } from "./board-card";
@ -109,6 +111,12 @@ export interface BoardViewProps {
}
export function BoardView({ config, className }: BoardViewProps) {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const effectiveConfig = React.useMemo(
() => ({
...config,
@ -117,7 +125,11 @@ export function BoardView({ config, className }: BoardViewProps) {
[config],
);
const { grouped, isLoading, total } = useViewData(effectiveConfig);
const { grouped, isLoading, total } = useViewData(
effectiveConfig,
workspaceId,
parentId,
);
const groupField = effectiveConfig.groupBy ?? "status";
const columnKeys = React.useMemo(
@ -134,6 +146,17 @@ export function BoardView({ config, className }: BoardViewProps) {
React.useState<Record<string, ViewObject[]>>(initialColumns);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [creatingColumnId, setCreatingColumnId] = React.useState<string | null>(null);
const [newTitle, setNewTitle] = React.useState("");
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setCreatingColumnId(null);
},
});
React.useEffect(() => {
setColumns(initialColumns);
}, [initialColumns]);
@ -300,6 +323,31 @@ export function BoardView({ config, className }: BoardViewProps) {
items={columns[columnId] ?? []}
dotClass={theme.dot}
borderTopClass={theme.borderTop}
inlineCreate={{
isCreating: creatingColumnId === columnId,
newTitle,
onNewTitleChange: setNewTitle,
onOpen: () => {
setCreatingColumnId(columnId);
setNewTitle("");
},
onSubmit: () => {
const t = newTitle.trim();
if (!t || !workspaceId || createObject.isPending) return;
createObject.mutate({
type: "task",
title: t,
workspaceId,
parentId: parentId ?? undefined,
...(groupField === "status" ? { status: columnId } : {}),
});
},
onCancel: () => {
setCreatingColumnId(null);
setNewTitle("");
},
isPending: createObject.isPending,
}}
/>
);
})}

View file

@ -1,15 +1,17 @@
"use client";
import * as Tabs from "@radix-ui/react-tabs";
import { LayoutGrid, List, Table2 } from "lucide-react";
import { ClipboardList, LayoutDashboard, LayoutGrid, List, Table2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { type ActiveViewType, useViewStore } from "@/lib/stores/view-store";
const tabs: { id: ActiveViewType; label: string; icon: typeof List }[] = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "list", label: "List", icon: List },
{ id: "board", label: "Board", icon: LayoutGrid },
{ id: "table", label: "Table", icon: Table2 },
{ id: "form", label: "Form", icon: ClipboardList },
];
export function ViewSwitcher() {

View file

@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import { Loader2 } from "lucide-react";
import { FormRenderer } from "@/components/forms/form-renderer";
import { Button } from "@/components/ui/button";
import type { ViewConfig } from "@/lib/hooks/use-view-data";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
export interface FormViewProps {
config: ViewConfig;
className?: string;
}
export function FormView({ config, className }: FormViewProps) {
void config;
const workspaceId = useWorkspaceStore((s) => s.currentWorkspace?.id);
const listQuery = api.forms.list.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const forms = listQuery.data?.forms ?? [];
if (!workspaceId) {
return (
<div className={cn("p-6 text-sm text-muted-foreground", className)}>
Select a workspace to use forms.
</div>
);
}
if (listQuery.isPending) {
return (
<div className={cn("flex flex-1 items-center justify-center py-16", className)}>
<Loader2 className="size-8 animate-spin text-muted-foreground" aria-hidden />
<span className="sr-only">Loading forms</span>
</div>
);
}
if (listQuery.isError) {
return (
<div className={cn("p-6 text-sm text-muted-foreground", className)}>
Could not load forms for this workspace.
</div>
);
}
if (forms.length === 0) {
return (
<div className={cn("p-6 text-sm text-muted-foreground", className)}>
No forms in this workspace yet. Create a form to show it here.
</div>
);
}
return (
<div className={cn("flex min-h-0 flex-1 flex-col gap-4 p-4", className)}>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="form-view-picker" className="text-sm font-medium text-muted-foreground">
Form
</label>
<select
id="form-view-picker"
value={selectedId ?? ""}
onChange={(e) => setSelectedId(e.target.value || null)}
className={cn(
"h-9 min-w-[200px] rounded-md border border-input bg-background px-3 text-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<option value="">Choose a form</option>
{forms.map((f) => (
<option key={f.id} value={f.id}>
{f.title}
</option>
))}
</select>
{selectedId ? (
<Button type="button" variant="ghost" size="sm" onClick={() => setSelectedId(null)}>
Clear
</Button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-card p-6">
{selectedId ? (
<FormRenderer key={selectedId} formId={selectedId} />
) : (
<p className="text-sm text-muted-foreground">
Pick a form above to fill it out in this view.
</p>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
ArrowDown,
ArrowUp,
@ -8,6 +9,7 @@ import {
ChevronDown,
ChevronRight,
ListTodo,
Plus,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
@ -18,6 +20,7 @@ import {
} from "@/components/ui/tooltip";
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import {
@ -202,6 +205,12 @@ export interface ListViewProps {
}
export function ListView({ config }: ListViewProps) {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
React.useEffect(() => {
setSorts(config.sorts);
@ -212,7 +221,11 @@ export function ListView({ config }: ListViewProps) {
[config, sorts],
);
const { items, isLoading, total } = useViewData(effectiveConfig);
const { items, isLoading, total } = useViewData(
effectiveConfig,
workspaceId,
parentId,
);
const displayItems = React.useMemo(
() => refineSort(items, sorts),
@ -239,6 +252,17 @@ export function ListView({ config }: ListViewProps) {
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
}, []);
const [isCreating, setIsCreating] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setIsCreating(false);
},
});
const onHeaderSort = React.useCallback((field: SortField) => {
setSorts((s) => nextSort(field, s));
}, []);
@ -361,7 +385,61 @@ export function ListView({ config }: ListViewProps) {
{headerRow}
<Separator />
<ScrollArea className="min-h-0 flex-1">
<div className="pb-2">{body}</div>
<div className="pb-2">
{body}
{!isLoading && (
<>
{isCreating ? (
<div className="flex items-center gap-2 border-t border-border px-4 py-2">
<input
autoFocus
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Task title…"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) {
e.preventDefault();
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
}
if (e.key === "Escape") {
setIsCreating(false);
setNewTitle("");
}
}}
onBlur={() => {
if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) {
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
} else {
setIsCreating(false);
}
}}
/>
</div>
) : (
<button
type="button"
className="flex w-full items-center gap-2 border-t border-border px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => setIsCreating(true)}
>
<Plus className="size-4 shrink-0" />
Add task
</button>
)}
</>
)}
</div>
</ScrollArea>
</div>
</TooltipProvider>

View file

@ -0,0 +1,125 @@
"use client";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge";
import { api } from "@/lib/trpc";
export interface OverviewViewProps {
workspaceId?: string;
spaceId?: string;
}
function formatUpdatedAt(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString();
}
export function OverviewView({ workspaceId, spaceId }: OverviewViewProps) {
const spaceQuery = api.objects.getById.useQuery(
{ id: spaceId! },
{ enabled: Boolean(spaceId) },
);
const childrenQuery = api.objects.list.useQuery(
{
workspaceId: workspaceId!,
parentId: spaceId ?? undefined,
limit: 200,
},
{ enabled: Boolean(workspaceId) },
);
const statusCounts = useMemo(() => {
const counts = { open: 0, in_progress: 0, done: 0 };
for (const row of childrenQuery.data?.objects ?? []) {
const s = row.status;
if (s === "in_progress") counts.in_progress += 1;
else if (s === "done") counts.done += 1;
else counts.open += 1;
}
return counts;
}, [childrenQuery.data?.objects]);
const recentChildren = useMemo(() => {
const rows = childrenQuery.data?.objects ?? [];
return [...rows]
.sort((a, b) => {
const ta = new Date(a.updatedAt).getTime();
const tb = new Date(b.updatedAt).getTime();
return tb - ta;
})
.slice(0, 10);
}, [childrenQuery.data?.objects]);
const isLoading = spaceQuery.isLoading || childrenQuery.isLoading;
const children = childrenQuery.data?.objects ?? [];
const hasChildren = children.length > 0;
const space = spaceQuery.data as { title: string } | undefined;
return (
<div className="h-full overflow-auto p-6">
{spaceQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading space</div>
) : space ? (
<h1 className="text-2xl font-semibold tracking-tight">{space.title}</h1>
) : null}
<div className="mt-8 space-y-8">
<section>
<h2 className="text-sm font-semibold">By status</h2>
<div className="mt-3 grid grid-cols-3 gap-4">
{(
[
{ key: "open" as const, label: "Open" },
{ key: "in_progress" as const, label: "In progress" },
{ key: "done" as const, label: "Done" },
] as const
).map(({ key, label }) => (
<div
key={key}
className="rounded-lg border border-border bg-card p-4 shadow-sm"
>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">
{isLoading ? "—" : statusCounts[key]}
</div>
</div>
))}
</div>
</section>
<section>
<h2 className="text-sm font-semibold">Recent activity</h2>
{childrenQuery.isLoading ? (
<p className="mt-3 text-sm text-muted-foreground">Loading</p>
) : !hasChildren ? (
<div className="mt-4 rounded-lg border border-dashed border-border bg-muted/30 p-10 text-center text-sm text-muted-foreground">
No items in this space yet.
</div>
) : (
<ul className="mt-3 divide-y divide-border rounded-lg border border-border bg-card shadow-sm">
{recentChildren.map((obj) => (
<li
key={obj.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span className="min-w-0 truncate font-medium">
{obj.title}
</span>
<div className="flex shrink-0 items-center gap-2">
<Badge variant="outline">{obj.type}</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
{formatUpdatedAt(obj.updatedAt)}
</span>
</div>
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useParams } from "next/navigation";
import {
ArrowDown,
ArrowUp,
@ -8,7 +9,6 @@ import {
Plus,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
getDueDateValue,
@ -16,6 +16,7 @@ import {
} from "@/components/views/list/list-item";
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
import { useViewData } from "@/lib/hooks/use-view-data";
import { api } from "@/lib/trpc";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
@ -345,6 +346,12 @@ export interface TableViewProps {
}
export function TableView({ config }: TableViewProps) {
const params = useParams();
const workspaceId =
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
const parentId =
typeof params?.projectId === "string" ? params.projectId : undefined;
const openDetail = usePanelStore((s) => s.open);
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
React.useEffect(() => {
@ -356,12 +363,26 @@ export function TableView({ config }: TableViewProps) {
[config, sorts],
);
const { items, isLoading, total } = useViewData(effectiveConfig);
const { items, isLoading, total } = useViewData(
effectiveConfig,
workspaceId,
parentId,
);
const [localRows, setLocalRows] = React.useState<ViewObject[]>([]);
const [edits, setEdits] = React.useState<Record<string, RowEdit>>({});
const [isCreating, setIsCreating] = React.useState(false);
const [newTitle, setNewTitle] = React.useState("");
const baseItems = React.useMemo(() => [...items, ...localRows], [items, localRows]);
const utils = api.useUtils();
const createObject = api.objects.create.useMutation({
onSuccess: () => {
utils.objects.list.invalidate();
setNewTitle("");
setIsCreating(false);
},
});
const baseItems = items;
const customCols = React.useMemo(() => collectCustomColumns(baseItems), [baseItems]);
@ -457,30 +478,6 @@ export function TableView({ config }: TableViewProps) {
return sum;
}, [widths, customCols]);
const addRow = React.useCallback(() => {
const id = `new-${Date.now()}`;
const row: ViewObject = {
id,
type: "task",
title: "New task",
status: "open",
icon: null,
sortOrder: baseItems.length,
parentId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
assignees: [],
propertyValues: [
{
propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" },
value: "Medium",
},
],
};
setLocalRows((r) => [...r, row]);
setSelected(new Set([id]));
}, [baseItems.length]);
return (
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
<ScrollArea className="min-h-0 flex-1">
@ -585,7 +582,7 @@ export function TableView({ config }: TableViewProps) {
</thead>
{isLoading ? (
<TableSkeleton colCount={colCount} />
) : total === 0 && localRows.length === 0 ? (
) : total === 0 && !isCreating ? (
<tbody>
<tr>
<td colSpan={colCount} className="p-0">
@ -789,16 +786,85 @@ export function TableView({ config }: TableViewProps) {
</tr>
);
})}
{isCreating && (
<tr
key="__inline-create"
className="border-b border-border/60 bg-muted/[0.08]"
>
<td
style={{
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
}}
className="sticky left-0 z-20 border-r border-border/60 bg-muted/[0.08] px-0"
/>
<td
style={{
width: widths.title ?? DEFAULT_COL_WIDTHS.title,
minWidth: widths.title ?? DEFAULT_COL_WIDTHS.title,
}}
className="sticky left-9 z-10 border-r border-border/60 bg-muted/[0.08] p-0"
>
<div className="flex h-9 items-center px-2">
<input
autoFocus
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Task title…"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newTitle.trim() && workspaceId) {
e.preventDefault();
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
}
if (e.key === "Escape") {
setIsCreating(false);
setNewTitle("");
}
}}
onBlur={() => {
if (createObject.isPending) return;
if (newTitle.trim() && workspaceId) {
createObject.mutate({
type: "task",
title: newTitle.trim(),
workspaceId,
parentId: parentId ?? undefined,
});
} else {
setIsCreating(false);
}
}}
/>
</div>
</td>
<td
colSpan={colCount - 2}
className="border-r border-border/60 bg-muted/[0.08] p-0"
/>
</tr>
)}
</tbody>
)}
</table>
</ScrollArea>
<div className="flex shrink-0 border-t border-border/70 bg-muted/20 px-2 py-1.5">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
Add row
</Button>
{!isCreating ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-2.5 text-xs font-medium text-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => setIsCreating(true)}
>
<Plus className="h-3.5 w-3.5" />
Add row
</button>
) : null}
</div>
</div>
);

View file

@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { WhiteboardToolbar } from "./toolbar";
import { syncTldrawStoreWithYjs } from "./yjs-store";
import { customShapeUtils } from "./shapes";
import "tldraw/tldraw.css";
@ -46,11 +47,11 @@ function TldrawCollabBody({
className?: string;
tldraw: TldrawModule;
}) {
const { doc, isConnected, isSynced } = useCollaboration(`whiteboard:${documentId}`);
const { doc, isConnected, isSynced } = useCollaboration(`object:${documentId}`);
const dark = useHtmlDarkClass();
const store = React.useMemo<TLStore>(
() => tldraw.createTLStore(),
() => tldraw.createTLStore({ shapeUtils: customShapeUtils }),
[documentId, tldraw],
);
@ -81,6 +82,7 @@ function TldrawCollabBody({
return (
<Tldraw
store={store}
shapeUtils={customShapeUtils}
className={cn("h-full min-h-0 w-full min-w-0", className)}
inferDarkMode={false}
onMount={(editor) => {
@ -111,6 +113,11 @@ function TldrawLocalBody({
const dark = useHtmlDarkClass();
const editorRef = React.useRef<Editor | null>(null);
const store = React.useMemo<TLStore>(
() => tldraw.createTLStore({ shapeUtils: customShapeUtils }),
[tldraw],
);
React.useEffect(() => {
editorRef.current?.updateInstanceState({ isReadonly: readOnly });
}, [readOnly]);
@ -125,6 +132,8 @@ function TldrawLocalBody({
return (
<Tldraw
store={store}
shapeUtils={customShapeUtils}
className={cn("h-full min-h-0 w-full min-w-0", className)}
inferDarkMode={false}
onMount={(editor) => {

View file

@ -1,5 +1,6 @@
import NextAuth from "next-auth";
import type { DefaultSession, NextAuthConfig } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import Credentials from "next-auth/providers/credentials";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
@ -77,6 +78,20 @@ if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
);
}
if (
process.env.AUTH_AUTHENTIK_ID &&
process.env.AUTH_AUTHENTIK_SECRET &&
process.env.AUTH_AUTHENTIK_ISSUER
) {
providers.push(
Authentik({
clientId: process.env.AUTH_AUTHENTIK_ID,
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
);
}
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {

View file

@ -1,6 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { api } from "@/lib/trpc";
export type SortDirection = "asc" | "desc";
@ -35,17 +37,6 @@ export interface ViewObject {
propertyValues?: { propertyDefinition: { id: string; name: string; fieldType: string }; value: unknown }[];
}
const MOCK_OBJECTS: ViewObject[] = [
{ id: "1", type: "task", title: "Design landing page mockups", status: "in_progress", icon: null, sortOrder: 0, parentId: null, createdAt: "2025-03-20", updatedAt: "2025-03-25", assignees: [{ user: { id: "u1", name: "Alice", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "High" }, { propertyDefinition: { id: "p2", name: "Due Date", fieldType: "date" }, value: "2025-04-01" }] },
{ id: "2", type: "task", title: "Implement authentication flow", status: "open", icon: null, sortOrder: 1, parentId: null, createdAt: "2025-03-21", updatedAt: "2025-03-24", assignees: [{ user: { id: "u2", name: "Bob", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "High" }] },
{ id: "3", type: "task", title: "Set up CI/CD pipeline", status: "done", icon: null, sortOrder: 2, parentId: null, createdAt: "2025-03-19", updatedAt: "2025-03-23", assignees: [{ user: { id: "u3", name: "Charlie", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "Medium" }] },
{ id: "4", type: "task", title: "Write API documentation", status: "open", icon: null, sortOrder: 3, parentId: null, createdAt: "2025-03-22", updatedAt: "2025-03-25", assignees: [], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "Low" }] },
{ id: "5", type: "task", title: "Database schema review", status: "in_progress", icon: null, sortOrder: 4, parentId: null, createdAt: "2025-03-18", updatedAt: "2025-03-25", assignees: [{ user: { id: "u1", name: "Alice", avatarUrl: null } }, { user: { id: "u2", name: "Bob", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "High" }, { propertyDefinition: { id: "p2", name: "Due Date", fieldType: "date" }, value: "2025-03-28" }] },
{ id: "6", type: "task", title: "User testing session", status: "open", icon: null, sortOrder: 5, parentId: null, createdAt: "2025-03-23", updatedAt: "2025-03-25", assignees: [{ user: { id: "u4", name: "Diana", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "Medium" }, { propertyDefinition: { id: "p2", name: "Due Date", fieldType: "date" }, value: "2025-04-05" }] },
{ id: "7", type: "task", title: "Performance optimization", status: "closed", icon: null, sortOrder: 6, parentId: null, createdAt: "2025-03-15", updatedAt: "2025-03-20", assignees: [{ user: { id: "u3", name: "Charlie", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "Low" }] },
{ id: "8", type: "task", title: "Mobile responsive fixes", status: "in_progress", icon: null, sortOrder: 7, parentId: null, createdAt: "2025-03-24", updatedAt: "2025-03-25", assignees: [{ user: { id: "u2", name: "Bob", avatarUrl: null } }], propertyValues: [{ propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "High" }] },
];
function applyFilters(objects: ViewObject[], filters: ViewFilter[]): ViewObject[] {
return objects.filter((obj) =>
filters.every((f) => {
@ -89,8 +80,32 @@ function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record<str
return groups;
}
export function useViewData(config: ViewConfig) {
const [objects] = useState<ViewObject[]>(MOCK_OBJECTS);
export function useViewData(
config: ViewConfig,
workspaceId?: string,
parentId?: string | null,
) {
const { data, isLoading: queryLoading } = api.objects.list.useQuery(
{ workspaceId: workspaceId!, parentId: parentId ?? undefined, limit: 200 },
{ enabled: Boolean(workspaceId) },
);
const objects: ViewObject[] = useMemo(() => {
if (!data?.objects) return [];
return data.objects.map((row) => ({
id: row.id,
type: row.type,
title: row.title,
status: row.status,
icon: row.icon,
sortOrder: row.sortOrder,
parentId: row.parentId,
createdAt:
row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
updatedAt:
row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
}));
}, [data?.objects]);
const processed = useMemo(() => {
let result = applyFilters(objects, config.filters);
@ -102,7 +117,7 @@ export function useViewData(config: ViewConfig) {
return {
items: processed.items,
grouped: processed.grouped,
isLoading: false,
isLoading: queryLoading,
total: processed.items.length,
};
}

View file

@ -2,7 +2,7 @@ import { create } from "zustand";
import type { ViewConfig, ViewFilter, ViewSort } from "@/lib/hooks/use-view-data";
export type ActiveViewType = "list" | "board" | "table" | "embed" | "whiteboard";
export type ActiveViewType = "list" | "board" | "table" | "embed" | "whiteboard" | "overview" | "form";
const defaultConfig: ViewConfig = {
filters: [],

View file

@ -6,15 +6,23 @@ import { relationsRouter } from "@/server/routers/relations";
import { aiRouter } from "@/server/routers/ai";
import { templatesRouter } from "@/server/routers/templates";
import { searchRouter } from "@/server/routers/search";
import { workspacesRouter } from "@/server/routers/workspaces";
import { typesRouter } from "@/server/routers/types";
import { formsRouter } from "@/server/routers/forms";
import { favoritesRouter } from "@/server/routers/favorites";
export const appRouter = router({
health: healthRouter,
workspaces: workspacesRouter,
types: typesRouter,
objects: objectsRouter,
properties: propertiesRouter,
relations: relationsRouter,
ai: aiRouter,
templates: templatesRouter,
search: searchRouter,
forms: formsRouter,
favorites: favoritesRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,66 @@
import { z } from "zod";
import { and, eq, desc } from "drizzle-orm";
import { userFavorites, objects } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
export const favoritesRouter = router({
list: protectedProcedure
.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
id: userFavorites.id,
objectId: userFavorites.objectId,
createdAt: userFavorites.createdAt,
objectTitle: objects.title,
objectType: objects.type,
objectIcon: objects.icon,
})
.from(userFavorites)
.innerJoin(objects, eq(userFavorites.objectId, objects.id))
.where(eq(userFavorites.userId, ctx.session.user.id))
.orderBy(desc(userFavorites.createdAt));
return rows;
}),
toggle: protectedProcedure
.input(z.object({ objectId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db
.select({ id: userFavorites.id })
.from(userFavorites)
.where(
and(
eq(userFavorites.userId, ctx.session.user.id),
eq(userFavorites.objectId, input.objectId),
),
)
.limit(1);
if (existing.length > 0) {
await ctx.db.delete(userFavorites).where(eq(userFavorites.id, existing[0].id));
return { favorited: false };
}
await ctx.db.insert(userFavorites).values({
userId: ctx.session.user.id,
objectId: input.objectId,
});
return { favorited: true };
}),
isFavorited: protectedProcedure
.input(z.object({ objectId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select({ id: userFavorites.id })
.from(userFavorites)
.where(
and(
eq(userFavorites.userId, ctx.session.user.id),
eq(userFavorites.objectId, input.objectId),
),
)
.limit(1);
return { favorited: rows.length > 0 };
}),
});

View file

@ -0,0 +1,364 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, asc, desc, eq } from "drizzle-orm";
import {
formResponses,
forms,
objects,
propertyDefinitions,
propertyValues,
} from "@tasks/database/schema";
import { type Context, router, protectedProcedure } from "@/server/trpc";
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type FormFieldRow = {
id: string;
type?: string;
mappedProperty?: string | null;
};
async function resolvePropertyDefId(
db: Pick<Context["db"], "select">,
workspaceId: string,
mappedProperty: string,
): Promise<string | null> {
if (UUID_RE.test(mappedProperty)) {
const [def] = await db
.select({ id: propertyDefinitions.id })
.from(propertyDefinitions)
.where(
and(
eq(propertyDefinitions.id, mappedProperty),
eq(propertyDefinitions.workspaceId, workspaceId),
),
)
.limit(1);
return def?.id ?? null;
}
const [def] = await db
.select({ id: propertyDefinitions.id })
.from(propertyDefinitions)
.where(
and(
eq(propertyDefinitions.workspaceId, workspaceId),
eq(propertyDefinitions.name, mappedProperty),
),
)
.limit(1);
return def?.id ?? null;
}
export const formsRouter = router({
list: protectedProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select()
.from(forms)
.where(eq(forms.workspaceId, input.workspaceId))
.orderBy(desc(forms.updatedAt), asc(forms.id));
return { forms: rows };
}),
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const row = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.id),
});
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
return row;
}),
create: protectedProcedure
.input(
z.object({
workspaceId: z.string().uuid(),
title: z.string().min(1).max(500),
description: z.string().optional(),
coverImage: z.string().optional(),
objectId: z.string().uuid().nullable().optional(),
targetType: z.string().max(50).optional().default("task"),
fields: z.array(z.unknown()).optional().default([]),
settings: z.record(z.unknown()).optional().default({}),
isPublished: z.boolean().optional().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing user id",
});
}
const now = new Date();
const [created] = await ctx.db
.insert(forms)
.values({
workspaceId: input.workspaceId,
title: input.title,
description: input.description ?? null,
coverImage: input.coverImage ?? null,
objectId: input.objectId ?? null,
targetType: input.targetType ?? "task",
fields: input.fields ?? [],
settings: input.settings ?? {},
isPublished: input.isPublished ?? false,
createdBy: userId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!created) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create form",
});
}
return created;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(500).optional(),
description: z.string().nullable().optional(),
coverImage: z.string().nullable().optional(),
objectId: z.string().uuid().nullable().optional(),
targetType: z.string().max(50).optional(),
fields: z.array(z.unknown()).optional(),
settings: z.record(z.unknown()).optional(),
isPublished: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
const now = new Date();
const [updated] = await ctx.db
.update(forms)
.set({
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.coverImage !== undefined ? { coverImage: patch.coverImage } : {}),
...(patch.objectId !== undefined ? { objectId: patch.objectId } : {}),
...(patch.targetType !== undefined ? { targetType: patch.targetType } : {}),
...(patch.fields !== undefined ? { fields: patch.fields } : {}),
...(patch.settings !== undefined ? { settings: patch.settings } : {}),
...(patch.isPublished !== undefined ? { isPublished: patch.isPublished } : {}),
updatedAt: now,
})
.where(eq(forms.id, id))
.returning();
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
return updated;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const deleted = await ctx.db
.delete(forms)
.where(eq(forms.id, input.id))
.returning({ id: forms.id });
if (deleted.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
}),
submit: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
data: z.record(z.unknown()),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
if (!userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing user id",
});
}
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId),
});
if (!form) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
const fieldRows = Array.isArray(form.fields) ? (form.fields as FormFieldRow[]) : [];
const workspaceId = form.workspaceId;
let title = form.title;
let description: string | null | undefined;
let icon: string | null | undefined;
let status: string | null | undefined;
for (const field of fieldRows) {
const key = field.id;
if (!key || !(key in input.data)) continue;
const raw = input.data[key];
const mapKey = field.mappedProperty?.trim();
if (!mapKey) continue;
const lower = mapKey.toLowerCase();
if (lower === "title") {
title = raw == null ? title : String(raw);
continue;
}
if (lower === "description") {
description = raw == null ? null : String(raw);
continue;
}
if (lower === "icon") {
icon = raw == null ? null : String(raw);
continue;
}
if (lower === "status") {
status = raw == null ? null : String(raw);
continue;
}
}
const now = new Date();
const result = await ctx.db.transaction(async (tx) => {
const [createdObject] = await tx
.insert(objects)
.values({
type: form.targetType,
title,
parentId: form.objectId ?? null,
workspaceId,
...(description !== undefined ? { description } : {}),
...(icon !== undefined ? { icon } : {}),
...(status !== undefined ? { status } : {}),
createdBy: userId,
createdAt: now,
updatedAt: now,
})
.returning();
if (!createdObject) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create object from form",
});
}
for (const field of fieldRows) {
const key = field.id;
if (!key || !(key in input.data)) continue;
const skipTypes = new Set(["section_header", "divider"]);
if (field.type && skipTypes.has(field.type)) continue;
const mapKey = field.mappedProperty?.trim();
if (!mapKey) continue;
const lower = mapKey.toLowerCase();
if (["title", "description", "icon", "status"].includes(lower)) {
continue;
}
const propertyDefId = await resolvePropertyDefId(tx, workspaceId, mapKey);
if (!propertyDefId) continue;
const value = input.data[key];
await tx
.insert(propertyValues)
.values({
objectId: createdObject.id,
propertyDefId,
value: value as unknown,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [propertyValues.objectId, propertyValues.propertyDefId],
set: {
value: value as unknown,
updatedAt: now,
},
});
}
const [responseRow] = await tx
.insert(formResponses)
.values({
formId: form.id,
respondentId: userId,
createdObjectId: createdObject.id,
data: input.data,
submittedAt: now,
})
.returning();
if (!responseRow) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to record form response",
});
}
return { object: createdObject, response: responseRow };
});
return result;
}),
listResponses: protectedProcedure
.input(
z.object({
formId: z.string().uuid(),
limit: z.number().int().positive().max(500).optional(),
offset: z.number().int().nonnegative().optional(),
}),
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 50;
const offset = input.offset ?? 0;
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId),
columns: { id: true },
});
if (!form) {
throw new TRPCError({ code: "NOT_FOUND", message: "Form not found" });
}
const rows = await ctx.db
.select()
.from(formResponses)
.where(eq(formResponses.formId, input.formId))
.orderBy(desc(formResponses.submittedAt), asc(formResponses.id))
.limit(limit)
.offset(offset);
return { responses: rows };
}),
});

View file

@ -1,3 +1,5 @@
import { sql } from "drizzle-orm";
import {
router,
publicProcedure,
@ -13,7 +15,7 @@ export const healthRouter = router({
me: protectedProcedure.query(({ ctx }) => ctx.session!.user),
dbCheck: protectedProcedure.query(async ({ ctx }) => {
await ctx.db.$client`SELECT 1`;
await ctx.db.execute(sql`SELECT 1`);
return { ok: true as const };
}),
});

View file

@ -18,7 +18,13 @@ import { router, protectedProcedure } from "@/server/trpc";
const objectTypeSchema = z.enum(objectTypes);
const TREE_TYPES = ["project", "group", "document", "whiteboard"] as const;
const TREE_TYPES = [
"project",
"space",
"group",
"document",
"whiteboard",
] as const;
export type ObjectTreeNode = {
id: string;
@ -285,6 +291,21 @@ export const objectsRouter = router({
return row;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const deleted = await ctx.db
.delete(objects)
.where(eq(objects.id, input.id))
.returning({ id: objects.id });
if (deleted.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Object not found" });
}
return deleted[0];
}),
reorder: protectedProcedure
.input(
z.object({

View file

@ -0,0 +1,102 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { asc, eq } from "drizzle-orm";
import { objectTypeDefs } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
export const typesRouter = router({
list: protectedProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(objectTypeDefs)
.where(eq(objectTypeDefs.workspaceId, input.workspaceId))
.orderBy(asc(objectTypeDefs.name));
}),
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const [row] = await ctx.db
.select()
.from(objectTypeDefs)
.where(eq(objectTypeDefs.id, input.id))
.limit(1);
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return row;
}),
create: protectedProcedure
.input(
z.object({
workspaceId: z.string().uuid(),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100),
icon: z.string().optional(),
color: z.string().max(50).optional(),
layout: z.enum(["task", "document", "board", "custom"]).default("task"),
defaultProperties: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [created] = await ctx.db
.insert(objectTypeDefs)
.values({
workspaceId: input.workspaceId,
name: input.name,
slug: input.slug,
icon: input.icon ?? null,
color: input.color ?? null,
layout: input.layout,
defaultProperties: input.defaultProperties ?? null,
})
.returning();
if (!created)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create type",
});
return created;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255).optional(),
icon: z.string().optional(),
color: z.string().max(50).optional(),
layout: z.enum(["task", "document", "board", "custom"]).optional(),
defaultProperties: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const updates: Record<string, unknown> = { updatedAt: new Date() };
if (data.name !== undefined) updates.name = data.name;
if (data.icon !== undefined) updates.icon = data.icon;
if (data.color !== undefined) updates.color = data.color;
if (data.layout !== undefined) updates.layout = data.layout;
if (data.defaultProperties !== undefined) updates.defaultProperties = data.defaultProperties;
const [updated] = await ctx.db
.update(objectTypeDefs)
.set(updates)
.where(eq(objectTypeDefs.id, id))
.returning();
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return updated;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const [deleted] = await ctx.db
.delete(objectTypeDefs)
.where(eq(objectTypeDefs.id, input.id))
.returning();
if (!deleted) throw new TRPCError({ code: "NOT_FOUND", message: "Type not found" });
return { success: true };
}),
});

View file

@ -0,0 +1,59 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { objects, workspaceMembers, users } from "@tasks/database/schema";
import { router, protectedProcedure } from "@/server/trpc";
export const workspacesRouter = router({
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const [row] = await ctx.db
.select({
id: objects.id,
title: objects.title,
type: objects.type,
icon: objects.icon,
})
.from(objects)
.where(and(eq(objects.id, input.id), eq(objects.type, "workspace")))
.limit(1);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "Workspace not found" });
}
return row;
}),
listForUser: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
return ctx.db
.select({
id: objects.id,
title: objects.title,
icon: objects.icon,
role: workspaceMembers.role,
})
.from(workspaceMembers)
.innerJoin(objects, eq(workspaceMembers.workspaceId, objects.id))
.where(eq(workspaceMembers.userId, userId));
}),
listMembers: protectedProcedure
.input(z.object({ workspaceId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
return ctx.db
.select({
id: users.id,
name: users.name,
email: users.email,
avatarUrl: users.avatarUrl,
role: workspaceMembers.role,
})
.from(workspaceMembers)
.innerJoin(users, eq(workspaceMembers.userId, users.id))
.where(eq(workspaceMembers.workspaceId, input.workspaceId));
}),
});

67
config/CursorSync.md Normal file
View file

@ -0,0 +1,67 @@
# Cursor sync configuration
This document describes how the application and Cursor should keep **plans, epics, and tasks** aligned. Implementation is incremental; treat this as the contract the data layer and jobs will follow.
## Goals
1. **App → Cursor**: Tasks and status updates in the app appear as Cursor to-dos / plan items where configured.
2. **Cursor → App**: To-dos created or completed in Cursor are mirrored into the correct plan/epic in the app.
3. **Markdown as source of truth (optional mode)**: Repo markdown can be authoritative; the app imports on change, or the app exports on change—policy is per tenant (see Modes).
## Multitenancy
- Each **tenant** has its own:
- API credentials or OAuth connection to Cursor (when available).
- Mapping table: internal plan/epic/task id ↔ Cursor identifiers ↔ filesystem paths under `plans/`.
- No cross-tenant sync or shared Cursor workspace.
## Modes (to implement)
| Mode | Behavior |
|------|----------|
| `markdown_authoritative` | Watch `plans/**`; import on save; push summaries to Cursor. |
| `app_authoritative` | UI/API edits win; export markdown + update Cursor on commit or interval. |
| `bidirectional` | Reconcile by `updated_at` and explicit conflict rules (last-write-wins per field or manual resolution). |
## API surface (target)
Lightweight endpoints or jobs (names indicative):
- `POST /api/v1/tenants/:tenantId/plans` — create plan + optional seed markdown paths.
- `GET/PATCH /api/v1/tenants/:tenantId/plans/:planId` — read/update metadata and Cursor mapping.
- `GET/PATCH /api/v1/.../epics/:epicId`, `.../tasks/:taskId` — same for epics and tasks.
- `POST /api/v1/tenants/:tenantId/sync/cursor/pull` — ingest Cursor to-dos into tasks.
- `POST /api/v1/tenants/:tenantId/sync/cursor/push` — export task state to Cursor.
- Webhook receiver (future): `POST /webhooks/cursor` for push notifications when Cursor exposes them; until then **polling** on a tenant schedule.
## Mapping record (logical schema)
Implemented in Postgres as `markdown_backlog_items` plus `cursor_sync_mappings` (see `docs/Glossary.md`). Logical fields:
- `tenant_id``markdown_backlog_items.workspace_id` (workspace object UUID)
- `plan_slug`, `epic_slug`, `slug` (filesystem / frontmatter alignment)
- `cursor_plan_id` / `cursor_item_id``cursor_sync_mappings` (nullable until connected)
- `last_pulled_at`, `last_pushed_at`, `sync_content_hash` on the mapping row
- `content_hash` on the backlog row (file body hash for import idempotency)
## Environment variables (placeholder)
Document only; wire in app config when implementing.
| Variable | Purpose |
|----------|---------|
| `CURSOR_SYNC_ENABLED` | `true` / `false` per environment. |
| `CURSOR_SYNC_POLL_INTERVAL_SEC` | Polling fallback interval. |
| `CURSOR_API_BASE_URL` | When a stable API exists for your integration tier. |
| `CURSOR_WEBHOOK_SECRET` | Verify inbound webhooks. |
## Security
- Store tokens in tenant-scoped secrets (env, vault, or DB encrypted column)—never in markdown.
- Audit log for every push/pull with actor (user id or system job).
## References
- Backlog layout: `plans/README.md`
- Terminology: `docs/Glossary.md`
- Templates: `docs/templates/`

View file

@ -0,0 +1,119 @@
# Docker Compose for Coolify deployment of ECHODO.
#
# Usage in Coolify:
# - New Resource → Docker Compose → connect this repo
# - Build pack: Docker Compose
# - Compose file path: docker/docker-compose.coolify.yml
# - Set domains in Coolify on the `web` and `collab` services using http://
# (CT 100 Traefik handles TLS termination per AGENT-DEPLOY.md)
#
# Differences vs. docker/docker-compose.yml:
# - No bundled postgres / redis (uses shared CT 102 services)
# - No host port mappings (Coolify's Traefik routes by container labels)
# - Uses Coolify SERVICE_FQDN_* magic env vars so Coolify auto-wires Traefik labels
# - Build context is the repository root (Coolify clones the whole repo there)
services:
web:
build:
context: ..
dockerfile: docker/Dockerfile.web
restart: unless-stopped
environment:
# Coolify FQDN magic var — set the domain in the Coolify UI for this service.
# SERVICE_FQDN_WEB_3000 makes Coolify expose the container on port 3000 via Traefik.
- SERVICE_FQDN_WEB_3000
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${AUTH_SECRET}
- AUTH_DEV_PASSWORD=${AUTH_DEV_PASSWORD}
- AUTH_GITHUB_ID=${AUTH_GITHUB_ID}
- AUTH_GITHUB_SECRET=${AUTH_GITHUB_SECRET}
- AUTH_GOOGLE_ID=${AUTH_GOOGLE_ID}
- AUTH_GOOGLE_SECRET=${AUTH_GOOGLE_SECRET}
- AUTH_AUTHENTIK_ID=${AUTH_AUTHENTIK_ID}
- AUTH_AUTHENTIK_SECRET=${AUTH_AUTHENTIK_SECRET}
- AUTH_AUTHENTIK_ISSUER=${AUTH_AUTHENTIK_ISSUER}
- COLLAB_SERVER_URL=${COLLAB_SERVER_URL}
- NEXT_PUBLIC_COLLAB_SERVER_URL=${NEXT_PUBLIC_COLLAB_SERVER_URL}
- NEXT_PUBLIC_UMAMI_SCRIPT=${NEXT_PUBLIC_UMAMI_SCRIPT}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_LIBREDESK_URL=${NEXT_PUBLIC_LIBREDESK_URL}
- NEXT_PUBLIC_LIBREDESK_WIDGET_ID=${NEXT_PUBLIC_LIBREDESK_WIDGET_ID}
- NEXT_PUBLIC_DIRECTUS_URL=${NEXT_PUBLIC_DIRECTUS_URL}
- DIRECTUS_FEEDBACK_TOKEN=${DIRECTUS_FEEDBACK_TOKEN}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
expose:
- "3000"
healthcheck:
test:
- CMD-SHELL
- "wget -qO- --timeout=3 http://127.0.0.1:3000/ >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
networks:
- echodo
collab:
build:
context: ..
dockerfile: docker/Dockerfile.collab
restart: unless-stopped
environment:
# Separate FQDN for the websocket service, e.g. collab.echodo.stillwell.cloud
- SERVICE_FQDN_COLLAB_1234
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- PORT=1234
- NODE_ENV=production
expose:
- "1234"
healthcheck:
test:
- CMD-SHELL
- 'node -e "const p=+(process.env.PORT||1234);require(''net'').createConnection(p,''127.0.0.1'').on(''connect'',()=>process.exit(0)).on(''error'',()=>process.exit(1))"'
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
# MCP server is stdio-based today; deploy only if you want it reachable on the LAN
# for future HTTP/SSE transport. Comment the whole block out if not needed.
mcp:
build:
context: ..
dockerfile: docker/Dockerfile.mcp
args:
MCP_SERVER_PORT: ${MCP_SERVER_PORT:-3001}
restart: unless-stopped
environment:
- DATABASE_URL=${DATABASE_URL}
- MCP_SERVER_PORT=${MCP_SERVER_PORT:-3001}
- NODE_ENV=production
stdin_open: true
expose:
- "${MCP_SERVER_PORT:-3001}"
healthcheck:
test:
- CMD-SHELL
- "pgrep -f 'node.*dist/index\\.js' > /dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
networks:
- echodo
networks:
echodo:
driver: bridge

60
docs/Glossary.md Normal file
View file

@ -0,0 +1,60 @@
# Glossary
Vocabulary for the markdown backlog, multitenant product, and Cursor synchronization. Aligned with [Gas Town](https://github.com/gastownhall/gastown) ideas of **persistent, chunked work** agents can pick up without losing context.
## Backlog hierarchy
| Term | Meaning |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Plan** | Top-level initiative. One markdown tree root under `plans/`. Maps to a **Cursor plan** (or equivalent top-level container) when sync is enabled. |
| **Epic** | Coherent milestone under a plan. Groups related tasks; acceptable scope for a focused agent or subagent run. |
| **Task** (working item) | Smallest tracked unit of work. Sized so a single Cursor agent session can usually complete or materially advance it. Prefer one concern per file. |
## Sync and tenancy
| Term | Meaning |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **Tenant** | Isolated customer or org boundary. All plans, epics, tasks, and **Cursor mappings** are scoped to a tenant. |
| **Cursor mapping** | Stored association between app entities and Cursor artifacts (plan id, todo id, file path, or future API identifiers). |
| **Import** | Creating or updating app records from markdown and/or Cursor state. |
| **Export** | Writing or refreshing markdown (and optionally pushing state to Cursor). |
| **Sync** | Bidirectional reconciliation so neither side is silently stale. |
## Gastown-style chunking (conceptual)
| Gastown idea | Backlog analogue |
| ----------------------------- | ------------------------------------------------------ |
| Beads / issues as durable ids | Stable task slugs + optional `id` in YAML frontmatter |
| Convoys / epics | **Epic** folders and epic docs |
| Rigs / town scope | **Plan** per major initiative or product area |
| Handoffs between agents | Task doc **Owner**, **Status**, and **Links** sections |
## Document conventions
- **Frontmatter**: YAML at the top of Plan, Epic, and Task files for tooling (`status`, `priority`, `tenant_id`, `cursor_`* ids when known).
- **Status values** (suggested): `draft`, `ready`, `in_progress`, `blocked`, `done`, `cancelled`.
- **Priority** (suggested): `P0``P3` matching severity-style triage.
## Paths
| Path | Role |
| ---------------------------------------------- | ------------------------------------------------- |
| `plans/Plan-<slug>/` | Plan root directory. |
| `plans/Plan-<slug>/Plan-<slug>.md` | Plan overview and epic index. |
| `plans/Plan-<slug>/Epic-<slug>/Epic-<slug>.md` | Epic definition and task index. |
| `plans/Plan-<slug>/Epic-<slug>/Task-<slug>.md` | Executable work item. |
| `config/CursorSync.md` | Sync behavior, limits, and per-environment notes. |
## Database tables (implementation)
| Table | Role |
| ----- | ---- |
| `markdown_backlog_items` | One row per imported markdown file under `plans/`, scoped by `workspace_id` (tenant). Stores parsed frontmatter, body, content hash, hierarchy (`parent_id`). |
| `cursor_sync_mappings` | At most one row per backlog item: Cursor plan/item ids and last push/pull timestamps. |

47
docs/templates/epic-template.md vendored Normal file
View file

@ -0,0 +1,47 @@
---
kind: epic
slug: "<epic-slug>"
title: "<Epic title>"
plan_slug: "<plan-slug>"
status: draft
priority: P2
tenant_id: "<tenant-uuid-or-placeholder>"
cursor_epic_id: null
updated_at: "<ISO-8601>"
---
# Epic objective
One paragraph: what milestone this epic delivers.
## In scope / out of scope
**In scope**
- …
**Out of scope**
- …
## Related tasks
| Task | Link |
|------|------|
| | `./Task-<task-slug>.md` |
## Dependencies
- Depends on: …
- Blocks: …
## Acceptance criteria
- [ ] Criterion 1
- [ ] Criterion 2
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| | | |

49
docs/templates/plan-template.md vendored Normal file
View file

@ -0,0 +1,49 @@
---
kind: plan
slug: "<plan-slug>"
title: "<Plan title>"
status: draft
priority: P2
tenant_id: "<tenant-uuid-or-placeholder>"
cursor_plan_id: null
updated_at: "<ISO-8601>"
---
# Plan overview
Short summary of the initiative and why it exists.
## Objectives and success criteria
- Objective 1 — measurable outcome
- Objective 2
## Scope and boundaries
**In scope:** …
**Out of scope:** …
## Cross-team collaborators
| Name / group | Role |
|--------------|------|
| | |
## Epics
| Epic | Link |
|------|------|
| `<Epic title>` | `./Epic-<epic-slug>/Epic-<epic-slug>.md` |
## Metrics and milestones
| Milestone | Target date | Metric |
|-----------|-------------|--------|
| | | |
## Risks and mitigations
| Risk | Mitigation |
|------|------------|
| | |

47
docs/templates/task-template.md vendored Normal file
View file

@ -0,0 +1,47 @@
---
kind: task
slug: "<task-slug>"
title: "<Task title>"
plan_slug: "<plan-slug>"
epic_slug: "<epic-slug>"
status: ready
priority: P2
tenant_id: "<tenant-uuid-or-placeholder>"
owner: "<name-or-unassigned>"
cursor_todo_id: null
updated_at: "<ISO-8601>"
---
# Task summary
One or two sentences.
## Description
Implementation notes, context links, and technical constraints.
## Subtasks
- [ ] …
- [ ] …
## Owner or assignee
`<owner>`
## Status
`<draft | ready | in_progress | blocked | done | cancelled>`
## Estimation
`<e.g. S / M / L or hours>`
## Acceptance criteria
- [ ] …
## Links to related Epic / Plan
- Epic: `./Epic-<epic-slug>.md` (same directory as this task)
- Plan: `../Plan-<plan-slug>.md` (parent of `Epic-<epic-slug>/`; matches `plans/Plan-<plan-slug>/Plan-<plan-slug>.md`)

View file

@ -0,0 +1,168 @@
CREATE TABLE "object_assignees" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"object_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" varchar(50) DEFAULT 'assignee' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "objects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"type" varchar(50) NOT NULL,
"parent_id" uuid,
"title" varchar(500) DEFAULT '' NOT NULL,
"icon" text,
"cover_image" text,
"description" text,
"content" jsonb,
"status" varchar(50),
"sort_order" integer DEFAULT 0 NOT NULL,
"template_id" uuid,
"workspace_id" uuid,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"archived_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "workspace_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" varchar(50) DEFAULT 'member' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_definitions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"field_type" varchar(50) NOT NULL,
"config" jsonb,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_values" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"object_id" uuid NOT NULL,
"property_def_id" uuid NOT NULL,
"value" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"object_id" uuid NOT NULL,
"view_type" varchar(50) NOT NULL,
"config" jsonb,
"name" varchar(255) NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" varchar(255) NOT NULL,
"provider" varchar(255) NOT NULL,
"provider_account_id" varchar(255) NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" varchar(255),
"scope" varchar(255),
"id_token" text,
"session_state" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"session_token" varchar(255) NOT NULL,
"user_id" uuid NOT NULL,
"expires" timestamp with time zone NOT NULL,
CONSTRAINT "sessions_session_token_unique" UNIQUE("session_token")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(255) NOT NULL,
"name" varchar(255),
"avatar_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification_tokens" (
"identifier" varchar(255) NOT NULL,
"token" varchar(255) NOT NULL,
"expires" timestamp with time zone NOT NULL,
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
CREATE TABLE "object_relations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_id" uuid NOT NULL,
"target_id" uuid NOT NULL,
"relation_type" varchar(50) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"target_type" varchar(50) NOT NULL,
"schema" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "object_assignees" ADD CONSTRAINT "object_assignees_object_id_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "object_assignees" ADD CONSTRAINT "object_assignees_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "objects" ADD CONSTRAINT "objects_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "objects" ADD CONSTRAINT "objects_parent_id_objects_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "objects" ADD CONSTRAINT "objects_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "objects" ADD CONSTRAINT "objects_template_id_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_definitions" ADD CONSTRAINT "property_definitions_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_values" ADD CONSTRAINT "property_values_object_id_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_values" ADD CONSTRAINT "property_values_property_def_id_property_definitions_id_fk" FOREIGN KEY ("property_def_id") REFERENCES "public"."property_definitions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "views" ADD CONSTRAINT "views_object_id_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "object_relations" ADD CONSTRAINT "object_relations_source_id_objects_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "object_relations" ADD CONSTRAINT "object_relations_target_id_objects_id_fk" FOREIGN KEY ("target_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "templates" ADD CONSTRAINT "templates_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "object_assignees_object_id_user_id_unique" ON "object_assignees" USING btree ("object_id","user_id");--> statement-breakpoint
CREATE INDEX "object_assignees_object_id_idx" ON "object_assignees" USING btree ("object_id");--> statement-breakpoint
CREATE INDEX "object_assignees_user_id_idx" ON "object_assignees" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "objects_parent_id_idx" ON "objects" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "objects_type_idx" ON "objects" USING btree ("type");--> statement-breakpoint
CREATE INDEX "objects_workspace_id_idx" ON "objects" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "objects_template_id_idx" ON "objects" USING btree ("template_id");--> statement-breakpoint
CREATE INDEX "objects_created_by_idx" ON "objects" USING btree ("created_by");--> statement-breakpoint
CREATE INDEX "objects_type_workspace_id_idx" ON "objects" USING btree ("type","workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "workspace_members_workspace_id_user_id_unique" ON "workspace_members" USING btree ("workspace_id","user_id");--> statement-breakpoint
CREATE INDEX "workspace_members_workspace_id_idx" ON "workspace_members" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "workspace_members_user_id_idx" ON "workspace_members" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "property_definitions_workspace_id_idx" ON "property_definitions" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "property_definitions_workspace_id_name_idx" ON "property_definitions" USING btree ("workspace_id","name");--> statement-breakpoint
CREATE UNIQUE INDEX "property_values_object_id_property_def_id_unique" ON "property_values" USING btree ("object_id","property_def_id");--> statement-breakpoint
CREATE INDEX "property_values_object_id_idx" ON "property_values" USING btree ("object_id");--> statement-breakpoint
CREATE INDEX "property_values_property_def_id_idx" ON "property_values" USING btree ("property_def_id");--> statement-breakpoint
CREATE INDEX "views_object_id_idx" ON "views" USING btree ("object_id");--> statement-breakpoint
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_unique" ON "accounts" USING btree ("provider","provider_account_id");--> statement-breakpoint
CREATE INDEX "accounts_user_id_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
CREATE INDEX "object_relations_source_id_idx" ON "object_relations" USING btree ("source_id");--> statement-breakpoint
CREATE INDEX "object_relations_target_id_idx" ON "object_relations" USING btree ("target_id");--> statement-breakpoint
CREATE INDEX "object_relations_relation_type_idx" ON "object_relations" USING btree ("relation_type");--> statement-breakpoint
CREATE UNIQUE INDEX "object_relations_source_target_type_unique" ON "object_relations" USING btree ("source_id","target_id","relation_type");--> statement-breakpoint
CREATE INDEX "templates_workspace_id_idx" ON "templates" USING btree ("workspace_id");

View file

@ -0,0 +1,16 @@
CREATE TABLE "object_type_defs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"slug" varchar(100) NOT NULL,
"icon" text,
"color" varchar(50),
"layout" varchar(50) DEFAULT 'task' NOT NULL,
"default_properties" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "object_type_defs" ADD CONSTRAINT "object_type_defs_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "object_type_defs_workspace_id_idx" ON "object_type_defs" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "object_type_defs_slug_idx" ON "object_type_defs" USING btree ("slug","workspace_id");

View file

@ -0,0 +1,88 @@
CREATE TABLE "form_responses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"form_id" uuid NOT NULL,
"respondent_id" uuid,
"created_object_id" uuid,
"data" jsonb DEFAULT '{}' NOT NULL,
"submitted_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "forms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"title" varchar(500) NOT NULL,
"description" text,
"cover_image" text,
"object_id" uuid,
"target_type" varchar(50) DEFAULT 'task' NOT NULL,
"fields" jsonb DEFAULT '[]' NOT NULL,
"settings" jsonb DEFAULT '{}' NOT NULL,
"is_published" boolean DEFAULT false NOT NULL,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_favorites" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"object_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "markdown_backlog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"kind" varchar(20) NOT NULL,
"slug" varchar(200) NOT NULL,
"plan_slug" varchar(200) NOT NULL,
"epic_slug" varchar(200),
"parent_id" uuid,
"repo_path" text NOT NULL,
"title" varchar(500) DEFAULT '' NOT NULL,
"status" varchar(50),
"priority" varchar(20),
"owner" text,
"frontmatter" jsonb,
"body_markdown" text DEFAULT '' NOT NULL,
"content_hash" varchar(64) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cursor_sync_mappings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"backlog_item_id" uuid NOT NULL,
"cursor_plan_id" varchar(500),
"cursor_item_id" varchar(500),
"last_pulled_at" timestamp with time zone,
"last_pushed_at" timestamp with time zone,
"sync_content_hash" varchar(64),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "form_responses" ADD CONSTRAINT "form_responses_form_id_forms_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "form_responses" ADD CONSTRAINT "form_responses_respondent_id_users_id_fk" FOREIGN KEY ("respondent_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "form_responses" ADD CONSTRAINT "form_responses_created_object_id_objects_id_fk" FOREIGN KEY ("created_object_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forms" ADD CONSTRAINT "forms_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forms" ADD CONSTRAINT "forms_object_id_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "forms" ADD CONSTRAINT "forms_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_favorites" ADD CONSTRAINT "user_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_favorites" ADD CONSTRAINT "user_favorites_object_id_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "markdown_backlog_items" ADD CONSTRAINT "markdown_backlog_items_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "markdown_backlog_items" ADD CONSTRAINT "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."markdown_backlog_items"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cursor_sync_mappings" ADD CONSTRAINT "cursor_sync_mappings_workspace_id_objects_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cursor_sync_mappings" ADD CONSTRAINT "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk" FOREIGN KEY ("backlog_item_id") REFERENCES "public"."markdown_backlog_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "form_responses_form_id_idx" ON "form_responses" USING btree ("form_id");--> statement-breakpoint
CREATE INDEX "form_responses_respondent_id_idx" ON "form_responses" USING btree ("respondent_id");--> statement-breakpoint
CREATE INDEX "forms_workspace_id_idx" ON "forms" USING btree ("workspace_id");--> statement-breakpoint
CREATE INDEX "forms_object_id_idx" ON "forms" USING btree ("object_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_favorites_user_object_idx" ON "user_favorites" USING btree ("user_id","object_id");--> statement-breakpoint
CREATE UNIQUE INDEX "markdown_backlog_workspace_repo_path_unique" ON "markdown_backlog_items" USING btree ("workspace_id","repo_path");--> statement-breakpoint
CREATE INDEX "markdown_backlog_workspace_plan_idx" ON "markdown_backlog_items" USING btree ("workspace_id","plan_slug");--> statement-breakpoint
CREATE INDEX "markdown_backlog_parent_id_idx" ON "markdown_backlog_items" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "markdown_backlog_workspace_kind_idx" ON "markdown_backlog_items" USING btree ("workspace_id","kind");--> statement-breakpoint
CREATE UNIQUE INDEX "cursor_sync_mappings_backlog_item_id_unique" ON "cursor_sync_mappings" USING btree ("backlog_item_id");--> statement-breakpoint
CREATE INDEX "cursor_sync_mappings_workspace_id_idx" ON "cursor_sync_mappings" USING btree ("workspace_id");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774617985473,
"tag": "0000_nervous_ogun",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774632231368,
"tag": "0001_parched_red_hulk",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1777225115319,
"tag": "0002_markdown_backlog_cursor_sync",
"breakpoints": true
}
]
}

View file

@ -8,22 +8,28 @@
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema/index.ts",
"./client": "./src/client.ts"
"./client": "./src/client.ts",
"./markdown-backlog": "./src/markdown-backlog/index.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"watch:markdown-backlog": "tsx src/scripts/watch-markdown-backlog.ts"
},
"dependencies": {
"drizzle-orm": "^0.38.0",
"postgres": "^3.4.5",
"yaml": "^2.8.3",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"chokidar": "^5.0.0",
"drizzle-kit": "^0.30.0",
"tsx": "^4.19.2",
"typescript": "^5.7.0"
}
}

View file

@ -0,0 +1,10 @@
export { backlogKinds, isBacklogKind, type BacklogKind } from "./kinds";
export {
toRepoPathPosix,
isTrackedBacklogMarkdown,
parentRepoPath,
planSlugFromPath,
epicFolderFromPath,
} from "./paths";
export { parseBacklogMarkdown, hashFileContents, type ParsedBacklogFile } from "./parse";
export { syncMarkdownBacklogScan, type SyncMarkdownBacklogResult } from "./sync";

View file

@ -0,0 +1,6 @@
export const backlogKinds = ["plan", "epic", "task"] as const;
export type BacklogKind = (typeof backlogKinds)[number];
export function isBacklogKind(v: string): v is BacklogKind {
return (backlogKinds as readonly string[]).includes(v);
}

View file

@ -0,0 +1,103 @@
import { createHash } from "node:crypto";
import { parse as parseYaml } from "yaml";
import type { BacklogKind } from "./kinds";
import { isBacklogKind } from "./kinds";
import { epicFolderFromPath, planSlugFromPath } from "./paths";
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
export type ParsedBacklogFile = {
contentHash: string;
frontmatter: Record<string, unknown>;
bodyMarkdown: string;
kind: BacklogKind;
slug: string;
planSlug: string;
epicSlug: string | null;
title: string;
status: string | null;
priority: string | null;
owner: string | null;
};
function inferKindFromFilename(filename: string): BacklogKind | null {
if (filename.startsWith("Plan-")) return "plan";
if (filename.startsWith("Epic-")) return "epic";
if (filename.startsWith("Task-")) return "task";
return null;
}
function readString(fm: Record<string, unknown>, key: string): string | null {
const v = fm[key];
return typeof v === "string" && v.trim() ? v.trim() : null;
}
function firstHeading(markdown: string): string | null {
const m = markdown.match(/^\s*#\s+(.+)$/m);
return m?.[1]?.trim() ?? null;
}
export function hashFileContents(raw: string): string {
return createHash("sha256").update(raw, "utf8").digest("hex");
}
/**
* Parse a markdown document with optional YAML frontmatter.
* `repoPathPosix` is used to infer plan/epic segments when frontmatter omits them.
*/
export function parseBacklogMarkdown(
raw: string,
repoPathPosix: string,
): ParsedBacklogFile {
const contentHash = hashFileContents(raw);
let frontmatter: Record<string, unknown> = {};
let bodyMarkdown = raw;
const fmMatch = raw.match(FRONTMATTER);
if (fmMatch) {
try {
const parsed = parseYaml(fmMatch[1]);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
frontmatter = {};
}
bodyMarkdown = raw.slice(fmMatch[0].length);
}
const filename = repoPathPosix.split("/").pop() ?? "";
const baseName = filename.replace(/\.md$/i, "");
const fmKind = readString(frontmatter, "kind");
const inferred = inferKindFromFilename(filename);
const kind: BacklogKind =
fmKind && isBacklogKind(fmKind) ? fmKind : inferred ?? "task";
const slug =
readString(frontmatter, "slug") ??
(baseName.replace(/^(Plan|Epic|Task)-/, "") || baseName);
const planSlug =
readString(frontmatter, "plan_slug") ?? planSlugFromPath(repoPathPosix) ?? slug;
const epicSlug =
readString(frontmatter, "epic_slug") ??
(kind === "task" ? epicFolderFromPath(repoPathPosix) : null);
const title =
readString(frontmatter, "title") ?? firstHeading(bodyMarkdown) ?? baseName;
return {
contentHash,
frontmatter,
bodyMarkdown,
kind,
slug,
planSlug,
epicSlug,
title,
status: readString(frontmatter, "status"),
priority: readString(frontmatter, "priority"),
owner: readString(frontmatter, "owner"),
};
}

View file

@ -0,0 +1,67 @@
import { sep } from "node:path";
/** POSIX-style path relative to repository root (e.g. `plans/Plan-a/Task-b.md`). */
export function toRepoPathPosix(relativeFromRepoRoot: string): string {
return relativeFromRepoRoot.split(sep).join("/");
}
/**
* Whether this repo-relative path should be imported as backlog markdown.
* Expects POSIX `plans/...` paths.
*/
export function isTrackedBacklogMarkdown(repoPathPosix: string): boolean {
if (!repoPathPosix.startsWith("plans/")) return false;
if (repoPathPosix === "plans/README.md") return false;
const base = repoPathPosix.split("/").pop() ?? "";
return (
base.startsWith("Plan-") || base.startsWith("Epic-") || base.startsWith("Task-")
);
}
/**
* Parent document path for hierarchy links, or `null` for plan roots.
*/
export function parentRepoPath(repoPathPosix: string): string | null {
const parts = repoPathPosix.split("/").filter(Boolean);
if (parts.length < 3 || parts[0] !== "plans") return null;
const planDir = parts[1];
if (!planDir.startsWith("Plan-")) return null;
if (parts.length === 3) {
const file = parts[2];
if (file.startsWith("Plan-") && file.endsWith(".md")) return null;
}
if (parts.length === 4) {
const file = parts[3];
if (file.startsWith("Epic-") && file.endsWith(".md")) {
return `plans/${planDir}/${planDir}.md`;
}
}
if (parts.length === 5) {
const epicDir = parts[2];
const file = parts[4];
if (epicDir.startsWith("Epic-") && file.startsWith("Task-") && file.endsWith(".md")) {
return `plans/${planDir}/${epicDir}/${epicDir}.md`;
}
}
return null;
}
export function planSlugFromPath(repoPathPosix: string): string | null {
const parts = repoPathPosix.split("/").filter(Boolean);
if (parts.length < 2 || parts[0] !== "plans") return null;
const planDir = parts[1];
return planDir.startsWith("Plan-") ? planDir.replace(/^Plan-/, "") : null;
}
export function epicFolderFromPath(repoPathPosix: string): string | null {
const parts = repoPathPosix.split("/").filter(Boolean);
if (parts.length < 3 || parts[0] !== "plans") return null;
const epicDir = parts[2];
if (parts.length >= 4 && epicDir.startsWith("Epic-")) return epicDir.replace(/^Epic-/, "");
return null;
}

View file

@ -0,0 +1,153 @@
import { readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { and, eq, inArray, like, notInArray } from "drizzle-orm";
import type { db as dbClient } from "../client";
import { markdownBacklogItems } from "../schema/markdown_backlog";
import { parseBacklogMarkdown, type ParsedBacklogFile } from "./parse";
import { isTrackedBacklogMarkdown, parentRepoPath, toRepoPathPosix } from "./paths";
type Database = typeof dbClient;
function walkMarkdownFiles(dir: string, out: string[]): void {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const p = join(dir, ent.name);
if (ent.isDirectory()) walkMarkdownFiles(p, out);
else if (ent.isFile() && ent.name.endsWith(".md")) out.push(p);
}
}
export type SyncMarkdownBacklogResult = {
scannedFiles: number;
upsertedRows: number;
deletedRows: number;
repoPaths: string[];
};
/**
* Scan `plans/**` under `repoRootAbs`, upsert rows for `workspaceId`, delete DB rows under
* `plans/` that no longer exist on disk.
*/
export async function syncMarkdownBacklogScan(
database: Database,
options: { workspaceId: string; repoRootAbs: string },
): Promise<SyncMarkdownBacklogResult> {
const { workspaceId, repoRootAbs } = options;
const plansDir = join(repoRootAbs, "plans");
const absFiles: string[] = [];
walkMarkdownFiles(plansDir, absFiles);
const tracked = absFiles
.map((abs) => toRepoPathPosix(relative(repoRootAbs, abs)))
.filter(isTrackedBacklogMarkdown);
const rows: (ParsedBacklogFile & { repoPath: string })[] = [];
for (const repoPath of tracked) {
const abs = join(repoRootAbs, ...repoPath.split("/"));
const raw = await readFile(abs, "utf8");
rows.push({ repoPath, ...parseBacklogMarkdown(raw, repoPath) });
}
let deletedRows = 0;
await database.transaction(async (tx) => {
for (const row of rows) {
await tx
.insert(markdownBacklogItems)
.values({
workspaceId,
kind: row.kind,
slug: row.slug,
planSlug: row.planSlug,
epicSlug: row.epicSlug,
parentId: null,
repoPath: row.repoPath,
title: row.title,
status: row.status,
priority: row.priority,
owner: row.owner,
frontmatter: row.frontmatter,
bodyMarkdown: row.bodyMarkdown,
contentHash: row.contentHash,
})
.onConflictDoUpdate({
target: [markdownBacklogItems.workspaceId, markdownBacklogItems.repoPath],
set: {
kind: row.kind,
slug: row.slug,
planSlug: row.planSlug,
epicSlug: row.epicSlug,
title: row.title,
status: row.status,
priority: row.priority,
owner: row.owner,
frontmatter: row.frontmatter,
bodyMarkdown: row.bodyMarkdown,
contentHash: row.contentHash,
updatedAt: new Date(),
},
});
}
const pathToId = new Map(
(
await tx
.select({ id: markdownBacklogItems.id, repoPath: markdownBacklogItems.repoPath })
.from(markdownBacklogItems)
.where(
and(
eq(markdownBacklogItems.workspaceId, workspaceId),
inArray(markdownBacklogItems.repoPath, tracked),
),
)
).map((r) => [r.repoPath, r.id] as const),
);
for (const row of rows) {
const parentPath = parentRepoPath(row.repoPath);
const parentId = parentPath ? pathToId.get(parentPath) ?? null : null;
await tx
.update(markdownBacklogItems)
.set({ parentId, updatedAt: new Date() })
.where(
and(
eq(markdownBacklogItems.workspaceId, workspaceId),
eq(markdownBacklogItems.repoPath, row.repoPath),
),
);
}
const plansUnderWorkspace = and(
eq(markdownBacklogItems.workspaceId, workspaceId),
like(markdownBacklogItems.repoPath, "plans/%"),
);
if (tracked.length === 0) {
const removed = await tx
.delete(markdownBacklogItems)
.where(plansUnderWorkspace)
.returning({ id: markdownBacklogItems.id });
deletedRows = removed.length;
} else {
const removed = await tx
.delete(markdownBacklogItems)
.where(and(plansUnderWorkspace, notInArray(markdownBacklogItems.repoPath, tracked)))
.returning({ id: markdownBacklogItems.id });
deletedRows = removed.length;
}
});
return {
scannedFiles: absFiles.length,
upsertedRows: rows.length,
deletedRows,
repoPaths: tracked,
};
}

View file

@ -0,0 +1,41 @@
import {
pgTable,
uuid,
varchar,
timestamp,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { objects } from "./objects";
import { markdownBacklogItems } from "./markdown_backlog";
/**
* Cursor-side identifiers for a markdown backlog row (one row per backlog item).
* `workspace_id` mirrors the backlog item for tenant-scoped queries without an extra join.
*/
export const cursorSyncMappings = pgTable(
"cursor_sync_mappings",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
backlogItemId: uuid("backlog_item_id")
.notNull()
.references(() => markdownBacklogItems.id, { onDelete: "cascade" }),
cursorPlanId: varchar("cursor_plan_id", { length: 500 }),
cursorItemId: varchar("cursor_item_id", { length: 500 }),
lastPulledAt: timestamp("last_pulled_at", { withTimezone: true }),
lastPushedAt: timestamp("last_pushed_at", { withTimezone: true }),
/** Hash of payload last successfully pushed or pulled (optional dedupe). */
syncContentHash: varchar("sync_content_hash", { length: 64 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
backlogItemUnique: uniqueIndex("cursor_sync_mappings_backlog_item_id_unique").on(
table.backlogItemId,
),
workspaceIdx: index("cursor_sync_mappings_workspace_id_idx").on(table.workspaceId),
}),
);

View file

@ -0,0 +1,16 @@
import { pgTable, uuid, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
import { users } from "./users";
import { objects } from "./objects";
export const userFavorites = pgTable(
"user_favorites",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
objectId: uuid("object_id").notNull().references(() => objects.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userObjectIdx: uniqueIndex("user_favorites_user_object_idx").on(table.userId, table.objectId),
}),
);

View file

@ -0,0 +1,55 @@
import {
pgTable,
uuid,
varchar,
text,
jsonb,
boolean,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { objects } from "./objects";
import { users } from "./users";
export const forms = pgTable(
"forms",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
title: varchar("title", { length: 500 }).notNull(),
description: text("description"),
coverImage: text("cover_image"),
objectId: uuid("object_id").references(() => objects.id, { onDelete: "set null" }),
targetType: varchar("target_type", { length: 50 }).notNull().default("task"),
fields: jsonb("fields").notNull().default("[]"),
settings: jsonb("settings").notNull().default("{}"),
isPublished: boolean("is_published").notNull().default(false),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceIdIdx: index("forms_workspace_id_idx").on(table.workspaceId),
objectIdIdx: index("forms_object_id_idx").on(table.objectId),
}),
);
export const formResponses = pgTable(
"form_responses",
{
id: uuid("id").primaryKey().defaultRandom(),
formId: uuid("form_id")
.notNull()
.references(() => forms.id, { onDelete: "cascade" }),
respondentId: uuid("respondent_id").references(() => users.id, { onDelete: "set null" }),
createdObjectId: uuid("created_object_id").references(() => objects.id, { onDelete: "set null" }),
data: jsonb("data").notNull().default("{}"),
submittedAt: timestamp("submitted_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
formIdIdx: index("form_responses_form_id_idx").on(table.formId),
respondentIdIdx: index("form_responses_respondent_id_idx").on(table.respondentId),
}),
);

View file

@ -1,7 +1,12 @@
export * from "./objects";
export * from "./types";
export * from "./properties";
export * from "./values";
export * from "./views";
export * from "./users";
export * from "./relations";
export * from "./templates";
export * from "./forms";
export * from "./favorites";
export * from "./markdown_backlog";
export * from "./cursor_sync";

View file

@ -0,0 +1,60 @@
import {
pgTable,
uuid,
varchar,
text,
jsonb,
timestamp,
index,
uniqueIndex,
foreignKey,
} from "drizzle-orm/pg-core";
import { objects } from "./objects";
/**
* Imported plan / epic / task rows sourced from repo markdown under `plans/`.
* Scoped by workspace (tenant boundary in this app).
*/
export const markdownBacklogItems = pgTable(
"markdown_backlog_items",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
kind: varchar("kind", { length: 20 }).notNull(),
slug: varchar("slug", { length: 200 }).notNull(),
planSlug: varchar("plan_slug", { length: 200 }).notNull(),
epicSlug: varchar("epic_slug", { length: 200 }),
parentId: uuid("parent_id"),
repoPath: text("repo_path").notNull(),
title: varchar("title", { length: 500 }).notNull().default(""),
status: varchar("status", { length: 50 }),
priority: varchar("priority", { length: 20 }),
owner: text("owner"),
frontmatter: jsonb("frontmatter").$type<Record<string, unknown> | null>(),
bodyMarkdown: text("body_markdown").notNull().default(""),
contentHash: varchar("content_hash", { length: 64 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
parentFk: foreignKey({
columns: [table.parentId],
foreignColumns: [table.id],
}).onDelete("set null"),
workspaceRepoUnique: uniqueIndex("markdown_backlog_workspace_repo_path_unique").on(
table.workspaceId,
table.repoPath,
),
workspacePlanIdx: index("markdown_backlog_workspace_plan_idx").on(
table.workspaceId,
table.planSlug,
),
parentIdIdx: index("markdown_backlog_parent_id_idx").on(table.parentId),
workspaceKindIdx: index("markdown_backlog_workspace_kind_idx").on(
table.workspaceId,
table.kind,
),
}),
);

View file

@ -13,6 +13,9 @@ import { propertyDefinitions } from "./properties";
import { propertyValues } from "./values";
import { views } from "./views";
import { templates } from "./templates";
import { objectTypeDefs } from "./types";
import { markdownBacklogItems } from "./markdown_backlog";
import { cursorSyncMappings } from "./cursor_sync";
export const objectRelations = pgTable(
"object_relations",
@ -76,6 +79,7 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({
workspaceMembers: many(workspaceMembers),
outgoingRelations: many(objectRelations, { relationName: "relationSource" }),
incomingRelations: many(objectRelations, { relationName: "relationTarget" }),
markdownBacklogItems: many(markdownBacklogItems),
}));
export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({
@ -134,6 +138,13 @@ export const templatesRelations = relations(templates, ({ one, many }) => ({
objects: many(objects),
}));
export const objectTypeDefsRelations = relations(objectTypeDefs, ({ one }) => ({
workspace: one(objects, {
fields: [objectTypeDefs.workspaceId],
references: [objects.id],
}),
}));
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@ -160,3 +171,34 @@ export const objectRelationsRelations = relations(objectRelations, ({ one }) =>
relationName: "relationTarget",
}),
}));
export const markdownBacklogItemsRelations = relations(
markdownBacklogItems,
({ one, many }) => ({
workspace: one(objects, {
fields: [markdownBacklogItems.workspaceId],
references: [objects.id],
}),
parent: one(markdownBacklogItems, {
fields: [markdownBacklogItems.parentId],
references: [markdownBacklogItems.id],
relationName: "backlogHierarchy",
}),
children: many(markdownBacklogItems, { relationName: "backlogHierarchy" }),
cursorMapping: one(cursorSyncMappings, {
fields: [markdownBacklogItems.id],
references: [cursorSyncMappings.backlogItemId],
}),
}),
);
export const cursorSyncMappingsRelations = relations(cursorSyncMappings, ({ one }) => ({
workspace: one(objects, {
fields: [cursorSyncMappings.workspaceId],
references: [objects.id],
}),
backlogItem: one(markdownBacklogItems, {
fields: [cursorSyncMappings.backlogItemId],
references: [markdownBacklogItems.id],
}),
}));

View file

@ -0,0 +1,32 @@
import {
pgTable,
uuid,
varchar,
text,
jsonb,
timestamp,
index,
} from "drizzle-orm/pg-core";
import { objects } from "./objects";
export const objectTypeDefs = pgTable(
"object_type_defs",
{
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id")
.notNull()
.references(() => objects.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull(),
icon: text("icon"),
color: varchar("color", { length: 50 }),
layout: varchar("layout", { length: 50 }).notNull().default("task"),
defaultProperties: jsonb("default_properties"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
workspaceIdIdx: index("object_type_defs_workspace_id_idx").on(table.workspaceId),
slugIdx: index("object_type_defs_slug_idx").on(table.slug, table.workspaceId),
}),
);

View file

@ -0,0 +1,68 @@
/**
* Watch all Markdown files under `plans/` and upsert `markdown_backlog_items` for one workspace.
*
* Env:
* - DATABASE_URL (required)
* - MARKDOWN_BACKLOG_WORKSPACE_ID UUID of the workspace object (required)
* - MARKDOWN_BACKLOG_REPO_ROOT absolute path to repo root (default: cwd)
*/
import { resolve } from "node:path";
import chokidar from "chokidar";
import { db } from "../client";
import { syncMarkdownBacklogScan } from "../markdown-backlog/sync";
const workspaceId = process.env.MARKDOWN_BACKLOG_WORKSPACE_ID?.trim();
const repoRootAbs = resolve(
process.env.MARKDOWN_BACKLOG_REPO_ROOT?.trim() || process.cwd(),
);
if (!workspaceId) {
console.error(
"[watch-markdown-backlog] Set MARKDOWN_BACKLOG_WORKSPACE_ID to your workspace object UUID.",
);
process.exit(1);
}
const workspaceIdResolved = workspaceId;
let timer: ReturnType<typeof setTimeout> | undefined;
async function runSync() {
try {
const result = await syncMarkdownBacklogScan(db, {
workspaceId: workspaceIdResolved,
repoRootAbs,
});
console.log(
`[watch-markdown-backlog] synced ${result.upsertedRows} file(s), deleted ${result.deletedRows}, scanned ${result.scannedFiles} markdown under plans/`,
);
} catch (e) {
console.error("[watch-markdown-backlog] sync failed:", e);
}
}
function schedule() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void runSync();
}, 400);
}
const watcher = chokidar.watch("plans/**/*.md", {
cwd: repoRootAbs,
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
});
watcher.on("all", (_evt, path) => {
if (path?.endsWith(".md")) schedule();
});
void (async () => {
await runSync();
console.log(
`[watch-markdown-backlog] watching plans/ tree in ${repoRootAbs} for workspace ${workspaceIdResolved}`,
);
})();

View file

@ -0,0 +1,59 @@
export const formFieldTypes = [
"short_text",
"long_text",
"number",
"email",
"url",
"date",
"datetime",
"select",
"multi_select",
"checkbox",
"radio",
"file_upload",
"rating",
"section_header",
"divider",
] as const;
export type FormFieldType = (typeof formFieldTypes)[number];
export interface FormConditional {
fieldId: string;
operator: "eq" | "neq" | "contains" | "isEmpty";
value: unknown;
action: "show" | "hide";
}
export interface FormFieldOption {
label: string;
value: string;
}
export interface FormFieldValidation {
min?: number;
max?: number;
pattern?: string;
maxLength?: number;
}
export interface FormField {
id: string;
label: string;
type: FormFieldType;
required: boolean;
placeholder?: string;
helpText?: string;
options?: FormFieldOption[];
defaultValue?: unknown;
mappedProperty: string | null;
validation?: FormFieldValidation;
conditionals?: FormConditional[];
}
export interface FormSettings {
allowAnonymous: boolean;
requireAuth: boolean;
confirmationMessage: string;
redirectUrl?: string;
}

View file

@ -1,4 +1,5 @@
export { objectTypes, type ObjectType } from "./objects";
export * from "./forms";
export { fieldTypes, type FieldType } from "./fields";
export { viewTypes, type ViewType } from "./views";
export { workspaceRoles, type WorkspaceRole } from "./roles";

View file

@ -1,14 +1,29 @@
export const objectTypes = [
"workspace",
"project",
"space",
"task",
"document",
"whiteboard",
"group",
"form",
] as const;
export type ObjectType = (typeof objectTypes)[number];
export const builtInTypes = [
"workspace",
"project",
"space",
"task",
"document",
"whiteboard",
"group",
"form",
] as const;
export type BuiltInType = (typeof builtInTypes)[number];
export const objectStatuses = [
"open",
"in_progress",

View file

@ -0,0 +1,51 @@
---
kind: epic
slug: cursor-sync-layer
title: Cursor integration and bidirectional to-do sync
plan_slug: multitenant-cursor-sync
status: ready
priority: P0
tenant_id: global
cursor_epic_id: null
updated_at: "2026-04-26"
---
# Epic objective
Implement the **data integration layer** that maps internal plans/epics/tasks to Cursor artifacts and runs **push**, **pull**, and optional **reconciliation** per tenant.
## In scope / out of scope
**In scope**
- Mapping schema (see `config/CursorSync.md`)
- API stubs or routers for sync triggers
- Polling job skeleton; webhook handler behind feature flag
**Out of scope**
- Cursor product features not exposed by API (document as risk; use export fallback)
## Related tasks
| Task | Link |
|------|------|
| Mapping model and migration | `./Task-mapping-model-and-migration.md` |
| Push/pull job interface | `./Task-push-pull-job-interface.md` |
## Dependencies
- Depends on: Markdown backlog epic (stable paths + frontmatter)
- Blocks: None for MVP if file export is enough for v0
## Acceptance criteria
- [ ] Tenant can store `cursor_plan_id` / `cursor_todo_id` on entities (DB or sidecar table).
- [ ] One documented manual flow: export task list to Cursor via copy or file.
## Proposed timeline
| Phase | Window | Notes |
|-------|--------|-------|
| Spec | Week 1 | `config/CursorSync.md` |
| MVP | Week 24 | Mapping + manual id |

View file

@ -0,0 +1,49 @@
---
kind: task
slug: mapping-model-and-migration
title: Add DB tables for Cursor mapping (tenant-scoped)
plan_slug: multitenant-cursor-sync
epic_slug: cursor-sync-layer
status: ready
priority: P0
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-04-26"
---
# Task summary
Introduce Drizzle schema + migration for **cursor_sync_mappings** (name indicative) keyed by tenant and internal entity ids.
## Description
Columns (logical): `tenant_id`, `entity_kind` (`plan`|`epic`|`task`), `entity_id`, `cursor_plan_id`, `cursor_item_id`, `repo_path`, `last_pushed_at`, `last_pulled_at`, `content_hash`.
## Subtasks
- [ ] Schema in `packages/database`
- [ ] Migration under `packages/database/migrations`
- [ ] tRPC procedures restricted to workspace/tenant members
## Owner or assignee
Unassigned
## Status
ready
## Estimation
M
## Acceptance criteria
- [ ] Migration applies cleanly on empty DB.
- [ ] No mapping row without `tenant_id`.
## Links to related Epic / Plan
- Epic: `./Epic-cursor-sync-layer.md`
- Plan: `../Plan-multitenant-cursor-sync.md`

View file

@ -0,0 +1,49 @@
---
kind: task
slug: push-pull-job-interface
title: Define push/pull sync job interface and feature flags
plan_slug: multitenant-cursor-sync
epic_slug: cursor-sync-layer
status: draft
priority: P1
tenant_id: global
owner: unassigned
cursor_todo_id: null
updated_at: "2026-04-26"
---
# Task summary
Specify the job runner contract: inputs (tenant, mode), outputs (diff counts, errors), idempotency, and `CURSOR_SYNC_*` env toggles from `config/CursorSync.md`.
## Description
Start with **no-op** implementation that logs would-be operations; swap in HTTP client when Cursor API is available. Support **dry-run** flag for operators.
## Subtasks
- [ ] Interface types in `packages/shared` or app server package
- [ ] CLI or internal route `POST .../sync/cursor` with dry-run
- [ ] Structured logs with tenant id on every call
## Owner or assignee
Unassigned
## Status
draft
## Estimation
M
## Acceptance criteria
- [ ] Dry-run never writes to Cursor or DB mapping table.
- [ ] Same request twice is idempotent (no duplicate side effects).
## Links to related Epic / Plan
- Epic: `./Epic-cursor-sync-layer.md`
- Plan: `../Plan-multitenant-cursor-sync.md`

Some files were not shown because too many files have changed in this diff Show more