feat: add Redis-backed SSE updates

This commit is contained in:
2026-07-24 21:31:47 +07:00 Unverified
parent 5cc7edaa13
commit 29df784aeb
22 changed files with 912 additions and 96 deletions
+3 -2
View File
@@ -57,13 +57,14 @@ export async function refetchPlatform(
? JSON.parse(cached)
: {};
counts[platform] = count;
await redis.set("erika:follower_counts", JSON.stringify(counts), { ex: 3600 });
await redis.set("erika:follower_counts", JSON.stringify(counts), "EX", 3600);
if (tiktokProfile) {
await redis.set(
"erika:tiktok_profile",
JSON.stringify(tiktokProfile),
{ ex: 3600 }
"EX",
3600,
);
}
} catch {
+58 -2
View File
@@ -78,6 +78,14 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formDirtyRef = useRef(false);
const questionsDirtyRef = useRef(false);
const formSavingRef = useRef(false);
const questionsSavingRef = useRef(false);
const formRevisionRef = useRef(0);
const questionsRevisionRef = useRef(0);
const activeFormSaveRef = useRef(0);
const activeQuestionsSaveRef = useRef(0);
// Refs to always read latest state inside debounced callbacks
const formTitleRef = useRef(formTitle);
@@ -98,6 +106,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
formTimerRef.current = setTimeout(async () => {
const title = formTitleRef.current;
if (!title.trim()) return;
const revision = formRevisionRef.current;
activeFormSaveRef.current = revision;
formSavingRef.current = true;
setFormSaveStatus("saving");
try {
await updateForm(form.id, {
@@ -105,12 +116,19 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
});
if (formRevisionRef.current === revision) {
formDirtyRef.current = false;
}
setFormSaveStatus("saved");
router.refresh();
setTimeout(() => setFormSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save form settings.");
setFormSaveStatus("idle");
} finally {
if (activeFormSaveRef.current === revision) {
formSavingRef.current = false;
}
}
}, 1000);
}, [form.id, router]);
@@ -121,6 +139,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
questionsTimerRef.current = setTimeout(async () => {
const current = questionsRef.current;
if (current.length === 0) return;
const revision = questionsRevisionRef.current;
activeQuestionsSaveRef.current = revision;
questionsSavingRef.current = true;
setQuestionsSaveStatus("saving");
try {
const formattedUpdates = current.map(q => ({
@@ -129,12 +150,19 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
allowOther: q.allowOther,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
if (questionsRevisionRef.current === revision) {
questionsDirtyRef.current = false;
}
setQuestionsSaveStatus("saved");
router.refresh();
setTimeout(() => setQuestionsSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save questions.");
setQuestionsSaveStatus("idle");
} finally {
if (activeQuestionsSaveRef.current === revision) {
questionsSavingRef.current = false;
}
}
}, 1500);
}, [form.id, router]);
@@ -148,16 +176,22 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
}, []);
const handleFormTitleChange = (val: string) => {
formDirtyRef.current = true;
formRevisionRef.current += 1;
setFormTitle(val);
autoSaveForm();
};
const handleFormDescriptionChange = (val: string) => {
formDirtyRef.current = true;
formRevisionRef.current += 1;
setFormDescription(val);
autoSaveForm();
};
const handleFormIsOpenChange = (val: boolean) => {
formDirtyRef.current = true;
formRevisionRef.current += 1;
setFormIsOpen(val);
autoSaveForm();
};
@@ -179,6 +213,8 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
};
const handleUpdateQuestion = (id: string, updates: Partial<Question>) => {
questionsDirtyRef.current = true;
questionsRevisionRef.current += 1;
setQuestions((prev) =>
prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q))
);
@@ -188,6 +224,11 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const handleDeleteQuestion = async (id: string) => {
try {
await deleteQuestion(id, form.id);
setQuestions((current) => {
const next = current.filter((question) => question.id !== id);
questionsRef.current = next;
return next;
});
router.refresh();
} catch (err: unknown) {
console.error("Failed to delete question:", err);
@@ -208,6 +249,8 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
q.displayOrder = idx;
});
questionsDirtyRef.current = true;
questionsRevisionRef.current += 1;
setQuestions(newQuestions);
autoSaveQuestions();
};
@@ -287,8 +330,21 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
};
useEffect(() => {
const timeout = setTimeout(() => setQuestions(initialQuestions), 0);
return () => clearTimeout(timeout);
if (formDirtyRef.current || formSavingRef.current) return;
setFormTitle(form.title);
setFormDescription(form.description || "");
setFormIsOpen(form.isOpen);
formTitleRef.current = form.title;
formDescriptionRef.current = form.description || "";
formIsOpenRef.current = form.isOpen;
}, [form]);
useEffect(() => {
if (questionsDirtyRef.current || questionsSavingRef.current) return;
setQuestions(initialQuestions);
questionsRef.current = initialQuestions;
}, [initialQuestions]);
return (
+83 -38
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, useTransition, useEffect, useRef } from "react";
import { useCallback, useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -45,12 +45,21 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
const [roles, setRoles] = useState<DiscordRole[]>([]);
const [loadingRoles, setLoadingRoles] = useState(true);
const [, startSaveTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<AutoSaveStatus>("idle");
const [lastEdited, setLastEdited] = useState<"access" | "webhook" | null>(null);
const isFirstRender = useRef(true);
const saveTimeout = useRef<NodeJS.Timeout | null>(null);
const dirtyRef = useRef(false);
const savingRef = useRef(false);
const revisionRef = useRef(0);
const activeSaveRef = useRef(0);
const valuesRef = useRef({
webhookUrl,
webhookTemplate,
webhookUpdateTemplate,
allowedRoles,
deniedRoles,
});
useEffect(() => {
getGuildRoles()
@@ -59,55 +68,91 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
.finally(() => setLoadingRoles(false));
}, []);
const handleSave = useCallback(() => {
setSaveStatus("saving");
startSaveTransition(async () => {
useEffect(() => {
valuesRef.current = {
webhookUrl,
webhookTemplate,
webhookUpdateTemplate,
allowedRoles,
deniedRoles,
};
}, [
allowedRoles,
deniedRoles,
webhookTemplate,
webhookUpdateTemplate,
webhookUrl,
]);
const scheduleSave = useCallback(() => {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
setSaveStatus("idle");
saveTimeout.current = setTimeout(async () => {
const revision = revisionRef.current;
const values = valuesRef.current;
activeSaveRef.current = revision;
savingRef.current = true;
setSaveStatus("saving");
try {
await updateForm(form.id, {
discordWebhookUrl: webhookUrl.trim() || null,
discordWebhookTemplate: webhookTemplate.trim() || null,
discordWebhookUpdateTemplate: webhookUpdateTemplate.trim() || null,
allowedRoles,
deniedRoles,
discordWebhookUrl: values.webhookUrl.trim() || null,
discordWebhookTemplate: values.webhookTemplate.trim() || null,
discordWebhookUpdateTemplate: values.webhookUpdateTemplate.trim() || null,
allowedRoles: values.allowedRoles,
deniedRoles: values.deniedRoles,
});
if (revisionRef.current === revision) {
dirtyRef.current = false;
}
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
router.refresh();
} catch (err: unknown) {
setSaveStatus("idle");
toast.error(getErrorMessage(err, "Failed to update settings."));
} finally {
if (activeSaveRef.current === revision) {
savingRef.current = false;
}
}
});
}, [
allowedRoles,
deniedRoles,
form.id,
router,
startSaveTransition,
webhookTemplate,
webhookUpdateTemplate,
webhookUrl,
]);
}, 1000);
}, [form.id, router]);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => {
handleSave();
}, 1000);
return () => {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
};
}, [handleSave]);
}, []);
useEffect(() => {
if (dirtyRef.current || savingRef.current) return;
const nextValues = {
webhookUrl: form.discordWebhookUrl || "",
webhookTemplate: form.discordWebhookTemplate || "",
webhookUpdateTemplate: form.discordWebhookUpdateTemplate || "",
allowedRoles: form.allowedRoles || [],
deniedRoles: form.deniedRoles || [],
};
setWebhookUrl(nextValues.webhookUrl);
setWebhookTemplate(nextValues.webhookTemplate);
setWebhookUpdateTemplate(nextValues.webhookUpdateTemplate);
setAllowedRoles(nextValues.allowedRoles);
setDeniedRoles(nextValues.deniedRoles);
valuesRef.current = nextValues;
}, [form]);
const markEdited = (section: "access" | "webhook") => {
dirtyRef.current = true;
revisionRef.current += 1;
setLastEdited(section);
scheduleSave();
};
const toggleRole = (list: string[], setList: (v: string[]) => void, roleId: string) => {
setLastEdited("access");
markEdited("access");
if (list.includes(roleId)) {
setList(list.filter(id => id !== roleId));
} else {
@@ -232,7 +277,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
<Input
id="webhook-url"
value={webhookUrl}
onChange={(e) => { setLastEdited("webhook"); setWebhookUrl(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookUrl(e.target.value); }}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
@@ -241,7 +286,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
<Textarea
id="webhook-template"
value={webhookTemplate}
onChange={(e) => { setLastEdited("webhook"); setWebhookTemplate(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookTemplate(e.target.value); }}
placeholder="New submission received from {Name} for skill: {skill}!"
className="min-h-24"
/>
@@ -254,7 +299,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
<Textarea
id="webhook-update-template"
value={webhookUpdateTemplate}
onChange={(e) => { setLastEdited("webhook"); setWebhookUpdateTemplate(e.target.value); }}
onChange={(e) => { markEdited("webhook"); setWebhookUpdateTemplate(e.target.value); }}
placeholder="Submission updated by {Name} for skill: {skill}!"
className="min-h-24"
/>
+14 -10
View File
@@ -1,6 +1,7 @@
import { db } from "@/db";
import { notFound } from "next/navigation";
import FormExtraClient from "./client";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -19,17 +20,20 @@ export default async function AdminExtraPage(
}
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Extra Settings</h1>
<p className="text-muted-foreground mt-1">
Configure Discord webhook integrations.
</p>
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Extra Settings</h1>
<p className="text-muted-foreground mt-1">
Configure Discord webhook integrations.
</p>
</div>
</div>
</div>
<FormExtraClient form={form} />
</div>
<FormExtraClient form={form} />
</div>
</>
);
}
+14 -10
View File
@@ -1,6 +1,7 @@
import { db } from "@/db";
import FormEditorClient from "./client";
import { notFound } from "next/navigation";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -24,17 +25,20 @@ export default async function AdminDashboardPage(
});
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit Form</h1>
<p className="text-muted-foreground mt-1">
Customize form settings, add/edit questions, or upload images.
</p>
<>
<RealtimeRefresh topic="forms" formId={form.id} />
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit Form</h1>
<p className="text-muted-foreground mt-1">
Customize form settings, add/edit questions, or upload images.
</p>
</div>
</div>
</div>
<FormEditorClient form={form} initialQuestions={questionsList} />
</div>
<FormEditorClient form={form} initialQuestions={questionsList} />
</div>
</>
);
}
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
import SubmissionDetailClient from "./client";
import { DeleteSubmissionButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh";
interface SubmissionDetailPageProps {
params: Promise<{
@@ -48,6 +49,7 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6 pb-10">
<RealtimeRefresh topic="submissions" formId={formId} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/admin/form/${formId}/result`}>
+2
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Users, ArrowLeft } from "lucide-react";
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "@/components/delete-buttons";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -21,6 +22,7 @@ export default async function ResultsListPage(
return (
<div className="space-y-6 w-full max-w-6xl mx-auto p-4 md:p-6">
<RealtimeRefresh topic="submissions" formId={params.id} />
<div>
<Button variant="ghost" asChild className="mb-4">
<Link href={`/admin/form/${params.id}`}>
+21
View File
@@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/auth";
import { sanitizeHtml } from "@/lib/sanitize-html";
import { sse } from "@/lib/sse";
export async function createForm(formData: FormData) {
await requireAdmin();
@@ -19,6 +20,11 @@ export async function createForm(formData: FormData) {
.values({ title, description: description ? sanitizeHtml(description) : null })
.returning();
void sse.forms.pub("update", {
formId: form.id,
entity: "form",
action: "created",
});
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
@@ -31,6 +37,11 @@ export async function createDefaultForm() {
.values({ title: "New Form", description: "" })
.returning();
void sse.forms.pub("update", {
formId: form.id,
entity: "form",
action: "created",
});
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
@@ -48,6 +59,11 @@ export async function updateForm(id: string, data: { title?: string; description
};
await db.update(forms).set(nextData).where(eq(forms.id, id));
void sse.forms.pub("update", {
formId: id,
entity: "form",
action: "updated",
});
revalidatePath("/form");
revalidatePath("/admin/form");
}
@@ -56,5 +72,10 @@ export async function deleteForm(id: string) {
await requireAdmin();
await db.delete(forms).where(eq(forms.id, id));
void sse.forms.pub("update", {
formId: id,
entity: "form",
action: "deleted",
});
revalidatePath("/admin/form");
}
+2
View File
@@ -14,6 +14,7 @@ import { Plus } from "lucide-react";
import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShellServer } from "@/components/admin-shell-server";
import { createDefaultForm } from "./actions";
import { RealtimeRefresh } from "@/components/realtime-refresh";
export const dynamic = "force-dynamic";
@@ -24,6 +25,7 @@ export default async function AdminFormsPage() {
return (
<AdminShellServer>
<RealtimeRefresh topic="forms" />
<div className="space-y-6 p-4 md:p-6 pb-12 w-full">
<div className="flex items-center justify-between">
<div>