feat(guides) : add safer publishing and discovery
CI / Verify (push) Successful in 1m35s
CI / Build immutable images and deploy (push) Successful in 5m27s

This commit is contained in:
2026-09-19 13:32:57 +07:00 Unverified
parent 6698dc1302
commit 9896e8a4ad
27 changed files with 3213 additions and 108 deletions
+22 -3
View File
@@ -4,9 +4,6 @@ on:
push: push:
branches: branches:
- main - main
pull_request:
branches:
- main
env: env:
BASE_URL: https://guide.sudloh.com BASE_URL: https://guide.sudloh.com
@@ -16,8 +13,30 @@ env:
KUBE_API_SERVER: https://100.112.189.33:6443/ KUBE_API_SERVER: https://100.112.189.33:6443/
jobs: jobs:
verify:
name: Verify
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Set up kubectl
uses: azure/setup-kubectl@v4
- name: Verify application and manifests
run: |
bun install --frozen-lockfile
bun run test
bun run typecheck
bun run lint
kubectl kustomize k8s/ >/dev/null
build-and-deploy: build-and-deploy:
name: Build immutable images and deploy name: Build immutable images and deploy
needs: verify
if: >- if: >-
gitea.event_name == 'push' && gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main' && gitea.ref == 'refs/heads/main' &&
+3
View File
@@ -84,6 +84,9 @@ export default async function StructuredEditorPage({
</h1> </h1>
<EditorPresence guideId={guide.id} section={sectionTitle} /> <EditorPresence guideId={guide.id} section={sectionTitle} />
</div> </div>
<Button className="ml-auto" variant="outline" render={<Link href={`/admin/${guide.characterKey}/preview/${page}`} target="_blank" />} nativeButton={false}>
Preview
</Button>
</header> </header>
<GuideSectionNav <GuideSectionNav
guideId={guide.id} guideId={guide.id}
+4 -1
View File
@@ -51,7 +51,10 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
<h1 className="font-heading text-3xl font-semibold">{guide.name}</h1> <h1 className="font-heading text-3xl font-semibold">{guide.name}</h1>
<EditorPresence guideId={guide.id} section="Overview" /> <EditorPresence guideId={guide.id} section="Overview" />
</div> </div>
<GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} /> <GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} version={guide.version} />
<Button variant="outline" render={<Link href={`/admin/${guide.characterKey}/preview`} target="_blank" />} nativeButton={false}>
Preview
</Button>
</header> </header>
<GuideSectionNav guideId={guide.id} characterKey={guide.characterKey} current="overview" extras={extras} /> <GuideSectionNav guideId={guide.id} characterKey={guide.characterKey} current="overview" extras={extras} />
<GuideOverviewForm key={guide.version} guide={guide} /> <GuideOverviewForm key={guide.version} guide={guide} />
@@ -0,0 +1,27 @@
import { notFound, redirect } from "next/navigation";
import { connection } from "next/server";
import { StructuredGuideShell } from "@/components/public/structured-guide";
import { getAdminSession } from "@/lib/auth/server";
import { publicGuideSections } from "@/lib/guides/public-sections";
import { getAdminGuideContent, getGuideEditorHeader } from "@/lib/guides/queries";
export const instant = false;
export default async function GuidePreviewPage({
params,
}: {
params: Promise<{ character: string; page?: string[] }>;
}) {
await connection();
if (!(await getAdminSession())) redirect("/admin/login");
const { character, page } = await params;
if (page && page.length > 1) notFound();
const header = await getGuideEditorHeader(character);
if (!header) notFound();
const guide = await getAdminGuideContent(header.slug);
if (!guide) notFound();
const section = page?.[0];
if (section && !publicGuideSections(guide).some((item) => item.slug === section)) notFound();
return <StructuredGuideShell guide={guide} section={section} preview />;
}
+36 -10
View File
@@ -9,6 +9,10 @@ import {
deleteGlossaryAlias, deleteGlossaryAlias,
} from "@/lib/glossary-repository"; } from "@/lib/glossary-repository";
import { requireAdmin } from "@/lib/auth/server"; import { requireAdmin } from "@/lib/auth/server";
import { getCurrentCatalogVersion } from "@/lib/catalog/sync";
import { publicGuideSections } from "@/lib/guides/public-sections";
import { publicationIssues } from "@/lib/guides/publication";
import { getAdminGuideContentById } from "@/lib/guides/queries";
import { import {
createExtraSection, createExtraSection,
createGuide, createGuide,
@@ -20,7 +24,6 @@ import {
saveArtifacts, saveArtifacts,
saveConstellations, saveConstellations,
saveExtraSection, saveExtraSection,
saveExtraSections,
saveTeams, saveTeams,
setGuideVisibility, setGuideVisibility,
saveWeapons, saveWeapons,
@@ -126,13 +129,6 @@ export async function loadTeamImportGuide(guideId: string) {
} }
} }
export async function saveExtraSectionList(guideId: string, input: unknown) {
await requireAdmin();
const result = await runGuideAction(() => saveExtraSections(guideId, input));
if (result.status === "saved") updateTag(pageCacheTag(guideId));
return result;
}
export async function createGuideExtra(guideId: string, input: unknown) { export async function createGuideExtra(guideId: string, input: unknown) {
await requireAdmin(); await requireAdmin();
try { try {
@@ -204,10 +200,40 @@ export async function restoreStructuredGuide(guideId: string) {
export async function setStructuredGuideVisibility( export async function setStructuredGuideVisibility(
guideId: string, guideId: string,
isPublic: boolean, input: unknown,
) { ) {
await requireAdmin(); await requireAdmin();
return runGuideAction(() => setGuideVisibility(guideId, isPublic)); const parsed = z.strictObject({
isPublic: z.boolean(),
expectedVersion: z.number().int().positive(),
publicationNote: z.string().trim().max(500).optional(),
}).safeParse(input);
if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" };
if (!parsed.data.isPublic) {
return runGuideAction(() => setGuideVisibility(guideId, parsed.data));
}
const [guide, catalogVersion] = await Promise.all([
getAdminGuideContentById(guideId),
getCurrentCatalogVersion(),
]);
const issues = publicationIssues({
exists: Boolean(guide),
overview: guide?.overview ?? "",
coverReady: guide?.coverStatus === "ready",
catalogVersion,
renderableSectionCount: guide ? publicGuideSections(guide).length : 0,
version: guide?.version ?? 0,
expectedVersion: parsed.data.expectedVersion,
});
if (issues.length || !catalogVersion) {
return { status: "error" as const, message: issues.join(" • "), issues };
}
return runGuideAction(() => setGuideVisibility(guideId, {
...parsed.data,
publicationNote: parsed.data.publicationNote || null,
catalogVersion,
}));
} }
export async function deleteStructuredGuide(guideId: string) { export async function deleteStructuredGuide(guideId: string) {
+34 -3
View File
@@ -19,28 +19,41 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function GuideVisibilityControl({ export function GuideVisibilityControl({
guideId, guideId,
guideName, guideName,
isPublic, isPublic,
version,
className, className,
}: { }: {
guideId: string; guideId: string;
guideName: string; guideName: string;
isPublic: boolean; isPublic: boolean;
version: number;
className?: string; className?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [publicationNote, setPublicationNote] = useState("");
const [error, setError] = useState<string | null>(null);
const updateVisibility = (nextPublic: boolean) => { const updateVisibility = (nextPublic: boolean) => {
startTransition(async () => { startTransition(async () => {
const result = await setStructuredGuideVisibility(guideId, nextPublic); setError(null);
const result = await setStructuredGuideVisibility(guideId, {
isPublic: nextPublic,
expectedVersion: version,
publicationNote,
});
if (result.status !== "saved") { if (result.status !== "saved") {
toast.error(result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ"); const message = result.message ?? "เปลี่ยนสถานะ Guide ไม่สำเร็จ";
setError(message);
toast.error(message);
return; return;
} }
toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว"); toast.success(nextPublic ? "เผยแพร่ Guide แล้ว" : "เปลี่ยน Guide เป็น Private แล้ว");
@@ -78,12 +91,30 @@ export function GuideVisibilityControl({
: "Guide จะแสดงบนหน้าเว็บไซต์และทุกคนจะเปิดดูผ่านลิงก์สาธารณะได้"} : "Guide จะแสดงบนหน้าเว็บไซต์และทุกคนจะเปิดดูผ่านลิงก์สาธารณะได้"}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{!isPublic ? (
<Textarea
value={publicationNote}
onChange={(event) => setPublicationNote(event.target.value)}
maxLength={500}
placeholder="สรุปสิ่งที่อัปเดต (ไม่บังคับ)"
aria-label="สรุปสิ่งที่อัปเดต"
/>
) : null}
{error ? (
<Alert variant="destructive">
<AlertTitle>ยังเผยแพร่ไม่ได้</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={pending}>ยกเลิก</AlertDialogCancel> <AlertDialogCancel disabled={pending}>ยกเลิก</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
variant={isPublic ? "destructive" : "default"} variant={isPublic ? "destructive" : "default"}
disabled={pending} disabled={pending}
onClick={() => updateVisibility(!isPublic)} onClick={(event) => {
event.preventDefault();
updateVisibility(!isPublic);
}}
> >
{pending && <Spinner data-icon="inline-start" />} {pending && <Spinner data-icon="inline-start" />}
{isPublic ? "ยืนยัน Private" : "ยืนยัน Public"} {isPublic ? "ยืนยัน Private" : "ยืนยัน Public"}
@@ -258,6 +258,7 @@ export function StructuredDashboard({
guideId={guide.id} guideId={guide.id}
guideName={guide.name} guideName={guide.name}
isPublic={guide.isPublic} isPublic={guide.isPublic}
version={guide.version}
/> />
<DeleteConfirmationDialog <DeleteConfirmationDialog
showSuccessToast={false} showSuccessToast={false}
+91 -5
View File
@@ -4,8 +4,9 @@ import { searchItems } from "@/lib/search";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { SearchIcon, UsersIcon } from "lucide-react"; import { SearchIcon, UsersIcon } from "lucide-react";
import { useState } from "react"; import { useMemo } from "react";
import { import {
Card, Card,
@@ -27,6 +28,14 @@ import {
InputGroupInput, InputGroupInput,
} from "@/components/ui/input-group"; } from "@/components/ui/input-group";
import { useVisitorPresence } from "@/hooks/use-presence"; import { useVisitorPresence } from "@/hooks/use-presence";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type GuideCard = { type GuideCard = {
id: string; id: string;
@@ -36,15 +45,48 @@ type GuideCard = {
coverUrl: string; coverUrl: string;
version: number; version: number;
aliases: string[]; aliases: string[];
element: string | null;
weaponType: string | null;
rarity: number | null;
updatedAt: Date;
publishedAt: Date | null;
publishedCatalogVersion: string | null;
}; };
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState(""); const searchParams = useSearchParams();
const query = searchParams.get("q") ?? "";
const presence = useVisitorPresence(null); const presence = useVisitorPresence(null);
const search = searchItems(guides, query, const elements = useMemo(() => [...new Set(guides.flatMap((guide) => guide.element ? [guide.element] : []))].sort(), [guides]);
const weapons = useMemo(() => [...new Set(guides.flatMap((guide) => guide.weaponType ? [guide.weaponType] : []))].sort(), [guides]);
const rarities = useMemo(() => [...new Set(guides.flatMap((guide) => guide.rarity ? [guide.rarity] : []))].sort((a, b) => b - a), [guides]);
const elementParam = searchParams.get("element");
const weaponParam = searchParams.get("weapon");
const rarityParam = searchParams.get("rarity");
const element = elementParam && elements.includes(elementParam) ? elementParam : "all";
const weapon = weaponParam && weapons.includes(weaponParam) ? weaponParam : "all";
const rarity = rarityParam && rarities.includes(Number(rarityParam)) ? rarityParam : "all";
const sort = searchParams.get("sort") === "recent" ? "recent" : "manual";
const matchingGuides = guides.filter((guide) =>
(element === "all" || guide.element === element) &&
(weapon === "all" || guide.weaponType === weapon) &&
(rarity === "all" || guide.rarity === Number(rarity)),
);
const search = searchItems(matchingGuides, query,
(guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`, (guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`,
(guide) => [guide.name, ...guide.aliases]); (guide) => [guide.name, ...guide.aliases]);
const filtered = search.items; const filtered = sort === "recent"
? [...search.items].sort((left, right) =>
new Date(right.publishedAt ?? right.updatedAt).getTime() - new Date(left.publishedAt ?? left.updatedAt).getTime())
: search.items;
function setParam(name: string, value: string) {
const next = new URLSearchParams(searchParams.toString());
if (!value || value === "all" || (name === "sort" && value === "manual")) next.delete(name);
else next.set(name, value);
const suffix = next.toString();
window.history.replaceState(null, "", suffix ? `?${suffix}` : window.location.pathname);
}
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<InputGroup className="h-11 max-w-2xl"> <InputGroup className="h-11 max-w-2xl">
@@ -53,11 +95,17 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</InputGroupAddon> </InputGroupAddon>
<InputGroupInput <InputGroupInput
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setParam("q", event.target.value)}
placeholder="ค้นหาไกด์…" placeholder="ค้นหาไกด์…"
aria-label="ค้นหาไกด์" aria-label="ค้นหาไกด์"
/> />
</InputGroup> </InputGroup>
<div className="flex flex-wrap gap-2" aria-label="ตัวกรองไกด์">
<GuideFilter label="ธาตุ" value={element} options={elements} onChange={(value) => setParam("element", value)} />
<GuideFilter label="อาวุธ" value={weapon} options={weapons} onChange={(value) => setParam("weapon", value)} />
<GuideFilter label="ดาว" value={rarity} options={rarities.map(String)} onChange={(value) => setParam("rarity", value)} />
<GuideFilter label="เรียงตาม" value={sort} options={["manual", "recent"]} optionLabels={{ manual: "แนะนำ", recent: "อัปเดตล่าสุด" }} includeAll={false} onChange={(value) => setParam("sort", value)} />
</div>
{filtered.length ? ( {filtered.length ? (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
@@ -92,6 +140,12 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
<CardDescription className="line-clamp-3"> <CardDescription className="line-clamp-3">
{guide.overview} {guide.overview}
</CardDescription> </CardDescription>
<div className="flex flex-wrap gap-2 pt-2">
{guide.publishedCatalogVersion ? <Badge variant="outline">v{guide.publishedCatalogVersion}</Badge> : null}
<Badge variant="secondary">
อัปเดต {new Date(guide.publishedAt ?? guide.updatedAt).toLocaleDateString("th-TH")}
</Badge>
</div>
</CardHeader> </CardHeader>
</Card> </Card>
</Link> </Link>
@@ -108,3 +162,35 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</div> </div>
); );
} }
function GuideFilter({
label,
value,
options,
optionLabels = {},
includeAll = true,
onChange,
}: {
label: string;
value: string;
options: string[];
optionLabels?: Record<string, string>;
includeAll?: boolean;
onChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={(next) => next && onChange(next)}>
<SelectTrigger aria-label={label}>
<SelectValue>{optionLabels[value] ?? (value === "all" ? label : value)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{includeAll ? <SelectItem value="all">{label}: ทั้งหมด</SelectItem> : null}
{options.map((option) => (
<SelectItem key={option} value={option}>{optionLabels[option] ?? option}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
+24 -27
View File
@@ -4,6 +4,7 @@ import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -56,33 +57,11 @@ import {
} from "@/lib/guides/talents"; } from "@/lib/guides/talents";
import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text"; import { normalizeExtraRichText } from "@/lib/rich-text";
import { publicGuideSections } from "@/lib/guides/public-sections";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact")
return (
guide.artifactGroups.some((condition) => condition.tiers.some((group) => group.rows.length > 0)) ||
Boolean(
guide.artifactProfile &&
(guide.artifactProfile.recommendedConditions.some((condition) => condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length) ||
guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length ||
guide.artifactProfile.substats.length ||
guide.artifactProfile.targetConditions.some((group) => group.rows.length) ||
guide.artifactProfile.talents.length),
)
);
if (slug === "constellations") return guide.constellationGroups.some((group) => group.rows.length > 0);
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some(
(extra) => extra.slug === slug && extra.rows.length > 0,
);
}
export function publicSections(guide: PublicGuide) { export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug)); return publicGuideSections(guide);
} }
function publicSectionTitle(slug: string, fallback: string): string { function publicSectionTitle(slug: string, fallback: string): string {
@@ -96,11 +75,14 @@ function publicSectionTitle(slug: string, fallback: string): string {
export function StructuredGuideShell({ export function StructuredGuideShell({
guide, guide,
section, section,
preview = false,
}: { }: {
guide: PublicGuide; guide: PublicGuide;
section?: string; section?: string;
preview?: boolean;
}) { }) {
const sections = publicSections(guide); const sections = publicSections(guide);
const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`;
const currentPageTitle = section const currentPageTitle = section
? (sections.find((item) => item.slug === section)?.title ?? section) ? (sections.find((item) => item.slug === section)?.title ?? section)
: "Overview"; : "Overview";
@@ -108,9 +90,17 @@ export function StructuredGuideShell({
<div className="flex min-h-svh flex-col bg-background"> <div className="flex min-h-svh flex-col bg-background">
<LiveRefresh endpoint={`/api/events/page/${guide.id}`} /> <LiveRefresh endpoint={`/api/events/page/${guide.id}`} />
<SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} /> <SiteHeader editHref={`/admin/${guide.characterKey}`} guideId={guide.id} />
{preview ? (
<div className="mx-auto w-full max-w-7xl px-4 pt-4 sm:px-6 lg:px-8">
<Alert>
<AlertTitle>Preview</AlertTitle>
<AlertDescription>หน้านี้แสดงข้อมูลที่บันทึกล่าสุดและเปิดดูได้เฉพาะผู้ดูแล</AlertDescription>
</Alert>
</div>
) : null}
<div className="sticky top-0 z-40 bg-background/95 supports-backdrop-filter:backdrop-blur"> <div className="sticky top-0 z-40 bg-background/95 supports-backdrop-filter:backdrop-blur">
<div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6 lg:px-8"> <div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6 lg:px-8">
<Button variant="ghost" size="icon" render={<Link href="/" />} nativeButton={false} aria-label="กลับหน้ารวม"> <Button variant="ghost" size="icon" render={<Link href={preview ? `/admin/${guide.characterKey}` : "/"} />} nativeButton={false} aria-label={preview ? "กลับหน้าแก้ไข" : "กลับหน้ารวม"}>
<ArrowLeftIcon /> <ArrowLeftIcon />
</Button> </Button>
<div className="relative size-16 overflow-hidden rounded-lg border"> <div className="relative size-16 overflow-hidden rounded-lg border">
@@ -137,7 +127,7 @@ export function StructuredGuideShell({
className="min-h-11 w-full rounded-xl border bg-card p-2" className="min-h-11 w-full rounded-xl border bg-card p-2"
> >
<Link <Link
href={`/${guide.slug}`} href={baseHref}
className={cn( className={cn(
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring", "flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
!section !section
@@ -158,7 +148,7 @@ export function StructuredGuideShell({
{sections.map((item) => ( {sections.map((item) => (
<Link <Link
key={item.id} key={item.id}
href={`/${guide.slug}/${item.slug}`} href={`${baseHref}/${item.slug}`}
className={cn( className={cn(
"flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring", "flex min-h-8 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
section === item.slug section === item.slug
@@ -212,6 +202,13 @@ function GuideOverview({
<p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p> <p className="text-sm font-medium text-primary">ภาพรวมแนวทาง</p>
<h1 className="font-heading text-4xl font-bold">{guide.name}</h1> <h1 className="font-heading text-4xl font-bold">{guide.name}</h1>
<p className="text-lg font-semibold">{guide.overview}</p> <p className="text-lg font-semibold">{guide.overview}</p>
<div className="flex flex-wrap justify-center gap-2">
{guide.publishedCatalogVersion ? <Badge variant="outline">เวอร์ชัน {guide.publishedCatalogVersion}</Badge> : null}
<Badge variant="secondary">
อัปเดต {new Date(guide.publishedAt ?? guide.updatedAt).toLocaleDateString("th-TH")}
</Badge>
</div>
{guide.publicationNote ? <p className="text-sm text-muted-foreground">{guide.publicationNote}</p> : null}
</div> </div>
<div className="grid items-start gap-6 lg:grid-cols-2"> <div className="grid items-start gap-6 lg:grid-cols-2">
<div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1"> <div className="order-1 relative aspect-square w-full overflow-hidden rounded-xl border bg-muted lg:col-start-1 lg:row-start-1">
+3
View File
@@ -293,6 +293,9 @@ export const guides = guidesSchema.table(
.notNull(), .notNull(),
version: integer("version").default(1).notNull(), version: integer("version").default(1).notNull(),
isPublic: boolean("is_public").default(false).notNull(), isPublic: boolean("is_public").default(false).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
publishedCatalogVersion: varchar("published_catalog_version", { length: 32 }),
publicationNote: varchar("publication_note", { length: 500 }),
sortOrder: integer("sort_order").default(0).notNull(), sortOrder: integer("sort_order").default(0).notNull(),
trashedAt: timestamp("trashed_at", { withTimezone: true }), trashedAt: timestamp("trashed_at", { withTimezone: true }),
...timestamps, ...timestamps,
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "guides"."guide" ADD COLUMN "published_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "guides"."guide" ADD COLUMN "published_catalog_version" varchar(32);--> statement-breakpoint
ALTER TABLE "guides"."guide" ADD COLUMN "publication_note" varchar(500);
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -330,6 +330,13 @@
"when": 1789641217244, "when": 1789641217244,
"tag": "0046_spicy_lucky_pierre", "tag": "0046_spicy_lucky_pierre",
"breakpoints": true "breakpoints": true
},
{
"idx": 47,
"version": "7",
"when": 1789764017232,
"tag": "0047_green_riptide",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -89,7 +89,7 @@ export function avatarIconName(value: string): string {
return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_"); return value.replace(/^UI_Gacha_AvatarIcon_/u, "UI_AvatarIcon_");
} }
export function characterAssetName(value: string): string { function characterAssetName(value: string): string {
return avatarIconName(value).replace(/^UI_AvatarIcon_/u, ""); return avatarIconName(value).replace(/^UI_AvatarIcon_/u, "");
} }
+1 -1
View File
@@ -19,7 +19,7 @@ end
return 0 return 0
`; `;
export class CatalogSyncBusyError extends Error { class CatalogSyncBusyError extends Error {
constructor() { constructor() {
super("กำลัง Sync Catalog อยู่"); super("กำลัง Sync Catalog อยู่");
this.name = "CatalogSyncBusyError"; this.name = "CatalogSyncBusyError";
+1 -9
View File
@@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { and, asc, eq } from "drizzle-orm"; import { asc, eq } from "drizzle-orm";
import { getDb } from "@/db"; import { getDb } from "@/db";
import { import {
@@ -12,7 +12,6 @@ import {
import { import {
glossaryAliasInputSchema, glossaryAliasInputSchema,
normalizeGlossaryAlias, normalizeGlossaryAlias,
type GlossaryKind,
} from "@/lib/glossary"; } from "@/lib/glossary";
const catalogTables = { const catalogTables = {
@@ -53,10 +52,3 @@ export async function deleteGlossaryAlias(id: string) {
if (!deleted) throw new Error("ไม่พบ Shortcut"); if (!deleted) throw new Error("ไม่พบ Shortcut");
return deleted; return deleted;
} }
export async function aliasesForItem(kind: GlossaryKind, catalogKey: string) {
return getDb().select().from(glossaryAliases).where(and(
eq(glossaryAliases.kind, kind),
eq(glossaryAliases.catalogKey, catalogKey),
));
}
+32 -32
View File
@@ -31,7 +31,6 @@ import {
constellationSectionSchema, constellationSectionSchema,
createExtraSectionSchema, createExtraSectionSchema,
createGuideSchema, createGuideSchema,
extraSectionSchema,
reorderExtraSectionsSchema, reorderExtraSectionsSchema,
saveExtraSectionSchema, saveExtraSectionSchema,
slugFromCharacterName, slugFromCharacterName,
@@ -39,7 +38,6 @@ import {
updateGuideSchema, updateGuideSchema,
weaponSectionSchema, weaponSectionSchema,
} from "@/lib/guides/schemas"; } from "@/lib/guides/schemas";
import { processCatalogSync } from "@/lib/catalog/sync";
import { normalizeExtraRichText } from "@/lib/rich-text"; import { normalizeExtraRichText } from "@/lib/rich-text";
import type { AdminEditorScope } from "@/lib/events/invalidation"; import type { AdminEditorScope } from "@/lib/events/invalidation";
@@ -279,27 +277,6 @@ export async function saveTeams(guideId: string, input: unknown, details?: Guide
}); });
} }
export async function saveExtraSections(guideId: string, input: unknown) {
const data = extraSectionSchema.parse(input);
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "extras:list");
await tx.delete(guideSections).where(and(eq(guideSections.guideId, guideId), eq(guideSections.kind, "extra")));
for (const [sortOrder, section] of data.sections.entries()) {
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
highlights: [],
percent: row.percent?.toString() ?? null,
sectionId: createdSection.id,
sortOrder: rowOrder,
})));
}
return updated;
});
}
export async function createExtraSection(guideId: string, input: unknown) { export async function createExtraSection(guideId: string, input: unknown) {
const data = createExtraSectionSchema.parse(input); const data = createExtraSectionSchema.parse(input);
return getDb().transaction(async (tx) => { return getDb().transaction(async (tx) => {
@@ -447,14 +424,42 @@ export async function restoreGuide(guideId: string) {
}); });
} }
export async function setGuideVisibility(guideId: string, isPublic: boolean) { export async function setGuideVisibility(
guideId: string,
input: {
isPublic: boolean;
expectedVersion: number;
catalogVersion?: string;
publicationNote?: string | null;
},
) {
return getDb().transaction(async (tx) => { return getDb().transaction(async (tx) => {
const [guide] = await tx const [guide] = await tx
.update(guides) .update(guides)
.set({ isPublic, version: sql`${guides.version} + 1` }) .set({
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`)) isPublic: input.isPublic,
version: sql`${guides.version} + 1`,
...(input.isPublic ? {
publishedAt: new Date(),
publishedCatalogVersion: input.catalogVersion,
publicationNote: input.publicationNote ?? null,
} : {}),
})
.where(and(
eq(guides.id, guideId),
eq(guides.version, input.expectedVersion),
sql`${guides.trashedAt} is null`,
))
.returning(); .returning();
if (!guide) throw new Error("ไม่พบ Guide"); if (!guide) {
const [current] = await tx
.select({ version: guides.version })
.from(guides)
.where(and(eq(guides.id, guideId), sql`${guides.trashedAt} is null`))
.limit(1);
if (!current) throw new Error("ไม่พบ Guide");
throw new GuideVersionConflictError(current.version);
}
await emitGuideEvents(tx, guide.id, guide.version, true); await emitGuideEvents(tx, guide.id, guide.version, true);
return guide; return guide;
}); });
@@ -477,8 +482,3 @@ export async function permanentlyDeleteGuide(guideId: string) {
return { version: guide.version + 1 }; return { version: guide.version + 1 };
}); });
} }
export async function runCatalogSync() {
await processCatalogSync();
return { status: "saved" as const };
}
+31
View File
@@ -0,0 +1,31 @@
import type { PublicGuide } from "@/lib/guides/queries";
function hasPublicSectionContent(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact") {
return guide.artifactGroups.some((condition) =>
condition.tiers.some((group) => group.rows.length > 0),
) || Boolean(
guide.artifactProfile && (
guide.artifactProfile.recommendedConditions.some((condition) =>
condition.sands.length || condition.goblet.length || condition.circlet.length || condition.substats.length,
) ||
guide.artifactProfile.sands.length ||
guide.artifactProfile.goblet.length ||
guide.artifactProfile.circlet.length ||
guide.artifactProfile.substats.length ||
guide.artifactProfile.targetConditions.some((group) => group.rows.length) ||
guide.artifactProfile.talents.length
),
);
}
if (slug === "constellations") {
return guide.constellationGroups.some((group) => group.rows.length > 0);
}
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
}
export function publicGuideSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasPublicSectionContent(guide, section.slug));
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { publicationIssues } from "@/lib/guides/publication";
const complete = {
exists: true,
overview: "Build overview",
coverReady: true,
catalogVersion: "6.0",
renderableSectionCount: 1,
version: 4,
expectedVersion: 4,
};
describe("publicationIssues", () => {
it("accepts a complete persisted guide", () => {
expect(publicationIssues(complete)).toEqual([]);
});
it("reports every incomplete core requirement", () => {
expect(publicationIssues({
...complete,
overview: " ",
coverReady: false,
catalogVersion: null,
renderableSectionCount: 0,
expectedVersion: 3,
})).toEqual([
"กรุณากรอก Overview",
"รูปปกยังไม่พร้อมใช้งาน",
"กรุณา Sync Catalog ก่อนเผยแพร่",
"กรุณาเพิ่มเนื้อหาอย่างน้อยหนึ่ง Section",
"Guide ถูกแก้ไขแล้ว กรุณาลองใหม่",
]);
});
it("stops at not found", () => {
expect(publicationIssues({ ...complete, exists: false })).toEqual(["ไม่พบ Guide"]);
});
});
+20
View File
@@ -0,0 +1,20 @@
export interface PublicationCheckInput {
exists: boolean;
overview: string;
coverReady: boolean;
catalogVersion: string | null;
renderableSectionCount: number;
version: number;
expectedVersion: number;
}
export function publicationIssues(input: PublicationCheckInput): string[] {
if (!input.exists) return ["ไม่พบ Guide"];
const issues: string[] = [];
if (!input.overview.trim()) issues.push("กรุณากรอก Overview");
if (!input.coverReady) issues.push("รูปปกยังไม่พร้อมใช้งาน");
if (!input.catalogVersion) issues.push("กรุณา Sync Catalog ก่อนเผยแพร่");
if (input.renderableSectionCount === 0) issues.push("กรุณาเพิ่มเนื้อหาอย่างน้อยหนึ่ง Section");
if (input.version !== input.expectedVersion) issues.push("Guide ถูกแก้ไขแล้ว กรุณาลองใหม่");
return issues;
}
+18
View File
@@ -239,10 +239,17 @@ export async function listPublicGuideCards() {
name: guides.name, name: guides.name,
overview: guides.overview, overview: guides.overview,
version: guides.version, version: guides.version,
updatedAt: guides.updatedAt,
publishedAt: guides.publishedAt,
publishedCatalogVersion: guides.publishedCatalogVersion,
element: catalogCharacters.element,
weaponType: catalogCharacters.weaponType,
rarity: catalogCharacters.rarity,
coverObjectKey: media.objectKey, coverObjectKey: media.objectKey,
}) })
.from(guides) .from(guides)
.innerJoin(media, eq(media.id, guides.coverMediaId)) .innerJoin(media, eq(media.id, guides.coverMediaId))
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
.where(and(eq(guides.isPublic, true), isNull(guides.trashedAt))) .where(and(eq(guides.isPublic, true), isNull(guides.trashedAt)))
.orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)), getDb().select({ .orderBy(desc(guides.sortOrder), desc(guides.createdAt), asc(guides.name)), getDb().select({
kind: glossaryAliases.kind, kind: glossaryAliases.kind,
@@ -307,6 +314,7 @@ export async function getGuideByCharacterKey(characterKey: string) {
return { return {
...row.guide, ...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey), coverUrl: publicMediaUrl(row.cover.objectKey),
coverStatus: row.cover.status,
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null, posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })), posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
character: catalogCharacterWithUrls(row.character, materialByKey), character: catalogCharacterWithUrls(row.character, materialByKey),
@@ -399,6 +407,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
return { return {
...row.guide, ...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey), coverUrl: publicMediaUrl(row.cover.objectKey),
coverStatus: row.cover.status,
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null, posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })), posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, blendMode: (posterBlendModes.has(layer.blendMode) ? layer.blendMode : "multiply") as PosterBlendMode, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })),
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] }, character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
@@ -449,4 +458,13 @@ export function getAdminGuideContent(slug: string) {
return getGuideContent(slug, false); return getGuideContent(slug, false);
} }
export async function getAdminGuideContentById(guideId: string) {
const [guide] = await getDb()
.select({ slug: guides.slug })
.from(guides)
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
.limit(1);
return guide ? getGuideContent(guide.slug, false) : null;
}
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>; export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
-4
View File
@@ -76,7 +76,3 @@ export function statPriorityColorClass(index: number): string {
? "text-tier-copper" ? "text-tier-copper"
: "text-foreground"; : "text-foreground";
} }
export function statPriorityColor(index: number): string {
return index === 0 ? "#e0b35f" : index === 1 ? "#c7cbd3" : index === 2 ? "#bd8055" : "#ffffff";
}
+1 -1
View File
@@ -21,7 +21,7 @@ export interface DpsTeam {
export type DpsOrder = "best" | "worst" | null; export type DpsOrder = "best" | "worst" | null;
export function teamDps(team: DpsTeam): number { function teamDps(team: DpsTeam): number {
const seconds = Number(team.rotationSeconds); const seconds = Number(team.rotationSeconds);
if (!(seconds > 0)) return 0; if (!(seconds > 0)) return 0;
return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds; return team.members.reduce((sum, member) => sum + Number(member.damage), 0) / seconds;
+1 -1
View File
@@ -247,7 +247,7 @@ export function applyPresetFields(
).slice(0, teams.length); ).slice(0, teams.length);
} }
export function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string { function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
return JSON.stringify(team); return JSON.stringify(team);
} }
+1 -8
View File
@@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { and, eq, isNull, sql } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import sharp from "sharp"; import sharp from "sharp";
import { getDb } from "@/db"; import { getDb } from "@/db";
@@ -95,13 +95,6 @@ export async function uploadPublicMedia(
}; };
} }
export async function addCurrentMediaReference(mediaId: string): Promise<void> {
await getDb()
.update(media)
.set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` })
.where(eq(media.id, mediaId));
}
export async function completePendingMedia(mediaId: string) { export async function completePendingMedia(mediaId: string) {
const [record] = await getDb() const [record] = await getDb()
.select() .select()
+1 -1
View File
@@ -107,7 +107,7 @@ export function sanitizeRichTextHtml(value: string): string {
}); });
} }
export function isRichTextHtml(value: string): boolean { function isRichTextHtml(value: string): boolean {
return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value); return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value);
} }
+2
View File
@@ -109,6 +109,8 @@ describe("production deployment contract", () => {
expect(workflow).toContain("bun run typecheck"); expect(workflow).toContain("bun run typecheck");
expect(workflow).toContain("bun run lint"); expect(workflow).toContain("bun run lint");
expect(workflow).toContain("kubectl kustomize k8s/"); expect(workflow).toContain("kubectl kustomize k8s/");
expect(workflow).toContain("needs: verify");
expect(workflow).not.toContain("pull_request:");
expect(workflow).toContain("--target app"); expect(workflow).toContain("--target app");
expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"'); expect(workflow).toContain('--build-arg BASE_URL="$BASE_URL"');
expect(workflow).toContain( expect(workflow).toContain(