feat : update

This commit is contained in:
2026-10-03 14:51:40 +07:00 Unverified
parent 6ceff15e20
commit f5cfe0a22e
36 changed files with 47 additions and 70 deletions
+3 -3
View File
@@ -32,8 +32,8 @@ formula fixtures. Their guide text and media are not imported or published.
| `/` | Searchable public character directory |
| `/[character]` | Redirect to the first visible page |
| `/[character]/[page]` | Render a public guide page |
| `/login` | Public account sign-in |
| `/register` | Public account registration |
| `/auth/login` | Public account sign-in |
| `/auth/register` | Public account registration |
| `/admin` | Character and page overview |
| `/admin/[character]/[page]` | Visual page editor |
| `/admin/create` | Create a structured guide from the synced character catalog |
@@ -66,7 +66,7 @@ cp .env.example .env
`.env.example` contains placeholders only. Configure `.env` yourself; it is
ignored by Git and must never be committed. `BETTER_AUTH_URL` must exactly
match the application origin. Set `BASE_URL` to the public site origin for SEO
metadata, sitemap, and robots.txt. Visitors can create public accounts at `/register`.
metadata, sitemap, and robots.txt. Visitors can create public accounts at `/auth/register`.
Prepare the database and start the application:
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function StructuredEditorPage({
params: Promise<{ character: string; page: string }>;
}) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const { character, page } = await params;
const guide = await getGuideEditorHeader(character);
if (!guide) notFound();
+1 -1
View File
@@ -27,7 +27,7 @@ export const instant = false;
export default async function GuideOverviewPage({ params }: { params: Promise<{ character: string }> }) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const { character } = await params;
const guide = await getGuideByCharacterKey(character);
if (!guide) notFound();
@@ -14,7 +14,7 @@ export default async function GuidePreviewPage({
params: Promise<{ character: string; page?: string[] }>;
}) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const { character, page } = await params;
if (page && page.length > 1) notFound();
const header = await getGuideEditorHeader(character);
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function AuditLogPage({
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const query = await searchParams;
const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
+1 -1
View File
@@ -19,7 +19,7 @@ export const instant = false;
export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) {
await connection();
const session = await getAdminSession();
if (!session) redirect("/login?next=%2Fadmin");
if (!session) redirect("/auth/login?next=%2Fadmin");
const query = await searchParams;
const rawPage = Number(query.page ?? 1);
const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1;
+1 -1
View File
@@ -11,7 +11,7 @@ import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries";
export default async function CreateGuidePage() {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [catalog, guides] = await Promise.all([getActiveCatalog(), listGuideCards()]);
if (!catalog) redirect("/admin?sync=required");
return (
+1 -1
View File
@@ -9,7 +9,7 @@ import { getCatalogOptions } from "@/lib/guides/queries";
export default async function GlossaryPage() {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [catalog, aliases] = await Promise.all([
getCatalogOptions(),
listGlossaryAliases(),
+1 -1
View File
@@ -9,7 +9,7 @@ export default async function ImageGeneratorLayout({
children,
}: LayoutProps<"/admin/image">) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
return (
<div className="min-h-svh">
+9 -1
View File
@@ -5,7 +5,15 @@ import { TooltipProvider } from "@/components/ui/tooltip";
export default function AdminLayout({ children }: LayoutProps<"/admin">) {
return (
<TooltipProvider>
<Suspense fallback={null}>{children}</Suspense>
<Suspense
fallback={
<main className="mx-auto flex min-h-svh w-full max-w-7xl items-start p-4 sm:p-6 lg:p-8">
<p role="status" className="text-sm text-muted-foreground">กำลังโหลด…</p>
</main>
}
>
{children}
</Suspense>
</TooltipProvider>
);
}
-7
View File
@@ -1,7 +0,0 @@
export default function AdminLoading() {
return (
<main className="mx-auto flex min-h-svh w-full max-w-7xl items-start p-4 sm:p-6 lg:p-8">
<p role="status" className="text-sm text-muted-foreground">กำลังโหลด…</p>
</main>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const instant = false;
export default async function AdminPage() {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const [guides, trash, catalogVersion] = await Promise.all([
listGuideCards(),
listGuideCards(true),
+1 -1
View File
@@ -11,7 +11,7 @@ import { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/reposito
export default async function AdminStygianPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
await connection();
if (!(await getAdminSession())) redirect("/login?next=%2Fadmin");
if (!(await getAdminSession())) redirect("/auth/login?next=%2Fadmin");
const query = await searchParams;
const schedules = await listStygianSchedules();
const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule;
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default async function TemplatesPage() {
redirect("/admin/create");
}
@@ -5,7 +5,7 @@ import { safeAuthReturnPath } from "@/lib/auth/return-path";
export const instant = false;
export default async function CheckEmailPage({ searchParams }: PageProps<"/check-email">) {
export default async function CheckEmailPage({ searchParams }: PageProps<"/auth/check-email">) {
await connection();
const { email, next, setup } = await searchParams;
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
@@ -3,7 +3,7 @@ import { AccountPage } from "@/components/auth/account-page";
export const instant = false;
export default async function LoginPage({ searchParams }: PageProps<"/login">) {
export default async function LoginPage({ searchParams }: PageProps<"/auth/login">) {
await connection();
const { next, verified, error } = await searchParams;
return <AccountPage mode="login" next={next} verified={verified === "1" && !error}
@@ -4,7 +4,7 @@ import { EmailActionForm } from "@/components/auth/email-action-form";
export const instant = false;
export default async function ResetPasswordPage({ searchParams }: PageProps<"/password-reset">) {
export default async function ResetPasswordPage({ searchParams }: PageProps<"/auth/password-reset">) {
await connection();
const { token, error } = await searchParams;
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
@@ -3,7 +3,7 @@ import { AccountPage } from "@/components/auth/account-page";
export const instant = false;
export default async function RegisterPage({ searchParams }: PageProps<"/register">) {
export default async function RegisterPage({ searchParams }: PageProps<"/auth/register">) {
await connection();
const { next } = await searchParams;
return <AccountPage mode="register" next={next} />;
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function LegacyCommissionLoginPage() {
redirect("/login?next=%2Fcommission");
}
+3 -3
View File
@@ -25,8 +25,8 @@ async function CommissionContent() {
if (!session) return <div className="flex flex-col gap-4 rounded-xl border p-6">
<p>เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอ</p>
<div className="flex flex-wrap gap-2">
<Button nativeButton={false} render={<Link href="/login?next=%2Fcommission" />}>เข้าสู่ระบบ</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/register?next=%2Fcommission" />}>สมัครสมาชิก</Button>
<Button nativeButton={false} render={<Link href="/auth/login?next=%2Fcommission" />}>เข้าสู่ระบบ</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/auth/register?next=%2Fcommission" />}>สมัครสมาชิก</Button>
</div>
</div>;
const catalog = await getCatalogOptions();
@@ -43,7 +43,7 @@ export default function CommissionPage() {
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4 py-10 sm:p-8">
<header><h1 className="font-heading text-3xl font-semibold">Commission</h1>
<p className="text-muted-foreground">ขอทีม เปรียบเทียบอาวุธหรือกลุ่มดาวตามที่ต้องการ</p></header>
<Suspense fallback={<p>กำลังโหลด...</p>}><CommissionContent /></Suspense>
<Suspense fallback={null}><CommissionContent /></Suspense>
</main>
</div>;
}
+1 -1
View File
@@ -21,7 +21,7 @@ export default async function CommissionPayPage({ params }: PageProps<"/commissi
await connection();
const { id } = await params;
const session = await getCustomerSession();
if (!session) redirect(`/login?next=${encodeURIComponent(`/commission/pay/${id}`)}`);
if (!session) redirect(`/auth/login?next=${encodeURIComponent(`/commission/pay/${id}`)}`);
const [checkout] = await getDb().select().from(commissionCheckouts)
.where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, session.user.id))).limit(1);
if (!checkout) notFound();
+1 -1
View File
@@ -16,7 +16,7 @@ export const instant = false;
export default async function CommissionTicketsPage() {
await connection();
const session = await getCustomerSession();
if (!session) redirect("/login?next=%2Fcommission%2Ftickets");
if (!session) redirect("/auth/login?next=%2Fcommission%2Ftickets");
const [tickets, pending] = await Promise.all([
getDb().select({ ticket: commissionTickets, amount: commissionCheckouts.amountBaht })
.from(commissionTickets).innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id))
+2 -2
View File
@@ -20,11 +20,11 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
const nextPath = safeAuthReturnPath(next);
const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath;
const session = await getCustomerSession();
if (!session) redirect("/login?next=%2Fprofile");
if (!session) redirect("/auth/login?next=%2Fprofile");
const [user] = await getDb().select({ name: users.name, email: users.email,
emailVerified: users.emailVerified, image: users.image })
.from(users).where(eq(users.id, session.user.id)).limit(1);
if (!user) redirect("/login?next=%2Fprofile");
if (!user) redirect("/auth/login?next=%2Fprofile");
return <div className="min-h-svh">{isAuthorizedAdmin(session.user) ? <AdminHeader /> : <SiteHeader />}
<main className="mx-auto max-w-5xl p-4 py-10 sm:p-8">
<h1 className="mb-6 font-heading text-3xl font-semibold">โปรไฟล์ของฉัน</h1>
+1 -1
View File
@@ -7,7 +7,7 @@ export default function robots(): MetadataRoute.Robots {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api", "/login", "/register"],
disallow: ["/admin", "/api", "/auth", "/login", "/register"],
},
sitemap: `${siteUrl}/sitemap.xml`,
};
+1 -6
View File
@@ -2579,7 +2579,7 @@ function TeamEditor({
const [importOpen, setImportOpen] = useState(false);
const [importFiles, setImportFiles] = useState<File[]>([]);
const [guideImportOpen, setGuideImportOpen] = useState(false);
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; isPublic: boolean; coverUrl: string }> | null>(null);
const [importGuides, setImportGuides] = useState<Array<{ id: string; name: string; characterKey: string; coverUrl: string }> | null>(null);
const [activeImportGuide, setActiveImportGuide] = useState<TeamImportGuide | null>(null);
const [selectedImports, setSelectedImports] = useState<Record<string, Omit<TeamToolValue, "clientId">>>({});
const [guideImportLoading, setGuideImportLoading] = useState<"guides" | "teams" | null>(null);
@@ -3998,11 +3998,6 @@ function TeamEditor({
<CardHeader className="pt-4">
<CardTitle>{sourceGuide.name}</CardTitle>
<CardDescription>{sourceGuide.characterKey}</CardDescription>
<CardAction>
<Badge variant={sourceGuide.isPublic ? "default" : "secondary"}>
{sourceGuide.isPublic ? "Published" : "Private"}
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="relative pb-4">
<Button type="button" size="xs" variant="outline" onClick={() => void enterImportGuide(sourceGuide.id)}>
@@ -16,7 +16,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -61,9 +60,6 @@ export function GuideVisibilityControl({
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
<Badge variant={isPublic ? "default" : "secondary"}>
{isPublic ? "Public" : "Private"}
</Badge>
<Button
type="button"
variant={isPublic ? "outline" : "default"}
+4 -4
View File
@@ -157,7 +157,7 @@ export function AccountForm({
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
});
if (result.error?.code === "EMAIL_NOT_VERIFIED") {
router.push(`/check-email?email=${encodeURIComponent(email)}&next=${encodeURIComponent(nextPath)}`);
router.push(`/auth/check-email?email=${encodeURIComponent(email)}&next=${encodeURIComponent(nextPath)}`);
return;
}
if (result.error) throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
@@ -262,15 +262,15 @@ export function AccountForm({
</FieldGroup>
</form>
{register ? <Button variant="link" nativeButton={false}
render={<Link href={`/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
render={<Link href={`/auth/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
มีบัญชีแล้ว? เข้าสู่ระบบ
</Button> : <div className="flex items-center justify-between gap-2">
<Button variant="link" size="sm" className="px-0" nativeButton={false}
render={<Link href={`/register${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
render={<Link href={`/auth/register${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
สมัครสมาชิก
</Button>
<Button variant="link" size="sm" className="px-0" nativeButton={false}
render={<Link href="/forgot-password" />}>
render={<Link href="/auth/forgot-password" />}>
ลืมรหัสผ่าน?
</Button>
</div>}
+1 -1
View File
@@ -17,7 +17,7 @@ export function AccountMenu({ admin = false }: { admin?: boolean }) {
async function signOut() {
await unsubscribeCommissionPush().catch(() => undefined);
await authClient.signOut();
router.push(admin ? "/login?next=%2Fadmin" : "/commission");
router.push(admin ? "/auth/login?next=%2Fadmin" : "/commission");
router.refresh();
}
+3 -3
View File
@@ -27,7 +27,7 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
setError("");
try {
if (mode === "forgot") {
const result = await authClient.requestPasswordReset({ email: address, redirectTo: "/reset-password" });
const result = await authClient.requestPasswordReset({ email: address, redirectTo: "/auth/password-reset" });
if (result.error) throw new Error("ส่งลิงก์รีเซ็ตไม่สำเร็จ กรุณาลองอีกครั้ง");
} else {
const password = String(form.get("password") ?? "");
@@ -74,10 +74,10 @@ export function EmailActionForm({ mode, token = "", invalidToken = false }: {
mode === "forgot" ? "ส่งลิงก์รีเซ็ต" : "ตั้งรหัสผ่าน"}</Button>
</FieldGroup>
</form>}
{mode === "reset" && !token && <Button variant="link" nativeButton={false} render={<Link href="/forgot-password" />}>
{mode === "reset" && !token && <Button variant="link" nativeButton={false} render={<Link href="/auth/forgot-password" />}>
ขอลิงก์รีเซ็ตใหม่
</Button>}
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
<Button variant="link" nativeButton={false} render={<Link href="/auth/login" />}>กลับไปเข้าสู่ระบบ</Button>
</CardContent>
</Card>;
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function RegistrationOtpForm({ email, nextPath, setup }: {
<Button variant="link" onClick={resend} disabled={busy || sending}>
{sending ? "กำลังส่ง..." : "ส่งรหัสใหม่"}
</Button>
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
<Button variant="link" nativeButton={false} render={<Link href="/auth/login" />}>กลับไปเข้าสู่ระบบ</Button>
</CardContent>
</Card>;
}
+1 -1
View File
@@ -7,7 +7,7 @@ describe("public auth return path", () => {
});
it("rejects external destinations and auth loops", () => {
for (const value of ["https://example.com", "//example.com", "/\\example.com", "/login", "/lo%67in", "/register", "/admin/login", 4])
for (const value of ["https://example.com", "//example.com", "/\\example.com", "/login", "/lo%67in", "/register", "/auth/login", "/auth/register", "/auth/check-email", "/auth/password-reset", "/admin/login", 4])
expect(safeAuthReturnPath(value)).toBe("/");
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ export function safeAuthReturnPath(value: unknown): string {
try {
const url = new URL(value, "https://auth.local");
const path = decodeURIComponent(url.pathname);
if (url.origin !== "https://auth.local" || ["/login", "/register", "/commission/login", "/admin/login"].includes(path))
if (url.origin !== "https://auth.local" || path === "/auth" || path.startsWith("/auth/") || ["/login", "/register", "/commission/login", "/admin/login"].includes(path))
return "/";
return `${url.pathname}${url.search}${url.hash}`;
} catch {
-1
View File
@@ -187,7 +187,6 @@ export async function listAdminTeamImportGuides(excludeGuideId: string) {
id: guides.id,
name: guides.name,
characterKey: guides.characterKey,
isPublic: guides.isPublic,
coverObjectKey: media.objectKey,
})
.from(guides)
-4
View File
@@ -7,10 +7,6 @@ const nextConfig: NextConfig = {
async headers() {
return [{ source: "/:path*", headers: securityHeaders(process.env.NODE_ENV === "production", "https://cdn.jsdelivr.net") }];
},
async rewrites() {
// The reset-password segment resolves to the dynamic guide route in this Next version.
return { beforeFiles: [{ source: "/reset-password", destination: "/password-reset" }] };
},
cacheComponents: true,
cacheHandlers: {
remote: resolve(process.cwd(), "cache-handlers/redis-handler.cjs"),
+1 -1
View File
@@ -53,7 +53,7 @@ describe("public rendering performance", () => {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin", "/api", "/login", "/register"],
disallow: ["/admin", "/api", "/auth", "/login", "/register"],
},
sitemap: "https://guide.example.test/sitemap.xml",
});