feat: add Redis-backed SSE updates
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user