Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor
312 lines
9.4 KiB
TypeScript
312 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
AlertTriangle,
|
|
ExternalLink,
|
|
Loader2,
|
|
Pencil,
|
|
RefreshCw,
|
|
} from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import type { ViewConfig } from "@/lib/hooks/use-view-data";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/** Allowed schemes for embed src; rejects javascript:, data:, etc. */
|
|
export function sanitizeEmbedUrl(raw: string): string | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return null;
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(trimmed);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
// href is serialized safely for iframe src (no HTML/script injection)
|
|
return parsed.href;
|
|
}
|
|
|
|
const EXAMPLE_SERVICES = [
|
|
"Figma",
|
|
"Google Docs",
|
|
"Miro",
|
|
"Loom",
|
|
"YouTube",
|
|
] as const;
|
|
|
|
export interface EmbedViewProps {
|
|
config: ViewConfig & { embedUrl?: string };
|
|
className?: string;
|
|
}
|
|
|
|
export function EmbedView({ config, className }: EmbedViewProps) {
|
|
const [sessionUrl, setSessionUrl] = React.useState<string | null>(null);
|
|
const [draft, setDraft] = React.useState("");
|
|
const [validationError, setValidationError] = React.useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
const effectiveUrl = config.embedUrl ?? sessionUrl;
|
|
|
|
React.useEffect(() => {
|
|
if (config.embedUrl) {
|
|
setDraft(config.embedUrl);
|
|
}
|
|
}, [config.embedUrl]);
|
|
|
|
const handlePreview = () => {
|
|
setValidationError(null);
|
|
const safe = sanitizeEmbedUrl(draft);
|
|
if (!safe) {
|
|
setValidationError(
|
|
"Enter a valid URL starting with http:// or https://",
|
|
);
|
|
return;
|
|
}
|
|
setSessionUrl(safe);
|
|
};
|
|
|
|
const handleChangeUrl = () => {
|
|
setSessionUrl(null);
|
|
setDraft(effectiveUrl ?? "");
|
|
setValidationError(null);
|
|
};
|
|
|
|
if (!effectiveUrl) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex min-h-[320px] flex-1 flex-col items-center justify-center gap-6 p-8",
|
|
className,
|
|
)}
|
|
>
|
|
<div className="w-full max-w-md space-y-4 text-center">
|
|
<div className="space-y-1">
|
|
<h2 className="text-lg font-semibold tracking-tight">
|
|
Embed a page
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Paste a link to show it inside this view. Works best with tools
|
|
that allow embedding.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
|
<Input
|
|
type="url"
|
|
inputMode="url"
|
|
autoComplete="url"
|
|
placeholder="Paste a URL to embed..."
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
setValidationError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handlePreview();
|
|
}}
|
|
className="text-left"
|
|
aria-invalid={!!validationError}
|
|
/>
|
|
<Button type="button" className="shrink-0 sm:w-auto" onClick={handlePreview}>
|
|
Preview
|
|
</Button>
|
|
</div>
|
|
{validationError ? (
|
|
<p className="text-left text-sm text-destructive" role="alert">
|
|
{validationError}
|
|
</p>
|
|
) : null}
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
Often works well with
|
|
</p>
|
|
<div className="flex flex-wrap justify-center gap-1.5">
|
|
{EXAMPLE_SERVICES.map((label) => (
|
|
<Badge key={label} variant="outline" className="font-normal">
|
|
{label}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-left text-xs text-muted-foreground"
|
|
role="note"
|
|
>
|
|
<AlertTriangle
|
|
className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-500"
|
|
aria-hidden
|
|
/>
|
|
<span>
|
|
Many sites block embedding in iframes for security. If you see a
|
|
blank page or an error, the site may not allow this view—use{" "}
|
|
<span className="font-medium text-foreground">Open in new tab</span>{" "}
|
|
after loading.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<EmbedFrame
|
|
key={effectiveUrl}
|
|
url={effectiveUrl}
|
|
onChangeUrl={handleChangeUrl}
|
|
className={className}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface EmbedFrameProps {
|
|
url: string;
|
|
onChangeUrl: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
function EmbedFrame({ url, onChangeUrl, className }: EmbedFrameProps) {
|
|
const [loadState, setLoadState] = React.useState<
|
|
"loading" | "loaded" | "error"
|
|
>("loading");
|
|
const [reloadToken, setReloadToken] = React.useState(0);
|
|
|
|
const safeUrl = sanitizeEmbedUrl(url);
|
|
const iframeSrc = safeUrl ?? "";
|
|
|
|
React.useEffect(() => {
|
|
if (!safeUrl) {
|
|
setLoadState("error");
|
|
return;
|
|
}
|
|
setLoadState("loading");
|
|
}, [safeUrl, reloadToken]);
|
|
|
|
const handleRefresh = () => {
|
|
setLoadState("loading");
|
|
setReloadToken((x) => x + 1);
|
|
};
|
|
|
|
const displayUrl = safeUrl ?? url;
|
|
const truncated =
|
|
displayUrl.length > 64 ? `${displayUrl.slice(0, 61)}…` : displayUrl;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background",
|
|
className,
|
|
)}
|
|
>
|
|
<div
|
|
className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-muted/30 px-2"
|
|
role="toolbar"
|
|
aria-label="Embed controls"
|
|
>
|
|
<span
|
|
className="min-w-0 flex-1 truncate px-1 text-xs text-muted-foreground"
|
|
title={displayUrl}
|
|
>
|
|
{truncated}
|
|
</span>
|
|
<div className="flex shrink-0 items-center gap-0.5">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
onClick={handleRefresh}
|
|
disabled={!safeUrl}
|
|
aria-label="Refresh embed"
|
|
>
|
|
<RefreshCw className="size-4" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
asChild
|
|
>
|
|
<a
|
|
href={safeUrl || "#"}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Open in new tab"
|
|
>
|
|
<ExternalLink className="size-4" />
|
|
</a>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2"
|
|
onClick={onChangeUrl}
|
|
aria-label="Change URL"
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="relative min-h-0 flex-1 bg-muted/20">
|
|
{!safeUrl ? (
|
|
<div className="flex h-full min-h-[200px] items-center justify-center p-6 text-center text-sm text-destructive">
|
|
Invalid or unsupported URL. Use Change URL to enter an http(s)
|
|
link.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{loadState === "loading" ? (
|
|
<div
|
|
className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-[1px]"
|
|
aria-live="polite"
|
|
aria-busy="true"
|
|
>
|
|
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
|
|
<span className="sr-only">Loading embed</span>
|
|
</div>
|
|
) : null}
|
|
{loadState === "error" ? (
|
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-background p-6 text-center">
|
|
<p className="text-sm font-medium text-destructive">
|
|
Could not load this page in the embed.
|
|
</p>
|
|
<p className="max-w-sm text-xs text-muted-foreground">
|
|
The URL may be invalid, blocked, or the site may forbid
|
|
framing. Try opening in a new tab or a different URL.
|
|
</p>
|
|
<div className="flex flex-wrap justify-center gap-2">
|
|
<Button type="button" size="sm" onClick={handleRefresh}>
|
|
Try again
|
|
</Button>
|
|
<Button type="button" size="sm" variant="outline" asChild>
|
|
<a href={safeUrl} target="_blank" rel="noopener noreferrer">
|
|
Open in new tab
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{loadState !== "error" ? (
|
|
<iframe
|
|
key={`${iframeSrc}-${reloadToken}`}
|
|
title="Embedded content"
|
|
src={iframeSrc}
|
|
className="size-full min-h-[400px] border-0"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
onLoad={() => setLoadState("loaded")}
|
|
onError={() => setLoadState("error")}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|