Compare commits
@@ -12,27 +12,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -sLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/kubectl
|
||||
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true
|
||||
kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }}
|
||||
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
|
||||
kubectl config use-context buzz
|
||||
|
||||
- name: Generate .env from K8s secret
|
||||
run: |
|
||||
kubectl get secret buzz-env -n buzz \
|
||||
-o go-template='{{range $k,$v := .data}}{{$k}}={{$v|base64decode}}{{"\n"}}{{end}}' > .env
|
||||
- run: |
|
||||
docker info
|
||||
env="${{ secrets.ENV_B64 }}"
|
||||
length=${#env}
|
||||
echo "Length: $length"
|
||||
echo "${{ secrets.ENV_B64 }}" | base64 -d > .env
|
||||
ls -l .env
|
||||
|
||||
- run: |
|
||||
TAG=${{ gitea.sha }}
|
||||
docker build \
|
||||
@@ -44,27 +30,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
|
||||
curl -sLO "https://dl.k8s.io/release/v1.35.0/bin/linux/${ARCH}/kubectl"
|
||||
chmod +x kubectl
|
||||
mv kubectl /usr/local/bin/kubectl
|
||||
|
||||
- name: Configure kubectl
|
||||
run: |
|
||||
kubectl config set-cluster buzz --server=https://100.75.220.33:6443 --insecure-skip-tls-verify=true
|
||||
kubectl config set-credentials ci-deployer --token=${{ secrets.KUBE_TOKEN }}
|
||||
kubectl config set-context buzz --cluster=buzz --user=ci-deployer --namespace=buzz
|
||||
kubectl config use-context buzz
|
||||
|
||||
- name: Generate .env from K8s secret
|
||||
run: |
|
||||
kubectl get secret buzz-env -n buzz \
|
||||
-o go-template='{{range $k,$v := .data}}{{$k}}={{$v|base64decode}}{{"\n"}}{{end}}' > .env
|
||||
- run: |
|
||||
docker info
|
||||
env="${{ secrets.ENV_B64 }}"
|
||||
length=${#env}
|
||||
echo "Length: $length"
|
||||
echo "${{ secrets.ENV_B64 }}" | base64 -d > .env
|
||||
ls -l .env
|
||||
|
||||
- run: |
|
||||
TAG=${{ gitea.sha }}
|
||||
docker build \
|
||||
@@ -109,11 +81,10 @@ jobs:
|
||||
--image=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
|
||||
-n buzz \
|
||||
-- bun dr push
|
||||
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=180s
|
||||
|
||||
# Roll app & backend
|
||||
kubectl set image deployment/app app=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG -n buzz
|
||||
kubectl set image deployment/backend backend=mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG -n buzz
|
||||
kubectl rollout status deployment/app -n buzz --timeout=180s &
|
||||
kubectl rollout status deployment/backend -n buzz --timeout=180s &
|
||||
kubectl wait --for=condition=complete job/db-migrate -n buzz --timeout=600s &
|
||||
wait
|
||||
kubectl rollout status deployment/app -n buzz --timeout=180s
|
||||
kubectl rollout status deployment/backend -n buzz --timeout=180s
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Background0 from "#/bg.webp";
|
||||
import Background1 from "#/bgassets/buzzbg1-r2.webp";
|
||||
import Background2 from "#/bgassets/buzzbg2-r1.webp";
|
||||
import Background3 from "#/bgassets/buzzbg3-r1.webp";
|
||||
import Background4 from "#/bgassets/buzzbg4-r1.webp";
|
||||
import Background5 from "#/bgassets/buzzbg5-r1.webp";
|
||||
import Background6 from "#/bgassets/buzzbg6-r1.webp";
|
||||
import Background7 from "#/bgassets/buzzbg7-r1.webp";
|
||||
import type { StaticImport } from "next/dist/shared/lib/get-img-props";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StarsRenderer } from "@/components/stars";
|
||||
|
||||
type PrefixBackground = [`/${string}`, string | StaticImport, `/${string}`];
|
||||
const backgrounds = [
|
||||
// path prefix, background, stars mask
|
||||
["/artifact", Background1, "/bgassets/buzzbg1-r2-mask.webp"],
|
||||
["/rubgram", Background2, "/bgassets/buzzbg2-r1-mask.webp"],
|
||||
["/donate", Background3, "/bgassets/buzzbg3-r1-mask.webp"],
|
||||
["/tl", Background5, "/bgassets/buzzbg4-r1-mask.webp"],
|
||||
["/guide", Background4, "/bgassets/buzzbg5-r1-mask.webp"],
|
||||
["/admin", Background6, "/bgassets/buzzbg6-r1-mask.webp"],
|
||||
// unused
|
||||
["/donate", Background7, "/bgassets/buzzbg7-r1-mask.webp"],
|
||||
// this should be last
|
||||
["/", Background0, "/mask.webp"],
|
||||
] satisfies PrefixBackground[];
|
||||
|
||||
export function BackgroundProvider() {
|
||||
const pathname = usePathname();
|
||||
const [, background, mask] = useMemo(
|
||||
() => backgrounds.find((b) => pathname.startsWith(b[0]))!,
|
||||
[pathname],
|
||||
);
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [current, setCurrent] = useState<string | StaticImport>(background);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
src={background}
|
||||
alt="Background Swap"
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-0",
|
||||
transitioning && "opacity-40 transition-opacity duration-500",
|
||||
)}
|
||||
onLoad={() => {
|
||||
if (background === current) return;
|
||||
setTransitioning(true);
|
||||
}}
|
||||
onTransitionEnd={() => {
|
||||
if (!transitioning) return;
|
||||
setCurrent(background);
|
||||
setTransitioning(false);
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
src={current}
|
||||
alt="Background Current"
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-40",
|
||||
transitioning && "opacity-0 transition-opacity duration-500",
|
||||
)}
|
||||
/>
|
||||
{transitioning || <StarsRenderer mask={mask} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,10 @@ import { ActionButton } from "../../../../components/action-button";
|
||||
import { getImage, reloadWidget, resendPopup, testPopup } from "./api";
|
||||
|
||||
const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||
{ accessorKey: "name", header: "ชื่อ", meta: { className: "w-50 truncate" } },
|
||||
{ accessorKey: "name", header: "Name", meta: { className: "w-50 truncate" } },
|
||||
{
|
||||
accessorFn: (row) => `${row.amount}฿`,
|
||||
header: "จำนวน",
|
||||
header: "Amount",
|
||||
meta: { className: "w-24" },
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||
</div>
|
||||
);
|
||||
},
|
||||
header: "ข้อความ",
|
||||
header: "Message",
|
||||
meta: { className: "w-full" },
|
||||
},
|
||||
{
|
||||
@@ -105,7 +105,7 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||
cell(row) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<SimpleTooltip text="แสดง Popup อีกครั้ง">
|
||||
<SimpleTooltip text="Re-send Popup">
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@@ -120,7 +120,7 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||
</ActionButton>
|
||||
</SimpleTooltip>
|
||||
{row.row.original.uid ? (
|
||||
<SimpleTooltip text="ก็อบ UID">
|
||||
<SimpleTooltip text="Copy Artifact UID">
|
||||
<ActionButton
|
||||
size="icon-sm"
|
||||
action={() =>
|
||||
@@ -134,7 +134,7 @@ const columns: ColumnDef<typeof donations.$inferSelect>[] = [
|
||||
<div className="size-8" />
|
||||
)}
|
||||
{row.row.original.image && (
|
||||
<SimpleTooltip text="เปิดภาพ">
|
||||
<SimpleTooltip text="Open Image">
|
||||
<ActionButton
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
@@ -165,11 +165,20 @@ export function DonateAdminPage({
|
||||
}: {
|
||||
data: (typeof donations.$inferSelect)[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!router) return;
|
||||
return sse.donate.subMany({
|
||||
ping: () => router.refresh(),
|
||||
update: () => router.refresh(),
|
||||
}).clean;
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[max(1280px,90%)] flex-col">
|
||||
<span className="flex items-center gap-1 pt-1 pb-2 text-3xl font-semibold">
|
||||
<BitcoinIcon size={32} />
|
||||
โดเนททั้งหมด
|
||||
Donate List
|
||||
</span>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -177,21 +186,6 @@ export function DonateAdminPage({
|
||||
data={data}
|
||||
className="max-h-[calc(100vh-93px)] w-full overflow-y-auto bg-black/25 backdrop-blur-sm"
|
||||
></DataTable>
|
||||
<DonateWatcher />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// component cuz simplicity sake
|
||||
export function DonateWatcher() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (!router) return;
|
||||
return sse.donate.subMany({
|
||||
ping: router.refresh.bind(router),
|
||||
update: router.refresh.bind(router),
|
||||
}).clean;
|
||||
}, [router]);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { shared } from "@/lib/comms";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { ExternalLink, RadioTower } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState, type ComponentProps } from "react";
|
||||
|
||||
const ReactPIP = dynamic(
|
||||
() => import("@/components/dpip").then((e) => e.DocumentPictureInPicture),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function PIP(props: ComponentProps<typeof ReactPIP>) {
|
||||
return (
|
||||
<ReactPIP
|
||||
{...props}
|
||||
shareStyles
|
||||
buttonRenderer={({ toggle, isOpen }) => (
|
||||
<div className="flex flex-col">
|
||||
{isOpen ? <span>Content popped out.</span> : props.children}
|
||||
<Button onClick={toggle} variant="outline" size="icon-sm">
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
featureUnavailableRenderer={
|
||||
<div className="flex flex-col">
|
||||
{props.children}
|
||||
<Button disabled variant="outline" size="icon-sm">
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientTracker() {
|
||||
const [connected, setConnected] = useState(0);
|
||||
const player = useRef<HTMLAudioElement>(null);
|
||||
const tracking = useRef(0);
|
||||
|
||||
shared.signal(
|
||||
"moderator.volume",
|
||||
(ev) => player.current && (player.current.volume = ev ?? 1),
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
player.current
|
||||
? sse.donate.subMany({
|
||||
heartbeat: () => tracking.current++,
|
||||
ping: player.current.play.bind(player.current),
|
||||
}).clean
|
||||
: undefined,
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setConnected(tracking.current);
|
||||
tracking.current = 0;
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
return (
|
||||
<Button variant="outline" disabled size="sm">
|
||||
<RadioTower /> {connected}
|
||||
<audio
|
||||
src="/assets/donate-mod-sfx.wav"
|
||||
preload="auto"
|
||||
ref={player}
|
||||
hidden
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VolumeChanger() {
|
||||
const [volume, setVolume] = shared.state("moderator.volume");
|
||||
|
||||
return (
|
||||
<div className="px-5 py-1">
|
||||
<Slider
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={[volume ?? 0.5]}
|
||||
onValueChange={(v) => setVolume(v[0])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { adminCheck } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DonateWatcher } from "../client";
|
||||
import { db } from "@/lib/db";
|
||||
import { donations } from "@/lib/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { Gavel, ImageOff, RefreshCw } from "lucide-react";
|
||||
import { fileToDataUrl } from "@/lib/utils";
|
||||
import Image from "@/components/image";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ActionButton } from "@/components/action-button";
|
||||
import { reloadWidget } from "../api";
|
||||
import { markDone } from "@/app/widget/donate/api";
|
||||
import { ClientTracker, PIP, VolumeChanger } from "./client";
|
||||
|
||||
export default async function DonateModeratorPage() {
|
||||
if (!(await adminCheck()))
|
||||
redirect(`/login?next=${encodeURIComponent("/donate/admin/moderator")}`);
|
||||
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(donations)
|
||||
.orderBy(desc(donations.created))
|
||||
// .where(isNotNull(donations.image))
|
||||
.limit(1);
|
||||
|
||||
async function reject() {
|
||||
"use server";
|
||||
markDone(sub.id);
|
||||
reloadWidget();
|
||||
}
|
||||
|
||||
return (
|
||||
<PIP width={400} height={600}>
|
||||
<div className="dark flex w-full max-w-100 flex-col gap-1 rounded-md bg-background p-1 text-foreground scheme-dark">
|
||||
<span>Donate Moderator</span>
|
||||
{sub.image ? (
|
||||
<div className="relative flex aspect-square w-full max-w-100 items-center justify-center overflow-hidden rounded-lg border bg-black/50">
|
||||
<Image
|
||||
src={await fileToDataUrl(
|
||||
new File([Buffer.from(sub.image)], "abc.jpeg"),
|
||||
)}
|
||||
alt="donation image"
|
||||
fill
|
||||
className="z-10"
|
||||
/>
|
||||
<Spinner className="size-16" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-square items-center justify-center rounded-lg border bg-black/50">
|
||||
<ImageOff className="size-16 opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold">{sub.name}</span>
|
||||
<span>{sub.message}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{sub.amount}฿</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-evenly gap-1">
|
||||
<ActionButton action={reject} variant="destructive" size="sm">
|
||||
<Gavel /> Reject
|
||||
</ActionButton>
|
||||
<ActionButton action={reloadWidget} variant="outline" size="sm">
|
||||
<RefreshCw /> Reload Widgets
|
||||
</ActionButton>
|
||||
<ClientTracker />
|
||||
</div>
|
||||
<VolumeChanger />
|
||||
<DonateWatcher />
|
||||
</div>
|
||||
</PIP>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
SlipUpload,
|
||||
} from "../rubgram/admin/@modal/manual/client";
|
||||
import { DownloadButton } from "../rubgram/client";
|
||||
import Link from "next/link";
|
||||
|
||||
const { TMN_DEST_PHONE_NUM, SASTIFY_API_PRIVKEY } = process.env as Record<
|
||||
string,
|
||||
@@ -191,20 +190,13 @@ export default async function () {
|
||||
|
||||
const { name, amount, message, image } = $;
|
||||
|
||||
const downscaled = image
|
||||
? await new Bun.Image(await image.arrayBuffer())
|
||||
.resize(512, 512)
|
||||
.webp()
|
||||
.toBuffer()
|
||||
: undefined;
|
||||
|
||||
const [{ id }] = await tx
|
||||
.insert(donations)
|
||||
.values({
|
||||
name,
|
||||
amount,
|
||||
message,
|
||||
image: downscaled,
|
||||
image: image ? Buffer.from(await image.arrayBuffer()) : undefined,
|
||||
uid: $.artifact === "true" ? $.uid : null,
|
||||
// dont send on screen if less than 10
|
||||
sent: $.amount < 10,
|
||||
@@ -223,12 +215,12 @@ export default async function () {
|
||||
.onConflictDoUpdate({
|
||||
target: submissions.uid,
|
||||
set: {
|
||||
comment: sql`${submissions.comment} || ${"\n"}::text || ${message}::text`,
|
||||
comment: sql`CONCAT(${submissions.comment}, ${"\n"}, ${message})`,
|
||||
promoted: true,
|
||||
},
|
||||
})
|
||||
// .onConflictDoNothing();
|
||||
.catch(console.error);
|
||||
});
|
||||
// .onConflictDoNothing();
|
||||
// .catch(() => "conflict");
|
||||
// if (res === "conflict") {
|
||||
// tx.rollback();
|
||||
// ph.capture({
|
||||
@@ -271,13 +263,11 @@ export default async function () {
|
||||
<div className="w-full max-w-md rounded-md border bg-card p-5">
|
||||
{/* oxlint-disable-next-line tailwindcss/enforce-canonical */}
|
||||
<div className="relative aspect-[304.5/30] w-full">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src={DonateLogo}
|
||||
alt="Donate Logo"
|
||||
className="absolute left-1/2 w-3/4 -translate-x-1/2 -translate-y-2/3"
|
||||
/>
|
||||
</Link>
|
||||
<Image
|
||||
src={DonateLogo}
|
||||
alt="Donate Logo"
|
||||
className="absolute left-1/2 w-3/4 -translate-x-1/2 -translate-y-2/3"
|
||||
/>
|
||||
</div>
|
||||
<FormProvider id="tip" inDialog={false} onSubmit={submit}>
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row sm:items-end">
|
||||
@@ -297,7 +287,7 @@ export default async function () {
|
||||
</FormInput>
|
||||
</div>
|
||||
</div>
|
||||
<FormInput name="message" label="ข้อความ" subLabel="สูงสุด 200 ตัวอักษร">
|
||||
<FormInput name="message" label="ข้อความ" subLabel="สูงสุด 500 ตัวอักษร">
|
||||
<Textarea placeholder="ข้อความ" />
|
||||
</FormInput>
|
||||
{!artifactConfig.locked && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { asc, desc, max, ne, sql, sum } from "drizzle-orm";
|
||||
import { asc, desc, max, sql, sum } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -49,7 +49,6 @@ async function Content() {
|
||||
amount: sum(donations.amount) as unknown as SQL<number>,
|
||||
})
|
||||
.from(donations)
|
||||
.where(ne(donations.name, "Anonymous"))
|
||||
.offset(3)
|
||||
.limit(7)
|
||||
.groupBy(donations.name)
|
||||
@@ -68,7 +67,6 @@ async function Content() {
|
||||
`,
|
||||
})
|
||||
.from(donations)
|
||||
.where(ne(donations.name, "Anonymous"))
|
||||
.groupBy(donations.name)
|
||||
.orderBy(desc(sum(donations.amount)), asc(max(donations.id)))
|
||||
.limit(3)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Anuphan } from "next/font/google";
|
||||
import { ViewTransition } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import Background from "#/bg.webp";
|
||||
import Image from "@/components/image";
|
||||
import { StarsRenderer } from "@/components/stars";
|
||||
import Providers, { VersionCheck } from "../client";
|
||||
import { Navbar } from "./navbar";
|
||||
import { BackgroundProvider } from "./background";
|
||||
|
||||
const anuphan = Anuphan({
|
||||
variable: "--font-anuphan",
|
||||
@@ -17,7 +19,12 @@ export default async function UiLayout({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BackgroundProvider />
|
||||
<Image
|
||||
src={Background}
|
||||
alt="Background"
|
||||
className="fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full object-cover opacity-40"
|
||||
/>
|
||||
<StarsRenderer />
|
||||
<Providers>
|
||||
<ViewTransition>{children}</ViewTransition>
|
||||
<VersionCheck />
|
||||
|
||||
@@ -44,7 +44,7 @@ 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" last />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,10 +126,7 @@ export function SlipUpload({
|
||||
type="button"
|
||||
>
|
||||
{isSelected ? (
|
||||
<span
|
||||
onClick={isSelected ? choose : undefined}
|
||||
className="min-w-0 truncate"
|
||||
>
|
||||
<span onClick={isSelected ? choose : undefined} className="truncate">
|
||||
{value.name} <Kbd>{b2sClient(Number(value.size))}</Kbd>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -6,200 +6,14 @@ import {
|
||||
CopyCheck,
|
||||
ImageOff,
|
||||
MessageSquareWarning,
|
||||
Send,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Note } from "@/lib/db/schema";
|
||||
import { addNote, debugUploadSlip, deleteNote, discordCall } from "../../api";
|
||||
import { debugUploadSlip, discordCall } from "../../api";
|
||||
|
||||
// by chatgpt
|
||||
const lePalette = [
|
||||
{
|
||||
bg: "#A34A5B",
|
||||
border: "#C26779",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#3D7EA6",
|
||||
border: "#5A9BC6",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#7A5A48",
|
||||
border: "#9B7763",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#6F5BA7",
|
||||
border: "#8C77C8",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#4F8C5A",
|
||||
border: "#6DAC79",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#A67C00",
|
||||
border: "#C59A17",
|
||||
text: "#FFF8E1",
|
||||
},
|
||||
{
|
||||
bg: "#8E4B73",
|
||||
border: "#AD6791",
|
||||
text: "#FAFAF9",
|
||||
},
|
||||
{
|
||||
bg: "#2E7D6A",
|
||||
border: "#4C9D89",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#A65D3A",
|
||||
border: "#C57A53",
|
||||
text: "#FFF7ED",
|
||||
},
|
||||
{
|
||||
bg: "#5C6673",
|
||||
border: "#7B8696",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#4B5FA3",
|
||||
border: "#6880C5",
|
||||
text: "#F8FAFC",
|
||||
},
|
||||
{
|
||||
bg: "#7A8742",
|
||||
border: "#99A75E",
|
||||
text: "#F7FEE7",
|
||||
},
|
||||
];
|
||||
|
||||
function colorFor(queueId: string, noteIndex: number) {
|
||||
const seed = parseInt(queueId.slice(-8), 16);
|
||||
return lePalette[(seed + noteIndex * 7) % lePalette.length];
|
||||
}
|
||||
|
||||
export function NotesPanel({
|
||||
sid,
|
||||
initialNotes,
|
||||
}: {
|
||||
sid: string;
|
||||
initialNotes: Note[];
|
||||
}) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
const [text, setText] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [cardOpen, setCardOpen] = useState(false);
|
||||
const [closeCooldown, setCloseCooldown] = useState(false);
|
||||
|
||||
async function handleAdd() {
|
||||
if (!text.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const note = await addNote(sid, text.trim());
|
||||
setNotes((prev) => [note, ...prev]);
|
||||
setText("");
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
setAdding(false);
|
||||
setCardOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(noteId: string) {
|
||||
try {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||
await deleteNote(sid, noteId);
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<HoverCard
|
||||
openDelay={0}
|
||||
closeDelay={200}
|
||||
open={cardOpen}
|
||||
onOpenChange={(r) => r || closeCooldown || setCardOpen(r)}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCloseCooldown(true);
|
||||
setCardOpen(true);
|
||||
setTimeout(() => setCloseCooldown(false), 300);
|
||||
}}
|
||||
>
|
||||
+ เพิ่มโน๊ต
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="พิมพ์โน๊ต..."
|
||||
className="min-w-48"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={adding || !text.trim()}
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2">
|
||||
{notes.map((n, i, _, pal = colorFor(sid, i)) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="group relative min-h-50 px-2"
|
||||
style={{
|
||||
background: `${pal.bg}bb`,
|
||||
border: `${pal.border} 1px solid`,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm" style={{ color: pal.text }}>
|
||||
{n.text}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDelete(n.id)}
|
||||
// className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
className="absolute -top-1.5 -right-1.5 size-5 opacity-0 backdrop-blur-md group-hover:opacity-100"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export function Evernight() {
|
||||
return <div className="relative h-full w-full"></div>;
|
||||
}
|
||||
|
||||
export function CopyButton({ text }: { text: string }) {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
endgameSubmissions,
|
||||
endgameTypes,
|
||||
} from "@/lib/db/schema";
|
||||
import { CallButton, CopyButton, DebugSlipUpload, NotesPanel } from "./client";
|
||||
import { CallButton, CopyButton, DebugSlipUpload, Evernight } from "./client";
|
||||
|
||||
export default async function AdminSubmissionView({
|
||||
params,
|
||||
@@ -129,7 +129,7 @@ export default async function AdminSubmissionView({
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden rounded-md border">
|
||||
<NotesPanel sid={sub.id} initialNotes={sub.notes || []} />
|
||||
<Evernight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useProgress } from "@bprogress/next";
|
||||
import { Calendar, Lock, Trash2, Unlock } from "lucide-react";
|
||||
import { Calendar, Lock, Unlock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -39,7 +39,7 @@ import { shared } from "@/lib/comms";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { getEndgameConfig } from "../api";
|
||||
import { bulkDelete, setFree, setLimit, toggleCheck, toggleLock } from "../api";
|
||||
import { setFree, setLimit, toggleCheck, toggleLock } from "../api";
|
||||
|
||||
export function SidebarLink({
|
||||
submission,
|
||||
@@ -259,7 +259,6 @@ export function SubmissionList({
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [debug] = shared.state("debug");
|
||||
const [selIds, setSelIds] = shared.state("rubgram.sel_id");
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -281,84 +280,23 @@ export function SubmissionList({
|
||||
(debug || (s.paid && !s.deleted)) &&
|
||||
(s.publicQueue + s.name).toLowerCase().includes(query),
|
||||
)
|
||||
.map((s) =>
|
||||
debug ? (
|
||||
<div key={s.id} className="flex items-center gap-0">
|
||||
<Checkbox
|
||||
className="mx-1 shrink-0"
|
||||
checked={selIds?.includes(s.id) ?? false}
|
||||
onCheckedChange={(ch) =>
|
||||
setSelIds((prev) =>
|
||||
ch
|
||||
? [...(prev || []), s.id]
|
||||
: (prev || []).filter((i) => i !== s.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
((debug && !s.paid) || s.deleted) && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<SidebarLink
|
||||
submission={{ ...s, queue: s.publicQueue }}
|
||||
prefetch={!s.deleted}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</div>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
key={s.id}
|
||||
asChild
|
||||
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
|
||||
>
|
||||
<SidebarLink
|
||||
submission={{ ...s, queue: s.publicQueue }}
|
||||
prefetch={!s.deleted}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
),
|
||||
)}
|
||||
.map((s) => (
|
||||
<SidebarMenuButton
|
||||
key={s.id}
|
||||
asChild
|
||||
className={cn(((debug && !s.paid) || s.deleted) && "opacity-50")}
|
||||
>
|
||||
<SidebarLink
|
||||
submission={{ ...s, queue: s.publicQueue }}
|
||||
prefetch={!s.deleted}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkDeleteButton() {
|
||||
const [selIds, setSelIds] = shared.state("rubgram.sel_id");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!selIds?.length) return null;
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await bulkDelete(selIds);
|
||||
setSelIds([]);
|
||||
toast.success(`ลบ ${selIds.length} รายการแล้ว`);
|
||||
} catch (e) {
|
||||
toast.error(`${e}`);
|
||||
}
|
||||
setLoading(false);
|
||||
}}
|
||||
>
|
||||
{loading ? "กำลังลบ..." : <Trash2 />}
|
||||
ลบ {selIds.length} รายการ
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function Watcher() {
|
||||
const router = useRouter();
|
||||
const count = useRef<number | undefined>(undefined);
|
||||
|
||||
@@ -33,7 +33,6 @@ import { db } from "@/lib/db";
|
||||
import { endgameSubmissions } from "@/lib/db/schema";
|
||||
import { getEndgameConfig, random, wipe } from "../api";
|
||||
import {
|
||||
BulkDeleteButton,
|
||||
CalendarButton,
|
||||
LimitManager,
|
||||
SubmissionList,
|
||||
@@ -124,15 +123,11 @@ export default async function AdminLayout({
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<BulkDeleteButton />
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>การตั้งค่า</SidebarGroupLabel>
|
||||
<LimitManager
|
||||
config={config}
|
||||
length={subs.reduce(
|
||||
(c, s) => c + (s.paid && !s.deleted ? 1 : 0),
|
||||
0,
|
||||
)}
|
||||
length={subs.reduce((c, s) => c + (s.paid ? 1 : 0), 0)}
|
||||
/>
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
and,
|
||||
count as sqlCount,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
inArray,
|
||||
lt,
|
||||
not,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { and, desc, eq, gt, lt, not, sql } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -24,7 +14,6 @@ import {
|
||||
endgameSlips,
|
||||
endgameSubmissions,
|
||||
endgameTypes,
|
||||
type Note,
|
||||
settings,
|
||||
} from "@/lib/db/schema";
|
||||
import { sse } from "@/lib/db/sse-endpoints";
|
||||
@@ -165,10 +154,7 @@ export async function getEndgameConfig() {
|
||||
.select()
|
||||
.from(settings)
|
||||
.limit(1);
|
||||
const [{ count }] = await db
|
||||
.select({ count: sqlCount() })
|
||||
.from(endgameSubmissions)
|
||||
.where(not(endgameSubmissions.deleted));
|
||||
const count = await db.$count(endgameSubmissions);
|
||||
const types = await db
|
||||
.select()
|
||||
.from(endgameTypes)
|
||||
@@ -192,37 +178,6 @@ export async function getEndgameConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserSubmissions(uid: string) {
|
||||
const types = await db.select().from(endgameTypes);
|
||||
const typeNames = Object.fromEntries(types.map((t) => [t.id, t.display]));
|
||||
|
||||
const subs = await db
|
||||
.select({
|
||||
id: endgameSubmissions.id,
|
||||
queue: endgameSubmissions.queue,
|
||||
name: endgameSubmissions.name,
|
||||
server: endgameSubmissions.server,
|
||||
price: endgameSubmissions.price,
|
||||
paid: endgameSubmissions.paid,
|
||||
checked: endgameSubmissions.checked,
|
||||
deleted: endgameSubmissions.deleted,
|
||||
expires: endgameSubmissions.expires,
|
||||
service: endgameSubmissions.service,
|
||||
submitDay: endgameSubmissions.submit_day,
|
||||
})
|
||||
.from(endgameSubmissions)
|
||||
.where(
|
||||
and(eq(endgameSubmissions.user, uid), not(endgameSubmissions.deleted)),
|
||||
)
|
||||
.orderBy(desc(endgameSubmissions.submit_day))
|
||||
.limit(20);
|
||||
|
||||
return subs.map((s) => ({
|
||||
...s,
|
||||
services: s.service.map((id) => typeNames[id] || id),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function submitEndgame(formData: FormData) {
|
||||
const { full, locked, limit, types, free } = await getEndgameConfig();
|
||||
const name = formData.get("name") as string;
|
||||
@@ -447,62 +402,6 @@ export async function cancel(sid: string) {
|
||||
revalidatePath("/rubgram");
|
||||
}
|
||||
|
||||
export async function bulkDelete(ids: string[]) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ deleted: true })
|
||||
.where(inArray(endgameSubmissions.id, ids));
|
||||
|
||||
revalidatePath("/rubgram/admin");
|
||||
|
||||
sse.rubgram.pub("update", { type: "wipe" });
|
||||
|
||||
await actionLog(`Bulk deleted ${ids.length} rubgram submissions`);
|
||||
}
|
||||
|
||||
export async function addNote(sid: string, text: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const note: Note = {
|
||||
id: Bun.randomUUIDv7(),
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const [sub] = await db
|
||||
.select({ notes: endgameSubmissions.notes })
|
||||
.from(endgameSubmissions)
|
||||
.where(eq(endgameSubmissions.id, sid))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ notes: [...(sub?.notes || []), note] })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
revalidatePath(`/rubgram/admin/${sid}`);
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
export async function deleteNote(sid: string, noteId: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
const [sub] = await db
|
||||
.select({ notes: endgameSubmissions.notes })
|
||||
.from(endgameSubmissions)
|
||||
.where(eq(endgameSubmissions.id, sid))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.update(endgameSubmissions)
|
||||
.set({ notes: (sub?.notes || []).filter((n) => n.id !== noteId) })
|
||||
.where(eq(endgameSubmissions.id, sid));
|
||||
|
||||
revalidatePath(`/rubgram/admin/${sid}`);
|
||||
}
|
||||
|
||||
export async function discordCall(id: string) {
|
||||
if (!(await adminCheck())) throw "Unauthorized";
|
||||
|
||||
|
||||
@@ -1,30 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Circle,
|
||||
Clock,
|
||||
Download,
|
||||
List,
|
||||
LogIn,
|
||||
Plus,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, Check, Download, LogIn, ScrollText } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Blocker } from "@/components/blocker";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -43,17 +25,14 @@ import {
|
||||
cancel,
|
||||
type getDiscordSession,
|
||||
type getEndgameConfig,
|
||||
type getUserSubmissions,
|
||||
loginDiscord,
|
||||
} from "./api";
|
||||
import { RulesDialog } from "./rules";
|
||||
|
||||
export function WelcomeScreening({
|
||||
session,
|
||||
userSubs,
|
||||
}: {
|
||||
session: Awaited<ReturnType<typeof getDiscordSession>>;
|
||||
userSubs: Awaited<ReturnType<typeof getUserSubmissions>>;
|
||||
}) {
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [updated] = shared.state("updated");
|
||||
@@ -79,13 +58,6 @@ export function WelcomeScreening({
|
||||
สลับบัญชี?
|
||||
</button>
|
||||
</span>
|
||||
<div className="absolute right-0 bottom-0 m-2">
|
||||
<SubmissionListModal subs={userSubs}>
|
||||
<Button variant="outline" size="sm">
|
||||
<List /> รายการคิว
|
||||
</Button>
|
||||
</SubmissionListModal>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
@@ -294,94 +266,3 @@ export function ClearCookie() {
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function SubmitAnotherButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("?new")}>
|
||||
<Plus /> ลงคิวเพิ่ม
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubmissionListModal({
|
||||
subs,
|
||||
children,
|
||||
}: {
|
||||
subs: Awaited<ReturnType<typeof getUserSubmissions>>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const serverLabels: Record<string, string> = {
|
||||
as: "Asia",
|
||||
eu: "Europe",
|
||||
us: "America",
|
||||
tw: "TW, HK, MO",
|
||||
};
|
||||
|
||||
function select(id: string) {
|
||||
try {
|
||||
// biome-ignore lint/suspicious/noTsIgnore: typescript issue
|
||||
// @ts-ignore
|
||||
cookieStore.set("rsid", id);
|
||||
} catch {
|
||||
console.warn("Cannot set rubgram cookie");
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[80svh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>รายการคิวของฉัน</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{subs.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">ไม่มีรายการคิว</p>
|
||||
)}
|
||||
{subs.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => select(r.id)}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md border p-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded bg-muted">
|
||||
{r.paid ? (
|
||||
<Check className="size-5 text-green-500" />
|
||||
) : r.expires && new Date(r.expires) <= new Date() ? (
|
||||
<Circle className="size-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Clock className="size-5 text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{r.queue}. {r.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{r.services.join(", ")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline">{serverLabels[r.server]}</Badge>
|
||||
<span className="font-medium">{r.price} ฿</span>
|
||||
{r.paid ? (
|
||||
<span className="text-green-500">ชำระแล้ว</span>
|
||||
) : r.expires && new Date(r.expires) <= new Date() ? (
|
||||
<span className="text-muted-foreground">หมดเวลา</span>
|
||||
) : (
|
||||
<span className="text-yellow-500">รอชำระ</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AlertCircle,
|
||||
BookAlert,
|
||||
CircleDollarSign,
|
||||
List,
|
||||
SendHorizonal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -55,7 +54,7 @@ import { db } from "@/lib/db";
|
||||
import { endgameSubmissions } from "@/lib/db/schema";
|
||||
import { LiveButton } from "../artifact/live";
|
||||
import { Watcher } from "./admin/client";
|
||||
import { getDiscordSession, getEndgameConfig, getUserSubmissions } from "./api";
|
||||
import { getDiscordSession, getEndgameConfig } from "./api";
|
||||
import {
|
||||
CancelButton,
|
||||
ClearCookie,
|
||||
@@ -64,8 +63,6 @@ import {
|
||||
PriceEstimation,
|
||||
ServiceSelector,
|
||||
SlipUpload,
|
||||
SubmissionListModal,
|
||||
SubmitAnotherButton,
|
||||
WelcomeScreening,
|
||||
} from "./client";
|
||||
import { EndgameFormWrapper } from "./form";
|
||||
@@ -76,25 +73,16 @@ export const metadata: Metadata = {
|
||||
description: "รับเล่นคอนเทนต์เอนเกมเกนชินแทนคนดู",
|
||||
};
|
||||
|
||||
export default async function EndgamePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ new?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const isNew = sp.new === "";
|
||||
|
||||
export default async function EndgamePage() {
|
||||
const cookie = await cookies();
|
||||
const sid = cookie.get("rsid");
|
||||
const session = await getDiscordSession();
|
||||
|
||||
const [q] =
|
||||
!isNew && sid?.value
|
||||
? await db
|
||||
.select({
|
||||
// Using endgame.submissions.queue here because
|
||||
// ${endgameSubmissions.queue} does not work
|
||||
queue: sql<number>`
|
||||
const [q] = sid?.value
|
||||
? await db
|
||||
.select({
|
||||
// Using endgame.submissions.queue here because
|
||||
// ${endgameSubmissions.queue} does not work
|
||||
queue: sql<number>`
|
||||
${endgameSubmissions.queue} - (
|
||||
select count(*)
|
||||
from ${endgameSubmissions} e2
|
||||
@@ -102,25 +90,24 @@ export default async function EndgamePage({
|
||||
and e2.queue < endgame.submissions.queue
|
||||
)
|
||||
`,
|
||||
paid: endgameSubmissions.paid,
|
||||
price: endgameSubmissions.price,
|
||||
expires: endgameSubmissions.expires,
|
||||
})
|
||||
.from(endgameSubmissions)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(endgameSubmissions.id, sid.value),
|
||||
and(
|
||||
eq(endgameSubmissions.user, session?.uid || "placeholder"),
|
||||
gt(endgameSubmissions.expires, new Date()),
|
||||
),
|
||||
paid: endgameSubmissions.paid,
|
||||
price: endgameSubmissions.price,
|
||||
expires: endgameSubmissions.expires,
|
||||
})
|
||||
.from(endgameSubmissions)
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(endgameSubmissions.id, sid.value),
|
||||
and(
|
||||
eq(endgameSubmissions.user, session?.uid || "placeholder"),
|
||||
gt(endgameSubmissions.expires, new Date()),
|
||||
),
|
||||
not(endgameSubmissions.deleted),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const userSubs = session ? await getUserSubmissions(session.uid) : [];
|
||||
not(endgameSubmissions.deleted),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const { count, ...config } = await getEndgameConfig();
|
||||
const [canExpire] = q
|
||||
? await db
|
||||
@@ -154,14 +141,6 @@ export default async function EndgamePage({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 m-2 flex gap-2">
|
||||
<SubmissionListModal subs={userSubs}>
|
||||
<Button variant="outline" size="sm">
|
||||
<List /> รายการคิว
|
||||
</Button>
|
||||
</SubmissionListModal>
|
||||
<SubmitAnotherButton />
|
||||
</div>
|
||||
</Blocker>
|
||||
)
|
||||
) : config.locked ? (
|
||||
@@ -175,7 +154,7 @@ export default async function EndgamePage({
|
||||
</div>
|
||||
</Blocker>
|
||||
) : (
|
||||
<WelcomeScreening {...{ session, userSubs }} />
|
||||
<WelcomeScreening {...{ session }} />
|
||||
)}
|
||||
<CardHeader className="justify-center">
|
||||
<CardTitle>
|
||||
@@ -354,7 +333,7 @@ export default async function EndgamePage({
|
||||
ช่องดิสคอร์ด
|
||||
</a>
|
||||
</span>
|
||||
{sid && !q && !isNew && <ClearCookie />}
|
||||
{sid && !q && <ClearCookie />}
|
||||
<Watcher />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -449,8 +449,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 && (
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(
|
||||
if (!char)
|
||||
return new Response(`Unknown character: ${sub.char}`, { status: 500 });
|
||||
const card = await fetch(
|
||||
`https://api.astrxl.dev/v1/card/genshin/${sub.uid}/${char.amber.split("-")[0]}?lang=th&substat=true&quality=true`,
|
||||
`http://mts.dgnr.us:8809/v1/card/genshin/${sub.uid}/${char.amber.split("-")[0]}?lang=th&substat=true&quality=true`,
|
||||
);
|
||||
if (!card.ok) return card;
|
||||
const fresh = await card.arrayBuffer();
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function TopDonateWidget() {
|
||||
}, [bar]);
|
||||
|
||||
return bar ? (
|
||||
<div className="relative h-full max-h-[20svw] w-full overflow-hidden rounded-full bg-[#252525]">
|
||||
<div className="relative h-full max-h-[20svw] w-full rounded-full bg-[#252525]">
|
||||
<div className="absolute inset-2 rounded-full bg-black" />
|
||||
<motion.div
|
||||
className="absolute inset-1.5 rounded-full bg-linear-to-b from-[#FD0000] to-[#830000]"
|
||||
|
||||
@@ -10,7 +10,6 @@ export default function TopDonateWidget() {
|
||||
const [top, setTop] = useState<Data>();
|
||||
const textRef = useRef<HTMLDivElement | null>(null);
|
||||
const stableSize = useRef<Record<string, number>>({});
|
||||
const [ping, setPing] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
async function update() {
|
||||
@@ -31,12 +30,6 @@ export default function TopDonateWidget() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setPing(Date.now());
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
});
|
||||
|
||||
// the most idiomatic way possible but it works
|
||||
useEffect(() => {
|
||||
if (!textRef.current) return;
|
||||
@@ -59,9 +52,9 @@ export default function TopDonateWidget() {
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [textRef, top, ping]);
|
||||
}, [textRef, top]);
|
||||
return top ? (
|
||||
<div className="grid h-full w-full grid-cols-[40px_minmax(0,1fr)_max-content] gap-1 rounded-full border-4 border-black bg-black/75 p-2 px-3 text-5xl font-semibold text-[#FFBA00] *:self-center">
|
||||
<div className="grid h-18.75 w-94.5 max-w-94.5 grid-cols-[40px_minmax(0,1fr)_max-content] gap-1 rounded-full border-4 border-black bg-black/75 p-2 px-3 text-5xl font-semibold text-[#FFBA00] *:self-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24px"
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@sfjs/snowflake": "^0.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/bun": "^1.3.12",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-auth": "^1.4.6",
|
||||
@@ -727,7 +727,7 @@
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/[email protected]", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]4", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
"@types/bun": ["@types/[email protected]2", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/d3-array": ["@types/[email protected]", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
@@ -801,7 +801,7 @@
|
||||
|
||||
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["[email protected]4", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
"bun-types": ["[email protected]2", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// oxlint-disable react-hooks/exhaustive-deps
|
||||
"use client";
|
||||
|
||||
// adaptation of react-document-picture-in-picture that use createPortal instead
|
||||
// cuz events dont propagate into the new window
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type DocumentPictureInPictureRef = {
|
||||
window: () => Window | undefined;
|
||||
isOpen: boolean;
|
||||
open: () => Promise<void>;
|
||||
close: () => void;
|
||||
toggle: () => Promise<void>;
|
||||
};
|
||||
|
||||
export enum FeatureUnavailableReasonEnum {
|
||||
USING_UNSECURE_PROTOCOL = "USING_UNSECURE_PROTOCOL",
|
||||
API_NOT_SUPPORTED = "API_NOT_SUPPORTED",
|
||||
}
|
||||
|
||||
export type DocumentPictureInPictureProps = {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
|
||||
shareStyles?: boolean;
|
||||
|
||||
children?: ReactNode;
|
||||
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
|
||||
featureUnavailableRenderer?:
|
||||
| ReactNode
|
||||
| ((reason: FeatureUnavailableReasonEnum) => ReactNode);
|
||||
|
||||
buttonRenderer?:
|
||||
| ReactNode
|
||||
| ((props: {
|
||||
open: () => Promise<void>;
|
||||
close: () => void;
|
||||
toggle: () => Promise<void>;
|
||||
isOpen: boolean;
|
||||
}) => ReactNode);
|
||||
};
|
||||
|
||||
export const DocumentPictureInPicture = forwardRef<
|
||||
DocumentPictureInPictureRef,
|
||||
DocumentPictureInPictureProps
|
||||
>(function DocumentPictureInPicture(props, ref) {
|
||||
const pipWindow = useRef<Window>(undefined);
|
||||
const resizeHandler = useRef<() => void>(undefined);
|
||||
const pageHideHandler = useRef<() => void>(undefined);
|
||||
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const absoluteDimensions = useMemo(() => {
|
||||
let width = 500;
|
||||
let height = 400;
|
||||
|
||||
if (typeof props.width === "number") width = props.width;
|
||||
else if (typeof props.width === "string") {
|
||||
if (props.width.endsWith("px")) width = parseInt(props.width);
|
||||
else if (props.width.endsWith("%"))
|
||||
width = (window.innerWidth * parseInt(props.width)) / 100;
|
||||
}
|
||||
|
||||
if (typeof props.height === "number") height = props.height;
|
||||
else if (typeof props.height === "string") {
|
||||
if (props.height.endsWith("px")) height = parseInt(props.height);
|
||||
else if (props.height.endsWith("%"))
|
||||
height = (window.innerHeight * parseInt(props.height)) / 100;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}, [props.width, props.height]);
|
||||
|
||||
const copyStyles = useCallback((target: Document) => {
|
||||
target.title = document.title;
|
||||
target.documentElement.lang = document.documentElement.lang;
|
||||
target.documentElement.dir = document.documentElement.dir;
|
||||
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
try {
|
||||
const style = document.createElement("style");
|
||||
|
||||
style.textContent = [...sheet.cssRules]
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
|
||||
target.head.appendChild(style);
|
||||
} catch {
|
||||
if (!sheet.href) continue;
|
||||
|
||||
const link = document.createElement("link");
|
||||
|
||||
link.rel = "stylesheet";
|
||||
link.href = sheet.href;
|
||||
link.type = sheet.type;
|
||||
|
||||
if (sheet.media.length > 0) {
|
||||
link.media = sheet.media.mediaText;
|
||||
}
|
||||
|
||||
target.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pipWindow.current) {
|
||||
if (resizeHandler.current) {
|
||||
pipWindow.current.removeEventListener("resize", resizeHandler.current);
|
||||
}
|
||||
|
||||
if (pageHideHandler.current) {
|
||||
pipWindow.current.removeEventListener(
|
||||
"pagehide",
|
||||
pageHideHandler.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setContainer(null);
|
||||
pipWindow.current = undefined;
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
const win = pipWindow.current;
|
||||
|
||||
if (!win) return;
|
||||
|
||||
cleanup();
|
||||
|
||||
win.close();
|
||||
|
||||
props.onClose?.();
|
||||
}, [cleanup, props.onClose]);
|
||||
|
||||
const open = useCallback(async () => {
|
||||
if (pipWindow.current) return;
|
||||
|
||||
const win =
|
||||
await window.documentPictureInPicture.requestWindow(absoluteDimensions);
|
||||
|
||||
pipWindow.current = win;
|
||||
|
||||
if (props.shareStyles) {
|
||||
copyStyles(win.document);
|
||||
}
|
||||
|
||||
win.document.body.style.margin = "0";
|
||||
|
||||
const div = win.document.createElement("div");
|
||||
|
||||
div.style.width = "100%";
|
||||
div.style.height = "100%";
|
||||
div.style.background = "black";
|
||||
div.style.backgroundImage = "radial-gradient(#222 1px, transparent 1px)";
|
||||
div.style.backgroundSize = "24px 24px";
|
||||
div.style.height = "100svh";
|
||||
div.style.display = "flex";
|
||||
div.style.justifyContent = "center";
|
||||
div.style.alignItems = "center";
|
||||
|
||||
win.document.body.appendChild(div);
|
||||
|
||||
resizeHandler.current = () => {
|
||||
props.onResize?.(win.innerWidth, win.innerHeight);
|
||||
};
|
||||
|
||||
pageHideHandler.current = () => {
|
||||
cleanup();
|
||||
props.onClose?.();
|
||||
};
|
||||
|
||||
win.addEventListener("resize", resizeHandler.current);
|
||||
win.addEventListener("pagehide", pageHideHandler.current);
|
||||
|
||||
setContainer(div);
|
||||
setIsOpen(true);
|
||||
|
||||
props.onOpen?.();
|
||||
}, [
|
||||
absoluteDimensions,
|
||||
cleanup,
|
||||
copyStyles,
|
||||
props.onOpen,
|
||||
props.onClose,
|
||||
props.onResize,
|
||||
props.shareStyles,
|
||||
]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (isOpen) close();
|
||||
else await open();
|
||||
}, [isOpen, open, close]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
window: () => pipWindow.current,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}),
|
||||
[isOpen, open, close, toggle],
|
||||
);
|
||||
|
||||
useEffect(() => close, []);
|
||||
|
||||
const unavailable = (() => {
|
||||
if (!window.isSecureContext)
|
||||
return FeatureUnavailableReasonEnum.USING_UNSECURE_PROTOCOL;
|
||||
|
||||
if (!("documentPictureInPicture" in window))
|
||||
return FeatureUnavailableReasonEnum.API_NOT_SUPPORTED;
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (unavailable) {
|
||||
if (!props.featureUnavailableRenderer) return null;
|
||||
|
||||
return typeof props.featureUnavailableRenderer === "function"
|
||||
? props.featureUnavailableRenderer(unavailable)
|
||||
: props.featureUnavailableRenderer;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof props.buttonRenderer === "function"
|
||||
? props.buttonRenderer({
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
isOpen,
|
||||
})
|
||||
: props.buttonRenderer}
|
||||
|
||||
{container && createPortal(props.children, container)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentRef } from "react";
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
import type { Button } from "./ui/button";
|
||||
|
||||
// use direct event attach to work in D-PIP
|
||||
// not needed anymore cause dpip.tsx rw
|
||||
|
||||
type NativeButtonProps<T extends React.ElementType = "button"> = {
|
||||
as?: T;
|
||||
} & Omit<ComponentProps<T>, "as"> &
|
||||
Omit<ComponentProps<typeof Button>, "ref">;
|
||||
|
||||
export function NativeButton<T extends React.ElementType = "button">({
|
||||
onClick,
|
||||
ref: parentRef,
|
||||
as,
|
||||
...props
|
||||
}: NativeButtonProps<T>) {
|
||||
const ref = useRef<ComponentRef<T>>(null);
|
||||
const combineRef = (n: ComponentRef<T>) =>
|
||||
((ref.current = n) &&
|
||||
(typeof parentRef === "function"
|
||||
? parentRef(n)
|
||||
: parentRef && (parentRef.current = n)) &&
|
||||
null) as void;
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
(ref.current as any).onclick = onClick as unknown as (
|
||||
ev: PointerEvent,
|
||||
) => void;
|
||||
// console.log("EVENT LISTENRE ATTCHED");
|
||||
}, [onClick]);
|
||||
const Comp = as ?? "button";
|
||||
return <Comp ref={combineRef as any} {...props} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StarsRenderer({ mask }: { mask: string }) {
|
||||
export function StarsRenderer() {
|
||||
const cv = useRef<HTMLCanvasElement | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
@@ -122,12 +122,9 @@ export function StarsRenderer({ mask }: { mask: string }) {
|
||||
<canvas
|
||||
ref={cv}
|
||||
className={cn(
|
||||
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full mask-cover mask-center mask-no-repeat object-cover opacity-0 transition-opacity duration-5000",
|
||||
"fixed top-0 left-0 z-[-1] h-fit min-h-dvh w-full mask-[url('/mask.webp')] mask-cover mask-center mask-no-repeat object-cover opacity-0 transition-opacity duration-5000",
|
||||
loaded && "opacity-40",
|
||||
)}
|
||||
style={{
|
||||
maskImage: `url(${JSON.stringify(mask)})`,
|
||||
}}
|
||||
></canvas>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
interface Window {
|
||||
documentPictureInPicture: {
|
||||
requestWindow: (options?: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => Promise<Window>;
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
## Architecture Decisions
|
||||
|
||||
### Ingress
|
||||
|
||||
- **k3s built-in Traefik**, not nginx.
|
||||
- Annotations `traefik.ingress.kubernetes.io/response-buffering: "false"` + `read-timeout: "200"` preserve SSE (Server-Sent Events).
|
||||
- Traefik Middleware `sse-optimizer` caps `maxRequestBodyBytes` at 100 MB (for slip image uploads).
|
||||
@@ -11,12 +10,10 @@
|
||||
- External port: **80/443** (standard). `m.dgnr.us` DNS should point at k3s node IP(s). Old port 8604 is retired.
|
||||
|
||||
### Namespace
|
||||
|
||||
- Everything lives in namespace `buzz`.
|
||||
- The namespace is created once manually (`kubectl create ns buzz`) and listed in kustomization.yaml.
|
||||
|
||||
### Secrets
|
||||
|
||||
- **`buzz-env`** Secret created once from `.env`:
|
||||
```
|
||||
kubectl create secret generic buzz-env --from-env-file=.env -n buzz
|
||||
@@ -28,7 +25,6 @@
|
||||
```
|
||||
|
||||
### Database Migration
|
||||
|
||||
- **k8s Job** (deploy-time), not in Dockerfile.
|
||||
- The `drizzle` stage was removed from `Dockerfile`. The build no longer runs `bun dr push`.
|
||||
- CI creates the `db-migrate` Job imperatively, waits for completion, then updates deployments.
|
||||
@@ -36,14 +32,12 @@
|
||||
- Old job is deleted before creating the new one (`kubectl delete job ... --ignore-not-found`).
|
||||
|
||||
### Service Discovery
|
||||
|
||||
- Frontend Service: `app` (ClusterIP, port 3000).
|
||||
- Backend reaches frontend at `http://app:3000` (same namespace, k8s DNS resolves `<service>`).
|
||||
- Backend references in `backend/index.ts:231,279` using `http://app:3000` remain unchanged.
|
||||
- `lib/auth.ts` `trustedOrigins` includes `http://app.buzz.svc.cluster.local:3000` for completeness.
|
||||
|
||||
### CI/CD
|
||||
|
||||
- Gitea Actions builds and pushes images as before (same tags: `:frontend`, `:backend`, `:frontend-$SHA`, `:backend-$SHA`).
|
||||
- Deploy step in `build.yml`:
|
||||
1. Delete old migration Job, create fresh one with `:frontend-$SHA`, wait for completion.
|
||||
@@ -51,40 +45,33 @@
|
||||
- Kustomize is used for initial cluster bootstrap (`kubectl apply -k k8s/`). Subsequent updates use imperative `kubectl set image` because Kustomize's `images` field cannot differentiate two images that share the same image name (different tags). If image naming changes to separate repos (e.g., `buzz-frontend:tag` / `buzz-backend:tag`), switch to `kustomize edit set image` + `kubectl apply -k .`.
|
||||
|
||||
### CI Authentication
|
||||
|
||||
- **ServiceAccount `ci-deployer`** in namespace `buzz`.
|
||||
- RBAC Role grants: Deployments (get/list/watch/update/patch), Pods (get/list/watch), Jobs (full CRUD), Services/Ingresses/Middlewares (get/list/watch/create/update/patch).
|
||||
- Token for the runner: `kubectl create token ci-deployer -n buzz` (or use a long-lived token from a Secret tied to the SA).
|
||||
- The runner also needs `docker` login access to `mts.dgnr.us:5000` (same as today).
|
||||
|
||||
### Replicated Stateful Services (external to buzz namespace)
|
||||
|
||||
- **PostgreSQL**: CloudNativePG operator. `DATABASE_URL` in Secret points to the CNPG cluster's read-write service (e.g. `postgres://...@<cluster>-rw.<cnpg-ns>.svc:5432/buzz`).
|
||||
- **Redis**: redis-operator. `REDIS_URL` in Secret points to the Redis operator's endpoint.
|
||||
|
||||
### Container Registry
|
||||
|
||||
- **Zot** running in-cluster (namespace `registry`), exposed via ServiceLB on host port 5000.
|
||||
- All k3s nodes have `/etc/rancher/k3s/registries.yaml` mirroring `mts.dgnr.us:5000` → `http://localhost:5000` (svclb binds host port 5000). k3s must be restarted after writing this file.
|
||||
- CI runner pushes to `mts.dgnr.us:5000` (Tailscale → `100.75.220.33` → svclb → zot pod).
|
||||
|
||||
### Resource Limits
|
||||
|
||||
- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval). Readiness probe same.
|
||||
- **backend**: 1 replica, limits 1 CPU / 512 Mi memory, requests 250m / 256 Mi. No probes (no HTTP endpoint).
|
||||
- **db-migrate Job**: limits 1 CPU / 512 Mi, requests 250m / 256 Mi.
|
||||
|
||||
### HPA / PDB
|
||||
|
||||
- HPA scales `app` on CPU > 70%, min 2 / max 6 replicas (replaces `swarm.autoscale` label).
|
||||
- PDB ensures min 1 pod always available during voluntary disruptions (node drains, rollouts).
|
||||
|
||||
### TLS
|
||||
|
||||
- Not configured in manifests. Currently HTTP-only behind Traefik. Add `spec.tls` + cert-manager or k3s Traefik's built-in Let's Encrypt when ready.
|
||||
|
||||
## First Deploy (manual bootstrap)
|
||||
|
||||
```bash
|
||||
kubectl create ns buzz
|
||||
kubectl create secret generic buzz-env --from-env-file=.env -n buzz
|
||||
@@ -92,7 +79,6 @@ kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
## Subsequent Deploys (CI)
|
||||
|
||||
```bash
|
||||
# In .github/workflows/build.yml
|
||||
TAG=${{ gitea.sha }}
|
||||
@@ -122,7 +108,6 @@ kubectl set image deployment/backend backend=mts.dgnr.us:5000/astral/buzz:backen
|
||||
```
|
||||
|
||||
CI runner needs Gitea secret `KUBE_TOKEN` containing the `ci-deployer` SA token. Rotate via:
|
||||
|
||||
```bash
|
||||
kubectl delete secret ci-deployer-token -n buzz && \
|
||||
kubectl apply -f k8s/service-account.yaml && \
|
||||
@@ -135,17 +120,15 @@ TOKEN=$(kubectl get secret ci-deployer-token -n buzz -o jsonpath='{.data.token}'
|
||||
```
|
||||
|
||||
## Files Changed from Swarm
|
||||
|
||||
| File | Change |
|
||||
| ----------------------------- | ----------------------------------------------------------- |
|
||||
| `Dockerfile` | Removed `drizzle` stage (migration moved to k8s Job) |
|
||||
| `lib/auth.ts` | Added `app.buzz.svc.cluster.local:3000` to `trustedOrigins` |
|
||||
| `.github/workflows/build.yml` | Replaced `docker service update` with k8s deploy commands |
|
||||
| `nginx/nginx.conf` | Archived — replaced by Traefik Ingress |
|
||||
| `docker-compose.yml` | Archived — replaced by k8s manifests |
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Dockerfile` | Removed `drizzle` stage (migration moved to k8s Job) |
|
||||
| `lib/auth.ts` | Added `app.buzz.svc.cluster.local:3000` to `trustedOrigins` |
|
||||
| `.github/workflows/build.yml` | Replaced `docker service update` with k8s deploy commands |
|
||||
| `nginx/nginx.conf` | Archived — replaced by Traefik Ingress |
|
||||
| `docker-compose.yml` | Archived — replaced by k8s manifests |
|
||||
|
||||
## Key Distinctions from Swarm
|
||||
|
||||
- No `overlay` network (k8s CNI handles multi-node networking).
|
||||
- No host port `8604` (Traefik on 80/443).
|
||||
- Service discovery via k8s DNS, not Swarm DNS.
|
||||
|
||||
@@ -23,16 +23,6 @@ spec:
|
||||
app: buzz
|
||||
component: app
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: region
|
||||
operator: In
|
||||
values:
|
||||
- sg
|
||||
terminationGracePeriodSeconds: 5
|
||||
containers:
|
||||
- name: app
|
||||
|
||||
@@ -6,3 +6,4 @@ metadata:
|
||||
spec:
|
||||
buffering:
|
||||
maxRequestBodyBytes: 104857600
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: [""]
|
||||
resources: ["services"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch"]
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function checkEnkaStatus(uid: string, char: string) {
|
||||
.from(characters)
|
||||
.where(eq(characters.name, char));
|
||||
const res = await fetch(
|
||||
`https://api.astrxl.dev/v1/card/genshin/${uid}/${ch ? ch.amber.split("-")[0] : "10000005"}?debug=dump`,
|
||||
`http://mts.dgnr.us:8809/v1/card/genshin/${uid}/${ch ? ch.amber.split("-")[0] : "10000005"}?debug=dump`,
|
||||
);
|
||||
const text = await res.text(),
|
||||
{ status } = res;
|
||||
|
||||
@@ -21,14 +21,12 @@ type SharedStates = Partial<{
|
||||
connected: boolean;
|
||||
updated: boolean;
|
||||
"rubgram.services": string[];
|
||||
"rubgram.sel_id": string[];
|
||||
"tl.deleteMode": boolean;
|
||||
inspect: Record<string, string>;
|
||||
// nf(Not Found) and showcase(Not In Showcase) is error dialog that opens guide
|
||||
warning: "showcase" | "nf" | "guide" | "private";
|
||||
"warning.uid": string;
|
||||
"warning.src": "submit" | "input";
|
||||
"moderator.volume": number;
|
||||
_raw: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
|
||||
@@ -52,15 +52,13 @@ export class PubSubManager {
|
||||
function ping() {
|
||||
clearTimeout(heartbeatTimeout);
|
||||
heartbeatTimeout = setTimeout(() => {
|
||||
void writer.write(":)\n\n").catch(() => {});
|
||||
writer.write(":)\n\n");
|
||||
ping();
|
||||
}, 90000); // cloudflare timeout = 100s
|
||||
}
|
||||
|
||||
const write = (payload: PubPayload) => {
|
||||
void writer
|
||||
.write(this.constructMessage(payload.data, payload.event))
|
||||
.catch(() => {});
|
||||
writer.write(this.constructMessage(payload.data, payload.event));
|
||||
ping();
|
||||
};
|
||||
|
||||
|
||||
@@ -161,15 +161,8 @@ export const endgameSubmissions = endgame.table("submissions", {
|
||||
),
|
||||
deleted: boolean().notNull().default(false),
|
||||
submit_day: timestamp().notNull().defaultNow(),
|
||||
notes: jsonb().$type<Note[]>().notNull().default([]),
|
||||
});
|
||||
|
||||
export type Note = {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// expired submission
|
||||
export const endgameExpired = endgame.table("expired", {
|
||||
id: text().primaryKey().$defaultFn(uuidv7),
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"@sfjs/snowflake": "^0.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/bun": "^1.3.12",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"better-auth": "^1.4.6",
|
||||
@@ -118,8 +118,7 @@
|
||||
"better-sqlite3": "npm:dry-uninstall"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"[email protected]": "patches/[email protected]",
|
||||
"[email protected]": "patches/[email protected]"
|
||||
"[email protected]": "patches/[email protected]"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@prisma/client",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
diff --git a/dist/ReactDocumentPictureInPicture.js b/dist/ReactDocumentPictureInPicture.js
|
||||
index c0e1d9e9df8dc57ea5e173a14d4125758b867b8c..2c18334b752fa0be300f3c1e962bae1fbaf78d6e 100644
|
||||
--- a/dist/ReactDocumentPictureInPicture.js
|
||||
+++ b/dist/ReactDocumentPictureInPicture.js
|
||||
@@ -98,9 +98,9 @@ const ReactDocumentPictureInPicture = (0, react_1.forwardRef)((props, ref) => {
|
||||
close,
|
||||
]);
|
||||
let featureUnavailableReason = (() => {
|
||||
- const isUsingSecureProtocol = window.location.protocol === 'https:';
|
||||
- if (isUsingSecureProtocol === false)
|
||||
- return FeatureUnavailableReasonEnum.USING_UNSECURE_PROTOCOL;
|
||||
+ // const isUsingSecureProtocol = window.location.protocol === 'https:';
|
||||
+ // if (isUsingSecureProtocol === false)
|
||||
+ // return FeatureUnavailableReasonEnum.USING_UNSECURE_PROTOCOL;
|
||||
const featureIsAvailable = 'documentPictureInPicture' in window;
|
||||
if (featureIsAvailable === false)
|
||||
return FeatureUnavailableReasonEnum.API_NOT_SUPPORTED;
|
||||
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 839 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 1016 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -26,7 +26,6 @@
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"globals.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { file, Glob, write } from "bun";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
const g = new Glob("public/bgassets/*.png");
|
||||
|
||||
for await (const img of g.scan()) {
|
||||
file(img)
|
||||
.image()
|
||||
.webp()
|
||||
.buffer()
|
||||
.then((b) => write(img.replace(".png", ".webp"), b))
|
||||
.then(() => rm(img))
|
||||
.finally(() => console.log(img, "processed."));
|
||||
}
|
||||
@@ -97,10 +97,10 @@ async function main() {
|
||||
console.log("Fetching Amber data...");
|
||||
const [changelog, avatars]: [ChangelogApiResponse, AvatarApiResponse] =
|
||||
await Promise.all([
|
||||
await fetch("https://buzz.sudloh.com/api/amber/log").then((e) =>
|
||||
await fetch("https://gi.yatta.moe/api/v2/static/changelog").then((e) =>
|
||||
e.json(),
|
||||
),
|
||||
await fetch("https://buzz.sudloh.com/api/amber/char").then((e) =>
|
||||
await fetch("https://gi.yatta.moe/api/v2/en/avatar").then((e) =>
|
||||
e.json(),
|
||||
),
|
||||
]);
|
||||
|
||||