Compare commits
@@ -0,0 +1,72 @@
|
||||
name: Build and Deploy Next Preview
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
|
||||
concurrency:
|
||||
group: buzz-next-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
if: gitea.ref == 'refs/heads/next'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -fsSLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/kubectl
|
||||
|
||||
- name: Configure kubectl
|
||||
env:
|
||||
KUBE_TOKEN: ${{ secrets.KUBE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl config set-cluster buzz --server=https://astrxl-la3.neko-piranha.ts.net:6443 --insecure-skip-tls-verify=true
|
||||
kubectl config set-credentials ci-deployer --token="$KUBE_TOKEN"
|
||||
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
|
||||
kubectl config use-context buzz
|
||||
|
||||
- name: Prepare shared environment for preview build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
kubectl get secret buzz-env -n buzz \
|
||||
-o go-template='{{range $k,$v := .data}}{{$k}}={{$v|base64decode}}{{"\n"}}{{end}}' > .env
|
||||
chmod 600 .env
|
||||
sed -i '/^BASE_URL=/d' .env
|
||||
sed -i '/^BETTER_AUTH_URL=/d;/^DISCORD_REDIRECT_URI=/d' .env
|
||||
printf '%s\n' \
|
||||
'BASE_URL=https://buzz-next.sudloh.com' \
|
||||
'BETTER_AUTH_URL=https://buzz-next.sudloh.com' \
|
||||
'DISCORD_REDIRECT_URI=https://buzz-next.sudloh.com/rubgram/callback' >> .env
|
||||
|
||||
- name: Build and push immutable frontend preview image
|
||||
env:
|
||||
TAG: ${{ gitea.sha }}
|
||||
IMAGE_REPOSITORY: registry.neko-piranha.ts.net/${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$IMAGE_REPOSITORY:frontend-next-$TAG" .
|
||||
docker push "$IMAGE_REPOSITORY:frontend-next-$TAG"
|
||||
|
||||
- name: Update prebootstrapped preview and apply networking resources
|
||||
env:
|
||||
TAG: ${{ gitea.sha }}
|
||||
IMAGE_REPOSITORY: registry.neko-piranha.ts.net/${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! kubectl get deployment/app-next -n buzz >/dev/null; then
|
||||
echo "Required prebootstrapped Deployment app-next is missing in namespace buzz; bootstrap it locally before running this workflow." >&2
|
||||
exit 1
|
||||
fi
|
||||
kubectl apply -f k8s/next/service-app-next.yaml -f k8s/next/ingress-app-next.yaml
|
||||
kubectl set image deployment/app-next app-next="$IMAGE_REPOSITORY:frontend-next-$TAG" -n buzz
|
||||
kubectl rollout status deployment/app-next -n buzz --timeout=180s
|
||||
@@ -59,3 +59,6 @@ node_modules/
|
||||
.direnv
|
||||
!.envrc
|
||||
.env*
|
||||
|
||||
.version
|
||||
.serena
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Section } from "./page";
|
||||
import { Section } from "./section";
|
||||
|
||||
export function SettingsAccountSection() {
|
||||
return <Section title="บัญชี">ยังไม่พร้อมให้บริการ</Section>;
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SettingsAccountSection } from "./account";
|
||||
import { getSettongs } from "./api";
|
||||
import { SettingsServicesSection } from "./services";
|
||||
@@ -36,22 +28,4 @@ export default async function () {
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { description?: string }) {
|
||||
return (
|
||||
<Card className={cn("h-full", className)} {...props}>
|
||||
<CardHeader>
|
||||
{title && <CardTitle className="text-2xl">{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="h-full">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { description?: string }) {
|
||||
return (
|
||||
<Card className={cn("h-full", className)} {...props}>
|
||||
<CardHeader>
|
||||
{title && <CardTitle className="text-2xl">{title}</CardTitle>}
|
||||
{description && <CardDescription>{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="h-full">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
toggleEnka,
|
||||
} from "./api";
|
||||
import { Broadcaster } from "./broadcaster";
|
||||
import { Section } from "./page";
|
||||
import { Section } from "./section";
|
||||
|
||||
export function SettingsServicesSection({
|
||||
enka: enkaInitial,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function DonateAdminDefault() {
|
||||
return null;
|
||||
}
|
||||
+4
-2
@@ -7,6 +7,7 @@ import {
|
||||
LoaderPinwheel,
|
||||
StickyNote,
|
||||
Table,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -19,7 +20,7 @@ export function Navbar() {
|
||||
const mobile = useIsMobile();
|
||||
|
||||
if (
|
||||
!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact|guide|donate)$/gm.test(
|
||||
!/^\/(?:tl(?:\/[a-zA-Z0-9]+)?|rubgram|artifact|guide|donate|repair)$/gm.test(
|
||||
pathname,
|
||||
)
|
||||
)
|
||||
@@ -44,7 +45,8 @@ export function Navbar() {
|
||||
/>
|
||||
<Item icon={<Table size={16} />} name="จัดเทียร์ลิสต์" href="/tl" />
|
||||
<Item icon={<StickyNote size={16} />} name="ไกด์ตัวละคร" href="/guide" />
|
||||
<Item icon={<Bitcoin size={16} />} name="โดเนท" href="/donate" last />
|
||||
<Item icon={<Bitcoin size={16} />} name="โดเนท" href="/donate" />
|
||||
<Item icon={<UserCog size={16} />} name="ซ่อมไอดี" href="/repair" last />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import DonateLogo from "#/logos/donate.webp";
|
||||
import GuideLogo from "#/logos/guide.webp";
|
||||
import RubgramLogo from "#/logos/rubgram.webp";
|
||||
import TierlistLogo from "#/logos/tierlist.webp";
|
||||
import CommissionLogo from "#/logos/commission.webp";
|
||||
import Image from "@/components/image";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -66,6 +67,18 @@ export default function Home() {
|
||||
href="/donate"
|
||||
logo={DonateLogo}
|
||||
/>
|
||||
{/* <HomeLink
|
||||
name="ซ่อมไอดีชาวบ้าน"
|
||||
desc="ซ่อมไอดีชาวบ้าน"
|
||||
href="/repair"
|
||||
logo={DonateLogo}
|
||||
/> */}
|
||||
<HomeLink
|
||||
name="คอมมิชชั่น"
|
||||
desc="คอมมิชชั่นจ้างแคลทีมดาเมท เปรียบเทียบดาเมท กลุ่มดาว"
|
||||
href="https://guide.sudloh.com/commission"
|
||||
logo={CommissionLogo}
|
||||
/>
|
||||
</div>
|
||||
<footer className="flex h-full flex-col justify-end pb-3 text-muted-foreground md:justify-center">
|
||||
<div className="flex flex-col justify-between gap-2 text-center sm:flex-row sm:text-left">
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function RubgramAdminDefault() {
|
||||
return null;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { MessageSquareText, Trash } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import AmberIcon from "#/amber.png";
|
||||
import Image from "@/components/image";
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
@@ -44,6 +44,7 @@ export function Draggable({
|
||||
const [comment, setComment] = useState("");
|
||||
const [assignedBadges, setBadges] = useState<string[]>([]);
|
||||
const [dirty, setDirty] = useState(0);
|
||||
const editGeneration = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (dirty) return;
|
||||
@@ -54,13 +55,20 @@ export function Draggable({
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!dirty || !cid) return;
|
||||
setDirty(0);
|
||||
setState(cid, { comment, badges: assignedBadges });
|
||||
const generation = editGeneration.current;
|
||||
void setState(cid, { comment, badges: assignedBadges })
|
||||
.then(() => {
|
||||
if (editGeneration.current === generation) setDirty(0);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the local edit dirty so a later edit retries the latest intent.
|
||||
});
|
||||
}, dirty);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [dirty, comment, setState, cid, assignedBadges]);
|
||||
|
||||
function toggleBadge(badge: string) {
|
||||
editGeneration.current++;
|
||||
setDirty(200);
|
||||
setBadges(
|
||||
assignedBadges.includes(badge)
|
||||
@@ -264,6 +272,7 @@ export function Draggable({
|
||||
className="aspect-square resize-none bg-card disabled:opacity-90"
|
||||
value={comment}
|
||||
onChange={(ev) => {
|
||||
editGeneration.current++;
|
||||
setDirty(500);
|
||||
setComment(ev.target.value);
|
||||
}}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const TierListContext = createContext<{
|
||||
setState: (
|
||||
char: string,
|
||||
data: Partial<typeof tierlistStates.$inferInsert>,
|
||||
) => void;
|
||||
) => Promise<void>;
|
||||
editable: boolean;
|
||||
deleteMode: boolean;
|
||||
removeChar: (cid: string) => void;
|
||||
@@ -23,7 +23,7 @@ export const TierListContext = createContext<{
|
||||
badges: [],
|
||||
tileSize: 64,
|
||||
badgeSize: 24,
|
||||
setState: () => {},
|
||||
setState: async () => {},
|
||||
editable: false,
|
||||
deleteMode: false,
|
||||
removeChar: () => {},
|
||||
|
||||
@@ -77,6 +77,16 @@ import type {
|
||||
tierlistVersions,
|
||||
} from "@/lib/db/schema";
|
||||
import { tlSse } from "@/lib/db/sse-endpoints";
|
||||
import {
|
||||
reconcileCanonicalStates,
|
||||
reconcileScopedStateWrite,
|
||||
TierlistCanonicalFetch,
|
||||
} from "@/lib/tierlist-canonical-fetch";
|
||||
import { TierlistSaveQueue } from "@/lib/tierlist-save-queue";
|
||||
import type {
|
||||
TierlistSyncData,
|
||||
TierlistWriteResult,
|
||||
} from "@/lib/tierlist-sync";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TierListCell } from "./cell";
|
||||
import { Draggable } from "./character";
|
||||
@@ -138,6 +148,22 @@ export function TierList({
|
||||
const [states, setStates] = useState<(typeof tierlistStates.$inferSelect)[]>(
|
||||
[],
|
||||
);
|
||||
const stateSaveGeneration = useRef(new Map<string, number>());
|
||||
const stateEditRevision = useRef(new Map<string, number>());
|
||||
const placementSaveGeneration = useRef(0);
|
||||
const canonicalFetcher = useRef(
|
||||
new TierlistCanonicalFetch<TierlistSyncData>(),
|
||||
);
|
||||
canonicalFetcher.current.setVersion(version.id);
|
||||
const activeSaveQueues = useRef(new Set<string>());
|
||||
const setSaveQueuePending = useCallback((key: string, pending: boolean) => {
|
||||
if (pending) activeSaveQueues.current.add(key);
|
||||
else activeSaveQueues.current.delete(key);
|
||||
setEvStatus((status) => ({
|
||||
...status,
|
||||
upload: activeSaveQueues.current.size > 0,
|
||||
}));
|
||||
}, []);
|
||||
const [_evStatus, setEvStatus] = useState<{
|
||||
upload: boolean;
|
||||
download: boolean;
|
||||
@@ -167,33 +193,79 @@ export function TierList({
|
||||
//#endregion
|
||||
|
||||
//#region states fetching
|
||||
const fetchStates = useCallback(async () => {
|
||||
setEvStatus((x) => ({ ...x, download: true }));
|
||||
setStates(
|
||||
await fetch(`/api/tl/${version.id}/states`)
|
||||
.then((r) => r.json())
|
||||
.finally(() => setEvStatus((x) => ({ ...x, download: false }))),
|
||||
const fetchCanonical = useCallback(() => {
|
||||
const revisionsAtRequest = new Map(stateEditRevision.current);
|
||||
setEvStatus((status) => ({ ...status, download: true }));
|
||||
canonicalFetcher.current.fetch(
|
||||
version.id,
|
||||
async () => {
|
||||
const response = await fetch(`/api/tl/${version.id}/sync`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Sync fetch failed (${response.status})`);
|
||||
return (await response.json()) as TierlistSyncData;
|
||||
},
|
||||
(canonical) => {
|
||||
setStates((current) => {
|
||||
const locallyPending = stateSaveGeneration.current;
|
||||
const protectedRefs = new Set(
|
||||
[...stateEditRevision.current]
|
||||
.filter(
|
||||
([ref, revision]) => revision !== revisionsAtRequest.get(ref),
|
||||
)
|
||||
.map(([ref]) => ref),
|
||||
);
|
||||
return reconcileCanonicalStates(
|
||||
canonical.states.filter((entry) => !locallyPending.has(entry.ref)),
|
||||
current,
|
||||
new Set([...protectedRefs, ...[...locallyPending.keys()]]),
|
||||
);
|
||||
});
|
||||
if (placementSaveGeneration.current === 0) {
|
||||
setPlacements((current) => ({
|
||||
...Object.fromEntries(
|
||||
tiers.flatMap((tier) =>
|
||||
columns.map((column) => [
|
||||
`${tier.id}-${column.id}`,
|
||||
[] as string[],
|
||||
]),
|
||||
),
|
||||
),
|
||||
...canonical.placements,
|
||||
untiered: current.untiered,
|
||||
}));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
toast.error(
|
||||
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
},
|
||||
() => setEvStatus((status) => ({ ...status, download: false })),
|
||||
);
|
||||
}, [version.id]);
|
||||
}, [version.id, tiers, columns]);
|
||||
// Reload after connection restored
|
||||
shared.signal("sync", fetchStates);
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, [fetchStates]);
|
||||
|
||||
shared.signal("sync", fetchCanonical);
|
||||
useEffect(() => {
|
||||
setEvStatus((x) => ({ ...x, ev: "connecting" }));
|
||||
return tlSse(version.id).subMany(
|
||||
canonicalFetcher.current.activate(version.id);
|
||||
const subscription = tlSse(version.id).subMany(
|
||||
{
|
||||
update_states: setStates,
|
||||
update_placements: setPlacements,
|
||||
update_states: () => void fetchCanonical(),
|
||||
update_placements: () => void fetchCanonical(),
|
||||
},
|
||||
{
|
||||
onopen: () => setEvStatus((x) => ({ ...x, ev: "ready" })),
|
||||
onerror: () => setEvStatus((x) => ({ ...x, ev: "unknown" })),
|
||||
},
|
||||
).clean;
|
||||
}, [version.id]);
|
||||
);
|
||||
void fetchCanonical();
|
||||
return () => {
|
||||
subscription.clean();
|
||||
};
|
||||
}, [version.id, fetchCanonical]);
|
||||
useEffect(() => () => canonicalFetcher.current.dispose(), []);
|
||||
//#endregion
|
||||
|
||||
const colRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -277,6 +349,69 @@ export function TierList({
|
||||
...version.placements,
|
||||
untiered: chars.map((ch) => ch.id).filter((c) => !tiered.includes(c)),
|
||||
});
|
||||
const placementQueueGeneration = useRef(0);
|
||||
const saveQueueVersion = useRef(version.id);
|
||||
const stateQueues = useRef(
|
||||
new Map<
|
||||
string,
|
||||
TierlistSaveQueue<
|
||||
typeof tierlistStates.$inferInsert,
|
||||
TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const placementQueue = useRef<TierlistSaveQueue<
|
||||
Record<string, string[]>,
|
||||
TierlistWriteResult<Record<string, string[]>>
|
||||
> | null>(null);
|
||||
if (saveQueueVersion.current !== version.id) {
|
||||
placementQueue.current?.dispose();
|
||||
for (const queue of stateQueues.current.values()) queue.dispose();
|
||||
stateQueues.current.clear();
|
||||
placementQueue.current = null;
|
||||
saveQueueVersion.current = version.id;
|
||||
stateSaveGeneration.current.clear();
|
||||
stateEditRevision.current.clear();
|
||||
placementSaveGeneration.current = 0;
|
||||
placementQueueGeneration.current = 0;
|
||||
}
|
||||
if (!placementQueue.current) {
|
||||
placementQueue.current = new TierlistSaveQueue(
|
||||
(value) => tlPlacements(version.id, value),
|
||||
({ generation, result }) => {
|
||||
if (generation !== placementQueueGeneration.current) return;
|
||||
canonicalFetcher.current.invalidate(version.id);
|
||||
setEvStatus((status) => ({ ...status, download: false }));
|
||||
placementSaveGeneration.current = 0;
|
||||
setPlacements((current) => ({
|
||||
...Object.fromEntries(
|
||||
tiers.flatMap((tier) =>
|
||||
columns.map((column) => [
|
||||
`${tier.id}-${column.id}`,
|
||||
[] as string[],
|
||||
]),
|
||||
),
|
||||
),
|
||||
...result.data,
|
||||
untiered: current.untiered,
|
||||
}));
|
||||
if (result.publication === "failed") void fetchCanonical();
|
||||
},
|
||||
(pending) => setSaveQueuePending("placements", pending),
|
||||
(error) =>
|
||||
toast.error(
|
||||
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}. เปลี่ยนแปลงยังไม่บันทึก; ลองแก้ไขอีกครั้งเพื่อ retry`,
|
||||
),
|
||||
);
|
||||
}
|
||||
useEffect(
|
||||
() => () => {
|
||||
placementQueue.current?.dispose();
|
||||
for (const queue of stateQueues.current.values()) queue.dispose();
|
||||
stateQueues.current.clear();
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
@@ -346,16 +481,15 @@ export function TierList({
|
||||
}
|
||||
|
||||
setPlacements(newPlacements);
|
||||
placementSaveGeneration.current++;
|
||||
posthog.capture("tierlist_character_placed", {
|
||||
character_id: charId.split("#")[0],
|
||||
target_cell: typeof over.id === "string" ? over.id : undefined,
|
||||
tierlist_version: version.id,
|
||||
tierlist_type: type.id,
|
||||
});
|
||||
setEvStatus((x) => ({ ...x, upload: true }));
|
||||
tlPlacements(version.id, newPlacements)
|
||||
.catch((e) => toast.error(`Sync ล้มเหลว: ${e.message || e}`))
|
||||
.finally(() => setEvStatus((x) => ({ ...x, upload: false })));
|
||||
placementQueueGeneration.current++;
|
||||
void placementQueue.current?.enqueue(newPlacements).catch(() => {});
|
||||
}
|
||||
//#endregion placements
|
||||
|
||||
@@ -383,9 +517,37 @@ export function TierList({
|
||||
ref,
|
||||
};
|
||||
setStates((s) => [...s.filter((e) => e.ref !== ref), newEntry]);
|
||||
tlState(newEntry).catch((e) =>
|
||||
toast.error(`Sync ล้มเหลว: ${e.message || e}`),
|
||||
const generation = (stateSaveGeneration.current.get(ref) ?? 0) + 1;
|
||||
stateSaveGeneration.current.set(ref, generation);
|
||||
stateEditRevision.current.set(
|
||||
ref,
|
||||
(stateEditRevision.current.get(ref) ?? 0) + 1,
|
||||
);
|
||||
canonicalFetcher.current.invalidate(version.id);
|
||||
let queue = stateQueues.current.get(ref);
|
||||
if (!queue) {
|
||||
queue = new TierlistSaveQueue(
|
||||
(value) => tlState(value),
|
||||
({ generation: savedGeneration, result }) => {
|
||||
if (stateSaveGeneration.current.get(ref) !== savedGeneration)
|
||||
return;
|
||||
canonicalFetcher.current.invalidate(version.id);
|
||||
setEvStatus((status) => ({ ...status, download: false }));
|
||||
stateSaveGeneration.current.delete(ref);
|
||||
setStates((current) =>
|
||||
reconcileScopedStateWrite(current, ref, result.data),
|
||||
);
|
||||
if (result.publication === "failed") void fetchCanonical();
|
||||
},
|
||||
(pending) => setSaveQueuePending(`state:${ref}`, pending),
|
||||
(error) =>
|
||||
toast.error(
|
||||
`Sync ล้มเหลว: ${error instanceof Error ? error.message : error}. เปลี่ยนแปลงยังไม่บันทึก; ลองแก้ไขอีกครั้งเพื่อ retry`,
|
||||
),
|
||||
);
|
||||
stateQueues.current.set(ref, queue);
|
||||
}
|
||||
await queue.enqueue(newEntry).then(() => undefined);
|
||||
},
|
||||
async removeChar(cid: string) {
|
||||
setPlacements((s) =>
|
||||
@@ -449,8 +611,7 @@ export function TierList({
|
||||
>
|
||||
<span>
|
||||
{type.name} เวอร์ชั่น {version.name} ระดับ
|
||||
<span className="text-yellow-400">{type.mode}</span>{" "}
|
||||
ใช้ได้ถึง{" "}
|
||||
<span className="text-yellow-400">{type.mode}</span> ใช้ได้ถึง{" "}
|
||||
</span>
|
||||
<span className="text-green-400">{version.deprecates}</span>
|
||||
{deleteMode && (
|
||||
|
||||
@@ -8,7 +8,7 @@ const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record<string, string>;
|
||||
|
||||
const redis = redisShared!;
|
||||
|
||||
export const revalidate = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const red = await redis
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { tierlistStates, tierlistVersions } from "@/lib/db/schema";
|
||||
import type { TierlistSyncData } from "@/lib/tierlist-sync";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ ver: string }> },
|
||||
) {
|
||||
const { ver } = await params;
|
||||
const data = await db.transaction(async (tx): Promise<TierlistSyncData> => {
|
||||
await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ`);
|
||||
const states = await tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.list, ver));
|
||||
const [version] = await tx
|
||||
.select({ placements: tierlistVersions.placements })
|
||||
.from(tierlistVersions)
|
||||
.where(eq(tierlistVersions.id, ver))
|
||||
.limit(1);
|
||||
|
||||
return { states, placements: version?.placements ?? {} };
|
||||
});
|
||||
|
||||
return NextResponse.json(data, {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${base}/donate`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${base}/repair`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
...tlTypes.map(
|
||||
(t) =>
|
||||
({
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@headless-tree/core": "^1.6.0",
|
||||
"@headless-tree/react": "^1.6.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@next/bundle-analyzer": "16.2.6",
|
||||
"@next/bundle-analyzer": "16.4.0",
|
||||
"@origin-space/image-cropper": "^0.1.9",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -56,7 +56,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.27.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "16.2.6",
|
||||
"next": "16.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"posthog-js": "^1.367.0",
|
||||
@@ -247,7 +247,7 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/[email protected]", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/[email protected]", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
|
||||
@@ -321,55 +321,59 @@
|
||||
|
||||
"@hookform/resolvers": ["@hookform/[email protected]", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
"@img/sharp-darwin-x64": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
"@img/sharp-freebsd-wasm32": ["@img/[email protected]", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
"@img/sharp-linux-arm": ["@img/[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x[email protected]4.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm[email protected]5.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia[email protected]4.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm[email protected]5.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x[email protected]4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm[email protected]5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
@@ -383,25 +387,25 @@
|
||||
|
||||
"@mrleebo/prisma-ast": ["@mrleebo/[email protected]", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-VxS+okLpNp6YUDLz3rAXv6sE8G4W5cX039bumkPub65xRr9BFxjHaT5ebX4DsiixIGaBwitjWTvx3mAbyI2EUQ=="],
|
||||
|
||||
"@next/bundle-analyzer": ["@next/bundle-analyzer@16.2.6", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA=="],
|
||||
"@next/bundle-analyzer": ["@next/bundle-analyzer@16.4.0", "", { "dependencies": { "webpack-bundle-analyzer": "4.10.1" } }, "sha512-z4iuh6QGVhPKa+Pq/e6WO1d5jFeVGDtjaVY2PVnguTyGTNY/Tw/ozRAHJoaQUyiKEcREot8iQnvmuIOkD+1wyw=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
"@next/env": ["@next/env@16.4.0", "", {}, "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/[email protected]", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
|
||||
|
||||
@@ -677,7 +681,7 @@
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/[email protected]", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/[email protected].15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
"@swc/helpers": ["@swc/[email protected].23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
@@ -1065,7 +1069,7 @@
|
||||
|
||||
"nanostores": ["[email protected]", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
|
||||
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
"next": ["next@16.4.0", "", { "dependencies": { "@next/env": "16.4.0", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.0", "@next/swc-darwin-x64": "16.4.0", "@next/swc-linux-arm64-gnu": "16.4.0", "@next/swc-linux-arm64-musl": "16.4.0", "@next/swc-linux-x64-gnu": "16.4.0", "@next/swc-linux-x64-musl": "16.4.0", "@next/swc-win32-arm64-msvc": "16.4.0", "@next/swc-win32-x64-msvc": "16.4.0", "sharp": "^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg=="],
|
||||
|
||||
"next-themes": ["[email protected]", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
@@ -1223,7 +1227,7 @@
|
||||
|
||||
"set-harmonic-interval": ["[email protected]", "", {}, "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g=="],
|
||||
|
||||
"sharp": ["[email protected]4.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
"sharp": ["[email protected]5.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
|
||||
|
||||
"sirv": ["[email protected]", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ=="],
|
||||
|
||||
@@ -1395,7 +1399,7 @@
|
||||
|
||||
"nano-css/csstype": ["[email protected]", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
"next/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"oxlint-tailwindcss/@tailwindcss/node": ["@tailwindcss/[email protected]", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
@@ -1413,7 +1417,7 @@
|
||||
|
||||
"radix-ui/@radix-ui/react-separator": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"stacktrace-gps/source-map": ["[email protected]", "", {}, "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA=="],
|
||||
|
||||
@@ -1473,6 +1477,8 @@
|
||||
|
||||
"https-proxy-agent/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"next/postcss/nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"oxlint-tailwindcss/@tailwindcss/node/enhanced-resolve": ["[email protected]", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="],
|
||||
|
||||
"oxlint-tailwindcss/@tailwindcss/node/jiti": ["[email protected]", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[install]
|
||||
globalStore = false
|
||||
@@ -13,7 +13,9 @@ export function ErrorModal({
|
||||
<ModalBase title="Modal Error">
|
||||
An error occured while spawning modal:
|
||||
<pre className="break-all whitespace-pre-wrap">
|
||||
{error.stack?.split("\n").slice(0, 2).join("\n")}
|
||||
{error instanceof Error
|
||||
? error.stack?.split("\n").slice(0, 2).join("\n")
|
||||
: String(error)}
|
||||
</pre>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Buzz Events — k3s Deployment
|
||||
|
||||
## Next.js Preview (`next` branch)
|
||||
|
||||
- The `next` branch deploys a separate preview at `buzz-next.sudloh.com` using only `k8s/next/` resources: Deployment, Service, and Ingress named `app-next` with label `component: app-next`.
|
||||
- The preview shares the `buzz` namespace and existing `buzz-env` secret, including its database credentials. `BASE_URL`, `BETTER_AUTH_URL`, and `DISCORD_REDIRECT_URI` are overridden for preview build and runtime behavior so production auth URLs in the shared secret cannot redirect preview traffic.
|
||||
- Register `https://buzz-next.sudloh.com/rubgram/callback` as an allowed redirect URI in the Discord OAuth application used by `buzz-env`. DNS and HTTPS/TLS for `buzz-next.sudloh.com` must also be routed to the cluster ingress before preview auth is usable.
|
||||
- The preview currently has no backend deployment. No backend images or production image tags are built, pushed, or rolled out by `.github/workflows/build-next.yml`.
|
||||
- Each run builds and pushes only the immutable `frontend-next-$SHA` image. The preview workflow never builds, tags, pushes, starts, waits for, or cleans up a database migration; it does not run migrations. The preview shares the production database, so schema changes remain a separate, deliberate operation.
|
||||
- The workflow is limited to the local `next` branch (push or manual dispatch from `next`) and does not alter the existing main/dev workflow or root `k8s/kustomization.yaml`.
|
||||
|
||||
Bootstrap the preview Deployment once from a trusted local kubectl context; do not have CI create it or apply the Deployment manifest. The CI service account can update/patch existing Deployments, but cannot create them. Before CI runs, a cluster operator must create `app-next` in namespace `buzz` using the `k8s/next/deployment-app-next.yaml` template, replacing `__TAG__` with a real immutable frontend tag (for example `frontend-next-<commit-sha>`), and ensure `buzz-env` exists. For example:
|
||||
|
||||
```bash
|
||||
TAG=<commit-sha>
|
||||
IMAGE=registry.neko-piranha.ts.net/astral/buzz
|
||||
sed "s|${IMAGE}:frontend-next-__TAG__|${IMAGE}:frontend-next-${TAG}|g" k8s/next/deployment-app-next.yaml | kubectl apply -f -
|
||||
kubectl apply -f k8s/next/service-app-next.yaml -f k8s/next/ingress-app-next.yaml
|
||||
```
|
||||
|
||||
The workflow checks that `deployment/app-next` exists, applies only the preview Service and Ingress, then updates its `app-next` container image and waits for rollout. Do not apply the root `k8s/` kustomization for this preview because it manages production and backend resources. The existing Role already permits creating Services and Ingresses and updating/patching Deployments; no Deployment `create` permission is required.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Ingress
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: app-next
|
||||
labels:
|
||||
app: buzz
|
||||
component: app-next
|
||||
spec:
|
||||
replicas: 2
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: buzz
|
||||
component: app-next
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: buzz
|
||||
component: app-next
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 5
|
||||
containers:
|
||||
- name: app-next
|
||||
image: registry.neko-piranha.ts.net/astral/buzz:frontend-next-__TAG__
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: buzz-env
|
||||
env:
|
||||
- name: BASE_URL
|
||||
value: https://buzz-next.sudloh.com
|
||||
- name: BETTER_AUTH_URL
|
||||
value: https://buzz-next.sudloh.com
|
||||
- name: DISCORD_REDIRECT_URI
|
||||
value: https://buzz-next.sudloh.com/rubgram/callback
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 60
|
||||
timeoutSeconds: 30
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 15
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: app-next
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/read-timeout: "200"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: buzz-next.sudloh.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: app-next
|
||||
port:
|
||||
number: 3000
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: buzz
|
||||
|
||||
resources:
|
||||
- deployment-app-next.yaml
|
||||
- service-app-next.yaml
|
||||
- ingress-app-next.yaml
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: app-next
|
||||
labels:
|
||||
app: buzz
|
||||
component: app-next
|
||||
spec:
|
||||
selector:
|
||||
app: buzz
|
||||
component: app-next
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
name: http
|
||||
type: ClusterIP
|
||||
+97
-43
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { fetch, randomUUIDv7 } from "bun";
|
||||
import { and, eq, inArray, isNotNull, lt, not, or, sql } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray, isNotNull, lt, not, sql } from "drizzle-orm";
|
||||
import type { PgDatabase } from "drizzle-orm/pg-core";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -23,6 +23,14 @@ import {
|
||||
tierlistVersions,
|
||||
} from "./db/schema";
|
||||
import { sse, tlSse } from "./db/sse-endpoints";
|
||||
import type { TierlistWriteResult } from "./tierlist-sync";
|
||||
import {
|
||||
finishTierlistWrite,
|
||||
lockTierlistList,
|
||||
normalizeTierlistPlacements,
|
||||
resolveTierlistStateTarget,
|
||||
validateTierlistStateInput,
|
||||
} from "./tierlist-write";
|
||||
import { b2s } from "./utils";
|
||||
|
||||
export async function getCharacters(chars: string[]) {
|
||||
@@ -262,60 +270,106 @@ export async function revalidateCard(sub: string) {
|
||||
|
||||
export async function tlState(
|
||||
data: Partial<typeof tierlistStates.$inferInsert>,
|
||||
) {
|
||||
): Promise<TierlistWriteResult<(typeof tierlistStates.$inferSelect)[]>> {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(
|
||||
or(
|
||||
eq(tierlistStates.uuid, `${data.uuid}`),
|
||||
and(
|
||||
eq(tierlistStates.ref, `${data.ref}`),
|
||||
eq(tierlistStates.list, `${data.list}`),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (existing)
|
||||
await db
|
||||
.update(tierlistStates)
|
||||
.set(data)
|
||||
.where(eq(tierlistStates.uuid, existing.uuid));
|
||||
else
|
||||
await db
|
||||
.insert(tierlistStates)
|
||||
.values(data as typeof tierlistStates.$inferInsert);
|
||||
const list = data.list || existing?.list;
|
||||
const states = await db
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.list, list));
|
||||
|
||||
revalidatePath(`/api/tl/${list}/states`);
|
||||
const { list, ref } = validateTierlistStateInput(data);
|
||||
|
||||
await actionLog(`Updated a state in tierlist ${list}`, data);
|
||||
tlSse(list).pub("update_states", states);
|
||||
const states = await db.transaction(async (tx) => {
|
||||
await lockTierlistList(tx, list);
|
||||
const [byUuid] = data.uuid
|
||||
? await tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.uuid, data.uuid))
|
||||
.limit(1)
|
||||
: [];
|
||||
// If legacy duplicate refs exist, update the lexicographically first UUID
|
||||
// deterministically. No duplicate rows are deleted by this write.
|
||||
const [byRef] = await tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(and(eq(tierlistStates.ref, ref), eq(tierlistStates.list, list)))
|
||||
.orderBy(asc(tierlistStates.uuid))
|
||||
.limit(1);
|
||||
const existing = resolveTierlistStateTarget(byUuid, byRef, list);
|
||||
|
||||
if (existing) {
|
||||
const { uuid: _uuid, ...updates } = data;
|
||||
await tx
|
||||
.update(tierlistStates)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(tierlistStates.uuid, existing.uuid),
|
||||
eq(tierlistStates.list, list),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await tx
|
||||
.insert(tierlistStates)
|
||||
.values(data as typeof tierlistStates.$inferInsert);
|
||||
}
|
||||
|
||||
return tx
|
||||
.select()
|
||||
.from(tierlistStates)
|
||||
.where(eq(tierlistStates.list, list))
|
||||
.orderBy(asc(tierlistStates.uuid));
|
||||
});
|
||||
|
||||
return finishTierlistWrite({
|
||||
list,
|
||||
operation: "state",
|
||||
data: states,
|
||||
audit: () => actionLog(`Updated a state in tierlist ${list}`, data),
|
||||
publish: () => tlSse(list).pub("update_states", states),
|
||||
revalidate: () => revalidatePath(`/api/tl/${list}/states`),
|
||||
});
|
||||
}
|
||||
|
||||
export async function tlPlacements(
|
||||
list: string,
|
||||
placements: Record<string, string[]>,
|
||||
) {
|
||||
): Promise<TierlistWriteResult<Record<string, string[]>>> {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const { untiered: _, ...placementObj } = placements;
|
||||
if (typeof list !== "string" || !list.trim())
|
||||
throw new Error("A tierlist version is required.");
|
||||
if (
|
||||
!placements ||
|
||||
typeof placements !== "object" ||
|
||||
Array.isArray(placements) ||
|
||||
Object.entries(placements).some(
|
||||
([tier, refs]) =>
|
||||
!tier.trim() ||
|
||||
!Array.isArray(refs) ||
|
||||
refs.some((ref) => typeof ref !== "string"),
|
||||
)
|
||||
)
|
||||
throw new Error("Invalid tierlist placements.");
|
||||
|
||||
await db
|
||||
.update(tierlistVersions)
|
||||
.set({
|
||||
placements: placementObj,
|
||||
})
|
||||
.where(eq(tierlistVersions.id, list));
|
||||
// Clone before removing the client-only untiered bucket; callers retain their
|
||||
// original object and the legacy SSE event still receives its raw payload.
|
||||
const placementObj = normalizeTierlistPlacements(placements);
|
||||
const [version] = await db.transaction(async (tx) => {
|
||||
await lockTierlistList(tx, list);
|
||||
return tx
|
||||
.update(tierlistVersions)
|
||||
.set({ placements: placementObj })
|
||||
.where(eq(tierlistVersions.id, list))
|
||||
.returning({ placements: tierlistVersions.placements });
|
||||
});
|
||||
if (!version) throw new Error("Tierlist version not found.");
|
||||
|
||||
revalidatePath(`/api/tl/${list}`);
|
||||
|
||||
await actionLog(`Updated a placement in tierlist ${list}`);
|
||||
tlSse(list).pub("update_placements", placements);
|
||||
return finishTierlistWrite({
|
||||
list,
|
||||
operation: "placements",
|
||||
data: version.placements,
|
||||
audit: () => actionLog(`Updated a placement in tierlist ${list}`),
|
||||
publish: () => tlSse(list).pub("update_placements", placements),
|
||||
revalidate: () => revalidatePath(`/api/tl/${list}`),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cdnDelete(ids: string[], force = false) {
|
||||
|
||||
+5
-3
@@ -4,7 +4,9 @@ import { withReplicas } from "drizzle-orm/pg-core";
|
||||
|
||||
export const db = process.env.REPLICA_DATABASE_URL
|
||||
? withReplicas(drizzle(process.env.DATABASE_URL!, { schema }), [
|
||||
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
|
||||
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
|
||||
])
|
||||
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
|
||||
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
|
||||
])
|
||||
: drizzle(process.env.DATABASE_URL!, { schema });
|
||||
|
||||
// export const db = drizzle(process.env.DATABASE_URL!, { schema });
|
||||
|
||||
+6
-1
@@ -136,10 +136,15 @@ export class EventSourceEndpoint<T extends EventSourceEventMap> {
|
||||
throw new Error(
|
||||
"EventSourceEndpoint.pub(...) can only be called on the server.",
|
||||
);
|
||||
this.manager.publish(this.eventMap[event].parse(data), {
|
||||
const publication = this.manager.publish(this.eventMap[event].parse(data), {
|
||||
topic: this.endpoint,
|
||||
event: String(event),
|
||||
});
|
||||
// Existing publishers intentionally fire-and-forget. Mark the rejection as
|
||||
// observed for those callers while still returning the original promise to
|
||||
// callers that need to await delivery.
|
||||
void publication.catch(() => {});
|
||||
return publication;
|
||||
}
|
||||
|
||||
subMany(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
reconcileCanonicalStates,
|
||||
reconcileScopedStateWrite,
|
||||
TierlistCanonicalFetch,
|
||||
} from "./tierlist-canonical-fetch";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("TierlistCanonicalFetch", () => {
|
||||
it("fetches the switched-to version while ignoring the older response", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const old = deferred<string>();
|
||||
const newVersion = deferred<string>();
|
||||
const applied: string[] = [];
|
||||
coordinator.setVersion("A");
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
() => old.promise,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
coordinator.setVersion("B");
|
||||
coordinator.fetch(
|
||||
"B",
|
||||
() => newVersion.promise,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
old.resolve("A");
|
||||
await flush();
|
||||
expect(applied).toEqual([]);
|
||||
newVersion.resolve("B");
|
||||
await flush();
|
||||
expect(applied).toEqual(["B"]);
|
||||
});
|
||||
|
||||
it("refetches after an SSE invalidation during the initial fetch", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const first = deferred<string>();
|
||||
const applied: string[] = [];
|
||||
let fetchCount = 0;
|
||||
coordinator.setVersion("A");
|
||||
const load = () =>
|
||||
++fetchCount === 1 ? first.promise : Promise.resolve("latest");
|
||||
const fetch = () =>
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
(value) => applied.push(value),
|
||||
() => {},
|
||||
);
|
||||
fetch();
|
||||
fetch();
|
||||
first.resolve("stale");
|
||||
await flush();
|
||||
expect(fetchCount).toBe(2);
|
||||
expect(applied).toEqual(["latest"]);
|
||||
});
|
||||
|
||||
it("does not start a replacement after disposal", async () => {
|
||||
const coordinator = new TierlistCanonicalFetch<string>();
|
||||
const first = deferred<string>();
|
||||
let fetchCount = 0;
|
||||
coordinator.setVersion("A");
|
||||
const load = () =>
|
||||
++fetchCount === 1 ? first.promise : Promise.resolve("next");
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
coordinator.fetch(
|
||||
"A",
|
||||
load,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
coordinator.dispose();
|
||||
first.resolve("stale");
|
||||
await flush();
|
||||
expect(fetchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves a local edit made during a fetch while reconciling other refs", () => {
|
||||
const merged = reconcileCanonicalStates(
|
||||
[
|
||||
{ ref: "one", value: "stale server value" },
|
||||
{ ref: "two", value: "fresh server value" },
|
||||
],
|
||||
[
|
||||
{ ref: "one", value: "local edit" },
|
||||
{ ref: "two", value: "old local value" },
|
||||
],
|
||||
new Set(["one"]),
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ ref: "two", value: "fresh server value" },
|
||||
{ ref: "one", value: "local edit" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes out-of-order server write responses to their own ref", () => {
|
||||
let states = [
|
||||
{ ref: "one", value: "newer one" },
|
||||
{ ref: "two", value: "newer two" },
|
||||
];
|
||||
states = reconcileScopedStateWrite(states, "two", [
|
||||
{ ref: "one", value: "stale one" },
|
||||
{ ref: "two", value: "saved two" },
|
||||
]);
|
||||
states = reconcileScopedStateWrite(states, "one", [
|
||||
{ ref: "one", value: "saved one" },
|
||||
{ ref: "two", value: "stale two" },
|
||||
]);
|
||||
expect(states).toEqual([
|
||||
{ ref: "two", value: "saved two" },
|
||||
{ ref: "one", value: "saved one" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Keeps canonical reads current across invalidations and version switches. */
|
||||
export function reconcileCanonicalStates<T extends { ref: string }>(
|
||||
canonical: T[],
|
||||
current: T[],
|
||||
protectedRefs: Set<string>,
|
||||
): T[] {
|
||||
return [
|
||||
...canonical.filter((entry) => !protectedRefs.has(entry.ref)),
|
||||
...current.filter((entry) => protectedRefs.has(entry.ref)),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconcileScopedStateWrite<T extends { ref: string }>(
|
||||
current: T[],
|
||||
ref: string,
|
||||
saved: T[],
|
||||
): T[] {
|
||||
return [
|
||||
...current.filter((entry) => entry.ref !== ref),
|
||||
...saved.filter((entry) => entry.ref === ref),
|
||||
];
|
||||
}
|
||||
|
||||
export class TierlistCanonicalFetch<T> {
|
||||
private activeVersion: string | undefined;
|
||||
private disposed = false;
|
||||
private readonly requests = new Map<
|
||||
string,
|
||||
{ generation: number; invalidated: boolean; inFlight: boolean }
|
||||
>();
|
||||
|
||||
activate(version: string) {
|
||||
this.disposed = false;
|
||||
this.activeVersion = version;
|
||||
}
|
||||
|
||||
setVersion(version: string) {
|
||||
if (this.activeVersion === version) return;
|
||||
this.activeVersion = version;
|
||||
const request = this.requests.get(version);
|
||||
if (request?.inFlight) request.invalidated = true;
|
||||
}
|
||||
|
||||
invalidate(version: string) {
|
||||
const request = this.requests.get(version);
|
||||
if (request?.inFlight) request.invalidated = true;
|
||||
}
|
||||
|
||||
fetch(
|
||||
version: string,
|
||||
load: () => Promise<T>,
|
||||
apply: (data: T) => void,
|
||||
onError: (error: unknown) => void,
|
||||
onSettled: () => void = () => {},
|
||||
) {
|
||||
if (this.disposed || this.activeVersion !== version) return;
|
||||
let request = this.requests.get(version);
|
||||
if (request?.inFlight) {
|
||||
request.invalidated = true;
|
||||
return;
|
||||
}
|
||||
request ??= { generation: 0, invalidated: false, inFlight: false };
|
||||
request.inFlight = true;
|
||||
request.invalidated = false;
|
||||
const generation = ++request.generation;
|
||||
this.requests.set(version, request);
|
||||
|
||||
void load()
|
||||
.then(
|
||||
(data) => {
|
||||
if (
|
||||
!this.disposed &&
|
||||
this.activeVersion === version &&
|
||||
request?.generation === generation &&
|
||||
!request.invalidated
|
||||
)
|
||||
apply(data);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!this.disposed && this.activeVersion === version) onError(error);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
if (request?.generation !== generation) return;
|
||||
request.inFlight = false;
|
||||
if (
|
||||
this.disposed ||
|
||||
this.activeVersion !== version ||
|
||||
!request.invalidated
|
||||
) {
|
||||
if (this.activeVersion === version) onSettled();
|
||||
return;
|
||||
}
|
||||
request.invalidated = false;
|
||||
this.fetch(version, load, apply, onError, onSettled);
|
||||
});
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.requests.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { TierlistSaveQueue } from "./tierlist-save-queue";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("TierlistSaveQueue", () => {
|
||||
it("serializes writes and persists the latest coalesced value", async () => {
|
||||
const writes: string[] = [];
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
return writes.length === 1 ? first.promise : second.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const a = queue.enqueue("a");
|
||||
const b = queue.enqueue("b");
|
||||
const c = queue.enqueue("c");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["a"]);
|
||||
first.resolve("saved a");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["a", "c"]);
|
||||
second.resolve("saved c");
|
||||
await expect(Promise.all([a, b, c])).resolves.toEqual([
|
||||
"saved a",
|
||||
"saved c",
|
||||
"saved c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains failed intent for explicit retry", async () => {
|
||||
const writes: string[] = [];
|
||||
const retry = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
if (writes.length === 1) return Promise.reject(new Error("offline"));
|
||||
return retry.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
await expect(queue.enqueue("latest")).rejects.toThrow("offline");
|
||||
const retried = queue.retry();
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["latest", "latest"]);
|
||||
retry.resolve("saved");
|
||||
await expect(retried).resolves.toBe("saved");
|
||||
});
|
||||
|
||||
it("continues with a newer queued value after an older write fails", async () => {
|
||||
const writes: string[] = [];
|
||||
const latest = deferred<string>();
|
||||
const queue = new TierlistSaveQueue<string, string>(
|
||||
(value) => {
|
||||
writes.push(value);
|
||||
return writes.length === 1
|
||||
? Promise.reject(new Error("offline"))
|
||||
: latest.promise;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
const oldIntent = queue.enqueue("old");
|
||||
const latestIntent = queue.enqueue("latest");
|
||||
await flushMicrotasks();
|
||||
await expect(oldIntent).rejects.toThrow("offline");
|
||||
await flushMicrotasks();
|
||||
expect(writes).toEqual(["old", "latest"]);
|
||||
latest.resolve("saved latest");
|
||||
await expect(latestIntent).resolves.toBe("saved latest");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export type SaveQueueResult<T, R> = {
|
||||
generation: number;
|
||||
value: T;
|
||||
result: R;
|
||||
};
|
||||
|
||||
type Waiter<R> = {
|
||||
generation: number;
|
||||
resolve: (result: R) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Serializes writes while coalescing edits made during an in-flight write. */
|
||||
export class TierlistSaveQueue<T, R> {
|
||||
private generation = 0;
|
||||
private desired: { generation: number; value: T } | undefined;
|
||||
private writing = false;
|
||||
private disposed = false;
|
||||
private waiters: Waiter<R>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly write: (value: T) => Promise<R>,
|
||||
private readonly onSuccess: (saved: SaveQueueResult<T, R>) => void,
|
||||
private readonly onPendingChange: (pending: boolean) => void = () => {},
|
||||
private readonly onFailure: (error: unknown, value: T) => void = () => {},
|
||||
) {}
|
||||
|
||||
get isPending() {
|
||||
return this.writing;
|
||||
}
|
||||
|
||||
enqueue(value: T): Promise<R> {
|
||||
if (this.disposed)
|
||||
return Promise.reject(new Error("Save queue is disposed"));
|
||||
const generation = ++this.generation;
|
||||
this.desired = { generation, value };
|
||||
this.onPendingChange(true);
|
||||
const promise = new Promise<R>((resolve, reject) => {
|
||||
this.waiters.push({ generation, resolve, reject });
|
||||
});
|
||||
this.flush();
|
||||
return promise;
|
||||
}
|
||||
|
||||
retry(): Promise<R> {
|
||||
if (!this.desired)
|
||||
return Promise.reject(new Error("No failed save to retry"));
|
||||
return this.enqueue(this.desired.value);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.desired = undefined;
|
||||
for (const waiter of this.waiters)
|
||||
waiter.reject(new Error("Save queue disposed"));
|
||||
this.waiters = [];
|
||||
this.onPendingChange(false);
|
||||
}
|
||||
|
||||
private flush() {
|
||||
if (this.writing || !this.desired) return;
|
||||
const attempted = this.desired;
|
||||
this.writing = true;
|
||||
this.onPendingChange(true);
|
||||
void Promise.resolve()
|
||||
.then(() => this.write(attempted.value))
|
||||
.then(
|
||||
(result) => {
|
||||
if (this.disposed) return;
|
||||
this.onSuccess({ ...attempted, result });
|
||||
this.waiters = this.waiters.filter((waiter) => {
|
||||
if (waiter.generation > attempted.generation) return true;
|
||||
waiter.resolve(result);
|
||||
return false;
|
||||
});
|
||||
if (this.desired?.generation === attempted.generation)
|
||||
this.desired = undefined;
|
||||
this.writing = false;
|
||||
this.onPendingChange(false);
|
||||
this.flush();
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (this.disposed) return;
|
||||
this.onFailure(error, attempted.value);
|
||||
this.waiters = this.waiters.filter((waiter) => {
|
||||
if (waiter.generation > attempted.generation) return true;
|
||||
waiter.reject(error);
|
||||
return false;
|
||||
});
|
||||
this.writing = false;
|
||||
this.onPendingChange(
|
||||
!!this.desired && this.desired.generation > attempted.generation,
|
||||
);
|
||||
// Continue newer work; otherwise keep failed intent for explicit retry.
|
||||
if (this.desired && this.desired.generation > attempted.generation)
|
||||
this.flush();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
|
||||
export type TierlistState = typeof tierlistStates.$inferSelect;
|
||||
|
||||
export type TierlistSyncData = {
|
||||
states: TierlistState[];
|
||||
placements: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type TierlistWriteResult<T> = {
|
||||
persisted: true;
|
||||
data: T;
|
||||
publication: "sent" | "failed";
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
import {
|
||||
finishTierlistWrite,
|
||||
lockTierlistList,
|
||||
normalizeTierlistPlacements,
|
||||
resolveTierlistStateTarget,
|
||||
validateTierlistStateInput,
|
||||
} from "./tierlist-write";
|
||||
|
||||
type StateRow = typeof tierlistStates.$inferSelect;
|
||||
|
||||
const stateRow = (uuid: string, list: string, ref = "char-1") =>
|
||||
({ uuid, list, ref }) as StateRow;
|
||||
|
||||
describe("tierlist write consistency helpers", () => {
|
||||
test("validates list, ref, and optional UUID before writing", () => {
|
||||
expect(
|
||||
validateTierlistStateInput({
|
||||
list: "v1",
|
||||
ref: "char-1",
|
||||
uuid: undefined,
|
||||
}),
|
||||
).toEqual({ list: "v1", ref: "char-1" });
|
||||
expect(() =>
|
||||
validateTierlistStateInput({ list: "", ref: "char-1" }),
|
||||
).toThrow();
|
||||
expect(() => validateTierlistStateInput({ list: "v1", ref: "" })).toThrow();
|
||||
expect(() =>
|
||||
validateTierlistStateInput({ list: "v1", ref: "char-1", uuid: "bad" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("rejects cross-list UUIDs and canonicalizes duplicate refs", () => {
|
||||
const uuidMatch = stateRow("uuid-1", "v1");
|
||||
const canonicalRefMatch = stateRow("uuid-0", "v1");
|
||||
expect(() =>
|
||||
resolveTierlistStateTarget(stateRow("uuid-2", "v2"), uuidMatch, "v1"),
|
||||
).toThrow("belongs to another version");
|
||||
expect(resolveTierlistStateTarget(uuidMatch, undefined, "v1")).toBe(
|
||||
uuidMatch,
|
||||
);
|
||||
expect(resolveTierlistStateTarget(uuidMatch, canonicalRefMatch, "v1")).toBe(
|
||||
canonicalRefMatch,
|
||||
);
|
||||
expect(resolveTierlistStateTarget(undefined, uuidMatch, "v1")).toBe(
|
||||
uuidMatch,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses one transaction-scoped advisory lock per list before mutations", async () => {
|
||||
const order: string[] = [];
|
||||
const queries: unknown[] = [];
|
||||
await lockTierlistList(
|
||||
{
|
||||
execute: async (query) => {
|
||||
order.push("lock");
|
||||
queries.push(query);
|
||||
},
|
||||
},
|
||||
"v1",
|
||||
);
|
||||
order.push("write");
|
||||
const compiled = new PgDialect().sqlToQuery(queries[0] as never);
|
||||
expect(compiled.sql).toContain("pg_advisory_xact_lock");
|
||||
expect(compiled.sql).toContain("hashtextextended");
|
||||
expect(compiled.params).toContain("v1");
|
||||
expect(order).toEqual(["lock", "write"]);
|
||||
});
|
||||
|
||||
test("normalizes placements without mutating the caller's untiered data", () => {
|
||||
const placements = { S: ["a"], untiered: ["b"] };
|
||||
expect(normalizeTierlistPlacements(placements)).toEqual({ S: ["a"] });
|
||||
expect(placements).toEqual({ S: ["a"], untiered: ["b"] });
|
||||
});
|
||||
|
||||
test("returns persisted success when publication fails after commit", async () => {
|
||||
const errorLog = spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const result = await finishTierlistWrite({
|
||||
list: "v1",
|
||||
operation: "state",
|
||||
data: [{ uuid: "state-1" }],
|
||||
audit: async () => {},
|
||||
revalidate: () => {},
|
||||
publish: async () => {
|
||||
throw new Error("redis unavailable");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
persisted: true,
|
||||
data: [{ uuid: "state-1" }],
|
||||
publication: "failed",
|
||||
});
|
||||
expect(errorLog).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
errorLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports sent when post-commit publication succeeds", async () => {
|
||||
const result = await finishTierlistWrite({
|
||||
list: "v1",
|
||||
operation: "placements",
|
||||
data: { S: ["char-1"] },
|
||||
audit: async () => {},
|
||||
revalidate: () => {},
|
||||
publish: async () => 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
persisted: true,
|
||||
data: { S: ["char-1"] },
|
||||
publication: "sent",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not turn a precommit transaction failure into persisted success", async () => {
|
||||
const failure = new Error("transaction failed");
|
||||
await expect(
|
||||
lockTierlistList({ execute: async () => Promise.reject(failure) }, "v1"),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { tierlistStates } from "./db/schema";
|
||||
import type { TierlistWriteResult } from "./tierlist-sync";
|
||||
|
||||
type StateInput = Partial<typeof tierlistStates.$inferInsert>;
|
||||
type StateRow = typeof tierlistStates.$inferSelect;
|
||||
|
||||
export function validateTierlistStateInput(data: StateInput) {
|
||||
const { list, ref, uuid } = data;
|
||||
if (typeof list !== "string" || !list.trim())
|
||||
throw new Error("A tierlist version is required.");
|
||||
if (typeof ref !== "string" || !ref.trim())
|
||||
throw new Error("A tierlist state reference is required.");
|
||||
if (
|
||||
uuid !== undefined &&
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
uuid,
|
||||
)
|
||||
)
|
||||
throw new Error("A valid tierlist state UUID is required.");
|
||||
return { list, ref };
|
||||
}
|
||||
|
||||
export function resolveTierlistStateTarget(
|
||||
byUuid: StateRow | undefined,
|
||||
byRef: StateRow | undefined,
|
||||
list: string,
|
||||
) {
|
||||
if (byUuid && byUuid.list !== list)
|
||||
throw new Error("Tierlist state UUID belongs to another version.");
|
||||
if (byUuid && byRef && byUuid.ref === byRef.ref) return byRef;
|
||||
return byUuid ?? byRef;
|
||||
}
|
||||
|
||||
type TransactionLike = {
|
||||
execute(query: ReturnType<typeof sql>): Promise<unknown>;
|
||||
};
|
||||
|
||||
export async function lockTierlistList(tx: TransactionLike, list: string) {
|
||||
await tx.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${list}, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeTierlistPlacements(
|
||||
placements: Record<string, string[]>,
|
||||
) {
|
||||
const { untiered: _untiered, ...persistedPlacements } =
|
||||
structuredClone(placements);
|
||||
return persistedPlacements;
|
||||
}
|
||||
|
||||
export async function finishTierlistWrite<T>({
|
||||
list,
|
||||
operation,
|
||||
data,
|
||||
audit,
|
||||
publish,
|
||||
revalidate,
|
||||
}: {
|
||||
list: string;
|
||||
operation: "state" | "placements";
|
||||
data: T;
|
||||
audit: () => Promise<unknown>;
|
||||
publish: () => Promise<unknown>;
|
||||
revalidate: () => void;
|
||||
}): Promise<TierlistWriteResult<T>> {
|
||||
try {
|
||||
revalidate();
|
||||
} catch (error) {
|
||||
logTierlistPostCommitFailure(list, operation, "revalidation", error);
|
||||
}
|
||||
try {
|
||||
await audit();
|
||||
} catch (error) {
|
||||
logTierlistPostCommitFailure(list, operation, "audit", error);
|
||||
}
|
||||
|
||||
let publication: TierlistWriteResult<T>["publication"] = "sent";
|
||||
try {
|
||||
await publish();
|
||||
} catch (error) {
|
||||
publication = "failed";
|
||||
logTierlistPostCommitFailure(list, operation, "publication", error);
|
||||
}
|
||||
return { persisted: true, data, publication };
|
||||
}
|
||||
|
||||
function logTierlistPostCommitFailure(
|
||||
list: string,
|
||||
operation: string,
|
||||
step: string,
|
||||
error: unknown,
|
||||
) {
|
||||
console.error(
|
||||
`Tierlist ${operation} post-commit ${step} failed for ${list} (${error instanceof Error ? error.name : "unknown error"}).`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { shouldSnapshot, type TierlistResolvedConfig } from "./tierlist";
|
||||
|
||||
function config({
|
||||
deprecates = "01/01/2000",
|
||||
chars = [{ id: "char-1" }, { id: "char-2" }],
|
||||
placements = { S: ["char-1#2026-01", "char-2"] },
|
||||
}: {
|
||||
deprecates?: string;
|
||||
chars?: { id: string }[];
|
||||
placements?: Record<string, string[]>;
|
||||
} = {}) {
|
||||
return {
|
||||
version: { deprecates, placements },
|
||||
chars,
|
||||
} as unknown as TierlistResolvedConfig;
|
||||
}
|
||||
|
||||
describe("shouldSnapshot", () => {
|
||||
test("does not snapshot a fully tiered version before its deprecation date", () => {
|
||||
expect(shouldSnapshot(config({ deprecates: "31/12/2999" }))).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot an old version with unfinished placements", () => {
|
||||
expect(shouldSnapshot(config({ placements: { S: ["char-1"] } }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshots an old version only when every character is tiered", () => {
|
||||
expect(shouldSnapshot(config())).toBe(true);
|
||||
});
|
||||
|
||||
test("does not consider an empty character list fully tiered", () => {
|
||||
expect(shouldSnapshot(config({ chars: [] }))).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot when the deprecation date is invalid", () => {
|
||||
expect(
|
||||
shouldSnapshot(
|
||||
config({
|
||||
deprecates: "not-a-date",
|
||||
placements: { S: ["char-1", "char-2"] },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not snapshot when the deprecation date is missing", () => {
|
||||
expect(
|
||||
shouldSnapshot(
|
||||
config({ deprecates: "", placements: { S: ["char-1", "char-2"] } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -102,7 +102,7 @@ async function buildLiveTierlistConfig(
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
export function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
const deprecatedAt = parseDate(config.version.deprecates);
|
||||
const olderThanMonth =
|
||||
Number.isFinite(deprecatedAt) &&
|
||||
@@ -117,7 +117,7 @@ function shouldSnapshot(config: TierlistResolvedConfig) {
|
||||
config.chars.length > 0 &&
|
||||
config.chars.every((char) => placed.has(char.id));
|
||||
|
||||
return olderThanMonth || allTiered;
|
||||
return olderThanMonth && allTiered;
|
||||
}
|
||||
|
||||
function parseDate(value: string) {
|
||||
|
||||
+1
-1
@@ -43,9 +43,9 @@ export default {
|
||||
minimumCacheTTL: 86400,
|
||||
},
|
||||
experimental: {
|
||||
agentUpgrade: "latest",
|
||||
imgOptTimeoutInSeconds: 30,
|
||||
typedEnv: true,
|
||||
viewTransition: true,
|
||||
serverActions: { bodySizeLimit: "30mb" },
|
||||
},
|
||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID,
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@
|
||||
"@headless-tree/core": "^1.6.0",
|
||||
"@headless-tree/react": "^1.6.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@next/bundle-analyzer": "16.2.6",
|
||||
"@next/bundle-analyzer": "16.4.0",
|
||||
"@origin-space/image-cropper": "^0.1.9",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -70,7 +70,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.27.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "16.2.6",
|
||||
"next": "16.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"posthog-js": "^1.367.0",
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Reference in New Issue
Block a user