From d8e7c29c51d8cf5a4e7f75cbfa986b503055a9f3 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sat, 12 Sep 2026 00:00:33 +0700 Subject: [PATCH] feat: add message broadcaster --- app/(ui)/admin/settings/api.ts | 17 ++++++ app/(ui)/admin/settings/broadcaster.tsx | 75 +++++++++++++++++++++++++ app/(ui)/admin/settings/services.tsx | 6 +- app/client.tsx | 5 ++ app/globals.css | 9 +++ components/fake-toast.tsx | 57 +++++++++++++++++++ k8s/README.md | 2 +- k8s/deployment-app.yaml | 3 +- lib/db/sse-endpoints.ts | 5 ++ 9 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 app/(ui)/admin/settings/broadcaster.tsx create mode 100644 components/fake-toast.tsx diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts index 65c1194..3f7f555 100755 --- a/app/(ui)/admin/settings/api.ts +++ b/app/(ui)/admin/settings/api.ts @@ -7,6 +7,7 @@ import { settings } from "@/lib/db/schema"; import { adminCheck } from "@/lib/auth"; import { sse } from "@/lib/db/sse-endpoints"; import { syncAmber as syncAmberRaw } from "@/util/sync"; +import type { ToastType } from "@/components/fake-toast"; export async function getSettongs() { if (!(await adminCheck())) throw "Unauthorized"; @@ -56,3 +57,19 @@ export async function toggleDonatePaymentMethod( await actionLog("Changed a settings", { [method]: state }); } + +export async function broadcastMessage( + prefix: string, + value: string, + type: ToastType, +) { + "use server"; + + sse.active.pub("announcement", { + severity: type, + message: value, + prefix, + }); + + await actionLog("Broadcasted message", { prefix, value, type }); +} diff --git a/app/(ui)/admin/settings/broadcaster.tsx b/app/(ui)/admin/settings/broadcaster.tsx new file mode 100644 index 0000000..7942f90 --- /dev/null +++ b/app/(ui)/admin/settings/broadcaster.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Send } from "lucide-react"; +import { useState } from "react"; +import { FakeToast, type ToastType } from "@/components/fake-toast"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { broadcastMessage } from "./api"; +import { ActionButton } from "@/components/action-button"; + +const toastTypes: ToastType[] = ["warning", "error", "success", "info"]; + +export function Broadcaster() { + const [expanded, setExpanded] = useState(false); + const [prefix, setPrefix] = useState("/"); + const [prefixFocused, setPrefixFocused] = useState(false); + const [value, setValue] = useState(""); + const [type, setType] = useState("warning"); + + function cycleType() { + setType((currentType) => { + const currentIndex = toastTypes.indexOf(currentType); + return toastTypes[(currentIndex + 1) % toastTypes.length]; + }); + } + + return ( +
+ + setExpanded(true)} + onBlur={(ev) => setValue(ev.target.textContent)} + contentEditable + data-placeholder="Broadcast message..." + className="w-full flex-1" + > + +
+ setPrefix(event.target.value)} + onFocus={() => setPrefixFocused(true)} + onBlur={() => setPrefixFocused(false)} + className="backdrop-blur-xs transition-[width] focus:w-48" + style={{ + width: prefixFocused + ? undefined + : `calc(24px + ${Math.max(prefix.length, 1)}ch)`, + }} + /> + { + await broadcastMessage(prefix, value, type); + setExpanded(false); + }} + > + + +
+
+ ); +} diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx index 42f94f4..02bd51a 100755 --- a/app/(ui)/admin/settings/services.tsx +++ b/app/(ui)/admin/settings/services.tsx @@ -2,7 +2,9 @@ import { Bomb, FolderSync } from "lucide-react"; import { useState } from "react"; +import { ActionButton } from "@/components/action-button"; import { CdnChooser } from "@/components/chooser"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { shared } from "@/lib/comms"; import { @@ -11,9 +13,8 @@ import { toggleDonatePaymentMethod, toggleEnka, } from "./api"; +import { Broadcaster } from "./broadcaster"; import { Section } from "./page"; -import { ActionButton } from "@/components/action-button"; -import { Input } from "@/components/ui/input"; export function SettingsServicesSection({ enka: enkaInitial, @@ -108,6 +109,7 @@ export function SettingsServicesSection({ {syncResult && (
{syncResult}
)} + ); diff --git a/app/client.tsx b/app/client.tsx index a422ff9..a8e656d 100755 --- a/app/client.tsx +++ b/app/client.tsx @@ -79,6 +79,11 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) { window.location.hash = "#update"; queueMicrotask(() => window.location.reload()); }, + announcement({ message, prefix, severity }) { + if (headless) return; + if (prefix && !window.location.pathname.startsWith(prefix)) return; + return toast[severity](message, { duration: Infinity }); + }, }, { endpoint: "/api/active", diff --git a/app/globals.css b/app/globals.css index 1777b7b..fcd2ac8 100755 --- a/app/globals.css +++ b/app/globals.css @@ -236,3 +236,12 @@ img { opacity: 1; } } + +[contenteditable="true"]:empty::before { + content: attr(data-placeholder); + color: #a0a0a0; + pointer-events: none; +} +[contenteditable="true"]:focus { + outline: none; +} diff --git a/components/fake-toast.tsx b/components/fake-toast.tsx new file mode 100644 index 0000000..652f218 --- /dev/null +++ b/components/fake-toast.tsx @@ -0,0 +1,57 @@ +import { Check, CircleAlert, CircleX, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type ToastType = "success" | "info" | "warning" | "error"; + +const toastStyles: Record = { + success: "border-emerald-950 bg-emerald-950/70 text-emerald-300", + info: "border-blue-950 bg-blue-950/70 text-blue-300", + warning: "border-yellow-950 bg-yellow-950/70 text-yellow-200", + error: "border-red-950 bg-red-950/70 text-red-200", +}; + +const toastIcons: Record = { + success: Check, + info: Info, + warning: CircleAlert, + error: CircleX, +}; + +export function FakeToast({ + type = "success", + children, + onIconClick, +}: { + type?: ToastType; + children: React.ReactNode; + onIconClick?: () => void; +}) { + const Icon = toastIcons[type]; + + return ( +
+ {onIconClick ? ( + + ) : ( +
+ ); +} diff --git a/k8s/README.md b/k8s/README.md index 685c5de..8895361 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -69,7 +69,7 @@ ### 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. +- **app**: 2 replicas, limits 1 CPU / 1 Gi memory, requests 500m / 512 Mi. Liveness probe on `/api/health` (60s interval, 30s timeout). Readiness probe uses a 15s timeout. - **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. diff --git a/k8s/deployment-app.yaml b/k8s/deployment-app.yaml index 7a0ecfb..9f07699 100644 --- a/k8s/deployment-app.yaml +++ b/k8s/deployment-app.yaml @@ -56,7 +56,7 @@ spec: port: 3000 initialDelaySeconds: 10 periodSeconds: 60 - timeoutSeconds: 5 + timeoutSeconds: 30 failureThreshold: 3 readinessProbe: httpGet: @@ -64,3 +64,4 @@ spec: port: 3000 initialDelaySeconds: 5 periodSeconds: 10 + timeoutSeconds: 15 diff --git a/lib/db/sse-endpoints.ts b/lib/db/sse-endpoints.ts index 1823696..069fc65 100644 --- a/lib/db/sse-endpoints.ts +++ b/lib/db/sse-endpoints.ts @@ -49,6 +49,11 @@ export const sse = sseEndpointMap({ version: z.string(), live: z.custom(), refresh: z.string().nullable(), + announcement: z.object({ + severity: z.enum(["info", "success", "warning", "error"]), + message: z.string(), + prefix: z.string().default("/"), + }), }, // Admin Live Log log: {