glorifying admin panel
This commit is contained in:
parent
ae2cc3ba90
commit
87173ae43f
12 changed files with 335 additions and 20 deletions
64
cmd/telesrv-admin/publicbranding.go
Normal file
64
cmd/telesrv-admin/publicbranding.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// The login screen shows which server it belongs to, so the two fields it needs
|
||||
// are served without a session.
|
||||
//
|
||||
// This discloses nothing new. owpengram-server already publishes the same name
|
||||
// and icon to every client that asks, over its own open endpoints
|
||||
// (/owpengram/server-info and /owpengram/server-icon) -- that is how a client
|
||||
// fills in the "Add Server" form and how the panel's own RefreshServersInfo
|
||||
// works. Branding is public by design; the point of a server name is to be
|
||||
// read before you are anybody.
|
||||
//
|
||||
// It stays deliberately narrow all the same: name and whether an icon exists,
|
||||
// and nothing else off identity.Info, which also carries the description and
|
||||
// the welcome/login-code message templates. Those are operator-facing settings
|
||||
// and stay behind server.manage.
|
||||
|
||||
type publicBrandingResponse struct {
|
||||
Name string `json:"name"`
|
||||
HasIcon bool `json:"has_icon"`
|
||||
}
|
||||
|
||||
func (s *server) handlePublicBrandingAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
out := publicBrandingResponse{}
|
||||
if s.identity != nil {
|
||||
if info, err := s.identity.Get(); err == nil {
|
||||
out.Name = info.Name
|
||||
}
|
||||
_, _, ok := s.identity.Icon()
|
||||
out.HasIcon = ok
|
||||
}
|
||||
// A server that has not been named yet answers with an empty name rather
|
||||
// than an error: the login page falls back to its own branding, and a
|
||||
// failed request there would only produce a console error for nothing.
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handlePublicIconAPI serves the same bytes as handleServerIconAPI, without the
|
||||
// session. Same file, same rationale as above.
|
||||
func (s *server) handlePublicIconAPI(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.identity == nil {
|
||||
http.NotFound(w, nil)
|
||||
return
|
||||
}
|
||||
data, ext, ok := s.identity.Icon()
|
||||
if !ok {
|
||||
http.Error(w, "no icon configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
contentType := map[string]string{
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif",
|
||||
}[ext]
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
|
@ -30,9 +30,15 @@ func TestEveryAPIRouteDeclaresAScope(t *testing.T) {
|
|||
"s.botVerificationRead(",
|
||||
"s.botVerificationManage(",
|
||||
}
|
||||
// /api/login is the way in, so it is authenticated by the credential it
|
||||
// carries rather than by a session that does not exist yet.
|
||||
exempt := map[string]bool{"POST /api/login": true}
|
||||
// Routes that are reachable before a session exists, each for a stated
|
||||
// reason: /api/login is the way in (it carries its own credential), and the
|
||||
// two branding routes feed the login screen with the server name and icon
|
||||
// that owpengram-server already publishes to every client.
|
||||
exempt := map[string]bool{
|
||||
"POST /api/login": true,
|
||||
"GET /api/public/branding": true,
|
||||
"GET /api/public/icon": true,
|
||||
}
|
||||
|
||||
route := regexp.MustCompile(`mux\.Handle(Func)?\("([A-Z]+ /api/[^"]*)"`)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ func (s *server) routes() http.Handler {
|
|||
// logout is a state change, and an invalid session is cleared by the gate
|
||||
// itself, so nothing is stranded by protecting it.
|
||||
mux.Handle("POST /api/logout", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleAPILogout)))
|
||||
// Unauthenticated on purpose: the login screen shows which server it is,
|
||||
// and the name/icon are already public from owpengram-server's own client
|
||||
// endpoints. See publicbranding.go.
|
||||
mux.HandleFunc("GET /api/public/branding", s.handlePublicBrandingAPI)
|
||||
mux.HandleFunc("GET /api/public/icon", s.handlePublicIconAPI)
|
||||
|
||||
mux.Handle("GET /api/session", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleSession)))
|
||||
|
||||
// Operator accounts. Every one of these is gated on admins.manage -- the
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-C9AiUiug.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-C9AiUiug.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
164
cmd/telesrv-admin/web/src/components/BgIcons.tsx
Normal file
164
cmd/telesrv-admin/web/src/components/BgIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue