@@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|||||||
import { getInitials } from "@/lib/utils";
|
import { getInitials } from "@/lib/utils";
|
||||||
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
||||||
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
||||||
|
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
|
||||||
import { getHetznerConfig } from "@/actions/settings";
|
import { getHetznerConfig } from "@/actions/settings";
|
||||||
|
|
||||||
export const metadata = { title: "Settings" };
|
export const metadata = { title: "Settings" };
|
||||||
@@ -13,6 +14,7 @@ export default async function SettingsPage() {
|
|||||||
if (!session?.user) return null;
|
if (!session?.user) return null;
|
||||||
|
|
||||||
const isAdmin = session.user.role === "ADMIN";
|
const isAdmin = session.user.role === "ADMIN";
|
||||||
|
const canManageTemplates = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role ?? "");
|
||||||
const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
|
const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,6 +50,14 @@ export default async function SettingsPage() {
|
|||||||
<HetznerConfigForm initialConfig={hetznerConfig} />
|
<HetznerConfigForm initialConfig={hetznerConfig} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageTemplates && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<SketchTemplatesSection />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
function canManage(role: string) {
|
||||||
|
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/sketch-templates/[id]
|
||||||
|
export async function DELETE(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!canManage(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const template = await db.sketchTemplate.findUnique({ where: { id } });
|
||||||
|
if (!template) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await db.sketchTemplate.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/sketch-templates/[id] — rename
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!canManage(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { name } = await req.json();
|
||||||
|
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
|
||||||
|
|
||||||
|
const template = await db.sketchTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name: name.trim() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
template: { ...template, fileSize: template.fileSize != null ? Number(template.fileSize) : null },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadFile } from "@/lib/storage";
|
||||||
|
|
||||||
|
function canManage(role: string) {
|
||||||
|
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/sketch-templates — list all templates (any authenticated user)
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const templates = await db.sketchTemplate.findMany({
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
templates: templates.map((t) => ({
|
||||||
|
...t,
|
||||||
|
fileSize: t.fileSize != null ? Number(t.fileSize) : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/sketch-templates — upload + create a new template
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!canManage(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const name = ((formData.get("name") as string) || "").trim();
|
||||||
|
|
||||||
|
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
if (!file.type.startsWith("image/"))
|
||||||
|
return NextResponse.json({ error: "Only image files accepted" }, { status: 400 });
|
||||||
|
|
||||||
|
const maxSize = 20 * 1024 * 1024; // 20 MB
|
||||||
|
if (file.size > maxSize)
|
||||||
|
return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 413 });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const uploaded = await uploadFile(buffer, file.name, file.type, "image");
|
||||||
|
|
||||||
|
const maxOrder = await db.sketchTemplate.aggregate({ _max: { sortOrder: true } });
|
||||||
|
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||||
|
|
||||||
|
const template = await db.sketchTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: name || file.name.replace(/\.[^.]+$/, ""),
|
||||||
|
fileUrl: uploaded.url,
|
||||||
|
fileKey: uploaded.key,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: BigInt(file.size),
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
template: { ...template, fileSize: Number(template.fileSize) },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
|
import { Upload, X, Loader2, Pencil, Check, LayoutTemplate } from "lucide-react";
|
||||||
|
|
||||||
|
interface SketchTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SketchTemplatesSection() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [templates, setTemplates] = useState<SketchTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pendingName, setPendingName] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
|
||||||
|
const fetchTemplates = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/sketch-templates");
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTemplates(data.templates);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
if (pendingName.trim()) fd.append("name", pendingName.trim());
|
||||||
|
const res = await fetch("/api/sketch-templates", { method: "POST", body: fd });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error ?? "Upload failed");
|
||||||
|
}
|
||||||
|
const { template } = await res.json();
|
||||||
|
setTemplates((prev) => [...prev, template]);
|
||||||
|
setPendingName("");
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sketch-templates/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("Delete failed");
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to delete template", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (t: SketchTemplate) => {
|
||||||
|
setEditingId(t.id);
|
||||||
|
setEditName(t.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (id: string) => {
|
||||||
|
if (!editName.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sketch-templates/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: editName }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Rename failed");
|
||||||
|
setTemplates((prev) => prev.map((t) => t.id === id ? { ...t, name: editName.trim() } : t));
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to rename template", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||||
|
<LayoutTemplate className="h-4 w-4 text-amber-500" />
|
||||||
|
Sketch Templates
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Upload background images (body diagrams, scene layouts, storyboard frames, etc.) that artists can load as a canvas background when sketching on a take.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Template grid */}
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div key={t.id} className="group relative rounded-lg border border-border overflow-hidden bg-zinc-900">
|
||||||
|
<div className="relative aspect-[4/3]">
|
||||||
|
<Image
|
||||||
|
src={t.fileUrl}
|
||||||
|
alt={t.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="180px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1.5 flex items-center gap-1">
|
||||||
|
{editingId === t.id ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveEdit(t.id);
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
className="h-6 text-xs px-1 bg-zinc-800 border-zinc-600"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => saveEdit(t.id)} className="shrink-0 text-green-400 hover:text-green-300">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-[11px] text-zinc-300 truncate">{t.name}</span>
|
||||||
|
<button onClick={() => startEdit(t)} className="shrink-0 text-zinc-600 hover:text-zinc-300 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(t.id)} className="shrink-0 text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Input
|
||||||
|
placeholder="Template name (optional)"
|
||||||
|
value={pendingName}
|
||||||
|
onChange={(e) => setPendingName(e.target.value)}
|
||||||
|
className="h-8 text-sm max-w-[200px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1.5" />}
|
||||||
|
{uploading ? "Uploading…" : "Upload Template"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600">No templates yet.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from "react";
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser } from "lucide-react";
|
import Image from "next/image";
|
||||||
|
import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser, LayoutTemplate, XCircle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TakeAttachmentData } from "./TakeEditorPanel";
|
import type { TakeAttachmentData } from "./TakeEditorPanel";
|
||||||
|
|
||||||
@@ -11,6 +12,13 @@ interface Stroke {
|
|||||||
color: string;
|
color: string;
|
||||||
width: number;
|
width: number;
|
||||||
points: { x: number; y: number }[];
|
points: { x: number; y: number }[];
|
||||||
|
isEraser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SketchTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,25 +39,537 @@ const COLORS = [
|
|||||||
|
|
||||||
const BRUSH_SIZES = [2, 4, 8, 14, 24];
|
const BRUSH_SIZES = [2, 4, 8, 14, 24];
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Template picker overlay ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TemplatePicker({
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
onClear,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
current: SketchTemplate | null;
|
||||||
|
onSelect: (t: SketchTemplate) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [templates, setTemplates] = useState<SketchTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/sketch-templates")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setTemplates(d.templates ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 bg-black/80 backdrop-blur-sm flex flex-col"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="m-auto w-full max-w-2xl bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Choose a sketch template</h3>
|
||||||
|
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-zinc-500 text-sm py-8 justify-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading templates…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border-2 aspect-[4/3] flex items-center justify-center text-xs font-medium transition-colors",
|
||||||
|
!current
|
||||||
|
? "border-amber-500 bg-amber-500/10 text-amber-400"
|
||||||
|
: "border-zinc-700 hover:border-zinc-500 text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
No template
|
||||||
|
</button>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onSelect(t)}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border-2 aspect-[4/3] overflow-hidden transition-all",
|
||||||
|
current?.id === t.id
|
||||||
|
? "border-amber-500 ring-2 ring-amber-500/30"
|
||||||
|
: "border-zinc-700 hover:border-zinc-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image src={t.fileUrl} alt={t.name} fill className="object-cover" sizes="160px" />
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[10px] text-zinc-200 truncate px-1.5 py-0.5">
|
||||||
|
{t.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{templates.length === 0 && (
|
||||||
|
<p className="text-center text-sm text-zinc-600 py-8">
|
||||||
|
No templates yet — upload some in Settings → Sketch Templates.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main SketchPad ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SketchPad({ takeId, onSaved, onClose }: Props) {
|
export function SketchPad({ takeId, onSaved, onClose }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
// Two-canvas stack:
|
||||||
|
// bgCanvas — BG_COLOR + optional template image (no pointer events)
|
||||||
|
// drawCanvas — transparent, user strokes, pointer events on top
|
||||||
|
const bgCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const drawCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const strokesRef = useRef<Stroke[]>([]);
|
const strokesRef = useRef<Stroke[]>([]);
|
||||||
const currentStrokeRef = useRef<Stroke | null>(null);
|
const currentStrokeRef = useRef<Stroke | null>(null);
|
||||||
const isDrawingRef = useRef(false);
|
const isDrawingRef = useRef(false);
|
||||||
|
const templateImageRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
// strokeCount is only used to drive button enabled/disabled state reactively
|
|
||||||
const [strokeCount, setStrokeCount] = useState(0);
|
const [strokeCount, setStrokeCount] = useState(0);
|
||||||
const [color, setColor] = useState("#ffffff");
|
const [color, setColor] = useState("#ffffff");
|
||||||
const [brushSize, setBrushSize] = useState(4);
|
const [brushSize, setBrushSize] = useState(4);
|
||||||
const [isEraser, setIsEraser] = useState(false);
|
const [isEraser, setIsEraser] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||||
|
const [activeTemplate, setActiveTemplate] = useState<SketchTemplate | null>(null);
|
||||||
|
|
||||||
// ── Redraw all committed strokes ───────────────────────────────────────────
|
// ── Background canvas: BG_COLOR + scaled template ────────────────────────
|
||||||
const redraw = useCallback(() => {
|
const redrawBg = useCallback(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = bgCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.fillStyle = BG_COLOR;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const img = templateImageRef.current;
|
||||||
|
if (img) {
|
||||||
|
const scale = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);
|
||||||
|
const w = img.naturalWidth * scale;
|
||||||
|
const h = img.naturalHeight * scale;
|
||||||
|
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Drawing canvas: transparent bg, strokes with proper eraser ───────────
|
||||||
|
const redrawDraw = useCallback(() => {
|
||||||
|
const canvas = drawCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (const stroke of strokesRef.current) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = stroke.width;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
if (stroke.isEraser) {
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.strokeStyle = "rgba(0,0,0,1)";
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||||
|
} else {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
ctx.fillStyle = stroke.color;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
if (stroke.points.length === 1) {
|
||||||
|
ctx.arc(stroke.points[0].x, stroke.points[0].y, stroke.width / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
||||||
|
for (let i = 1; i < stroke.points.length; i++) {
|
||||||
|
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Resize both canvases together ─────────────────────────────────────────
|
||||||
|
const fit = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const { width, height } = container.getBoundingClientRect();
|
||||||
|
const w = Math.floor(width);
|
||||||
|
const h = Math.floor(height);
|
||||||
|
for (const ref of [bgCanvasRef, drawCanvasRef]) {
|
||||||
|
const c = ref.current;
|
||||||
|
if (c) { c.width = w; c.height = h; }
|
||||||
|
}
|
||||||
|
redrawBg();
|
||||||
|
redrawDraw();
|
||||||
|
}, [redrawBg, redrawDraw]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fit();
|
||||||
|
window.addEventListener("resize", fit);
|
||||||
|
return () => window.removeEventListener("resize", fit);
|
||||||
|
}, [fit]);
|
||||||
|
|
||||||
|
// ── Load template image whenever activeTemplate changes ───────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeTemplate) {
|
||||||
|
templateImageRef.current = null;
|
||||||
|
redrawBg();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new window.Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => { templateImageRef.current = img; redrawBg(); };
|
||||||
|
img.src = activeTemplate.fileUrl;
|
||||||
|
}, [activeTemplate, redrawBg]);
|
||||||
|
|
||||||
|
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (showTemplatePicker) { setShowTemplatePicker(false); return; }
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
if (e.key === "p" && !e.metaKey && !e.ctrlKey) setIsEraser(false);
|
||||||
|
if (e.key === "e" && !e.metaKey && !e.ctrlKey) setIsEraser(true);
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
strokesRef.current = strokesRef.current.slice(0, -1);
|
||||||
|
setStrokeCount(strokesRef.current.length);
|
||||||
|
redrawDraw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose, redrawDraw, showTemplatePicker]);
|
||||||
|
|
||||||
|
// ── Pointer coordinate helper ─────────────────────────────────────────────
|
||||||
|
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = drawCanvasRef.current!;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * (canvas.width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (canvas.height / rect.height),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pointer handlers ──────────────────────────────────────────────────────
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
|
||||||
|
isDrawingRef.current = true;
|
||||||
|
const pos = getPos(e);
|
||||||
|
const stroke: Stroke = {
|
||||||
|
color: isEraser ? "" : color,
|
||||||
|
width: isEraser ? brushSize * 4 : brushSize,
|
||||||
|
points: [pos],
|
||||||
|
isEraser,
|
||||||
|
};
|
||||||
|
currentStrokeRef.current = stroke;
|
||||||
|
|
||||||
|
const ctx = drawCanvasRef.current!.getContext("2d")!;
|
||||||
|
ctx.save();
|
||||||
|
if (isEraser) {
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.fillStyle = "rgba(0,0,0,1)";
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pos.x, pos.y, stroke.width / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawingRef.current || !currentStrokeRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const pos = getPos(e);
|
||||||
|
const stroke = currentStrokeRef.current;
|
||||||
|
const prev = stroke.points[stroke.points.length - 1];
|
||||||
|
stroke.points.push(pos);
|
||||||
|
|
||||||
|
const ctx = drawCanvasRef.current!.getContext("2d")!;
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = stroke.width;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
if (isEraser) {
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.strokeStyle = "rgba(0,0,0,1)";
|
||||||
|
} else {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.strokeStyle = stroke.color;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(prev.x, prev.y);
|
||||||
|
ctx.lineTo(pos.x, pos.y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
isDrawingRef.current = false;
|
||||||
|
if (currentStrokeRef.current) {
|
||||||
|
strokesRef.current = [...strokesRef.current, currentStrokeRef.current];
|
||||||
|
setStrokeCount(strokesRef.current.length);
|
||||||
|
currentStrokeRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undo = () => {
|
||||||
|
strokesRef.current = strokesRef.current.slice(0, -1);
|
||||||
|
setStrokeCount(strokesRef.current.length);
|
||||||
|
redrawDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
strokesRef.current = [];
|
||||||
|
setStrokeCount(0);
|
||||||
|
redrawDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Save: composite bg + draw layers into one PNG ─────────────────────────
|
||||||
|
const save = async () => {
|
||||||
|
const bgCanvas = bgCanvasRef.current;
|
||||||
|
const drawCanvas = drawCanvasRef.current;
|
||||||
|
if (!bgCanvas || !drawCanvas) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const composite = document.createElement("canvas");
|
||||||
|
composite.width = bgCanvas.width;
|
||||||
|
composite.height = bgCanvas.height;
|
||||||
|
const ctx = composite.getContext("2d")!;
|
||||||
|
ctx.drawImage(bgCanvas, 0, 0);
|
||||||
|
ctx.drawImage(drawCanvas, 0, 0);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) =>
|
||||||
|
composite.toBlob(resolve, "image/png")
|
||||||
|
);
|
||||||
|
if (!blob) throw new Error("Canvas export failed");
|
||||||
|
|
||||||
|
const fileName = `sketch_${Date.now()}.png`;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", new File([blob], fileName, { type: "image/png" }));
|
||||||
|
fd.append("type", "image");
|
||||||
|
|
||||||
|
const uploadRes = await fetch("/api/upload", { method: "POST", body: fd });
|
||||||
|
if (!uploadRes.ok) throw new Error("Upload failed");
|
||||||
|
const { url, key } = await uploadRes.json();
|
||||||
|
|
||||||
|
const attRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fileUrl: url,
|
||||||
|
fileKey: key ?? "",
|
||||||
|
fileName,
|
||||||
|
fileSize: blob.size,
|
||||||
|
fileType: "IMAGE",
|
||||||
|
category: "MISCELLANEOUS",
|
||||||
|
caption: activeTemplate ? `Sketch — ${activeTemplate.name}` : "Sketch",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!attRes.ok) throw new Error("Failed to save attachment");
|
||||||
|
const { attachment } = await attRes.json();
|
||||||
|
onSaved(attachment as TakeAttachmentData);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Save failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Render ───────────────────────────────────────────────────────────────
|
||||||
|
const previewColor = isEraser ? "#ef4444" : color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: BG_COLOR }}>
|
||||||
|
|
||||||
|
{/* ── Toolbar ── */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur shrink-0 flex-wrap">
|
||||||
|
|
||||||
|
{/* Pen / Eraser */}
|
||||||
|
<div className="flex items-center gap-0.5 bg-zinc-800 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEraser(false)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
!isEraser ? "bg-zinc-700 text-white shadow" : "text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
title="Pen (P)"
|
||||||
|
>
|
||||||
|
<Pen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEraser(true)}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded-md transition-colors",
|
||||||
|
isEraser ? "bg-zinc-700 text-red-400 shadow" : "text-zinc-500 hover:text-zinc-300"
|
||||||
|
)}
|
||||||
|
title="Eraser (E)"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Colours */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => { setColor(c); setIsEraser(false); }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border-2 transition-all",
|
||||||
|
color === c && !isEraser ? "border-amber-400 scale-125" : "border-zinc-700 hover:border-zinc-400"
|
||||||
|
)}
|
||||||
|
style={{ width: 20, height: 20, backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Brush sizes */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{BRUSH_SIZES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setBrushSize(s)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center rounded-full bg-zinc-800 transition-all",
|
||||||
|
brushSize === s ? "ring-2 ring-amber-400" : "opacity-50 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
style={{ width: s + 10, height: s + 10 }}
|
||||||
|
title={`${s}px`}
|
||||||
|
>
|
||||||
|
<div className="rounded-full" style={{ width: s, height: s, backgroundColor: previewColor }} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Template selector */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTemplatePicker(true)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors",
|
||||||
|
activeTemplate
|
||||||
|
? "bg-amber-500/15 border border-amber-600/40 text-amber-400"
|
||||||
|
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
|
||||||
|
)}
|
||||||
|
title="Background template"
|
||||||
|
>
|
||||||
|
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||||
|
{activeTemplate ? activeTemplate.name : "Template"}
|
||||||
|
</button>
|
||||||
|
{activeTemplate && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTemplate(null)}
|
||||||
|
className="p-1 -ml-2 text-zinc-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Remove template"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={undo}
|
||||||
|
disabled={strokeCount === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 className="h-3.5 w-3.5" /> Undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clear}
|
||||||
|
disabled={strokeCount === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving || strokeCount === 0}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
|
{saving ? "Saving…" : "Save Sketch"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors ml-1"
|
||||||
|
title="Discard (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Canvas area ── */}
|
||||||
|
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||||
|
{/* Background layer: BG_COLOR + optional template */}
|
||||||
|
<canvas ref={bgCanvasRef} className="absolute inset-0" />
|
||||||
|
|
||||||
|
{/* Drawing layer: transparent, receives pointer events */}
|
||||||
|
<canvas
|
||||||
|
ref={drawCanvasRef}
|
||||||
|
className="absolute inset-0 cursor-crosshair"
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerUp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{strokeCount === 0 && !activeTemplate && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
|
||||||
|
<p className="text-zinc-700 text-base">Draw your sketch…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template picker modal */}
|
||||||
|
{showTemplatePicker && (
|
||||||
|
<TemplatePicker
|
||||||
|
current={activeTemplate}
|
||||||
|
onSelect={(t) => { setActiveTemplate(t); setShowTemplatePicker(false); }}
|
||||||
|
onClear={() => { setActiveTemplate(null); setShowTemplatePicker(false); }}
|
||||||
|
onClose={() => setShowTemplatePicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sketch_templates" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"fileUrl" TEXT NOT NULL,
|
||||||
|
"fileKey" TEXT NOT NULL DEFAULT '',
|
||||||
|
"fileName" TEXT NOT NULL DEFAULT '',
|
||||||
|
"fileSize" BIGINT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "sketch_templates_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -751,3 +751,16 @@ model TakeAttachment {
|
|||||||
|
|
||||||
@@map("take_attachments")
|
@@map("take_attachments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SketchTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
fileUrl String
|
||||||
|
fileKey String @default("")
|
||||||
|
fileName String @default("")
|
||||||
|
fileSize BigInt?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("sketch_templates")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user