@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
function requireRole(role: string) {
|
||||||
|
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/shoot-log/shots/[shotId]
|
||||||
|
// Returns a shot's thumbnail + references for display in the shoot-log
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!requireRole(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
|
||||||
|
const shot = await db.shot.findUnique({
|
||||||
|
where: { id: shotId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
shotCode: true,
|
||||||
|
scene: true,
|
||||||
|
episode: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
references: {
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fileUrl: true,
|
||||||
|
fileName: true,
|
||||||
|
label: true,
|
||||||
|
sortOrder: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shot) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
return NextResponse.json({ shot });
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadFile } from "@/lib/storage";
|
||||||
|
|
||||||
|
function requireAuth(role: string) {
|
||||||
|
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/shots/[shotId]/references
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
|
||||||
|
const references = await db.shotReference.findMany({
|
||||||
|
where: { shotId },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
references: references.map((r) => ({
|
||||||
|
...r,
|
||||||
|
fileSize: r.fileSize != null ? Number(r.fileSize) : null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/shots/[shotId]/references (multipart upload)
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!requireAuth(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
|
||||||
|
const shot = await db.shot.findUnique({ where: { id: shotId }, select: { id: true } });
|
||||||
|
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
const label = (formData.get("label") as string) || null;
|
||||||
|
|
||||||
|
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 = 50 * 1024 * 1024; // 50 MB
|
||||||
|
if (file.size > maxSize)
|
||||||
|
return NextResponse.json({ error: "File too large (max 50 MB)" }, { status: 413 });
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const uploaded = await uploadFile(buffer, file.name, file.type, "image");
|
||||||
|
|
||||||
|
const maxOrder = await db.shotReference.aggregate({
|
||||||
|
where: { shotId },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
});
|
||||||
|
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
|
||||||
|
|
||||||
|
const reference = await db.shotReference.create({
|
||||||
|
data: {
|
||||||
|
shotId,
|
||||||
|
label,
|
||||||
|
fileUrl: uploaded.url,
|
||||||
|
fileKey: uploaded.key,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: BigInt(file.size),
|
||||||
|
sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
reference: { ...reference, fileSize: Number(reference.fileSize) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/shots/[shotId]/references/[refId] handled via query param
|
||||||
|
// DELETE /api/shots/[shotId]/references?refId=xxx
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ shotId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (!requireAuth(session.user.role))
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const { shotId } = await params;
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const refId = searchParams.get("refId");
|
||||||
|
|
||||||
|
if (!refId) return NextResponse.json({ error: "refId required" }, { status: 400 });
|
||||||
|
|
||||||
|
const ref = await db.shotReference.findFirst({ where: { id: refId, shotId } });
|
||||||
|
if (!ref) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
await db.shotReference.delete({ where: { id: refId } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import { useEffect, useRef, useState, useCallback } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
|
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
|
||||||
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X,
|
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -201,6 +202,86 @@ function SectionHeader({ title }: { title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Linked shot panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LinkedShot {
|
||||||
|
id: string;
|
||||||
|
shotCode: string;
|
||||||
|
scene: string | null;
|
||||||
|
episode: string | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
references: { id: string; fileUrl: string; fileName: string; label: string | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedShotPanel({ shotId }: { shotId: string }) {
|
||||||
|
const { data, isLoading } = useQuery<{ shot: LinkedShot }>({
|
||||||
|
queryKey: ["linked-shot", shotId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/shoot-log/shots/${shotId}`);
|
||||||
|
if (!res.ok) throw new Error("Failed");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
enabled: !!shotId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading shot data…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shot = data?.shot;
|
||||||
|
if (!shot) return null;
|
||||||
|
|
||||||
|
const hasContent = shot.thumbnailUrl || shot.references.length > 0;
|
||||||
|
if (!hasContent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||||
|
<BookImage className="h-3 w-3" />
|
||||||
|
Shot Reference — {shot.shotCode}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shot.thumbnailUrl && (
|
||||||
|
<div className="relative w-full max-w-xs aspect-[2.39] rounded-md overflow-hidden border border-zinc-700">
|
||||||
|
<Image
|
||||||
|
src={shot.thumbnailUrl}
|
||||||
|
alt={shot.shotCode}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="320px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shot.references.length > 0 && (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{shot.references.map((ref) => (
|
||||||
|
<div key={ref.id} className="relative aspect-square rounded-md overflow-hidden border border-zinc-700 bg-zinc-950 group">
|
||||||
|
<Image
|
||||||
|
src={ref.fileUrl}
|
||||||
|
alt={ref.label ?? ref.fileName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="100px"
|
||||||
|
/>
|
||||||
|
{ref.label && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] text-zinc-300 truncate px-1 py-0.5">
|
||||||
|
{ref.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Shot selector ────────────────────────────────────────────────────────────
|
// ─── Shot selector ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ShotOption {
|
interface ShotOption {
|
||||||
@@ -440,6 +521,9 @@ export function TakeEditorPanel({
|
|||||||
value={fields.pipelineShotId}
|
value={fields.pipelineShotId}
|
||||||
onChange={(id) => set("pipelineShotId", id)}
|
onChange={(id) => set("pipelineShotId", id)}
|
||||||
/>
|
/>
|
||||||
|
{fields.pipelineShotId && (
|
||||||
|
<LinkedShotPanel shotId={fields.pipelineShotId} />
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Camera */}
|
{/* Camera */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -20,7 +20,143 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { updateShot, deleteShot } from "@/actions/shots";
|
import { updateShot, deleteShot } from "@/actions/shots";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react";
|
import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
// ─── Shot References ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ShotRef {
|
||||||
|
id: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileName: string;
|
||||||
|
label: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShotReferencesSection({ shotId }: { shotId: string }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [refs, setRefs] = useState<ShotRef[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const fetchRefs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/shots/${shotId}/references`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setRefs(data.references);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [shotId]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchRefs(); }, [fetchRefs]);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (!files.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(`/api/shots/${shotId}/references`, { method: "POST", body: fd });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const { reference } = await res.json();
|
||||||
|
setRefs((prev) => [...prev, reference]);
|
||||||
|
}
|
||||||
|
} 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 (refId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/shots/${shotId}/references?refId=${refId}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("Delete failed");
|
||||||
|
setRefs((prev) => prev.filter((r) => r.id !== refId));
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Failed to delete reference", variant: "destructive" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||||
|
<BookImage className="h-4 w-4 text-amber-500" />
|
||||||
|
References
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||||
|
{refs.map((ref) => (
|
||||||
|
<div key={ref.id} className="relative group aspect-square rounded-lg overflow-hidden border border-border bg-zinc-900">
|
||||||
|
<Image
|
||||||
|
src={ref.fileUrl}
|
||||||
|
alt={ref.label ?? ref.fileName}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="160px"
|
||||||
|
/>
|
||||||
|
{ref.label && (
|
||||||
|
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[10px] text-zinc-300 truncate px-1.5 py-0.5">
|
||||||
|
{ref.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(ref.id)}
|
||||||
|
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Remove reference"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 text-sm text-muted-foreground cursor-pointer transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{uploading ? "Uploading…" : "Add reference images"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings form ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
|
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
|
||||||
@@ -318,6 +454,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* References */}
|
||||||
|
<ShotReferencesSection shotId={shot.id} />
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving}>
|
||||||
{isSaving ? "Saving\u2026" : "Save Changes"}
|
{isSaving ? "Saving\u2026" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "shot_references" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"shotId" TEXT NOT NULL,
|
||||||
|
"label" TEXT,
|
||||||
|
"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 "shot_references_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "shot_references" ADD CONSTRAINT "shot_references_shotId_fkey" FOREIGN KEY ("shotId") REFERENCES "shots"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -357,6 +357,7 @@ model Shot {
|
|||||||
versions Version[]
|
versions Version[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
footagePlates FootagePlate[]
|
footagePlates FootagePlate[]
|
||||||
|
references ShotReference[]
|
||||||
loggedTakes Take[] @relation("TakeToShot")
|
loggedTakes Take[] @relation("TakeToShot")
|
||||||
|
|
||||||
@@unique([projectId, shotCode])
|
@@unique([projectId, shotCode])
|
||||||
@@ -379,6 +380,22 @@ model FootagePlate {
|
|||||||
@@map("footage_plates")
|
@@map("footage_plates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ShotReference {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
shotId String
|
||||||
|
label String?
|
||||||
|
fileUrl String
|
||||||
|
fileKey String @default("")
|
||||||
|
fileName String @default("")
|
||||||
|
fileSize BigInt?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
shot Shot @relation(fields: [shotId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("shot_references")
|
||||||
|
}
|
||||||
|
|
||||||
model ShotGroup {
|
model ShotGroup {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
|||||||
Reference in New Issue
Block a user