feat(guides) : add safer publishing and discovery
This commit is contained in:
+22
-3
@@ -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' &&
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>>>;
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user