glorifying admin panel

This commit is contained in:
onysd 2026-09-08 01:53:49 +03:00
parent ae2cc3ba90
commit 87173ae43f
12 changed files with 335 additions and 20 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-C1sjJr8N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHW7aAwX.css">
<script type="module" crossorigin src="/assets/index-C9AiUiug.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qygZBefQ.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,4 +1,5 @@
import type {
PublicBranding,
AdminConsoleUserList,
AccountDetail,
AccountListResponse,
@ -145,6 +146,9 @@ export function errorMessage(error: unknown): string {
export const api = {
session: () => request<AdminSession>("/api/session"),
// Reachable before login: the sign-in screen says which server it belongs to.
publicBranding: () => request<PublicBranding>("/api/public/branding"),
publicIconURL: () => `/api/public/icon?t=${Date.now()}`,
// The built-in operator is named "owpengram" and is checked against the
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
// that still works when the database is unreachable. A blank username is

View file

@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
// The drifting icon field from the marketing site (owpengram-site's
// BgIcons.vue), ported so the console's sign-in screen looks like the same
// product rather than a different one.
//
// Same 28 particles, same sizes, speeds, opacities and repulsion as the
// original -- this is meant to match, not to be a second interpretation of the
// idea.
// Inner markup for each icon, drawn as strokes on a 24x24 viewBox. These are
// source constants, never anything a user supplied, which is what makes
// injecting them as HTML below safe.
const ICON_PATHS = [
`<rect x="2.5" y="3.5" width="19" height="12" rx="2"/><path d="M8 20h8M12 15.5V20"/>`,
`<rect x="3.5" y="4" width="17" height="6" rx="2"/><rect x="3.5" y="14" width="17" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>`,
`<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 7l9 6.5L21 7"/>`,
`<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.6 2.5 4 5.7 4 9s-1.4 6.5-4 9c-2.6-2.5-4-5.7-4-9s1.4-6.5 4-9z"/>`,
`<rect x="7" y="3" width="10" height="18" rx="2.4"/><path d="M11 18h2"/>`,
`<path d="M8 6l-6 6 6 6M16 6l6 6-6 6"/>`,
`<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>`,
`<path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6l7-3z"/><path d="M8.8 12.2l2.1 2.1 4.3-4.3"/>`,
`<path d="M21.5 3.5l-19 7.5 5.5 2.5 3 5 3-2 5.5 5z"/><path d="M11 14l8.5-8.5"/>`,
`<rect x="4.5" y="11" width="15" height="9" rx="2.2"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9"/><path d="M12 15v2"/>`,
`<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>`,
`<circle cx="12" cy="12" r="2.5"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>`,
`<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`,
`<path d="M12 2l3.1 6.3L22 9.5l-5 4.9 1.2 7L12 17.3 5.8 21.4 7 14.4l-5-4.9 6.9-1.2z"/>`,
`<path d="M14.5 17.5L19 13l-4.5-4.5M9.5 6.5L5 11l4.5 4.5"/>`,
`<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
`<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/><path d="M15 12h4M17 10v4"/>`,
`<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>`,
`<circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>`,
`<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>`,
`<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>`,
`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>`,
`<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
`<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>`,
`<circle cx="12" cy="12" r="10"/><path d="M12 6v12M6 12h12"/>`
];
const PARTICLE_COUNT = 28;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
size: number;
rot: number;
rotSpeed: number;
};
export function BgIcons() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Someone who asked the system for less motion gets a still field rather
// than twenty-eight things drifting across their screen.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
const nodes = Array.from(host.querySelectorAll<SVGSVGElement>(".bg-icon"));
let particles: Particle[] = [];
function seed() {
const w = window.innerWidth;
const h = window.innerHeight;
const margin = 60;
particles = nodes.map((node) => {
const size = 22 + Math.random() * 30;
node.style.width = `${size}px`;
node.style.height = `${size}px`;
node.style.opacity = String(0.18 + Math.random() * 0.12);
return {
x: margin + Math.random() * Math.max(1, w - margin * 2),
y: margin + Math.random() * Math.max(1, h - margin * 2),
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size,
rot: Math.random() * 360,
rotSpeed: (Math.random() - 0.5) * 0.08
};
});
}
let frame = 0;
function tick() {
const w = window.innerWidth;
const h = window.innerHeight;
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
a.x += a.vx;
a.y += a.vy;
a.rot += a.rotSpeed;
// Bounce off the viewport edges so the field stays on screen.
const half = a.size / 2;
if (a.x < half) { a.x = half; a.vx *= -1; }
if (a.x > w - half) { a.x = w - half; a.vx *= -1; }
if (a.y < half) { a.y = half; a.vy *= -1; }
if (a.y > h - half) { a.y = h - half; a.vy *= -1; }
// Gentle mutual repulsion: without it the drift eventually clumps the
// icons into a corner and the field stops reading as a field.
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = (a.size + b.size) / 2 + 20;
if (dist < minDist && dist > 0.01) {
const force = ((minDist - dist) / minDist) * 0.02;
const nx = dx / dist;
const ny = dy / dist;
a.vx -= nx * force;
a.vy -= ny * force;
b.vx += nx * force;
b.vy += ny * force;
}
}
}
// Written straight to the nodes: this runs every frame, and putting 28
// positions through React state would re-render the sign-in form sixty
// times a second to move some background art.
for (let i = 0; i < nodes.length; i++) {
const p = particles[i];
nodes[i].style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg)`;
}
frame = requestAnimationFrame(tick);
}
seed();
frame = requestAnimationFrame(tick);
window.addEventListener("resize", seed);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", seed);
};
}, []);
return (
<div className="bg-icons" aria-hidden="true" ref={hostRef}>
{Array.from({ length: PARTICLE_COUNT }, (_, i) => (
<svg
key={i}
className="bg-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
dangerouslySetInnerHTML={{ __html: ICON_PATHS[i % ICON_PATHS.length] }}
/>
))}
</div>
);
}

View file

@ -1,9 +1,10 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api";
import { BgIcons } from "../components/BgIcons";
import { Alert } from "../components/ui";
import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
import type { AdminSession, PublicBranding } from "../types";
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
// Deliberately not pre-filled: the built-in operator is a default name, not a
@ -13,6 +14,41 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// Whose server this is. Fetched without a session -- the name and icon are
// already public from owpengram-server's client endpoints -- and left null on
// failure so the panel simply keeps its own branding.
const [branding, setBranding] = useState<PublicBranding | null>(null);
const [iconFailed, setIconFailed] = useState(false);
const orbsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api.publicBranding().then(setBranding).catch(() => undefined);
}, []);
// The orbs drift with the pointer, the same 30px parallax the marketing site
// uses. Written straight to the node instead of through state: this fires on
// every mouse move, and re-rendering the whole form for a background offset
// would be a lot of work to move three blurred circles.
useEffect(() => {
// Someone who asked the system for less motion gets none of this: the CSS
// drift is already disabled for them, and a JS-driven transform would walk
// straight past that preference.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
function onMove(event: MouseEvent) {
const node = orbsRef.current;
if (!node) return;
const x = (event.clientX / window.innerWidth - 0.5) * 30;
const y = (event.clientY / window.innerHeight - 0.5) * 30;
node.style.transform = `translate(${x}px, ${y}px)`;
}
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
const serverName = branding?.name?.trim() || "OwpenGram";
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
async function submit(event: FormEvent) {
event.preventDefault();
@ -32,17 +68,20 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
return (
<main className="login-page">
<div className="bg-orbs" aria-hidden="true">
<div className="bg-orbs" aria-hidden="true" ref={orbsRef}>
<div className="bg-orb bg-orb--1" />
<div className="bg-orb bg-orb--2" />
<div className="bg-orb bg-orb--3" />
</div>
<BgIcons />
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span className="brand-mark">
<img src={iconSrc} alt={serverName} onError={() => setIconFailed(true)} />
</span>
<span>
<strong>OwpenGram</strong>
<strong>{serverName}</strong>
<small>{"Admin Console"}</small>
</span>
</div>

View file

@ -277,6 +277,10 @@
inset: -60px;
z-index: 0;
pointer-events: none;
/* Eased so the pointer parallax glides instead of snapping to the cursor,
matching the marketing site. will-change keeps it off the main thread. */
transition: transform 0.3s ease-out;
will-change: transform;
}
.login-page .bg-orb {
@ -338,6 +342,7 @@
@media (prefers-reduced-motion: reduce) {
.login-page .bg-orb { animation: none; }
.login-page .bg-orbs { transition: none; }
}
.login-page .login-panel {
@ -483,3 +488,23 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* The drifting icon field behind the sign-in card. Sits above the blurred orbs
and below the panel, so the two background layers read as one scene.
Positioned absolutely inside .login-page rather than fixed like the site's
version, because here it belongs to one screen rather than the whole app. */
.login-page .bg-icons {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
color: var(--brand-2);
pointer-events: none;
}
.login-page .bg-icon {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}

View file

@ -937,3 +937,10 @@ export type AdminConsoleUserList = {
// from what the routes actually enforce.
available_permissions: string[];
};
// The two branding fields the login screen may read without a session.
export type PublicBranding = {
name: string;
has_icon: boolean;
};