feat(rubgram): add notes panel on admin
Build and Push Image / build-frontend (push) Successful in 56m40s
Build and Push Image / build-backend (push) Successful in 59m25s
Build and Push Image / push (push) Successful in 58m42s

This commit is contained in:
2026-06-20 22:12:29 +07:00 Unverified
parent 4eb402c5a9
commit 65749d2503
4 changed files with 241 additions and 5 deletions
+189 -3
View File
@@ -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 }) {
+2 -2
View File
@@ -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>
+43
View File
@@ -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";
+7
View File
@@ -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),