56 changed files with 129 additions and 1556 deletions
+15 -44
View File
@@ -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
-72
View File
@@ -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} />}
</>
);
}
+16 -22
View File
@@ -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>
);
}
-75
View File
@@ -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>
);
}
+11 -21
View File
@@ -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 -3
View File
@@ -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)
+9 -2
View File
@@ -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 />
+1 -1
View File
@@ -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>
) : (
+3 -189
View File
@@ -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 }) {
+2 -2
View File
@@ -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>
+14 -76
View File
@@ -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);
+1 -6
View File
@@ -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>
+2 -103
View File
@@ -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 -120
View File
@@ -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>
);
}
+26 -47
View File
@@ -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>
);
+1 -2
View File
@@ -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 && (
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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]"
+2 -9
View File
@@ -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"
+3 -3
View File
@@ -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=="],
-258
View File
@@ -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)}
</>
);
});
-37
View File
@@ -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} />;
}
+2 -5
View File
@@ -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>
);
}
-8
View File
@@ -1,8 +0,0 @@
interface Window {
documentPictureInPicture: {
requestWindow: (options?: {
width: number;
height: number;
}) => Promise<Window>;
};
}
+7 -24
View File
@@ -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.
-10
View File
@@ -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
+1
View File
@@ -6,3 +6,4 @@ metadata:
spec:
buffering:
maxRequestBodyBytes: 104857600
-3
View File
@@ -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"]
+1 -1
View File
@@ -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;
-2
View File
@@ -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>;
}>;
+2 -4
View File
@@ -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();
};
-7
View File
@@ -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),
+2 -3
View File
@@ -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",
-17
View File
@@ -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;
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

-1
View File
@@ -26,7 +26,6 @@
},
"include": [
"next-env.d.ts",
"globals.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
File diff suppressed because one or more lines are too long
-14
View File
@@ -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."));
}
+2 -2
View File
@@ -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(),
),
]);