feat: add message broadcaster
This commit is contained in:
@@ -7,6 +7,7 @@ import { settings } from "@/lib/db/schema";
|
|||||||
import { adminCheck } from "@/lib/auth";
|
import { adminCheck } from "@/lib/auth";
|
||||||
import { sse } from "@/lib/db/sse-endpoints";
|
import { sse } from "@/lib/db/sse-endpoints";
|
||||||
import { syncAmber as syncAmberRaw } from "@/util/sync";
|
import { syncAmber as syncAmberRaw } from "@/util/sync";
|
||||||
|
import type { ToastType } from "@/components/fake-toast";
|
||||||
|
|
||||||
export async function getSettongs() {
|
export async function getSettongs() {
|
||||||
if (!(await adminCheck())) throw "Unauthorized";
|
if (!(await adminCheck())) throw "Unauthorized";
|
||||||
@@ -56,3 +57,19 @@ export async function toggleDonatePaymentMethod(
|
|||||||
|
|
||||||
await actionLog("Changed a settings", { [method]: state });
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ToastType>("warning");
|
||||||
|
|
||||||
|
function cycleType() {
|
||||||
|
setType((currentType) => {
|
||||||
|
const currentIndex = toastTypes.indexOf(currentType);
|
||||||
|
return toastTypes[(currentIndex + 1) % toastTypes.length];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative w-fit rounded-lg border border-zinc-800 bg-[#050505] bg-[radial-gradient(#3f3f46_1px,transparent_1px)] bg-size-[16px_16px] p-0 transition-[padding] duration-300",
|
||||||
|
expanded && "p-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FakeToast type={type} onIconClick={cycleType}>
|
||||||
|
<span
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
onBlur={(ev) => setValue(ev.target.textContent)}
|
||||||
|
contentEditable
|
||||||
|
data-placeholder="Broadcast message..."
|
||||||
|
className="w-full flex-1"
|
||||||
|
></span>
|
||||||
|
</FakeToast>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 bottom-1 flex w-0 gap-1 opacity-0 transition-opacity duration-300",
|
||||||
|
expanded && "w-fit opacity-100 delay-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={prefix}
|
||||||
|
onChange={(event) => 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)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="backdrop-blur-xs"
|
||||||
|
action={async () => {
|
||||||
|
await broadcastMessage(prefix, value, type);
|
||||||
|
setExpanded(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { Bomb, FolderSync } from "lucide-react";
|
import { Bomb, FolderSync } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { ActionButton } from "@/components/action-button";
|
||||||
import { CdnChooser } from "@/components/chooser";
|
import { CdnChooser } from "@/components/chooser";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { shared } from "@/lib/comms";
|
import { shared } from "@/lib/comms";
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +13,8 @@ import {
|
|||||||
toggleDonatePaymentMethod,
|
toggleDonatePaymentMethod,
|
||||||
toggleEnka,
|
toggleEnka,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { Broadcaster } from "./broadcaster";
|
||||||
import { Section } from "./page";
|
import { Section } from "./page";
|
||||||
import { ActionButton } from "@/components/action-button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
export function SettingsServicesSection({
|
export function SettingsServicesSection({
|
||||||
enka: enkaInitial,
|
enka: enkaInitial,
|
||||||
@@ -108,6 +109,7 @@ export function SettingsServicesSection({
|
|||||||
{syncResult && (
|
{syncResult && (
|
||||||
<pre className="h-full rounded border bg-muted p-2">{syncResult}</pre>
|
<pre className="h-full rounded border bg-muted p-2">{syncResult}</pre>
|
||||||
)}
|
)}
|
||||||
|
<Broadcaster />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export function VersionCheck({ headless = false }: { headless?: boolean }) {
|
|||||||
window.location.hash = "#update";
|
window.location.hash = "#update";
|
||||||
queueMicrotask(() => window.location.reload());
|
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",
|
endpoint: "/api/active",
|
||||||
|
|||||||
@@ -236,3 +236,12 @@ img {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[contenteditable="true"]:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #a0a0a0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
[contenteditable="true"]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ToastType, string> = {
|
||||||
|
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<ToastType, React.ElementType> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-80 items-center gap-2 rounded-lg border p-4 shadow-[0_4px_12px_rgb(0_0_0/0.3)] backdrop-blur-xs",
|
||||||
|
toastStyles[type],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{onIconClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Change toast type"
|
||||||
|
onClick={onIconClick}
|
||||||
|
className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-current"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="-m-2 size-8 rounded-full p-2 transition-colors hover:bg-current/20"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Icon aria-hidden="true" className="size-4 shrink-0" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -69,7 +69,7 @@
|
|||||||
|
|
||||||
### Resource Limits
|
### 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).
|
- **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.
|
- **db-migrate Job**: limits 1 CPU / 512 Mi, requests 250m / 256 Mi.
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ spec:
|
|||||||
port: 3000
|
port: 3000
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 60
|
periodSeconds: 60
|
||||||
timeoutSeconds: 5
|
timeoutSeconds: 30
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
@@ -64,3 +64,4 @@ spec:
|
|||||||
port: 3000
|
port: 3000
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 15
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export const sse = sseEndpointMap({
|
|||||||
version: z.string(),
|
version: z.string(),
|
||||||
live: z.custom<YoutubeLiveInfo>(),
|
live: z.custom<YoutubeLiveInfo>(),
|
||||||
refresh: z.string().nullable(),
|
refresh: z.string().nullable(),
|
||||||
|
announcement: z.object({
|
||||||
|
severity: z.enum(["info", "success", "warning", "error"]),
|
||||||
|
message: z.string(),
|
||||||
|
prefix: z.string().default("/"),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
// Admin Live Log
|
// Admin Live Log
|
||||||
log: {
|
log: {
|
||||||
|
|||||||
Reference in New Issue
Block a user