feat : update

This commit is contained in:
2026-10-05 18:47:41 +07:00 Unverified
parent d44a2597ba
commit 2f1fe0ab76
14 changed files with 311 additions and 38 deletions
+38 -12
View File
@@ -49,6 +49,14 @@ import {
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { useAdminPresence } from "@/hooks/use-presence";
import type { CatalogSyncJob } from "@/lib/catalog/sync";
@@ -78,6 +86,7 @@ export function StructuredDashboard({
const router = useRouter();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [sort, setSort] = useState("default");
const [syncJob, setSyncJob] = useState<CatalogSyncJob | null>(null);
const presence = useAdminPresence(null);
const catalogReady = Boolean(catalogVersion);
@@ -86,7 +95,10 @@ export function StructuredDashboard({
const search = searchItems(guides, query,
(guide) => `${guide.name} ${guide.characterKey} ${guide.overview}`,
(guide) => [guide.name]);
const filteredGuides = search.items;
const filteredGuides = sort === "default" ? search.items : [...search.items].sort((left, right) => {
const order = left.name.localeCompare(right.name, "th", { numeric: true });
return sort === "name-asc" ? order : -order;
});
useEffect(() => {
if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return;
@@ -186,17 +198,31 @@ export function StructuredDashboard({
</Card>
) : null}
{guides.length ? (
<InputGroup className="h-11 max-w-2xl">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="ค้นหาตัวละคร…"
aria-label="ค้นหาตัวละคร"
/>
</InputGroup>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<InputGroup className="h-11 max-w-2xl flex-1">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="ค้นหาตัวละคร…"
aria-label="ค้นหาตัวละคร"
/>
</InputGroup>
<Select value={sort} onValueChange={(value) => value && setSort(value)}>
<SelectTrigger aria-label="เรียง Guide" className="h-11 w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="default">เรียงตามเดิม</SelectItem>
<SelectItem value="name-asc">ชื่อ ก-ฮ</SelectItem>
<SelectItem value="name-desc">ชื่อ ฮ-ก</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
) : null}
{filteredGuides.length ? (
+34 -1
View File
@@ -32,12 +32,18 @@ export function AccountForm({
nextPath,
verified,
verificationError,
oidcEnabled,
oidcOnly,
oidcError,
}: {
mode: "login" | "register";
siteKey: string;
nextPath: string;
verified?: boolean;
verificationError?: boolean;
oidcEnabled?: boolean;
oidcOnly?: boolean;
oidcError?: string;
}) {
const router = useRouter();
const register = mode === "register";
@@ -52,7 +58,7 @@ export function AccountForm({
const formRef = useRef<HTMLFormElement>(null);
const container = useRef<HTMLDivElement>(null);
const widget = useRef<string | null>(null);
const captcha = process.env.NODE_ENV !== "development";
const captcha = !oidcOnly && process.env.NODE_ENV !== "development";
useEffect(() => () => {
if (profilePreview) URL.revokeObjectURL(profilePreview);
}, [profilePreview]);
@@ -172,12 +178,38 @@ export function AccountForm({
} finally { setBusy(false); }
}
async function signInWithSudloh() {
if (busy) return;
setBusy(true);
setError("");
try {
const errorCallbackURL = `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`;
const result = await authClient.signIn.social({
provider: "sudloh", callbackURL: nextPath, errorCallbackURL,
});
if (result.error) throw new Error("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง");
} catch {
setError("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง");
setBusy(false);
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>{register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{oidcEnabled && <Button type="button" variant="outline" disabled={busy} onClick={() => void signInWithSudloh()}>
ดำเนินการต่อด้วย Sudloh
</Button>}
{oidcError && <Alert variant="destructive"><AlertDescription>
{oidcError === "account_not_linked"
? oidcOnly ? "บัญชีนี้ยังไม่เชื่อมต่อกับ Buzz Guide กรุณาติดต่อผู้ดูแลเพื่อยืนยันตัวตน"
: "มีบัญชี Buzz Guide ที่ใช้อีเมลนี้แล้ว กรุณาเข้าสู่ระบบด้วยอีเมลและเชื่อมต่อ Sudloh จากหน้าโปรไฟล์"
: "เข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"}
</AlertDescription></Alert>}
{!oidcOnly && <>
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
{verificationError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
{notice && <Alert><AlertDescription>{notice}</AlertDescription></Alert>}
@@ -279,6 +311,7 @@ export function AccountForm({
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>}
</CardContent>
</Card>
);
+12 -4
View File
@@ -1,12 +1,14 @@
import { redirect } from "next/navigation";
import { SiteHeader } from "@/components/public/site-header";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server";
import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { AccountForm } from "./account-form";
import { SudlohSignInRedirect } from "./sudloh-sign-in-redirect";
export async function AccountPage({ mode, next, verified, verificationError }: {
export async function AccountPage({ mode, next, verified, verificationError, oidcError }: {
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean;
oidcError?: string;
}) {
const nextPath = safeAuthReturnPath(next);
const session = await getCustomerSession();
@@ -15,9 +17,15 @@ export async function AccountPage({ mode, next, verified, verificationError }: {
redirect(nextPath);
}
const siteKey = process.env.TURNSTILE_SITE_KEY ?? "";
if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required");
const oidcEnabled = isSudlohOidcEnabled();
const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true";
if (mode === "login" && oidcEnabled && !oidcError) {
return <div className="min-h-svh"><SiteHeader /><SudlohSignInRedirect nextPath={nextPath} /></div>;
}
if (!oidcOnly && process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required");
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath}
verified={verified} verificationError={verificationError} />
oidcEnabled={oidcEnabled} oidcOnly={oidcOnly}
verified={verified} verificationError={verificationError} oidcError={oidcError} />
</main></div>;
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { authClient } from "@/lib/auth/client";
export function SudlohConnection({ linked, callbackError }: {
linked: boolean;
callbackError?: string;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function connect() {
if (busy) return;
setBusy(true);
setError("");
try {
const result = await authClient.linkSocial({
provider: "sudloh",
callbackURL: "/profile?sudloh=linked",
errorCallbackURL: "/profile?sudloh=error",
});
if (result.error) throw new Error();
} catch {
setError("เชื่อมต่อ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง");
setBusy(false);
}
}
const callbackMessage = callbackError === "email_does_not_match"
? "อีเมล Sudloh ต้องตรงกับอีเมล Buzz Guide ก่อนเชื่อมต่อบัญชี"
: callbackError === "account_already_linked_to_different_user"
? "บัญชี Sudloh นี้เชื่อมต่อกับบัญชี Buzz Guide อื่นแล้ว"
: callbackError ? "เชื่อมต่อ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง" : "";
return <Card>
<CardHeader><CardTitle>Sudloh Account</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
{linked ? "เชื่อมต่อแล้ว คุณสามารถใช้ Sudloh เพื่อเข้าสู่ระบบ Buzz Guide"
: "เชื่อมต่อบัญชี Sudloh เพื่อใช้เข้าสู่ระบบ Buzz Guide ในครั้งถัดไป"}
</p>
{!linked && <Button type="button" variant="outline" disabled={busy} onClick={() => void connect()}>
{busy ? "กำลังเชื่อมต่อ..." : "เชื่อมต่อ Sudloh"}
</Button>}
{(callbackMessage || error) && <Alert variant="destructive"><AlertDescription>
{callbackMessage || error}
</AlertDescription></Alert>}
</CardContent>
</Card>;
}
@@ -0,0 +1,33 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
export function SudlohSignInRedirect({ nextPath }: { nextPath: string }) {
const started = useRef(false);
const [failed, setFailed] = useState(false);
const start = useCallback(async () => {
setFailed(false);
const result = await authClient.signIn.social({
provider: "sudloh",
callbackURL: nextPath,
errorCallbackURL: `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`,
});
if (result.error) setFailed(true);
}, [nextPath]);
useEffect(() => {
if (started.current) return;
started.current = true;
void start().catch(() => setFailed(true));
}, [start]);
return <div className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<div className="flex flex-col items-center gap-4 text-center">
<p>{failed ? "ไม่สามารถเริ่มเข้าสู่ระบบได้" : "กำลังไปที่ Sudloh Account…"}</p>
{failed && <Button onClick={() => void start().catch(() => setFailed(true))}>ลองอีกครั้ง</Button>}
</div>
</div>;
}