feat(rubgram): add notes panel on admin
This commit is contained in:
@@ -6,14 +6,200 @@ import {
|
||||
CopyCheck,
|
||||
ImageOff,
|
||||
MessageSquareWarning,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { debugUploadSlip, discordCall } from "../../api";
|
||||
import type { Note } from "@/lib/db/schema";
|
||||
import { addNote, debugUploadSlip, deleteNote, discordCall } from "../../api";
|
||||
|
||||
export function Evernight() {
|
||||
return <div className="relative h-full w-full"></div>;
|
||||
// by chatgpt
|
||||
const lePalette = [
|
||||
{
|
||||
bg: "#A34A5B",
|
||||
border: "#C26779",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#3D7EA6",
|
||||
border: "#5A9BC6",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#7A5A48",
|
||||
border: "#9B7763",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#6F5BA7",
|
||||
border: "#8C77C8",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#4F8C5A",
|
||||
border: "#6DAC79",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#A67C00",
|
||||
border: "#C59A17",
|
||||
text: "#FFF8E1",
|
||||
},
|
||||
{
|
||||
bg: "#8E4B73",
|
||||
border: "#AD6791",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#2E7D6A",
|
||||
border: "#4C9D89",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#A65D3A",
|
||||
border: "#C57A53",
|
||||
text: "#FFF7ED",
|
||||
},
|
||||
{
|
||||
bg: "#5C6673",
|
||||
border: "#7B8696",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#4B5FA3",
|
||||
border: "#6880C5",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#7A8742",
|
||||
border: "#99A75E",
|
||||
text: "#F7FEE7",
|
||||
},
|
||||
];
|
||||
|
||||
function colorFor(queueId: string, noteIndex: number) {
|
||||
const seed = parseInt(queueId.slice(-8), 16);
|
||||
return lePalette[(seed + noteIndex * 7) % lePalette.length];
|
||||
}
|
||||
|
||||
export function NotesPanel({
|
||||
sid,
|
||||
initialNotes,
|
||||
}: {
|
||||
sid: string;
|
||||
initialNotes: Note[];
|
||||
}) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
const [text, setText] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [cardOpen, setCardOpen] = useState(false);
|
||||
const [closeCooldown, setCloseCooldown] = useState(false);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!text.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const note = await addNote(sid, text.trim());
|
||||
setNotes((prev) => [note, ...prev]);
|
||||
setText("");
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
setAdding(false);
|
||||
setCardOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(noteId: string) {
|
||||
try {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||
await deleteNote(sid, noteId);
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<HoverCard
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
open={cardOpen}
|
||||
onOpenChange={(r) => r || closeCooldown || setCardOpen(r)}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCloseCooldown(true);
|
||||
setCardOpen(true);
|
||||
setTimeout(() => setCloseCooldown(false), 100);
|
||||
}}
|
||||
>
|
||||
+ เพิ่มโน๊ต
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="พิมพ์โน๊ต..."
|
||||
className="min-w-48"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !text.trim()}
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
|
||||
{notes.map((n, i, _, pal = colorFor(sid, i)) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="group relative min-h-50 px-2"
|
||||
style={{
|
||||
background: `${pal.bg}bb`,
|
||||
border: `${pal.border} 1px solid`,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs" style={{ color: pal.text }}>
|
||||
{n.text}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDelete(n.id)}
|
||||
// className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
className="absolute -top-1.5 -right-1.5 size-5 opacity-0 backdrop-blur-md group-hover:opacity-100"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
endgameSubmissions,
|
||||
endgameTypes,
|
||||
} from "@/lib/db/schema";
|
||||
import { CallButton, CopyButton, DebugSlipUpload, Evernight } from "./client";
|
||||
import { CallButton, CopyButton, DebugSlipUpload, NotesPanel } from "./client";
|
||||
|
||||
export default async function AdminSubmissionView({
|
||||
params,
|
||||
@@ -129,7 +129,7 @@ export default async function AdminSubmissionView({
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden rounded-md border">
|
||||
<Evernight />
|
||||
<NotesPanel sid={sub.id} initialNotes={sub.notes || []} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
endgameSlips,
|
||||
endgameSubmissions,
|
||||
endgameTypes,
|
||||
type Note,
|
||||
settings,
|
||||
} from "@/lib/db/schema";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
@@ -447,6 +448,48 @@ export async function bulkDelete(ids: string[]) {
|
||||
await actionLog(`Bulk deleted ${ids.length} rubgram submissions`);
|
||||
}
|
||||
|
||||
export async function addNote(sid: string, text: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const note: Note = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const [sub] = await db
|
||||
.select({ notes: endgameSubmissions.notes })
|
||||
.from(endgameSubmissions)
|
||||
.where(eq(endgameSubmissions.id, sid))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ notes: [...(sub?.notes || []), note] })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
revalidatePath(`/rubgram/admin/${sid}`);
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
export async function deleteNote(sid: string, noteId: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const [sub] = await db
|
||||
.select({ notes: endgameSubmissions.notes })
|
||||
.from(endgameSubmissions)
|
||||
.where(eq(endgameSubmissions.id, sid))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ notes: (sub?.notes || []).filter((n) => n.id !== noteId) })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
revalidatePath(`/rubgram/admin/${sid}`);
|
||||
}
|
||||
|
||||
export async function discordCall(id: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
|
||||
@@ -161,8 +161,15 @@ export const endgameSubmissions = endgame.table("submissions", {
|
||||
),
|
||||
deleted: boolean().notNull().default(false),
|
||||
submit_day: timestamp().notNull().defaultNow(),
|
||||
notes: jsonb().$type<Note[]>().notNull().default([]),
|
||||
});
|
||||
|
||||
export type Note = {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// expired submission
|
||||
export const endgameExpired = endgame.table("expired", {
|
||||
id: text().primaryKey().$defaultFn(uuidv7),
|
||||
|
||||
Reference in New Issue
Block a user