fix : format document
This commit is contained in:
@@ -15,7 +15,7 @@ export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) {
|
|||||||
<div className="flex gap-3"><Skeleton className="h-11 w-16 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /></div>
|
<div className="flex gap-3"><Skeleton className="h-11 w-16 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /><Skeleton className="h-11 w-11 rounded-full md:h-7" /></div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0 md:w-[280px]">
|
<TableCell className="px-0 py-5 align-top whitespace-normal max-md:block max-md:pt-0 md:w-70">
|
||||||
<div className="flex items-start gap-2 md:justify-end">
|
<div className="flex items-start gap-2 md:justify-end">
|
||||||
<Skeleton className="size-11 shrink-0 rounded-full md:size-7" />
|
<Skeleton className="size-11 shrink-0 rounded-full md:size-7" />
|
||||||
<Skeleton className="size-28 shrink-0 rounded-lg" />
|
<Skeleton className="size-28 shrink-0 rounded-lg" />
|
||||||
|
|||||||
@@ -1,99 +1,328 @@
|
|||||||
'use client';
|
"use client";
|
||||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
import {
|
||||||
import Link from 'next/link';
|
createContext,
|
||||||
import Image from '@/components/ui/resilient-image';
|
useCallback,
|
||||||
import { BellIcon, BellOffIcon, SettingsIcon } from 'lucide-react';
|
useContext,
|
||||||
import { authClient } from '@/lib/auth/client';
|
useEffect,
|
||||||
import { Badge } from '@/components/ui/badge';
|
useRef,
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
useState,
|
||||||
import { Button } from '@/components/ui/button';
|
type ReactNode,
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
} from "react";
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import Link from "next/link";
|
||||||
import { cn } from '@/lib/utils';
|
import Image from "@/components/ui/resilient-image";
|
||||||
import { useSitePushDisabled } from './push-client';
|
import { BellIcon, BellOffIcon, SettingsIcon } from "lucide-react";
|
||||||
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSitePushDisabled } from "./push-client";
|
||||||
|
|
||||||
type Item = { id: string; title: string; body: string; url: string; readAt: string | null; authorName: string | null; authorImage: string | null; imageUrl: string | null };
|
type Item = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url: string;
|
||||||
|
readAt: string | null;
|
||||||
|
authorName: string | null;
|
||||||
|
authorImage: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
|
type Feed = { items: Item[]; unread: number; nextCursor: string | null };
|
||||||
const empty: Feed = { items: [], unread: 0, nextCursor: null };
|
const empty: Feed = { items: [], unread: 0, nextCursor: null };
|
||||||
type State = Feed & { signedIn: boolean; busy: boolean; error: string; refresh: (more?: boolean) => Promise<Feed | undefined>; read: (id?: string) => Promise<void> };
|
type State = Feed & {
|
||||||
const Context = createContext<State>({ ...empty, signedIn: false, busy: false, error: '', refresh: async () => undefined, read: async () => {} });
|
signedIn: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
error: string;
|
||||||
|
refresh: (more?: boolean) => Promise<Feed | undefined>;
|
||||||
|
read: (id?: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
const Context = createContext<State>({
|
||||||
|
...empty,
|
||||||
|
signedIn: false,
|
||||||
|
busy: false,
|
||||||
|
error: "",
|
||||||
|
refresh: async () => undefined,
|
||||||
|
read: async () => {},
|
||||||
|
});
|
||||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||||
const { data } = authClient.useSession();
|
const { data } = authClient.useSession();
|
||||||
const userId = data?.user.id;
|
const userId = data?.user.id;
|
||||||
const [feed, setFeed] = useState<Feed>(empty);
|
const [feed, setFeed] = useState<Feed>(empty);
|
||||||
const [owner, setOwner] = useState<string | undefined>(undefined);
|
const [owner, setOwner] = useState<string | undefined>(undefined);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const current = useRef(feed);
|
const current = useRef(feed);
|
||||||
useEffect(() => { current.current = feed; }, [feed]);
|
useEffect(() => {
|
||||||
|
current.current = feed;
|
||||||
|
}, [feed]);
|
||||||
const sequence = useRef(0);
|
const sequence = useRef(0);
|
||||||
const invalidate = useCallback(() => { ++sequence.current; }, []);
|
const invalidate = useCallback(() => {
|
||||||
const refresh = useCallback(async (more = false): Promise<Feed | undefined> => {
|
++sequence.current;
|
||||||
if (!userId) return;
|
}, []);
|
||||||
const request = ++sequence.current;
|
const refresh = useCallback(
|
||||||
const cursor = more ? current.current.nextCursor : null;
|
async (more = false): Promise<Feed | undefined> => {
|
||||||
setBusy(true);
|
if (!userId) return;
|
||||||
try {
|
const request = ++sequence.current;
|
||||||
const response = await fetch(`/api/notifications${cursor ? `?before=${encodeURIComponent(cursor)}` : ''}`, { cache: 'no-store' });
|
const cursor = more ? current.current.nextCursor : null;
|
||||||
if (!response.ok) throw new Error('โหลดการแจ้งเตือนไม่สำเร็จ');
|
setBusy(true);
|
||||||
const result = await response.json() as Feed;
|
try {
|
||||||
if (sequence.current !== request) return;
|
const response = await fetch(
|
||||||
setFeed(previous => ({ ...result, items: more ? [...previous.items, ...result.items.filter(item => !previous.items.some(old => old.id === item.id))] : result.items }));
|
`/api/notifications${cursor ? `?before=${encodeURIComponent(cursor)}` : ""}`,
|
||||||
setOwner(userId); setError('');
|
{ cache: "no-store" },
|
||||||
return result;
|
);
|
||||||
} catch (cause) { if (sequence.current === request) setError(cause instanceof Error ? cause.message : 'โหลดการแจ้งเตือนไม่สำเร็จ'); }
|
if (!response.ok) throw new Error("โหลดการแจ้งเตือนไม่สำเร็จ");
|
||||||
finally { if (sequence.current === request) setBusy(false); }
|
const result = (await response.json()) as Feed;
|
||||||
}, [userId]);
|
if (sequence.current !== request) return;
|
||||||
|
setFeed((previous) => ({
|
||||||
|
...result,
|
||||||
|
items: more
|
||||||
|
? [
|
||||||
|
...previous.items,
|
||||||
|
...result.items.filter(
|
||||||
|
(item) => !previous.items.some((old) => old.id === item.id),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: result.items,
|
||||||
|
}));
|
||||||
|
setOwner(userId);
|
||||||
|
setError("");
|
||||||
|
return result;
|
||||||
|
} catch (cause) {
|
||||||
|
if (sequence.current === request)
|
||||||
|
setError(
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "โหลดการแจ้งเตือนไม่สำเร็จ",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (sequence.current === request) setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userId],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
void Promise.resolve().then(() => refresh());
|
void Promise.resolve().then(() => refresh());
|
||||||
const source = new EventSource('/api/notifications/events');
|
const source = new EventSource("/api/notifications/events");
|
||||||
source.addEventListener('changed', () => void refresh());
|
source.addEventListener("changed", () => void refresh());
|
||||||
source.addEventListener('open', () => void refresh());
|
source.addEventListener("open", () => void refresh());
|
||||||
return () => { invalidate(); source.close(); };
|
return () => {
|
||||||
|
invalidate();
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
}, [userId, refresh, invalidate]);
|
}, [userId, refresh, invalidate]);
|
||||||
const read = useCallback(async (id?: string) => {
|
const read = useCallback(
|
||||||
if (!userId) return;
|
async (id?: string) => {
|
||||||
setFeed(previous => ({ ...previous, unread: id ? Math.max(0, previous.unread - (previous.items.some(item => item.id === id && !item.readAt) ? 1 : 0)) : 0, items: previous.items.map(item => !id || item.id === id ? { ...item, readAt: new Date().toISOString() } : item) }));
|
if (!userId) return;
|
||||||
try {
|
setFeed((previous) => ({
|
||||||
const response = await fetch('/api/notifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(id ? { id } : {}) });
|
...previous,
|
||||||
if (!response.ok) throw new Error('บันทึกการอ่านไม่สำเร็จ');
|
unread: id
|
||||||
} finally { await refresh(); }
|
? Math.max(
|
||||||
}, [userId, refresh]);
|
0,
|
||||||
return <Context value={{ ...(owner === userId ? feed : empty), signedIn: Boolean(userId), busy, error, refresh, read }}>{children}</Context>;
|
previous.unread -
|
||||||
|
(previous.items.some((item) => item.id === id && !item.readAt)
|
||||||
|
? 1
|
||||||
|
: 0),
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
items: previous.items.map((item) =>
|
||||||
|
!id || item.id === id
|
||||||
|
? { ...item, readAt: new Date().toISOString() }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/notifications", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(id ? { id } : {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("บันทึกการอ่านไม่สำเร็จ");
|
||||||
|
} finally {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userId, refresh],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Context
|
||||||
|
value={{
|
||||||
|
...(owner === userId ? feed : empty),
|
||||||
|
signedIn: Boolean(userId),
|
||||||
|
busy,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
read,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Context>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const state = useContext(Context);
|
const state = useContext(Context);
|
||||||
const disabled = useSitePushDisabled();
|
const disabled = useSitePushDisabled();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return <Popover open={open} onOpenChange={value => {
|
return (
|
||||||
setOpen(value);
|
<Popover
|
||||||
if (value) {
|
open={open}
|
||||||
if (state.unread > 0) void state.read().catch(() => undefined);
|
onOpenChange={(value) => {
|
||||||
else void state.refresh().then(result => {
|
setOpen(value);
|
||||||
if (result?.unread) void state.read().catch(() => undefined);
|
if (value) {
|
||||||
});
|
if (state.unread > 0) void state.read().catch(() => undefined);
|
||||||
}
|
else
|
||||||
}}>
|
void state.refresh().then((result) => {
|
||||||
<PopoverTrigger render={<Button variant="ghost" size="icon" className="relative shrink-0" aria-label={`การแจ้งเตือน${disabled ? ' ปิดชั่วคราว' : state.unread ? ` ใหม่ ${state.unread} รายการ` : ''}`} />}>{disabled ? <BellOffIcon /> : <BellIcon />}
|
if (result?.unread) void state.read().catch(() => undefined);
|
||||||
{!disabled && state.unread > 0 && <Badge variant="destructive" className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive">{state.unread > 9 ? '9+' : state.unread}</Badge>}
|
});
|
||||||
</PopoverTrigger>
|
}
|
||||||
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
}}
|
||||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">การแจ้งเตือน</h2><Button variant="ghost" size="icon-sm" aria-label="ตั้งค่าการแจ้งเตือน" onClick={() => { setOpen(false); window.dispatchEvent(new Event('notifications:enable')); }}><SettingsIcon /></Button></div>
|
>
|
||||||
{!state.signedIn ? <Link href="/auth/login" className="text-sm text-primary underline">เข้าสู่ระบบเพื่อดูการแจ้งเตือน</Link> : <>
|
<PopoverTrigger
|
||||||
{state.unread > 0 && <Button variant="link" size="sm" className="self-start" onClick={() => void state.read().catch(() => undefined)}>อ่านทั้งหมด</Button>}
|
render={
|
||||||
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
|
<Button
|
||||||
{state.items.map(item => <Link key={item.id} href={item.url} prefetch={false} className={cn('flex items-start gap-3 rounded-lg p-3', !item.readAt ? 'bg-accent' : 'hover:bg-muted')} onClick={() => { setOpen(false); void state.read(item.id).catch(() => undefined); }}>
|
variant="ghost"
|
||||||
{item.imageUrl ? <Image src={item.imageUrl} alt="" width={64} height={64} sizes="64px" className="size-16 shrink-0 rounded-lg object-contain" />
|
size="icon"
|
||||||
: (item.authorName || item.authorImage) && <Avatar size="lg"><AvatarImage src={item.authorImage ?? undefined} alt="" /><AvatarFallback>{item.authorName?.slice(0, 2) || '?'}</AvatarFallback></Avatar>}
|
className="relative shrink-0"
|
||||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{!item.readAt && <span className="mr-1 text-destructive" aria-label="ยังไม่ได้อ่าน">●</span>}{item.title}</p><p className="line-clamp-2 break-words text-xs text-muted-foreground">{item.body}</p></div>
|
aria-label={`การแจ้งเตือน${disabled ? " ปิดชั่วคราว" : state.unread ? ` ใหม่ ${state.unread} รายการ` : ""}`}
|
||||||
</Link>)}
|
/>
|
||||||
{state.busy && !state.items.length && <div role="status" aria-label="กำลังโหลดการแจ้งเตือน" className="flex flex-col gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>}
|
}
|
||||||
{!state.busy && !state.items.length && !state.error && <p className="py-6 text-center text-sm text-muted-foreground">ยังไม่มีการแจ้งเตือน</p>}
|
>
|
||||||
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
|
{disabled ? <BellOffIcon /> : <BellIcon />}
|
||||||
{state.nextCursor && <Button variant="ghost" disabled={state.busy} onClick={() => void state.refresh(true)}>แสดงเพิ่มเติม</Button>}
|
{!disabled && state.unread > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-1 -top-1 min-w-5 justify-center rounded-full bg-destructive px-1 text-background dark:bg-destructive"
|
||||||
|
>
|
||||||
|
{state.unread > 9 ? "9+" : state.unread}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)]">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="font-semibold">การแจ้งเตือน</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="ตั้งค่าการแจ้งเตือน"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
window.dispatchEvent(new Event("notifications:enable"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
{!state.signedIn ? (
|
||||||
</PopoverContent>
|
<Link href="/auth/login" className="text-sm text-primary underline">
|
||||||
</Popover>;
|
เข้าสู่ระบบเพื่อดูการแจ้งเตือน
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{state.unread > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => void state.read().catch(() => undefined)}
|
||||||
|
>
|
||||||
|
อ่านทั้งหมด
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex max-h-[60svh] flex-col gap-1 overflow-y-auto">
|
||||||
|
{state.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={item.url}
|
||||||
|
prefetch={false}
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 rounded-lg p-3",
|
||||||
|
!item.readAt ? "bg-accent" : "hover:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
void state.read(item.id).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt=""
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
sizes="64px"
|
||||||
|
className="size-16 shrink-0 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
(item.authorName || item.authorImage) && (
|
||||||
|
<Avatar size="lg">
|
||||||
|
<AvatarImage
|
||||||
|
src={item.authorImage ?? undefined}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{item.authorName?.slice(0, 2) || "?"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="wrap-break-word text-sm font-medium">
|
||||||
|
{!item.readAt && (
|
||||||
|
<span
|
||||||
|
className="mr-1 text-destructive"
|
||||||
|
aria-label="ยังไม่ได้อ่าน"
|
||||||
|
>
|
||||||
|
●
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="line-clamp-2 wrap-break-word text-xs text-muted-foreground">
|
||||||
|
{item.body}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{state.busy && !state.items.length && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="กำลังโหลดการแจ้งเตือน"
|
||||||
|
className="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!state.busy && !state.items.length && !state.error && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
ยังไม่มีการแจ้งเตือน
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.error && (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{state.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.nextCursor && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={state.busy}
|
||||||
|
onClick={() => void state.refresh(true)}
|
||||||
|
>
|
||||||
|
แสดงเพิ่มเติม
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user