Files
J-Board-Lite/src/app/(admin)/admin/announcements/announcement-form.tsx
2026-05-01 00:58:46 +10:00

420 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import type {
AnnouncementAudience,
AnnouncementDisplayType,
} from "@prisma/client";
import { toast } from "sonner";
import {
createAnnouncement,
updateAnnouncement,
} from "@/actions/admin/announcements";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { getErrorMessage } from "@/lib/errors";
interface AnnouncementOptionUser {
id: string;
email: string;
}
interface AnnouncementFormData {
id: string;
title: string;
body: string;
audience: AnnouncementAudience;
displayType: AnnouncementDisplayType;
targetUserId: string | null;
dismissible: boolean;
sendNotification: boolean;
startAt: Date | string | null;
endAt: Date | string | null;
}
const audienceLabels: Record<AnnouncementAudience, string> = {
PUBLIC: "公开",
USERS: "全部用户",
ADMINS: "全部管理员",
SPECIFIC_USER: "指定用户",
};
const displayTypeLabels: Record<AnnouncementDisplayType, string> = {
INLINE: "普通公告",
BIG: "大公告",
POPUP: "弹窗公告",
};
function getAudienceLabel(value: unknown) {
return audienceLabels[value as AnnouncementAudience] ?? "选择范围";
}
function getDisplayTypeLabel(value: unknown) {
return displayTypeLabels[value as AnnouncementDisplayType] ?? "选择展示方式";
}
function getTargetUserLabel(users: AnnouncementOptionUser[], value: unknown) {
if (!value) return "不指定";
return users.find((user) => user.id === value)?.email ?? "选择用户";
}
function toDateTimeLocalValue(value: Date | string | null) {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const localTime = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return localTime.toISOString().slice(0, 16);
}
export function AnnouncementForm({
users,
announcement,
triggerLabel,
triggerVariant = "outline",
}: {
users: AnnouncementOptionUser[];
announcement: AnnouncementFormData;
triggerLabel?: string;
triggerVariant?: "default" | "outline" | "ghost";
}) {
const [open, setOpen] = useState(false);
const [audience, setAudience] = useState<AnnouncementAudience>(announcement.audience);
async function handleSubmit(formData: FormData) {
try {
await updateAnnouncement(announcement.id, formData);
toast.success("公告已更新");
setOpen(false);
} catch (error) {
toast.error(getErrorMessage(error, "更新公告失败"));
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setAudience(announcement.audience);
}
setOpen(nextOpen);
}}
>
<DialogTrigger render={<Button variant={triggerVariant} size="sm" />}>
{triggerLabel ?? "编辑"}
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`title-${announcement.id}`}></Label>
<Input id={`title-${announcement.id}`} name="title" defaultValue={announcement.title} required />
</div>
<div className="space-y-1.5">
<Label htmlFor={`audience-${announcement.id}`}></Label>
<Select
name="audience"
value={audience}
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
>
<SelectTrigger id={`audience-${announcement.id}`} className="w-full">
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`displayType-${announcement.id}`}></Label>
<Select
name="displayType"
defaultValue={announcement.displayType}
>
<SelectTrigger id={`displayType-${announcement.id}`} className="w-full">
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`dismissible-${announcement.id}`}></Label>
<BooleanToggle
id={`dismissible-${announcement.id}`}
name="dismissible"
defaultValue={announcement.dismissible}
trueLabel="允许"
falseLabel="不允许"
ariaLabel="允许用户关闭"
size="compact"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor={`targetUserId-${announcement.id}`}></Label>
<Select
name="targetUserId"
defaultValue={announcement.targetUserId ?? ""}
disabled={audience !== "SPECIFIC_USER"}
>
<SelectTrigger id={`targetUserId-${announcement.id}`} className="w-full">
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor={`body-${announcement.id}`}></Label>
<Textarea
id={`body-${announcement.id}`}
name="body"
rows={5}
defaultValue={announcement.body}
required
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`startAt-${announcement.id}`}></Label>
<Input
id={`startAt-${announcement.id}`}
name="startAt"
type="datetime-local"
defaultValue={toDateTimeLocalValue(announcement.startAt)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`endAt-${announcement.id}`}></Label>
<Input
id={`endAt-${announcement.id}`}
name="endAt"
type="datetime-local"
defaultValue={toDateTimeLocalValue(announcement.endAt)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor={`sendNotification-${announcement.id}`}></Label>
<BooleanToggle
id={`sendNotification-${announcement.id}`}
name="sendNotification"
defaultValue={announcement.sendNotification}
trueLabel="发送"
falseLabel="不发送"
ariaLabel="同步发送站内通知"
size="compact"
/>
</div>
<PendingSubmitButton className="h-8 w-full" pendingLabel="保存中...">
</PendingSubmitButton>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);
}
export function CreateAnnouncementButton({
users,
}: {
users: AnnouncementOptionUser[];
}) {
const [open, setOpen] = useState(false);
const [audience, setAudience] = useState<AnnouncementAudience>("USERS");
async function handleSubmit(formData: FormData) {
try {
await createAnnouncement(formData);
toast.success("公告已发布");
setOpen(false);
setAudience("USERS");
} catch (error) {
toast.error(getErrorMessage(error, "发布公告失败"));
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) {
setAudience("USERS");
}
setOpen(nextOpen);
}}
>
<DialogTrigger render={<Button />}></DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden p-0 sm:max-w-[30rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<DialogTitle></DialogTitle>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<form action={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs [&_[data-slot=select-trigger]]:!h-8 [&_[data-slot=select-trigger]]:!min-h-8 [&_[data-slot=select-trigger]]:!px-2.5 [&_[data-slot=select-trigger]]:!text-xs [&_textarea]:!text-xs">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-title"></Label>
<Input id="create-announcement-title" name="title" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-audience"></Label>
<Select
name="audience"
value={audience}
onValueChange={(value) => setAudience((value ?? "USERS") as AnnouncementAudience)}
>
<SelectTrigger id="create-announcement-audience" className="w-full">
<SelectValue>{(value) => getAudienceLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="PUBLIC">/</SelectItem>
<SelectItem value="USERS"></SelectItem>
<SelectItem value="ADMINS"></SelectItem>
<SelectItem value="SPECIFIC_USER"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-displayType"></Label>
<Select
name="displayType"
defaultValue="INLINE"
>
<SelectTrigger id="create-announcement-displayType" className="w-full">
<SelectValue>{(value) => getDisplayTypeLabel(value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="INLINE"></SelectItem>
<SelectItem value="BIG"></SelectItem>
<SelectItem value="POPUP"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-dismissible"></Label>
<BooleanToggle
id="create-announcement-dismissible"
name="dismissible"
defaultValue
trueLabel="允许"
falseLabel="不允许"
ariaLabel="允许用户关闭"
size="compact"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-targetUserId"></Label>
<Select
name="targetUserId"
defaultValue=""
disabled={audience !== "SPECIFIC_USER"}
>
<SelectTrigger id="create-announcement-targetUserId" className="w-full">
<SelectValue>{(value) => getTargetUserLabel(users, value)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value=""></SelectItem>
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-body"></Label>
<Textarea id="create-announcement-body" name="body" rows={5} required />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="create-announcement-startAt"></Label>
<Input id="create-announcement-startAt" name="startAt" type="datetime-local" />
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-endAt"></Label>
<Input id="create-announcement-endAt" name="endAt" type="datetime-local" />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="create-announcement-sendNotification"></Label>
<BooleanToggle
id="create-announcement-sendNotification"
name="sendNotification"
defaultValue
trueLabel="发送"
falseLabel="不发送"
ariaLabel="同步发送站内通知"
size="compact"
/>
</div>
<PendingSubmitButton className="h-8 w-full" pendingLabel="发布中...">
</PendingSubmitButton>
</form>
</DialogBody>
</DialogContent>
</Dialog>
);
}