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:
parent
a508ece6e7
commit
663bc77afe
108 changed files with 14142 additions and 1036 deletions
88
.env.example
88
.env.example
|
|
@ -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
18
.gitignore
vendored
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
162
apps/web/app/(app)/[workspaceSlug]/ai/page.tsx
Normal file
162
apps/web/app/(app)/[workspaceSlug]/ai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx
Normal file
161
apps/web/app/(app)/[workspaceSlug]/docs/[docId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
112
apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx
Normal file
112
apps/web/app/(app)/[workspaceSlug]/forms/[formId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
apps/web/app/(app)/[workspaceSlug]/forms/page.tsx
Normal file
118
apps/web/app/(app)/[workspaceSlug]/forms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
apps/web/app/(app)/[workspaceSlug]/planner/page.tsx
Normal file
251
apps/web/app/(app)/[workspaceSlug]/planner/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/web/app/(app)/[workspaceSlug]/settings/templates/page.tsx
Normal file
137
apps/web/app/(app)/[workspaceSlug]/settings/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/app/(app)/[workspaceSlug]/settings/types/page.tsx
Normal file
16
apps/web/app/(app)/[workspaceSlug]/settings/types/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx
Normal file
106
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
378
apps/web/components/forms/form-builder.tsx
Normal file
378
apps/web/components/forms/form-builder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
apps/web/components/forms/form-field-card.tsx
Normal file
182
apps/web/components/forms/form-field-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
apps/web/components/forms/form-field-config.tsx
Normal file
407
apps/web/components/forms/form-field-config.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
73
apps/web/components/forms/form-field-type-picker.tsx
Normal file
73
apps/web/components/forms/form-field-type-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
apps/web/components/forms/form-mapping-picker.tsx
Normal file
88
apps/web/components/forms/form-mapping-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
587
apps/web/components/forms/form-renderer.tsx
Normal file
587
apps/web/components/forms/form-renderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/web/components/forms/form-responses.tsx
Normal file
137
apps/web/components/forms/form-responses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/components/forms/index.ts
Normal file
7
apps/web/components/forms/index.ts
Normal 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";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
200
apps/web/components/layout/icon-rail.tsx
Normal file
200
apps/web/components/layout/icon-rail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
apps/web/components/layout/top-header.tsx
Normal file
260
apps/web/components/layout/top-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
425
apps/web/components/objects/create-object-dialog.tsx
Normal file
425
apps/web/components/objects/create-object-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/web/components/objects/index.ts
Normal file
1
apps/web/components/objects/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./create-object-dialog";
|
||||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
3
apps/web/components/types/index.ts
Normal file
3
apps/web/components/types/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./type-picker";
|
||||
export * from "./type-manager";
|
||||
export * from "./type-editor";
|
||||
210
apps/web/components/types/type-editor.tsx
Normal file
210
apps/web/components/types/type-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
apps/web/components/types/type-manager.tsx
Normal file
266
apps/web/components/types/type-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
apps/web/components/types/type-picker.tsx
Normal file
128
apps/web/components/types/type-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/components/ui/card.tsx
Normal file
61
apps/web/components/ui/card.tsx
Normal 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 };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
105
apps/web/components/views/form/form-view.tsx
Normal file
105
apps/web/components/views/form/form-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
125
apps/web/components/views/overview/overview-view.tsx
Normal file
125
apps/web/components/views/overview/overview-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
66
apps/web/server/routers/favorites.ts
Normal file
66
apps/web/server/routers/favorites.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
364
apps/web/server/routers/forms.ts
Normal file
364
apps/web/server/routers/forms.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
102
apps/web/server/routers/types.ts
Normal file
102
apps/web/server/routers/types.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
59
apps/web/server/routers/workspaces.ts
Normal file
59
apps/web/server/routers/workspaces.ts
Normal 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
67
config/CursorSync.md
Normal 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/`
|
||||
119
docker/docker-compose.coolify.yml
Normal file
119
docker/docker-compose.coolify.yml
Normal 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
60
docs/Glossary.md
Normal 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
47
docs/templates/epic-template.md
vendored
Normal 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
49
docs/templates/plan-template.md
vendored
Normal 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
47
docs/templates/task-template.md
vendored
Normal 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`)
|
||||
168
packages/database/migrations/0000_nervous_ogun.sql
Normal file
168
packages/database/migrations/0000_nervous_ogun.sql
Normal 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");
|
||||
16
packages/database/migrations/0001_parched_red_hulk.sql
Normal file
16
packages/database/migrations/0001_parched_red_hulk.sql
Normal 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");
|
||||
|
|
@ -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");
|
||||
1436
packages/database/migrations/meta/0000_snapshot.json
Normal file
1436
packages/database/migrations/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1564
packages/database/migrations/meta/0001_snapshot.json
Normal file
1564
packages/database/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2304
packages/database/migrations/meta/0002_snapshot.json
Normal file
2304
packages/database/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
27
packages/database/migrations/meta/_journal.json
Normal file
27
packages/database/migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
packages/database/src/markdown-backlog/index.ts
Normal file
10
packages/database/src/markdown-backlog/index.ts
Normal 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";
|
||||
6
packages/database/src/markdown-backlog/kinds.ts
Normal file
6
packages/database/src/markdown-backlog/kinds.ts
Normal 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);
|
||||
}
|
||||
103
packages/database/src/markdown-backlog/parse.ts
Normal file
103
packages/database/src/markdown-backlog/parse.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
67
packages/database/src/markdown-backlog/paths.ts
Normal file
67
packages/database/src/markdown-backlog/paths.ts
Normal 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;
|
||||
}
|
||||
153
packages/database/src/markdown-backlog/sync.ts
Normal file
153
packages/database/src/markdown-backlog/sync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
41
packages/database/src/schema/cursor_sync.ts
Normal file
41
packages/database/src/schema/cursor_sync.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
16
packages/database/src/schema/favorites.ts
Normal file
16
packages/database/src/schema/favorites.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
55
packages/database/src/schema/forms.ts
Normal file
55
packages/database/src/schema/forms.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
60
packages/database/src/schema/markdown_backlog.ts
Normal file
60
packages/database/src/schema/markdown_backlog.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
32
packages/database/src/schema/types.ts
Normal file
32
packages/database/src/schema/types.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
68
packages/database/src/scripts/watch-markdown-backlog.ts
Normal file
68
packages/database/src/scripts/watch-markdown-backlog.ts
Normal 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}`,
|
||||
);
|
||||
})();
|
||||
59
packages/shared/src/types/forms.ts
Normal file
59
packages/shared/src/types/forms.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 2–4 | Mapping + manual id |
|
||||
|
|
@ -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`
|
||||
|
|
@ -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
Loading…
Reference in a new issue