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_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)
|
# Optional dev-only credentials provider (looks up users by email,
|
||||||
GITHUB_CLIENT_ID=""
|
# accepts this single password for everyone). Leave blank in prod.
|
||||||
GITHUB_CLIENT_SECRET=""
|
AUTH_DEV_PASSWORD=""
|
||||||
GOOGLE_CLIENT_ID=""
|
|
||||||
GOOGLE_CLIENT_SECRET=""
|
|
||||||
|
|
||||||
# Redis
|
# ----- OAuth Providers (all optional) -----
|
||||||
REDIS_URL="redis://localhost:6379"
|
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"
|
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_API_KEY=""
|
||||||
|
OPENAI_BASE_URL=""
|
||||||
ANTHROPIC_API_KEY=""
|
ANTHROPIC_API_KEY=""
|
||||||
|
|
||||||
# MCP Server (stdio transport today; port reserved for future HTTP/SSE)
|
# ----- MCP Server -----
|
||||||
MCP_SERVER_PORT=3001
|
MCP_SERVER_PORT=3001
|
||||||
|
|
||||||
# --- Docker Compose (optional; copy to .env and adjust) ---
|
# =====================================================================
|
||||||
# When all app services run in Docker, use service hostnames:
|
# Local Docker Compose only (docker/docker-compose.yml)
|
||||||
# DATABASE_URL="postgresql://postgres:postgres@postgres:5432/tasks"
|
# Not needed for Coolify (Coolify uses CT 102's shared Postgres/Redis).
|
||||||
# REDIS_URL="redis://redis:6379"
|
# =====================================================================
|
||||||
# COLLAB_SERVER_URL="ws://localhost:1234"
|
|
||||||
# (Browsers still reach the collab service via the published host port.)
|
|
||||||
|
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
POSTGRES_DB=tasks
|
POSTGRES_DB=tasks
|
||||||
|
|
|
||||||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -14,6 +14,24 @@ dist/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.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
|
# debug
|
||||||
npm-debug.log*
|
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() {
|
async function main() {
|
||||||
const port = Number(process.env.PORT) || 1234;
|
const port = Number(process.env.PORT) || 1234;
|
||||||
|
|
||||||
|
|
@ -206,14 +225,46 @@ async function main() {
|
||||||
throw Forbidden;
|
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");
|
const id = createHash("sha256").update(token).digest("hex");
|
||||||
return {
|
return {
|
||||||
user: { id, name: `User ${id.slice(0, 8)}` },
|
user: {
|
||||||
|
id,
|
||||||
|
name: `User ${id.slice(0, 8)}`,
|
||||||
|
color: colorFromName(id),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async onConnect({ documentName }) {
|
async onConnect({ documentName, connection }) {
|
||||||
console.log(`[collab] connect document=${documentName}`);
|
const user = connection.readOnly ? "read-only" : "editor";
|
||||||
|
console.log(`[collab] connect document=${documentName} (${user})`);
|
||||||
},
|
},
|
||||||
|
|
||||||
async onDisconnect({ documentName }) {
|
async onDisconnect({ documentName }) {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import { ViewToolbar } from "@/components/views/config";
|
import { ViewToolbar } from "@/components/views/config";
|
||||||
import { useViewStore } from "@/lib/stores/view-store";
|
import { useViewStore } from "@/lib/stores/view-store";
|
||||||
import { useViewData } from "@/lib/hooks/use-view-data";
|
import { useViewData } from "@/lib/hooks/use-view-data";
|
||||||
|
|
||||||
export default function ProjectLayout({ children }: { children: ReactNode }) {
|
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 config = useViewStore((s) => s.config);
|
||||||
const { total } = useViewData(config);
|
const { total } = useViewData(config, workspaceId, projectId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { useViewStore } from "@/lib/stores/view-store";
|
import { useViewStore } from "@/lib/stores/view-store";
|
||||||
import { ListView } from "@/components/views/list";
|
import { ListView } from "@/components/views/list";
|
||||||
import { BoardView } from "@/components/views/board";
|
import { BoardView } from "@/components/views/board";
|
||||||
import { TableView } from "@/components/views/table";
|
import { TableView } from "@/components/views/table";
|
||||||
import { EmbedView } from "@/components/views/embed";
|
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() {
|
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 activeView = useViewStore((s) => s.activeView);
|
||||||
const config = useViewStore((s) => s.config);
|
const config = useViewStore((s) => s.config);
|
||||||
|
|
||||||
|
|
@ -17,6 +27,12 @@ export default function ProjectPage() {
|
||||||
return <TableView config={config} />;
|
return <TableView config={config} />;
|
||||||
case "embed":
|
case "embed":
|
||||||
return <EmbedView config={config} />;
|
return <EmbedView config={config} />;
|
||||||
|
case "overview":
|
||||||
|
return (
|
||||||
|
<OverviewView workspaceId={workspaceSlug} spaceId={projectId} />
|
||||||
|
);
|
||||||
|
case "form":
|
||||||
|
return <FormView config={config} />;
|
||||||
case "list":
|
case "list":
|
||||||
default:
|
default:
|
||||||
return <ListView config={config} />;
|
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";
|
"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() {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-8 py-10">
|
<div className="mx-auto max-w-4xl px-8 py-10">
|
||||||
<h1 className="mb-6 text-3xl font-bold">Documents</h1>
|
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div className="rounded-lg border border-border bg-card p-1">
|
<h1 className="text-3xl font-bold tracking-tight">Documents</h1>
|
||||||
<BlockEditor
|
<Button
|
||||||
placeholder="Start typing, or use '/' for commands..."
|
type="button"
|
||||||
onChange={(html) => {
|
disabled={!workspaceId || createMutation.isPending}
|
||||||
// Will persist via tRPC in later phase
|
onClick={() => {
|
||||||
|
if (!workspaceId) return;
|
||||||
|
createMutation.mutate({
|
||||||
|
type: "document",
|
||||||
|
title: "Untitled",
|
||||||
|
workspaceId,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Plus className="mr-2 size-4" />
|
||||||
|
New Document
|
||||||
|
</Button>
|
||||||
</div>
|
</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>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { Plus, PenTool, Loader2 } from "lucide-react";
|
||||||
const WhiteboardCanvas = dynamic(
|
import { Button } from "@/components/ui/button";
|
||||||
() => import("@/components/whiteboard/canvas").then((m) => m.WhiteboardCanvas),
|
import { api } from "@/lib/trpc";
|
||||||
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground">Loading whiteboard...</div> },
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function WhiteboardsPage() {
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="shrink-0 border-b border-border px-6 py-3">
|
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4">
|
||||||
<h1 className="text-lg font-semibold">Whiteboard</h1>
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { TRPCProvider } from "@/components/providers/trpc-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" });
|
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Tasks",
|
title: "ECHODO",
|
||||||
description: "Project management, docs, and whiteboards — all in one place.",
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -27,6 +32,14 @@ export default function RootLayout({
|
||||||
>
|
>
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<TRPCProvider>{children}</TRPCProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
{umamiWebsiteId ? (
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src={umamiScript}
|
||||||
|
data-website-id={umamiWebsiteId}
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,25 @@
|
||||||
import { redirect } from "next/navigation";
|
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");
|
redirect("/sign-in");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import Image from "@tiptap/extension-image";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import Highlight from "@tiptap/extension-highlight";
|
import Highlight from "@tiptap/extension-highlight";
|
||||||
import Link from "@tiptap/extension-link";
|
|
||||||
import Typography from "@tiptap/extension-typography";
|
import Typography from "@tiptap/extension-typography";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
import TextAlign from "@tiptap/extension-text-align";
|
import TextAlign from "@tiptap/extension-text-align";
|
||||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||||
import CodeBlock from "@tiptap/extension-code-block";
|
import CodeBlock from "@tiptap/extension-code-block";
|
||||||
|
|
@ -130,6 +130,7 @@ export function BlockEditor({
|
||||||
multicolor: true,
|
multicolor: true,
|
||||||
}),
|
}),
|
||||||
Typography,
|
Typography,
|
||||||
|
Underline,
|
||||||
TextAlign.configure({
|
TextAlign.configure({
|
||||||
types: ["heading", "paragraph"],
|
types: ["heading", "paragraph"],
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { BlockEditor, type BlockEditorProps } from "./editor";
|
export { BlockEditor, type BlockEditorProps } from "./editor";
|
||||||
|
export { CollaborativeBlockEditor, type CollaborativeBlockEditorProps } from "./collaboration";
|
||||||
export { EditorToolbar } from "./toolbar";
|
export { EditorToolbar } from "./toolbar";
|
||||||
export { SlashMenu } from "./slash-menu";
|
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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
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 { RightPanel } from "@/components/panels/right-panel";
|
||||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||||
import { CommandPalette } from "@/components/ai/command-palette";
|
import { CommandPalette } from "@/components/ai/command-palette";
|
||||||
|
import { CreateObjectDialog } from "@/components/objects";
|
||||||
import { SearchDialog } from "@/components/search";
|
import { SearchDialog } from "@/components/search";
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createType, setCreateType] = useState<string | undefined>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -27,15 +32,36 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div className="flex h-[100dvh] w-full overflow-hidden bg-background">
|
<div className="flex h-[100dvh] w-full flex-col overflow-hidden bg-background">
|
||||||
<Sidebar onOpenSearch={() => setSearchOpen(true)} />
|
<TopHeader
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
onOpenSearch={() => setSearchOpen(true)}
|
||||||
<main className="flex-1 overflow-auto">{children}</main>
|
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>
|
</div>
|
||||||
<RightPanel />
|
|
||||||
</div>
|
</div>
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
||||||
|
<CreateObjectDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
defaultType={createType}
|
||||||
|
/>
|
||||||
</TooltipProvider>
|
</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 { useEffect } from "react";
|
||||||
|
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
function formatNameFromSlug(slug: string) {
|
|
||||||
return slug
|
|
||||||
.split("-")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspaceSync({
|
export function WorkspaceSync({
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
|
|
@ -22,14 +15,18 @@ export function WorkspaceSync({
|
||||||
}) {
|
}) {
|
||||||
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
|
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
|
||||||
|
|
||||||
|
const { data } = api.workspaces.getById.useQuery({ id: workspaceSlug });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setWorkspace({
|
if (data) {
|
||||||
id: workspaceSlug,
|
setWorkspace({
|
||||||
slug: workspaceSlug,
|
id: data.id,
|
||||||
name: formatNameFromSlug(workspaceSlug),
|
slug: data.id,
|
||||||
});
|
name: data.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
return () => setWorkspace(null);
|
return () => setWorkspace(null);
|
||||||
}, [workspaceSlug, setWorkspace]);
|
}, [workspaceSlug, data, setWorkspace]);
|
||||||
|
|
||||||
return <>{children}</>;
|
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";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { Check, Search } from "lucide-react";
|
import { Check, Search } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
AvatarFallback,
|
||||||
|
|
@ -18,8 +20,8 @@ export interface WorkspaceUser {
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default mock list; override via `users` prop when wiring real data */
|
/** Internal default for prop comparison only; list comes from `listMembers` when `workspaceId` is set */
|
||||||
export const WORKSPACE_USERS: WorkspaceUser[] = [
|
const WORKSPACE_USERS: WorkspaceUser[] = [
|
||||||
{ id: "u1", name: "Alice", avatarUrl: null },
|
{ id: "u1", name: "Alice", avatarUrl: null },
|
||||||
{ id: "u2", name: "Bob", avatarUrl: null },
|
{ id: "u2", name: "Bob", avatarUrl: null },
|
||||||
{ id: "u3", name: "Charlie", avatarUrl: null },
|
{ id: "u3", name: "Charlie", avatarUrl: null },
|
||||||
|
|
@ -41,6 +43,7 @@ export interface AssigneePickerProps {
|
||||||
assignedIds: string[];
|
assignedIds: string[];
|
||||||
onToggle: (userId: string) => void;
|
onToggle: (userId: string) => void;
|
||||||
users?: WorkspaceUser[];
|
users?: WorkspaceUser[];
|
||||||
|
workspaceId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
align?: "start" | "center" | "end";
|
align?: "start" | "center" | "end";
|
||||||
|
|
@ -52,16 +55,32 @@ export function AssigneePicker({
|
||||||
assignedIds,
|
assignedIds,
|
||||||
onToggle,
|
onToggle,
|
||||||
users = WORKSPACE_USERS,
|
users = WORKSPACE_USERS,
|
||||||
|
workspaceId,
|
||||||
children,
|
children,
|
||||||
side = "bottom",
|
side = "bottom",
|
||||||
align = "start",
|
align = "start",
|
||||||
}: AssigneePickerProps) {
|
}: 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 [q, setQ] = React.useState("");
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(() => {
|
||||||
const s = q.trim().toLowerCase();
|
const s = q.trim().toLowerCase();
|
||||||
if (!s) return users;
|
if (!s) return resolvedUsers;
|
||||||
return users.filter((u) => u.name.toLowerCase().includes(s));
|
return resolvedUsers.filter((u) => u.name.toLowerCase().includes(s));
|
||||||
}, [q, users]);
|
}, [q, resolvedUsers]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) setQ("");
|
if (!open) setQ("");
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
|
|
@ -15,13 +14,16 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { isTRPCClientError } from "@trpc/client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
type PanelDetailTab,
|
type PanelDetailTab,
|
||||||
} from "@/lib/stores/panel-store";
|
} 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 {
|
import {
|
||||||
PropertyEditor,
|
PropertyEditor,
|
||||||
type PropertyFieldType,
|
type PropertyFieldType,
|
||||||
|
|
@ -43,8 +45,6 @@ import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
const OBJECT_DETAIL_KEY = "object-detail";
|
|
||||||
|
|
||||||
type StatusValue = "open" | "in_progress" | "done" | "closed";
|
type StatusValue = "open" | "in_progress" | "done" | "closed";
|
||||||
|
|
||||||
const STATUS_OPTIONS: {
|
const STATUS_OPTIONS: {
|
||||||
|
|
@ -58,50 +58,6 @@ const STATUS_OPTIONS: {
|
||||||
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
|
{ 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 {
|
export interface ObjectDetailData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -114,6 +70,7 @@ export interface ObjectDetailData {
|
||||||
propertyValues: {
|
propertyValues: {
|
||||||
id?: string;
|
id?: string;
|
||||||
propertyDefinition: {
|
propertyDefinition: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
fieldType: string;
|
fieldType: string;
|
||||||
config?: Record<string, unknown> | null;
|
config?: Record<string, unknown> | null;
|
||||||
|
|
@ -166,24 +123,10 @@ function initials(name: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function useObjectDetailQuery(objectId: string | null) {
|
function useObjectDetailQuery(objectId: string | null) {
|
||||||
const utils = api.useUtils();
|
return api.objects.getById.useQuery(
|
||||||
return useQuery({
|
{ id: objectId as string },
|
||||||
queryKey: [OBJECT_DETAIL_KEY, objectId],
|
{ enabled: Boolean(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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailSkeleton() {
|
function DetailSkeleton() {
|
||||||
|
|
@ -210,10 +153,18 @@ export function ObjectDetail() {
|
||||||
const closePanel = usePanelStore((s) => s.close);
|
const closePanel = usePanelStore((s) => s.close);
|
||||||
const openPanel = usePanelStore((s) => s.open);
|
const openPanel = usePanelStore((s) => s.open);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const utils = api.useUtils();
|
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 [titleDraft, setTitleDraft] = React.useState("");
|
||||||
const [editingTitle, setEditingTitle] = React.useState(false);
|
const [editingTitle, setEditingTitle] = React.useState(false);
|
||||||
|
|
@ -230,56 +181,50 @@ export function ObjectDetail() {
|
||||||
|
|
||||||
const mergeObjectCache = React.useCallback(
|
const mergeObjectCache = React.useCallback(
|
||||||
(id: string, patch: Partial<ObjectDetailData>) => {
|
(id: string, patch: Partial<ObjectDetailData>) => {
|
||||||
queryClient.setQueryData<ObjectDetailData | null>(
|
utils.objects.getById.setData({ id }, (old) => {
|
||||||
[OBJECT_DETAIL_KEY, id],
|
if (!old) return old;
|
||||||
(old) => (old ? { ...old, ...patch } : old),
|
return {
|
||||||
);
|
...(old as ObjectDetailData),
|
||||||
|
...patch,
|
||||||
|
} as typeof old;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[queryClient],
|
[utils],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateObject = useMutation({
|
const updateObjectMutation = api.objects.update.useMutation({
|
||||||
mutationFn: async (patch: Partial<ObjectDetailData> & { id: string }) => {
|
onMutate: async (variables) => {
|
||||||
const u = utils as unknown as {
|
mergeObjectCache(variables.id, variables as Partial<ObjectDetailData>);
|
||||||
objects?: { update?: { mutate: (args: unknown) => Promise<unknown> } };
|
|
||||||
};
|
|
||||||
if (typeof u.objects?.update?.mutate === "function") {
|
|
||||||
return u.objects.update.mutate(patch);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMutate: async (patch) => {
|
onSuccess: async (_data, variables) => {
|
||||||
mergeObjectCache(patch.id, patch);
|
await utils.objects.getById.invalidate({ id: variables.id });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPropertyValue = useMutation({
|
const assignMutation = api.objects.assign.useMutation({
|
||||||
mutationFn: async (args: {
|
onSuccess: async (_data, variables) => {
|
||||||
objectId: string;
|
await utils.objects.getById.invalidate({ id: variables.objectId });
|
||||||
propertyValueId?: string;
|
},
|
||||||
propertyDefinitionId?: string;
|
});
|
||||||
value: unknown;
|
|
||||||
}) => {
|
const setPropertyValueMutation = api.properties.setValue.useMutation({
|
||||||
const u = utils as unknown as {
|
onSuccess: async (_data, variables) => {
|
||||||
properties?: { setValue?: { mutate: (a: unknown) => Promise<unknown> } };
|
await utils.objects.getById.invalidate({ id: variables.objectId });
|
||||||
};
|
|
||||||
if (typeof u.properties?.setValue?.mutate === "function") {
|
|
||||||
return u.properties.setValue.mutate(args);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handlePropertyChange = (
|
const handlePropertyChange = (
|
||||||
index: number,
|
index: number,
|
||||||
next: unknown,
|
|
||||||
row: ObjectDetailData["propertyValues"][number],
|
row: ObjectDetailData["propertyValues"][number],
|
||||||
|
next: unknown,
|
||||||
) => {
|
) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const nextRows = [...data.propertyValues];
|
const nextRows = [...data.propertyValues];
|
||||||
nextRows[index] = { ...row, value: next };
|
nextRows[index] = { ...row, value: next };
|
||||||
mergeObjectCache(data.id, { propertyValues: nextRows });
|
mergeObjectCache(data.id, { propertyValues: nextRows });
|
||||||
setPropertyValue.mutate({
|
setPropertyValueMutation.mutate({
|
||||||
objectId: data.id,
|
objectId: data.id,
|
||||||
propertyValueId: row.id,
|
propertyDefId: row.propertyDefinition.id,
|
||||||
value: next,
|
value: next,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -293,29 +238,32 @@ export function ObjectDetail() {
|
||||||
|
|
||||||
const toggleAssignee = (userId: string) => {
|
const toggleAssignee = (userId: string) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const user = WORKSPACE_USERS.find((u) => u.id === userId);
|
|
||||||
if (!user) return;
|
|
||||||
const has = assignedIds.includes(userId);
|
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"];
|
let nextAssignees: ObjectDetailData["assignees"];
|
||||||
if (has) {
|
if (has) {
|
||||||
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
|
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
|
||||||
} else {
|
} else {
|
||||||
nextAssignees = [
|
nextAssignees = [
|
||||||
...data.assignees,
|
...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 });
|
mergeObjectCache(data.id, { assignees: nextAssignees });
|
||||||
const u = utils as unknown as {
|
assignMutation.mutate({
|
||||||
objects?: { assign?: { mutate: (a: unknown) => Promise<unknown> } };
|
objectId: data.id,
|
||||||
};
|
userId,
|
||||||
if (typeof u.objects?.assign?.mutate === "function") {
|
action: has ? "remove" : "add",
|
||||||
u.objects.assign.mutate({
|
});
|
||||||
objectId: data.id,
|
|
||||||
userId,
|
|
||||||
assign: !has,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitTitle = () => {
|
const commitTitle = () => {
|
||||||
|
|
@ -323,19 +271,19 @@ export function ObjectDetail() {
|
||||||
setEditingTitle(false);
|
setEditingTitle(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateObject.mutate({ id: data.id, title: titleDraft.trim() });
|
updateObjectMutation.mutate({ id: data.id, title: titleDraft.trim() });
|
||||||
setEditingTitle(false);
|
setEditingTitle(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitDescription = () => {
|
const commitDescription = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (descriptionDraft === (data.description ?? "")) return;
|
if (descriptionDraft === (data.description ?? "")) return;
|
||||||
updateObject.mutate({ id: data.id, description: descriptionDraft });
|
updateObjectMutation.mutate({ id: data.id, description: descriptionDraft });
|
||||||
};
|
};
|
||||||
|
|
||||||
const setStatus = (status: StatusValue) => {
|
const setStatus = (status: StatusValue) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
updateObject.mutate({ id: data.id, status });
|
updateObjectMutation.mutate({ id: data.id, status });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = (v: string) => {
|
const onTabChange = (v: string) => {
|
||||||
|
|
@ -358,13 +306,17 @@ export function ObjectDetail() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
|
const notFound =
|
||||||
|
isTRPCClientError(error) && error.data?.code === "NOT_FOUND";
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-xs text-destructive">
|
<div className="p-4 text-xs text-destructive">
|
||||||
{isError
|
{isError
|
||||||
? error instanceof Error
|
? notFound
|
||||||
? error.message
|
? "Not found"
|
||||||
: "Failed to load object."
|
: error instanceof Error
|
||||||
: "Nothing to display."}
|
? error.message
|
||||||
|
: "Failed to load object."
|
||||||
|
: "Not found"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -520,6 +472,7 @@ export function ObjectDetail() {
|
||||||
onOpenChange={setAssigneeOpen}
|
onOpenChange={setAssigneeOpen}
|
||||||
assignedIds={assignedIds}
|
assignedIds={assignedIds}
|
||||||
onToggle={toggleAssignee}
|
onToggle={toggleAssignee}
|
||||||
|
workspaceId={workspaceId}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -572,7 +525,7 @@ export function ObjectDetail() {
|
||||||
| undefined,
|
| undefined,
|
||||||
}}
|
}}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
onChange={(v) => handlePropertyChange(index, v, row)}
|
onChange={(v) => handlePropertyChange(index, row, v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,5 @@ export * from "./sidebar-header";
|
||||||
export * from "./sidebar-item";
|
export * from "./sidebar-item";
|
||||||
export * from "./sidebar-nav";
|
export * from "./sidebar-nav";
|
||||||
export * from "./sidebar-toggle";
|
export * from "./sidebar-toggle";
|
||||||
|
export * from "./nav-tree";
|
||||||
|
export * from "./workspace-switcher";
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,13 @@
|
||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
LayoutGrid,
|
||||||
FolderPlus,
|
List as ListIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PenTool,
|
PenTool,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -30,12 +29,9 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { getBaseUrl } from "@/lib/trpc";
|
import type { ObjectType } from "@tasks/shared";
|
||||||
import {
|
import { api } from "@/lib/trpc";
|
||||||
isSidebarNodeExpanded,
|
import { isSidebarNodeExpanded, useSidebarStore } from "@/lib/stores/sidebar-store";
|
||||||
isSidebarSectionExpanded,
|
|
||||||
useSidebarStore,
|
|
||||||
} from "@/lib/stores/sidebar-store";
|
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -56,44 +52,12 @@ export type PartitionedTrees = {
|
||||||
whiteboards: TreeNodeData[];
|
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 {
|
export function partitionRoots(roots: TreeNodeData[]): PartitionedTrees {
|
||||||
const projects: TreeNodeData[] = [];
|
return {
|
||||||
const documents: TreeNodeData[] = [];
|
projects: roots,
|
||||||
const whiteboards: TreeNodeData[] = [];
|
documents: [],
|
||||||
|
whiteboards: [],
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_PARTITIONED: PartitionedTrees = {
|
const EMPTY_PARTITIONED: PartitionedTrees = {
|
||||||
|
|
@ -102,170 +66,54 @@ const EMPTY_PARTITIONED: PartitionedTrees = {
|
||||||
whiteboards: [],
|
whiteboards: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOCK_PARTITIONED: PartitionedTrees = {
|
const SPACE_COLORS = [
|
||||||
projects: [
|
"bg-amber-500",
|
||||||
{
|
"bg-blue-500",
|
||||||
id: "10000000-0000-4000-8000-000000000001",
|
"bg-emerald-500",
|
||||||
title: "Project Alpha",
|
"bg-purple-500",
|
||||||
type: "project",
|
"bg-pink-500",
|
||||||
icon: null,
|
"bg-red-500",
|
||||||
parentId: null,
|
"bg-cyan-500",
|
||||||
childCount: 2,
|
"bg-orange-500",
|
||||||
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: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
switch (type) {
|
||||||
case "project":
|
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":
|
case "group":
|
||||||
return <FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />;
|
return <Folder className="size-4 text-purple-400/80" />;
|
||||||
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" />;
|
|
||||||
case "task":
|
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:
|
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 {
|
function countNodes(roots: TreeNodeData[]): number {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
const walk = (nodes: TreeNodeData[]) => {
|
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({
|
export function TreeNode({
|
||||||
node,
|
node,
|
||||||
level,
|
level,
|
||||||
collapsed,
|
collapsed,
|
||||||
base,
|
base,
|
||||||
pathname,
|
pathname,
|
||||||
|
workspaceId,
|
||||||
}: {
|
}: {
|
||||||
node: TreeNodeData;
|
node: TreeNodeData;
|
||||||
level: number;
|
level: number;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
base: string;
|
base: string;
|
||||||
pathname: string | null;
|
pathname: string | null;
|
||||||
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
const expandedNodes = useSidebarStore((s) => s.expandedNodes);
|
const expandedNodes = useSidebarStore((s) => s.expandedNodes);
|
||||||
const toggleNode = useSidebarStore((s) => s.toggleNode);
|
const toggleNode = useSidebarStore((s) => s.toggleNode);
|
||||||
|
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const expanded = isSidebarNodeExpanded(expandedNodes, node.id);
|
const expanded = isSidebarNodeExpanded(expandedNodes, node.id);
|
||||||
const href = `${base}/o/${node.id}`;
|
const href = hrefForNode(base, node.id, node.type);
|
||||||
const active =
|
const active =
|
||||||
pathname === href ||
|
pathname === href || (pathname?.startsWith(href + "/") ?? false);
|
||||||
(pathname?.startsWith(`${base}/o/${node.id}/`) ?? false);
|
const showPlus = showCreateChildActions(node.type);
|
||||||
|
|
||||||
const onToggleExpand = useCallback(
|
const onToggleExpand = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(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))]",
|
"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>
|
</Link>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|
@ -361,6 +332,7 @@ export function TreeNode({
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
base={base}
|
base={base}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
workspaceId={workspaceId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,7 +341,7 @@ export function TreeNode({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentPx = 8 + level * 16;
|
const indentPx = level === 0 ? 0 : 8 + (level - 1) * 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="select-none">
|
<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"
|
className="group relative flex min-h-7 items-center gap-0.5 rounded-md pr-1 transition-colors duration-150"
|
||||||
style={{ paddingLeft: indentPx }}
|
style={{ paddingLeft: indentPx }}
|
||||||
>
|
>
|
||||||
<div className="flex min-h-7 min-w-0 flex-1 items-center gap-0.5">
|
<div className="flex min-h-7 min-w-0 flex-1 items-center">
|
||||||
{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 />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={cn(
|
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",
|
"hover:bg-sidebar-accent/80 hover:text-sidebar-accent-foreground",
|
||||||
active &&
|
active &&
|
||||||
"bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_3px_0_0_0_hsl(var(--primary))]",
|
"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>
|
<span className="min-w-0 flex-1 truncate font-medium">{node.title}</span>
|
||||||
{node.childCount > 0 ? (
|
{node.childCount > 0 ? (
|
||||||
<span className="shrink-0 tabular-nums text-[10px] text-muted-foreground">
|
<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",
|
"group-hover:pointer-events-auto group-hover:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip delayDuration={0}>
|
{showPlus ? (
|
||||||
<TooltipTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="size-6 text-muted-foreground hover:text-sidebar-accent-foreground"
|
||||||
e.preventDefault();
|
title="Create"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
e.stopPropagation();
|
||||||
<Plus className="size-3.5" />
|
}}
|
||||||
</Button>
|
>
|
||||||
</TooltipTrigger>
|
<Plus className="size-3.5" />
|
||||||
<TooltipContent side="top">Add child</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CreateChildMenu parentId={node.id} workspaceId={workspaceId} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
@ -455,11 +435,8 @@ export function TreeNode({
|
||||||
<MoreHorizontal className="size-3.5" />
|
<MoreHorizontal className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenuItem>Rename</DropdownMenuItem>
|
<MoreMenuItems node={node} href={href} workspaceId={workspaceId} />
|
||||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">Archive</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,6 +453,7 @@ export function TreeNode({
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
base={base}
|
base={base}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
workspaceId={workspaceId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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({
|
export function NavTree({
|
||||||
collapsed,
|
collapsed,
|
||||||
trees: treesProp,
|
trees: treesProp,
|
||||||
|
|
@ -554,36 +481,34 @@ export function NavTree({
|
||||||
? `/${slugParam}`
|
? `/${slugParam}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const workspaceId = workspace?.id;
|
const workspaceId = workspace?.id ?? "";
|
||||||
const { data, isLoading, isError } = useObjectsTreeQuery(workspaceId);
|
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(() => {
|
const partitioned = useMemo(() => {
|
||||||
if (treesProp) return treesProp;
|
if (treesProp) return treesProp;
|
||||||
if (!workspaceId) return MOCK_PARTITIONED;
|
if (!workspace?.id || isLoading) return EMPTY_PARTITIONED;
|
||||||
if (isLoading) return EMPTY_PARTITIONED;
|
if (isError || !data?.tree) return EMPTY_PARTITIONED;
|
||||||
if (isError || !data?.tree) return MOCK_PARTITIONED;
|
|
||||||
return partitionRoots(data.tree);
|
return partitionRoots(data.tree);
|
||||||
}, [treesProp, workspaceId, isLoading, isError, data?.tree]);
|
}, [treesProp, workspace?.id, isLoading, isError, data?.tree]);
|
||||||
|
|
||||||
const totalCount =
|
const totalCount = countNodes(partitioned.projects);
|
||||||
countNodes(partitioned.projects) +
|
|
||||||
countNodes(partitioned.documents) +
|
|
||||||
countNodes(partitioned.whiteboards);
|
|
||||||
|
|
||||||
const liveEmpty =
|
const liveEmpty =
|
||||||
Boolean(workspaceId) &&
|
Boolean(workspace?.id) &&
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
!isError &&
|
!isError &&
|
||||||
data?.tree &&
|
data?.tree &&
|
||||||
data.tree.length === 0;
|
data.tree.length === 0;
|
||||||
|
|
||||||
const showLoading = Boolean(workspaceId) && isLoading && !treesProp;
|
const showLoading = Boolean(workspace?.id) && isLoading && !treesProp;
|
||||||
|
|
||||||
const expandedSections = useSidebarStore((s) => s.expandedSections);
|
|
||||||
|
|
||||||
const projectsOpen = isSidebarSectionExpanded(expandedSections, "projects");
|
|
||||||
const documentsOpen = isSidebarSectionExpanded(expandedSections, "documents");
|
|
||||||
const whiteboardsOpen = isSidebarSectionExpanded(expandedSections, "whiteboards");
|
|
||||||
|
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -601,11 +526,69 @@ export function NavTree({
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="flex flex-col gap-0.5 px-2 pb-4 pt-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 ? (
|
{liveEmpty ? (
|
||||||
<div className="rounded-md border border-dashed border-sidebar-border px-3 py-6 text-center text-xs text-muted-foreground">
|
<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 />
|
<br />
|
||||||
<span className="text-[10px]">Create a project to get started.</span>
|
<span className="text-[10px]">Create a space to get started.</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -616,91 +599,19 @@ export function NavTree({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!liveEmpty && totalCount > 0 ? (
|
{!liveEmpty && totalCount > 0 ? (
|
||||||
<>
|
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
|
||||||
<div>
|
{partitioned.projects.map((node) => (
|
||||||
<SectionHeader title="Projects" sectionKey="projects" collapsed={collapsed} />
|
<TreeNode
|
||||||
<CollapsibleBody open={projectsOpen || collapsed}>
|
key={node.id}
|
||||||
<div className={cn("flex flex-col gap-px", collapsed && "items-center")}>
|
node={node}
|
||||||
{partitioned.projects.map((node) => (
|
level={0}
|
||||||
<TreeNode
|
collapsed={collapsed}
|
||||||
key={node.id}
|
base={base}
|
||||||
node={node}
|
pathname={pathname}
|
||||||
level={0}
|
workspaceId={workspaceId}
|
||||||
collapsed={collapsed}
|
/>
|
||||||
base={base}
|
))}
|
||||||
pathname={pathname}
|
</div>
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,174 @@
|
||||||
"use client";
|
"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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
Tooltip,
|
import { useSidebarStore } from "@/lib/stores/sidebar-store";
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function SidebarHeader({ collapsed }: { collapsed: boolean }) {
|
export interface SidebarHeaderProps {
|
||||||
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
|
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 = (
|
if (collapsed) {
|
||||||
<Button
|
return (
|
||||||
type="button"
|
<div className="flex shrink-0 justify-center border-b border-sidebar-border px-1 py-2">
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
className={cn(
|
<TooltipTrigger asChild>
|
||||||
"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",
|
<Button
|
||||||
collapsed && "justify-center px-0",
|
type="button"
|
||||||
)}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
{!collapsed ? (
|
className="size-7 shrink-0 text-muted-foreground hover:text-sidebar-foreground"
|
||||||
<>
|
onClick={toggle}
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{title}</span>
|
aria-label="Expand sidebar"
|
||||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
>
|
||||||
</>
|
<ChevronsRight className="size-4" />
|
||||||
) : (
|
</Button>
|
||||||
<span className="flex size-8 items-center justify-center rounded-md bg-sidebar-accent/80 text-xs font-bold text-primary">
|
</TooltipTrigger>
|
||||||
{title.slice(0, 2).toUpperCase()}
|
<TooltipContent side="right">Expand</TooltipContent>
|
||||||
</span>
|
</Tooltip>
|
||||||
)}
|
</div>
|
||||||
</Button>
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex shrink-0 items-center gap-1 border-b border-sidebar-border px-2 py-2">
|
||||||
className={cn(
|
<span className="min-w-0 flex-1 truncate text-base font-semibold text-sidebar-foreground">
|
||||||
"flex shrink-0 items-center gap-1 border-b border-sidebar-border px-2 py-2",
|
Home
|
||||||
collapsed && "flex-col px-1",
|
</span>
|
||||||
)}
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
>
|
<Tooltip>
|
||||||
<DropdownMenu>
|
<TooltipTrigger asChild>
|
||||||
{collapsed ? (
|
<Button
|
||||||
<Tooltip delayDuration={0}>
|
type="button"
|
||||||
<TooltipTrigger asChild>
|
variant="ghost"
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
size="icon"
|
||||||
</TooltipTrigger>
|
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||||
<TooltipContent side="right">{title}</TooltipContent>
|
aria-label="Search"
|
||||||
</Tooltip>
|
onClick={() => onOpenSearch?.()}
|
||||||
) : (
|
>
|
||||||
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
|
<Search className="size-4" />
|
||||||
{trigger}
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</TooltipTrigger>
|
||||||
)}
|
<TooltipContent side="bottom">Search</TooltipContent>
|
||||||
<DropdownMenuContent className="w-56" align="start" side="bottom">
|
</Tooltip>
|
||||||
<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>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<Tooltip>
|
||||||
<DropdownMenuTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-8 shrink-0 rounded-full text-sidebar-foreground hover:bg-sidebar-accent"
|
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||||
aria-label="Account menu"
|
aria-label="Filter"
|
||||||
>
|
>
|
||||||
<Avatar className="size-8 border border-sidebar-border">
|
<SlidersHorizontal className="size-4" />
|
||||||
<AvatarFallback className="bg-primary/20 text-xs font-semibold text-primary">
|
</Button>
|
||||||
ME
|
</TooltipTrigger>
|
||||||
</AvatarFallback>
|
<TooltipContent side="bottom">Filter</TooltipContent>
|
||||||
</Avatar>
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
<Tooltip>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuItem>
|
<Button
|
||||||
<User className="size-4" />
|
type="button"
|
||||||
Account
|
variant="ghost"
|
||||||
</DropdownMenuItem>
|
size="icon"
|
||||||
<DropdownMenuSeparator />
|
className="size-7 text-muted-foreground hover:text-sidebar-foreground"
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
onClick={toggle}
|
||||||
<LogOut className="size-4" />
|
aria-label="Collapse sidebar"
|
||||||
Sign out
|
>
|
||||||
</DropdownMenuItem>
|
<ChevronsLeft className="size-4" />
|
||||||
</DropdownMenuContent>
|
</Button>
|
||||||
</DropdownMenu>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { FileText, Home, Presentation, Search, Settings } from "lucide-react";
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
FileText,
|
|
||||||
FolderKanban,
|
|
||||||
Home,
|
|
||||||
LayoutGrid,
|
|
||||||
Presentation,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Star,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useParams, usePathname } from "next/navigation";
|
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 { Separator } from "@/components/ui/separator";
|
||||||
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
|
|
||||||
import { SidebarItem } from "./sidebar-item";
|
import { SidebarItem } from "./sidebar-item";
|
||||||
|
|
||||||
const PROJECT_DOTS = [
|
|
||||||
"hsl(var(--primary))",
|
|
||||||
"hsl(var(--teal))",
|
|
||||||
"hsl(38 92% 50%)",
|
|
||||||
"hsl(199 89% 48%)",
|
|
||||||
];
|
|
||||||
|
|
||||||
function NavSectionLabel({
|
function NavSectionLabel({
|
||||||
children,
|
children,
|
||||||
collapsed,
|
collapsed,
|
||||||
|
|
@ -62,166 +41,60 @@ export function SidebarNav({
|
||||||
? `/${slugParam}`
|
? `/${slugParam}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const [favoritesOpen, setFavoritesOpen] = useState(true);
|
|
||||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
|
||||||
|
|
||||||
const homeActive = pathname === base || pathname === `${base}/`;
|
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 (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<nav className="flex shrink-0 flex-col gap-0.5 px-2 pb-2 pt-1">
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-4 pt-1">
|
<SidebarItem
|
||||||
<SidebarItem
|
href={onOpenSearch ? "#" : `${base}/search`}
|
||||||
href={onOpenSearch ? "#" : `${base}/search`}
|
icon={<Search />}
|
||||||
icon={<Search />}
|
label="Search"
|
||||||
label="Search"
|
collapsed={collapsed}
|
||||||
collapsed={collapsed}
|
active={pathname.startsWith(`${base}/search`)}
|
||||||
active={pathname.startsWith(`${base}/search`)}
|
onClick={
|
||||||
onClick={
|
onOpenSearch
|
||||||
onOpenSearch
|
? (e) => {
|
||||||
? (e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
onOpenSearch();
|
||||||
onOpenSearch();
|
}
|
||||||
}
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<Separator className="my-2 bg-sidebar-border" />
|
<Separator className="my-2 bg-sidebar-border" />
|
||||||
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
href={base || "/"}
|
href={base || "/"}
|
||||||
icon={<Home />}
|
icon={<Home />}
|
||||||
label="Home"
|
label="Home"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={homeActive}
|
active={homeActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<Separator className="my-2 bg-sidebar-border" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<NavSectionLabel collapsed={collapsed}>Workspace</NavSectionLabel>
|
||||||
<Button
|
<SidebarItem
|
||||||
type="button"
|
href={`${base}/documents`}
|
||||||
variant="ghost"
|
icon={<FileText />}
|
||||||
className={cn(
|
label="Documents"
|
||||||
"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={collapsed}
|
||||||
collapsed && "justify-center px-0",
|
active={pathname.startsWith(`${base}/documents`)}
|
||||||
)}
|
/>
|
||||||
onClick={() => setProjectsOpen((o) => !o)}
|
<SidebarItem
|
||||||
>
|
href={`${base}/whiteboards`}
|
||||||
{!collapsed ? (
|
icon={<Presentation />}
|
||||||
<>
|
label="Whiteboards"
|
||||||
<span className="flex items-center gap-1.5">
|
collapsed={collapsed}
|
||||||
<FolderKanban className="size-3.5" />
|
active={pathname.startsWith(`${base}/whiteboards`)}
|
||||||
Projects
|
/>
|
||||||
</span>
|
<SidebarItem
|
||||||
<ChevronDown
|
href={`${base}/settings`}
|
||||||
className={cn(
|
icon={<Settings />}
|
||||||
"size-3.5 shrink-0 transition-transform duration-200",
|
label="Settings"
|
||||||
!projectsOpen && "-rotate-90",
|
collapsed={collapsed}
|
||||||
)}
|
active={pathname.startsWith(`${base}/settings`)}
|
||||||
/>
|
/>
|
||||||
</>
|
</nav>
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,16 @@
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarStore } from "@/lib/stores/sidebar-store";
|
import { useSidebarStore } from "@/lib/stores/sidebar-store";
|
||||||
|
|
||||||
|
import { NavTree } from "./nav-tree";
|
||||||
import { SidebarHeader } from "./sidebar-header";
|
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);
|
const collapsed = useSidebarStore((s) => s.isCollapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -17,12 +22,13 @@ export function Sidebar({ onOpenSearch }: { onOpenSearch?: () => void }) {
|
||||||
collapsed ? "w-12" : "w-[240px]",
|
collapsed ? "w-12" : "w-[240px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarHeader collapsed={collapsed} />
|
<SidebarHeader
|
||||||
<SidebarNav collapsed={collapsed} onOpenSearch={onOpenSearch} />
|
collapsed={collapsed}
|
||||||
<div className="mt-auto shrink-0 border-t border-sidebar-border px-1 py-2">
|
onOpenSearch={onOpenSearch}
|
||||||
<div className={cn("flex", collapsed ? "justify-center" : "justify-end")}>
|
onQuickAction={onQuickAction}
|
||||||
<SidebarToggle />
|
/>
|
||||||
</div>
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<NavTree collapsed={collapsed} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Building2, Check, Plus } from "lucide-react";
|
import { Building2, Check, Plus } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,30 +17,10 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
|
||||||
useWorkspaceStore,
|
import { api } from "@/lib/trpc";
|
||||||
type WorkspaceInfo,
|
|
||||||
} from "@/lib/stores/workspace-store";
|
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function WorkspaceSwitcher({
|
||||||
collapsed,
|
collapsed,
|
||||||
className,
|
className,
|
||||||
|
|
@ -47,13 +28,11 @@ export function WorkspaceSwitcher({
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const current = useWorkspaceStore((s) => s.currentWorkspace);
|
const current = useWorkspaceStore((s) => s.currentWorkspace);
|
||||||
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
|
const { data: workspaces } = api.workspaces.listForUser.useQuery();
|
||||||
|
|
||||||
const display =
|
const displayName = current?.name ?? "Select workspace...";
|
||||||
current ??
|
|
||||||
MOCK_WORKSPACES[0] ??
|
|
||||||
({ id: "", slug: "", name: "Workspace" } satisfies WorkspaceInfo);
|
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -67,7 +46,7 @@ export function WorkspaceSwitcher({
|
||||||
>
|
>
|
||||||
<Building2 className="size-4 shrink-0 text-primary" />
|
<Building2 className="size-4 shrink-0 text-primary" />
|
||||||
{!collapsed ? (
|
{!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}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
@ -80,7 +59,7 @@ export function WorkspaceSwitcher({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{display.name}</TooltipContent>
|
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
|
<DropdownMenuTrigger asChild className="min-w-0 flex-1">
|
||||||
|
|
@ -91,16 +70,18 @@ export function WorkspaceSwitcher({
|
||||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
Workspaces
|
Workspaces
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{MOCK_WORKSPACES.map((ws) => {
|
{(workspaces ?? []).map((ws) => {
|
||||||
const selected = display.id === ws.id;
|
const selected = current?.id === ws.id;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={ws.id}
|
key={ws.id}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => setWorkspace(ws)}
|
onClick={() => {
|
||||||
|
router.push(`/${ws.id}`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Building2 className="size-4 shrink-0 opacity-70" />
|
<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 ? (
|
{selected ? (
|
||||||
<Check className="size-4 shrink-0 text-primary" />
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -108,36 +108,47 @@ function propertyCount(schema: TemplateSchemaJson | null | undefined): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TemplatePickerProps = {
|
export type TemplatePickerProps = {
|
||||||
objectId: string;
|
|
||||||
objectType: string;
|
|
||||||
onSelect: (templateId: string) => void;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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({
|
export function TemplatePicker({
|
||||||
objectId,
|
|
||||||
objectType,
|
|
||||||
onSelect,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
objectType,
|
||||||
|
workspaceId: workspaceIdProp,
|
||||||
|
objectId,
|
||||||
|
onSelect,
|
||||||
|
onCreateNew,
|
||||||
}: TemplatePickerProps) {
|
}: TemplatePickerProps) {
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [showAllTypes, setShowAllTypes] = React.useState(false);
|
const [showAllTypes, setShowAllTypes] = React.useState(false);
|
||||||
|
|
||||||
const objectQuery = api.objects.getById.useQuery(
|
const objectQuery = api.objects.getById.useQuery(
|
||||||
{ id: objectId },
|
{ id: objectId! },
|
||||||
{ enabled: open && Boolean(objectId) },
|
{ enabled: open && Boolean(objectId) && !workspaceIdProp },
|
||||||
);
|
);
|
||||||
|
|
||||||
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
|
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
|
||||||
?.workspaceId;
|
?.workspaceId;
|
||||||
const workspaceId =
|
const workspaceFromObject =
|
||||||
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
|
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
|
||||||
|
const resolvedWorkspaceId = workspaceIdProp ?? workspaceFromObject;
|
||||||
|
|
||||||
const listQuery = api.templates.list.useQuery(
|
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(() => {
|
const merged = React.useMemo(() => {
|
||||||
|
|
@ -245,13 +256,13 @@ export function TemplatePicker({
|
||||||
|
|
||||||
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
|
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
|
||||||
<div className="space-y-4 pb-3 pr-3">
|
<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>
|
<p className="text-sm text-muted-foreground">Loading templates…</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!workspaceId && objectQuery.isPending ? (
|
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isPending ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading object…</p>
|
<p className="text-sm text-muted-foreground">Loading object…</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!workspaceId && objectQuery.isError ? (
|
{!resolvedWorkspaceId && objectId && !workspaceIdProp && objectQuery.isError ? (
|
||||||
<p className="text-sm text-destructive">Could not load workspace.</p>
|
<p className="text-sm text-destructive">Could not load workspace.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -299,7 +310,7 @@ export function TemplatePicker({
|
||||||
size="sm"
|
size="sm"
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(t.id);
|
onSelect(t);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -315,19 +326,21 @@ export function TemplatePicker({
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="border-t px-4 py-3">
|
{onCreateNew ? (
|
||||||
<Button
|
<div className="border-t px-4 py-3">
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full gap-2"
|
variant="outline"
|
||||||
onClick={() => {
|
className="w-full gap-2"
|
||||||
onSelect(CREATE_SENTINEL);
|
onClick={() => {
|
||||||
onOpenChange(false);
|
onCreateNew();
|
||||||
}}
|
onOpenChange(false);
|
||||||
>
|
}}
|
||||||
<FileStack className="h-4 w-4" />
|
>
|
||||||
Create New Template
|
<FileStack className="h-4 w-4" />
|
||||||
</Button>
|
Create New Template
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPrimitive.Portal>
|
</DialogPrimitive.Portal>
|
||||||
</DialogPrimitive.Root>
|
</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 { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import type { ViewObject } from "@/lib/hooks/use-view-data";
|
import type { ViewObject } from "@/lib/hooks/use-view-data";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { BoardCard } from "./board-card";
|
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 {
|
export interface BoardColumnProps {
|
||||||
columnId: string;
|
columnId: string;
|
||||||
label: string;
|
label: string;
|
||||||
items: ViewObject[];
|
items: ViewObject[];
|
||||||
dotClass: string;
|
dotClass: string;
|
||||||
borderTopClass: string;
|
borderTopClass: string;
|
||||||
onAddTask?: () => void;
|
inlineCreate: BoardColumnInlineCreate;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatColumnLabel(id: string): string {
|
function formatColumnLabel(id: string): string {
|
||||||
|
|
@ -31,7 +40,7 @@ export function BoardColumn({
|
||||||
items,
|
items,
|
||||||
dotClass,
|
dotClass,
|
||||||
borderTopClass,
|
borderTopClass,
|
||||||
onAddTask,
|
inlineCreate,
|
||||||
}: BoardColumnProps) {
|
}: BoardColumnProps) {
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: columnId,
|
id: columnId,
|
||||||
|
|
@ -83,17 +92,44 @@ export function BoardColumn({
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-border/40 p-2">
|
<div className="shrink-0 border-t border-border/40">
|
||||||
<Button
|
{inlineCreate.isCreating ? (
|
||||||
type="button"
|
<div className="flex items-center gap-2 px-2 py-2">
|
||||||
variant="ghost"
|
<input
|
||||||
size="sm"
|
autoFocus
|
||||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
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"
|
||||||
onClick={onAddTask}
|
placeholder="Task title…"
|
||||||
>
|
value={inlineCreate.newTitle}
|
||||||
<Plus className="h-4 w-4" />
|
onChange={(e) => inlineCreate.onNewTitleChange(e.target.value)}
|
||||||
Add task
|
onKeyDown={(e) => {
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
|
@ -16,6 +17,7 @@ import { arrayMove } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
|
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
|
||||||
import { useViewData } 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 { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { BoardCardPreview } from "./board-card";
|
import { BoardCardPreview } from "./board-card";
|
||||||
|
|
@ -109,6 +111,12 @@ export interface BoardViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardView({ config, className }: 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(
|
const effectiveConfig = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -117,7 +125,11 @@ export function BoardView({ config, className }: BoardViewProps) {
|
||||||
[config],
|
[config],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { grouped, isLoading, total } = useViewData(effectiveConfig);
|
const { grouped, isLoading, total } = useViewData(
|
||||||
|
effectiveConfig,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
);
|
||||||
const groupField = effectiveConfig.groupBy ?? "status";
|
const groupField = effectiveConfig.groupBy ?? "status";
|
||||||
|
|
||||||
const columnKeys = React.useMemo(
|
const columnKeys = React.useMemo(
|
||||||
|
|
@ -134,6 +146,17 @@ export function BoardView({ config, className }: BoardViewProps) {
|
||||||
React.useState<Record<string, ViewObject[]>>(initialColumns);
|
React.useState<Record<string, ViewObject[]>>(initialColumns);
|
||||||
const [activeId, setActiveId] = React.useState<string | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
setColumns(initialColumns);
|
setColumns(initialColumns);
|
||||||
}, [initialColumns]);
|
}, [initialColumns]);
|
||||||
|
|
@ -300,6 +323,31 @@ export function BoardView({ config, className }: BoardViewProps) {
|
||||||
items={columns[columnId] ?? []}
|
items={columns[columnId] ?? []}
|
||||||
dotClass={theme.dot}
|
dotClass={theme.dot}
|
||||||
borderTopClass={theme.borderTop}
|
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";
|
"use client";
|
||||||
|
|
||||||
import * as Tabs from "@radix-ui/react-tabs";
|
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 { cn } from "@/lib/utils";
|
||||||
import { type ActiveViewType, useViewStore } from "@/lib/stores/view-store";
|
import { type ActiveViewType, useViewStore } from "@/lib/stores/view-store";
|
||||||
|
|
||||||
const tabs: { id: ActiveViewType; label: string; icon: typeof List }[] = [
|
const tabs: { id: ActiveViewType; label: string; icon: typeof List }[] = [
|
||||||
|
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||||
{ id: "list", label: "List", icon: List },
|
{ id: "list", label: "List", icon: List },
|
||||||
{ id: "board", label: "Board", icon: LayoutGrid },
|
{ id: "board", label: "Board", icon: LayoutGrid },
|
||||||
{ id: "table", label: "Table", icon: Table2 },
|
{ id: "table", label: "Table", icon: Table2 },
|
||||||
|
{ id: "form", label: "Form", icon: ClipboardList },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ViewSwitcher() {
|
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";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -18,6 +20,7 @@ import {
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
|
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
|
||||||
import { useViewData } 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 { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -202,6 +205,12 @@ export interface ListViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListView({ config }: 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);
|
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setSorts(config.sorts);
|
setSorts(config.sorts);
|
||||||
|
|
@ -212,7 +221,11 @@ export function ListView({ config }: ListViewProps) {
|
||||||
[config, sorts],
|
[config, sorts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { items, isLoading, total } = useViewData(effectiveConfig);
|
const { items, isLoading, total } = useViewData(
|
||||||
|
effectiveConfig,
|
||||||
|
workspaceId,
|
||||||
|
parentId,
|
||||||
|
);
|
||||||
|
|
||||||
const displayItems = React.useMemo(
|
const displayItems = React.useMemo(
|
||||||
() => refineSort(items, sorts),
|
() => refineSort(items, sorts),
|
||||||
|
|
@ -239,6 +252,17 @@ export function ListView({ config }: ListViewProps) {
|
||||||
setCollapsed((c) => ({ ...c, [key]: !c[key] }));
|
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) => {
|
const onHeaderSort = React.useCallback((field: SortField) => {
|
||||||
setSorts((s) => nextSort(field, s));
|
setSorts((s) => nextSort(field, s));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -361,7 +385,61 @@ export function ListView({ config }: ListViewProps) {
|
||||||
{headerRow}
|
{headerRow}
|
||||||
<Separator />
|
<Separator />
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<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>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
|
@ -8,7 +9,6 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
getDueDateValue,
|
getDueDateValue,
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from "@/components/views/list/list-item";
|
} from "@/components/views/list/list-item";
|
||||||
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
|
import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data";
|
||||||
import { useViewData } 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 { usePanelStore } from "@/lib/stores/panel-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -345,6 +346,12 @@ export interface TableViewProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableView({ config }: 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 openDetail = usePanelStore((s) => s.open);
|
||||||
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
|
const [sorts, setSorts] = React.useState<ViewSort[]>(config.sorts);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -356,12 +363,26 @@ export function TableView({ config }: TableViewProps) {
|
||||||
[config, sorts],
|
[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 [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]);
|
const customCols = React.useMemo(() => collectCustomColumns(baseItems), [baseItems]);
|
||||||
|
|
||||||
|
|
@ -457,30 +478,6 @@ export function TableView({ config }: TableViewProps) {
|
||||||
return sum;
|
return sum;
|
||||||
}, [widths, customCols]);
|
}, [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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
|
<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">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
|
@ -585,7 +582,7 @@ export function TableView({ config }: TableViewProps) {
|
||||||
</thead>
|
</thead>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableSkeleton colCount={colCount} />
|
<TableSkeleton colCount={colCount} />
|
||||||
) : total === 0 && localRows.length === 0 ? (
|
) : total === 0 && !isCreating ? (
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={colCount} className="p-0">
|
<td colSpan={colCount} className="p-0">
|
||||||
|
|
@ -789,16 +786,85 @@ export function TableView({ config }: TableViewProps) {
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
)}
|
)}
|
||||||
</table>
|
</table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="flex shrink-0 border-t border-border/70 bg-muted/20 px-2 py-1.5">
|
<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}>
|
{!isCreating ? (
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<button
|
||||||
Add row
|
type="button"
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { WhiteboardToolbar } from "./toolbar";
|
import { WhiteboardToolbar } from "./toolbar";
|
||||||
import { syncTldrawStoreWithYjs } from "./yjs-store";
|
import { syncTldrawStoreWithYjs } from "./yjs-store";
|
||||||
|
import { customShapeUtils } from "./shapes";
|
||||||
|
|
||||||
import "tldraw/tldraw.css";
|
import "tldraw/tldraw.css";
|
||||||
|
|
||||||
|
|
@ -46,11 +47,11 @@ function TldrawCollabBody({
|
||||||
className?: string;
|
className?: string;
|
||||||
tldraw: TldrawModule;
|
tldraw: TldrawModule;
|
||||||
}) {
|
}) {
|
||||||
const { doc, isConnected, isSynced } = useCollaboration(`whiteboard:${documentId}`);
|
const { doc, isConnected, isSynced } = useCollaboration(`object:${documentId}`);
|
||||||
const dark = useHtmlDarkClass();
|
const dark = useHtmlDarkClass();
|
||||||
|
|
||||||
const store = React.useMemo<TLStore>(
|
const store = React.useMemo<TLStore>(
|
||||||
() => tldraw.createTLStore(),
|
() => tldraw.createTLStore({ shapeUtils: customShapeUtils }),
|
||||||
[documentId, tldraw],
|
[documentId, tldraw],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -81,6 +82,7 @@ function TldrawCollabBody({
|
||||||
return (
|
return (
|
||||||
<Tldraw
|
<Tldraw
|
||||||
store={store}
|
store={store}
|
||||||
|
shapeUtils={customShapeUtils}
|
||||||
className={cn("h-full min-h-0 w-full min-w-0", className)}
|
className={cn("h-full min-h-0 w-full min-w-0", className)}
|
||||||
inferDarkMode={false}
|
inferDarkMode={false}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
|
|
@ -111,6 +113,11 @@ function TldrawLocalBody({
|
||||||
const dark = useHtmlDarkClass();
|
const dark = useHtmlDarkClass();
|
||||||
const editorRef = React.useRef<Editor | null>(null);
|
const editorRef = React.useRef<Editor | null>(null);
|
||||||
|
|
||||||
|
const store = React.useMemo<TLStore>(
|
||||||
|
() => tldraw.createTLStore({ shapeUtils: customShapeUtils }),
|
||||||
|
[tldraw],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
editorRef.current?.updateInstanceState({ isReadonly: readOnly });
|
editorRef.current?.updateInstanceState({ isReadonly: readOnly });
|
||||||
}, [readOnly]);
|
}, [readOnly]);
|
||||||
|
|
@ -125,6 +132,8 @@ function TldrawLocalBody({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tldraw
|
<Tldraw
|
||||||
|
store={store}
|
||||||
|
shapeUtils={customShapeUtils}
|
||||||
className={cn("h-full min-h-0 w-full min-w-0", className)}
|
className={cn("h-full min-h-0 w-full min-w-0", className)}
|
||||||
inferDarkMode={false}
|
inferDarkMode={false}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import type { DefaultSession, NextAuthConfig } 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 Credentials from "next-auth/providers/credentials";
|
||||||
import GitHub from "next-auth/providers/github";
|
import GitHub from "next-auth/providers/github";
|
||||||
import Google from "next-auth/providers/google";
|
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({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
pages: {
|
pages: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export type SortDirection = "asc" | "desc";
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
|
@ -35,17 +37,6 @@ export interface ViewObject {
|
||||||
propertyValues?: { propertyDefinition: { id: string; name: string; fieldType: string }; value: unknown }[];
|
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[] {
|
function applyFilters(objects: ViewObject[], filters: ViewFilter[]): ViewObject[] {
|
||||||
return objects.filter((obj) =>
|
return objects.filter((obj) =>
|
||||||
filters.every((f) => {
|
filters.every((f) => {
|
||||||
|
|
@ -89,8 +80,32 @@ function applyGroupBy(objects: ViewObject[], groupBy: string | null): Record<str
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useViewData(config: ViewConfig) {
|
export function useViewData(
|
||||||
const [objects] = useState<ViewObject[]>(MOCK_OBJECTS);
|
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(() => {
|
const processed = useMemo(() => {
|
||||||
let result = applyFilters(objects, config.filters);
|
let result = applyFilters(objects, config.filters);
|
||||||
|
|
@ -102,7 +117,7 @@ export function useViewData(config: ViewConfig) {
|
||||||
return {
|
return {
|
||||||
items: processed.items,
|
items: processed.items,
|
||||||
grouped: processed.grouped,
|
grouped: processed.grouped,
|
||||||
isLoading: false,
|
isLoading: queryLoading,
|
||||||
total: processed.items.length,
|
total: processed.items.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||||
|
|
||||||
import type { ViewConfig, ViewFilter, ViewSort } from "@/lib/hooks/use-view-data";
|
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 = {
|
const defaultConfig: ViewConfig = {
|
||||||
filters: [],
|
filters: [],
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,23 @@ import { relationsRouter } from "@/server/routers/relations";
|
||||||
import { aiRouter } from "@/server/routers/ai";
|
import { aiRouter } from "@/server/routers/ai";
|
||||||
import { templatesRouter } from "@/server/routers/templates";
|
import { templatesRouter } from "@/server/routers/templates";
|
||||||
import { searchRouter } from "@/server/routers/search";
|
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({
|
export const appRouter = router({
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
|
workspaces: workspacesRouter,
|
||||||
|
types: typesRouter,
|
||||||
objects: objectsRouter,
|
objects: objectsRouter,
|
||||||
properties: propertiesRouter,
|
properties: propertiesRouter,
|
||||||
relations: relationsRouter,
|
relations: relationsRouter,
|
||||||
ai: aiRouter,
|
ai: aiRouter,
|
||||||
templates: templatesRouter,
|
templates: templatesRouter,
|
||||||
search: searchRouter,
|
search: searchRouter,
|
||||||
|
forms: formsRouter,
|
||||||
|
favorites: favoritesRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
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 {
|
import {
|
||||||
router,
|
router,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
|
|
@ -13,7 +15,7 @@ export const healthRouter = router({
|
||||||
me: protectedProcedure.query(({ ctx }) => ctx.session!.user),
|
me: protectedProcedure.query(({ ctx }) => ctx.session!.user),
|
||||||
|
|
||||||
dbCheck: protectedProcedure.query(async ({ ctx }) => {
|
dbCheck: protectedProcedure.query(async ({ ctx }) => {
|
||||||
await ctx.db.$client`SELECT 1`;
|
await ctx.db.execute(sql`SELECT 1`);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,13 @@ import { router, protectedProcedure } from "@/server/trpc";
|
||||||
|
|
||||||
const objectTypeSchema = z.enum(objectTypes);
|
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 = {
|
export type ObjectTreeNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -285,6 +291,21 @@ export const objectsRouter = router({
|
||||||
return row;
|
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
|
reorder: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
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": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./schema": "./src/schema/index.ts",
|
"./schema": "./src/schema/index.ts",
|
||||||
"./client": "./src/client.ts"
|
"./client": "./src/client.ts",
|
||||||
|
"./markdown-backlog": "./src/markdown-backlog/index.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"watch:markdown-backlog": "tsx src/scripts/watch-markdown-backlog.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
"drizzle-kit": "^0.30.0",
|
"drizzle-kit": "^0.30.0",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.0"
|
"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 "./objects";
|
||||||
|
export * from "./types";
|
||||||
export * from "./properties";
|
export * from "./properties";
|
||||||
export * from "./values";
|
export * from "./values";
|
||||||
export * from "./views";
|
export * from "./views";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./relations";
|
export * from "./relations";
|
||||||
export * from "./templates";
|
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 { propertyValues } from "./values";
|
||||||
import { views } from "./views";
|
import { views } from "./views";
|
||||||
import { templates } from "./templates";
|
import { templates } from "./templates";
|
||||||
|
import { objectTypeDefs } from "./types";
|
||||||
|
import { markdownBacklogItems } from "./markdown_backlog";
|
||||||
|
import { cursorSyncMappings } from "./cursor_sync";
|
||||||
|
|
||||||
export const objectRelations = pgTable(
|
export const objectRelations = pgTable(
|
||||||
"object_relations",
|
"object_relations",
|
||||||
|
|
@ -76,6 +79,7 @@ export const objectsRelations = relations(objects, ({ one, many }) => ({
|
||||||
workspaceMembers: many(workspaceMembers),
|
workspaceMembers: many(workspaceMembers),
|
||||||
outgoingRelations: many(objectRelations, { relationName: "relationSource" }),
|
outgoingRelations: many(objectRelations, { relationName: "relationSource" }),
|
||||||
incomingRelations: many(objectRelations, { relationName: "relationTarget" }),
|
incomingRelations: many(objectRelations, { relationName: "relationTarget" }),
|
||||||
|
markdownBacklogItems: many(markdownBacklogItems),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({
|
export const objectAssigneesRelations = relations(objectAssignees, ({ one }) => ({
|
||||||
|
|
@ -134,6 +138,13 @@ export const templatesRelations = relations(templates, ({ one, many }) => ({
|
||||||
objects: many(objects),
|
objects: many(objects),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const objectTypeDefsRelations = relations(objectTypeDefs, ({ one }) => ({
|
||||||
|
workspace: one(objects, {
|
||||||
|
fields: [objectTypeDefs.workspaceId],
|
||||||
|
references: [objects.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [accounts.userId],
|
fields: [accounts.userId],
|
||||||
|
|
@ -160,3 +171,34 @@ export const objectRelationsRelations = relations(objectRelations, ({ one }) =>
|
||||||
relationName: "relationTarget",
|
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 { objectTypes, type ObjectType } from "./objects";
|
||||||
|
export * from "./forms";
|
||||||
export { fieldTypes, type FieldType } from "./fields";
|
export { fieldTypes, type FieldType } from "./fields";
|
||||||
export { viewTypes, type ViewType } from "./views";
|
export { viewTypes, type ViewType } from "./views";
|
||||||
export { workspaceRoles, type WorkspaceRole } from "./roles";
|
export { workspaceRoles, type WorkspaceRole } from "./roles";
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,29 @@
|
||||||
export const objectTypes = [
|
export const objectTypes = [
|
||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
|
"space",
|
||||||
"task",
|
"task",
|
||||||
"document",
|
"document",
|
||||||
"whiteboard",
|
"whiteboard",
|
||||||
"group",
|
"group",
|
||||||
|
"form",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ObjectType = (typeof objectTypes)[number];
|
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 = [
|
export const objectStatuses = [
|
||||||
"open",
|
"open",
|
||||||
"in_progress",
|
"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