feat : ask for noti
CI / Verify (push) Successful in 1m50s
CI / Build immutable images and deploy (push) Successful in 2m59s

This commit is contained in:
2026-10-08 02:23:59 +07:00 Unverified
parent 5812d99715
commit 9bc710c94b
34 changed files with 10913 additions and 201 deletions
+9 -3
View File
@@ -9,18 +9,20 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { CommentPage } from "@/lib/comments/types";
import { characterRarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
import { useMarkCommentsRead } from "./admin-unread-comments";
import { CommentPushPrompt } from "./comment-push-prompt";
import { CommentCard } from "./comment-card";
import { CommentCard, CommentThread } from "./comment-card";
import { CommentSkeleton } from "./comment-skeleton";
import { useCommentEvents } from "./use-comment-events";
import { useCommentFeed } from "./use-comment-feed";
type TargetOption = { value: string; label: string; image: string | null; rarity: number | null; characterKey: string | null };
export function AdminCommentInbox({ initial, targets, initialTarget }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string }) {
export function AdminCommentInbox({ initial, targets, initialTarget, initialComment }: { initial: CommentPage; targets: TargetOption[]; initialTarget?: string; initialComment?: string }) {
const [notificationOpen, setNotificationOpen] = useState(Boolean(initialComment));
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
const [status, setStatus] = useState("all");
const [answered, setAnswered] = useState("all");
@@ -31,7 +33,11 @@ export function AdminCommentInbox({ initial, targets, initialTarget }: { initial
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
return <div className="flex flex-col gap-6">
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin />}
{initialComment && initialTarget && <Dialog open={notificationOpen} onOpenChange={setNotificationOpen}><DialogContent className="max-h-[90svh] overflow-y-auto sm:max-w-4xl">
<DialogHeader><DialogTitle>การสนทนา</DialogTitle><DialogDescription>ความคิดเห็นจากการแจ้งเตือนและคำตอบกลับ</DialogDescription></DialogHeader>
{notificationOpen && <CommentThread rootId={initialComment} expectedTarget={initialTarget} onChange={() => feed.refresh({ silent: true })} />}
</DialogContent></Dialog>}
<FieldGroup className="md:grid md:grid-cols-3">
<Field><FieldLabel>ไกด์หรือรอบ Stygian</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={setTarget} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
+1 -1
View File
@@ -39,7 +39,7 @@ export function CommentBoard({ target, initial }: { target: string; initial: Com
return () => window.removeEventListener("hashchange", showThread);
}, []);
return <div className="flex flex-col gap-6 py-2">
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} />}
{feed.page.viewer && <CommentPushPrompt userId={feed.page.viewer.id} admin={feed.page.viewer.admin} />}
<div className="flex items-start gap-3"><Avatar className="size-10 shrink-0"><AvatarImage src={feed.page.viewer?.image ?? undefined} alt={feed.page.viewer?.name ?? "โปรไฟล์"} /><AvatarFallback>{feed.page.viewer?.name?.slice(0, 2) || "คุณ"}</AvatarFallback></Avatar><div className="min-w-0 flex-1">{feed.page.viewer ? <CommentComposer target={target} onSaved={() => void feed.refresh()} />
: <div className="flex flex-col items-start gap-2"><p className="text-sm text-muted-foreground">เข้าสู่ระบบเพื่อแสดงความคิดเห็น ตอบกลับ และกดถูกใจ</p><Button onClick={() => router.push(commentSignInHref())}>เข้าสู่ระบบ</Button></div>}</div></div>
<div className="order-first flex flex-wrap items-center gap-3"><h2 className="font-heading text-xl font-semibold">ความคิดเห็น</h2>
+1
View File
@@ -256,6 +256,7 @@ export function CommentCard({ item, viewer, onChange, revision = 0, inbox = fals
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.targetImage} alt={`ภาพ Overview ของ ${item.targetName}`} loading="lazy" className="aspect-video w-full object-cover" />
</>}
{item.target.startsWith("stygian:") && <Badge variant="secondary" className="whitespace-normal">{item.targetName}</Badge>}
</Link>}
</div>
<Dialog open={history} onOpenChange={setHistory}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>ประวัติการแก้ไข</DialogTitle><DialogDescription>ดูข้อความและรูปภาพในแต่ละเวอร์ชันของความคิดเห็น</DialogDescription></DialogHeader>
-1
View File
@@ -96,7 +96,6 @@ export function CommentComposer({ target, replyTo, editing, onSaved, onCancel }:
});
setText(""); setFiles([]); setKept([]); setExpanded(false);
toast.success(editing ? "แก้ไขความคิดเห็นแล้ว" : "ส่งความคิดเห็นแล้ว");
window.dispatchEvent(new Event("comment:posted"));
onSaved();
} catch (cause) { setError(cause instanceof Error ? cause.message : "ไม่สามารถบันทึกความคิดเห็นได้"); }
finally { setPending(false); uploadRef.current = null; }
+5 -71
View File
@@ -1,72 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export function CommentPushPrompt({ userId }: { userId: string }) {
const [publicKey, setPublicKey] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [enabled, setEnabled] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const askedKey = `comment-push-asked-${userId}`;
const syncedKey = `comment-push-synced-${userId}`;
const syncPush = useCallback(async (key: string) => {
// Reuse the site's existing root service worker so ticket notifications keep working.
const registration = await navigator.serviceWorker.register("/commission-push-sw.js", { scope: "/" });
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
const response = await fetch("/api/comments/push-subscription", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription.toJSON()) });
if (!response.ok) throw new Error("subscription-failed");
sessionStorage.setItem(syncedKey, "1");
}, [syncedKey]);
useEffect(() => {
if (!window.isSecureContext || !("Notification" in window) || !("PushManager" in window) || !("serviceWorker" in navigator)) return;
let active = true;
void fetch("/api/comments/push-subscription", { cache: "no-store" }).then(async (response) => {
if (!response.ok) return;
const config = await response.json() as { publicKey: string };
if (!active) return;
setPublicKey(config.publicKey);
if (Notification.permission === "granted") {
if (!sessionStorage.getItem(syncedKey)) await syncPush(config.publicKey);
if (active) setEnabled(true);
}
}).catch(() => undefined);
return () => { active = false; };
}, [userId, syncedKey, syncPush]);
useEffect(() => {
function ask() {
if (!publicKey || Notification.permission !== "default" || localStorage.getItem(askedKey)) return;
localStorage.setItem(askedKey, "1");
setOpen(true);
}
window.addEventListener("comment:posted", ask);
return () => window.removeEventListener("comment:posted", ask);
}, [publicKey, askedKey]);
async function enable() {
if (!publicKey) return;
setBusy(true); setError("");
try {
if (await Notification.requestPermission() !== "granted") {
setError("ยังไม่ได้อนุญาตการแจ้งเตือน เปิดสิทธิ์การแจ้งเตือนในการตั้งค่าเว็บไซต์ของเบราว์เซอร์");
return;
}
await syncPush(publicKey);
setEnabled(true); setOpen(false);
} catch { setError("เปิดการแจ้งเตือนไม่สำเร็จ กรุณาลองอีกครั้ง"); }
finally { setBusy(false); }
}
if (!publicKey || enabled) return null;
return <>
<Button variant="outline" size="sm" className="self-start" onClick={() => setOpen(true)}><BellIcon data-icon="inline-start" />แจ้งเตือนเมื่อมีคนตอบกลับ</Button>
<Dialog open={open} onOpenChange={setOpen}><DialogContent>
<DialogHeader><DialogTitle>รับการแจ้งเตือนเมื่อมีคนตอบกลับ?</DialogTitle><DialogDescription>เราจะแจ้งเตือนเมื่อมีคนตอบกลับความคิดเห็นของคุณ แม้ปิดแท็บแล้ว กดที่การแจ้งเตือนเพื่อไปยังคำตอบนั้น</DialogDescription></DialogHeader>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setOpen(false)}>ไว้ก่อน</Button><Button disabled={busy} onClick={() => void enable()}>เปิดการแจ้งเตือน</Button></DialogFooter>
</DialogContent></Dialog>
</>;
'use client';
import { BellIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function CommentPushPrompt({ admin = false }: { userId: string; admin?: boolean }) {
return <Button variant="outline" size="sm" className="self-start" onClick={() => window.dispatchEvent(new Event('notifications:enable'))}><BellIcon data-icon="inline-start" />{admin ? 'แจ้งเตือนความคิดเห็นใหม่' : 'แจ้งเตือนเมื่อมีคนตอบกลับ'}</Button>;
}