33 changed files with 212 additions and 74 deletions
+11 -2
View File
@@ -39,6 +39,10 @@ jobs:
-t mts.dgnr.us:5000/${{ gitea.repository }}:frontend \
-t mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
.
docker build --target migration \
-t mts.dgnr.us:5000/${{ gitea.repository }}:migration \
-t mts.dgnr.us:5000/${{ gitea.repository }}:migration-$TAG \
.
build-backend:
runs-on: ubuntu-latest
@@ -83,6 +87,8 @@ jobs:
docker push mts.dgnr.us:5000/${{ gitea.repository }}:backend-$TAG &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:migration &
docker push mts.dgnr.us:5000/${{ gitea.repository }}:migration-$TAG &
wait
- name: Install kubectl
@@ -106,9 +112,12 @@ jobs:
# DB migration
kubectl delete job db-migrate -n buzz --ignore-not-found
kubectl create job db-migrate \
--image=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG \
--image=mts.dgnr.us:5000/${{ gitea.repository }}:migration-$TAG \
-n buzz \
-- bun dr push
--dry-run=client -o json \
-- bun dr push | \
jq '.spec.template.spec.containers[0].envFrom = [{"secretRef": {"name": "buzz-env"}}]' | \
kubectl apply -f -
# Roll app & backend
kubectl set image deployment/app app=mts.dgnr.us:5000/${{ gitea.repository }}:frontend-$TAG -n buzz
+20 -25
View File
@@ -4,17 +4,6 @@
FROM oven/bun:canary-alpine AS deps
WORKDIR /home/container
# RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# --mount=type=cache,target=/var/lib/apt,sharing=locked \
# apt-get update \
# && apt-get install -y \
# python3 \
# make \
# g++ \
# sqlite3 \
# libsqlite3-dev \
# && rm -rf /var/lib/apt/lists/*
COPY package*.json bun.lock ./
COPY patches ./patches
@@ -50,7 +39,22 @@ RUN --mount=type=cache,target=/home/container/.next/cache,uid=1001,gid=1001 \
# Fix nextjs caching(bind mount removes it after build process)
RUN mkdir -p .next/cache
### Stage 3: runner ###
### Stage 3: migration ###
FROM oven/bun:canary-alpine AS migration
WORKDIR /home/container
COPY --from=deps /home/container/node_modules ./node_modules
COPY --from=deps /home/container/package*.json ./
COPY --from=deps /home/container/bun.lock ./
COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /home/container/lib ./lib
COPY drizzle.config.ts ./
COPY lib/db/ ./lib/db/
CMD ["bun", "drizzle-kit"]
### Stage 4: runner ###
FROM oven/bun:canary-alpine AS runner
# Isolation
@@ -58,19 +62,10 @@ RUN adduser -Du 1001 container
USER container
WORKDIR /home/container
COPY --from=builder /home/container/node_modules ./node_modules
COPY --from=builder /home/container/next.config.ts ./
COPY --from=builder /home/container/bun.lock ./
COPY --from=builder /home/container/package*.json ./
COPY --from=builder /home/container/.next/standalone ./
COPY --from=builder /home/container/public ./public
COPY --from=builder /home/container/.env ./.env
COPY --from=builder /home/container/.next ./.next
COPY --from=builder /home/container/util ./util
COPY --from=builder /home/container/lib ./lib
COPY --from=builder /home/container/tsconfig.json ./tsconfig.json
COPY --from=builder /home/container/.next/static ./.next/static
COPY --from=builder /home/container/.version ./.version
COPY --from=builder /home/container/drizzle.config.ts ./drizzle.config.ts
CMD ["bun", "start"]
ENV HOSTNAME=0.0.0.0
CMD ["bun", "server.js"]
+72
View File
@@ -0,0 +1,72 @@
"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} />}
</>
);
}
+5 -13
View File
@@ -13,7 +13,7 @@ import {
import Link from "next/link";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useEllipsisVisible } from "react-hook-text-overflow";
import { DataTable } from "@/components/tantable";
import { SimpleTooltip } from "@/components/tooltip";
@@ -183,23 +183,15 @@ export function DonateAdminPage({
}
// component cuz simplicity sake
export function DonateWatcher({ sfx = false }: { sfx?: boolean }) {
const player = useRef<HTMLAudioElement | null>(null);
export function DonateWatcher() {
const router = useRouter();
useEffect(() => {
if (!router) return;
return sse.donate.subMany({
ping: () => {
router.refresh();
player.current?.play();
},
update: () => router.refresh(),
ping: router.refresh.bind(router),
update: router.refresh.bind(router),
}).clean;
}, [router]);
if (sfx)
return (
<audio src="/assets/donate-mod-sfx.wav" preload="auto" ref={player} />
);
else return undefined;
return undefined;
}
+38 -3
View File
@@ -1,5 +1,7 @@
"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";
@@ -39,22 +41,55 @@ export function PIP(props: ComponentProps<typeof ReactPIP>) {
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(
() => sse.donate.sub("heartbeat", () => tracking.current++).clean,
() =>
player.current
? sse.donate.subMany({
heartbeat: () => tracking.current++,
ping: player.current.play.bind(player.current),
}).clean
: undefined,
[],
);
useEffect(() => {
const interval = setInterval(() => {
setConnected(tracking.current);
if (tracking.current > 0) tracking.current--;
tracking.current = 0;
}, 10000);
return () => clearInterval(interval);
}, []);
return (
<Button variant="outline" disabled size="sm">
<RadioTower /> Connected: {connected}
<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>
);
}
+4 -3
View File
@@ -11,7 +11,7 @@ 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 } from "./client";
import { ClientTracker, PIP, VolumeChanger } from "./client";
export default async function DonateModeratorPage() {
if (!(await adminCheck()))
@@ -32,7 +32,7 @@ export default async function DonateModeratorPage() {
return (
<PIP width={400} height={600}>
<div className="dark flex w-full max-w-100 flex-col gap-1 bg-background p-1 text-foreground scheme-dark">
<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">
@@ -67,7 +67,8 @@ export default async function DonateModeratorPage() {
</ActionButton>
<ClientTracker />
</div>
<DonateWatcher sfx />
<VolumeChanger />
<DonateWatcher />
</div>
</PIP>
);
+9 -2
View File
@@ -191,13 +191,20 @@ 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: image ? Buffer.from(await image.arrayBuffer()) : undefined,
image: downscaled,
uid: $.artifact === "true" ? $.uid : null,
// dont send on screen if less than 10
sent: $.amount < 10,
@@ -290,7 +297,7 @@ export default async function () {
</FormInput>
</div>
</div>
<FormInput name="message" label="ข้อความ" subLabel="สูงสุด 500 ตัวอักษร">
<FormInput name="message" label="ข้อความ" subLabel="สูงสุด 200 ตัวอักษร">
<Textarea placeholder="ข้อความ" />
</FormInput>
{!artifactConfig.locked && (
+2 -9
View File
@@ -1,11 +1,9 @@
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",
@@ -19,12 +17,7 @@ export default async function UiLayout({
}) {
return (
<>
<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 />
<BackgroundProvider />
<Providers>
<ViewTransition>{children}</ViewTransition>
<VersionCheck />
@@ -126,7 +126,10 @@ export function SlipUpload({
type="button"
>
{isSelected ? (
<span onClick={isSelected ? choose : undefined} className="truncate">
<span
onClick={isSelected ? choose : undefined}
className="min-w-0 truncate"
>
{value.name} <Kbd>{b2sClient(Number(value.size))}</Kbd>
</span>
) : (
+4 -1
View File
@@ -129,7 +129,10 @@ export default async function AdminLayout({
<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 && !s.deleted ? 1 : 0),
0,
)}
/>
</SidebarGroup>
</SidebarFooter>
+9 -2
View File
@@ -10,6 +10,7 @@ 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() {
@@ -30,6 +31,12 @@ 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;
@@ -52,9 +59,9 @@ export default function TopDonateWidget() {
}
}, 100);
return () => clearInterval(interval);
}, [textRef, top]);
}, [textRef, top, ping]);
return top ? (
<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">
<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">
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
+3 -7
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.12",
"@types/bun": "^1.3.14",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "^1.4.6",
@@ -64,7 +64,6 @@
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-day-picker": "^9.12.0",
"react-document-picture-in-picture": "^1.2.2",
"react-dom": "19.2.6",
"react-hook-form": "^7.68.0",
"react-hook-text-overflow": "^1.0.1",
@@ -111,7 +110,6 @@
],
"patchedDependencies": {
"[email protected]": "patches/[email protected]",
"[email protected]": "patches/[email protected]",
},
"overrides": {
"@types/react": "19.2.14",
@@ -729,7 +727,7 @@
"@tanstack/virtual-core": ["@tanstack/[email protected]", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
"@types/bun": ["@types/[email protected]2", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/bun": ["@types/[email protected]4", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/d3-array": ["@types/[email protected]", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -803,7 +801,7 @@
"buffer-from": ["[email protected]", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["[email protected]2", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-types": ["[email protected]4", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@@ -1173,8 +1171,6 @@
"react-day-picker": ["[email protected]", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="],
"react-document-picture-in-picture": ["[email protected]", "", { "peerDependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "sha512-MqwBF4gL56qe8QoFGu/Cc2QxplZcCMK125gMqcGvQQm8xL3z1iiGPTUE2Fk7Ct6IEvmuYtsupsdIwCNl2i16aQ=="],
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="],
+7
View File
@@ -168,6 +168,13 @@ export const DocumentPictureInPicture = forwardRef<
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);
+5 -2
View File
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export function StarsRenderer() {
export function StarsRenderer({ mask }: { mask: string }) {
const cv = useRef<HTMLCanvasElement | null>(null);
const [loaded, setLoaded] = useState(false);
@@ -122,9 +122,12 @@ export function StarsRenderer() {
<canvas
ref={cv}
className={cn(
"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",
"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",
loaded && "opacity-40",
)}
style={{
maskImage: `url(${JSON.stringify(mask)})`,
}}
></canvas>
);
}
+1
View File
@@ -28,6 +28,7 @@ type SharedStates = Partial<{
warning: "showcase" | "nf" | "guide" | "private";
"warning.uid": string;
"warning.src": "submit" | "input";
"moderator.volume": number;
_raw: Record<string, unknown>;
}>;
+1
View File
@@ -52,4 +52,5 @@ export default {
// reactCompiler: true,
allowedDevOrigins: ["astral:3000", "dev3000.astrxl.dev"],
// basePath: "/beta"
output: "standalone",
} satisfies NextConfig;
+1 -2
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.12",
"@types/bun": "^1.3.14",
"auto-text-size": "^0.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-auth": "^1.4.6",
@@ -78,7 +78,6 @@
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-day-picker": "^9.12.0",
"react-document-picture-in-picture": "^1.2.2",
"react-dom": "19.2.6",
"react-hook-form": "^7.68.0",
"react-hook-text-overflow": "^1.0.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+14
View File
@@ -0,0 +1,14 @@
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://gi.yatta.moe/api/v2/static/changelog").then((e) =>
await fetch("https://buzz.sudloh.com/api/amber/log").then((e) =>
e.json(),
),
await fetch("https://gi.yatta.moe/api/v2/en/avatar").then((e) =>
await fetch("https://buzz.sudloh.com/api/amber/char").then((e) =>
e.json(),
),
]);