Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ First public release.
- Nets page: every past net with roster, searchable transcript, CSV and transcript downloads.
- Stats page: check-ins per net over time (returning vs first time), typical check-ins vs the previous period, stations heard, first-timers, regulars with attendance. Filter by range and scheduled net.
- Health page (admins): live health checks, plus an AllStar setup guide with your values filled in that opens itself when something breaks.
- Works on a phone, light and dark mode, screen reader and keyboard friendly (WCAG 2.2 AA).
- Works on a phone (navigation folds into a Menu button), light and dark mode, screen reader and keyboard friendly (WCAG 2.2 AA).

### Accounts and security
- Accounts for licensed hams only: callsign checked against the FCC database (callook.info) at setup and when adding users, re-checked every 30 days. Admins confirm licenses from other countries; a first admin outside the US is accepted as entered. `REQUIRE_LICENSE=0` turns it off.
Expand Down
81 changes: 49 additions & 32 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Radio } from "lucide-react";
import { Menu as MenuIcon, Radio, X } from "lucide-react";
import { get, post, setOnUnauthorized, type User } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { AuthScreen, VerifyLicense } from "@/pages/AuthScreen";
Expand Down Expand Up @@ -27,6 +27,15 @@ export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState("");
const [view, setView] = useState<View>(viewFromHash);
const [menuOpen, setMenuOpen] = useState(false);
const menuButton = useRef<HTMLButtonElement>(null);
// Escape closes the phone menu and puts focus back on its button
const onMenuKey = (e: React.KeyboardEvent) => {
if (e.key === "Escape" && menuOpen) {
setMenuOpen(false);
menuButton.current?.focus();
}
};

const load = useCallback(async () => {
try {
Expand All @@ -44,7 +53,7 @@ export default function App() {

// Hash routing so the back button works
useEffect(() => {
const on = () => setView(viewFromHash());
const on = () => { setView(viewFromHash()); setMenuOpen(false); };
window.addEventListener("hashchange", on);
return () => window.removeEventListener("hashchange", on);
}, []);
Expand Down Expand Up @@ -108,34 +117,42 @@ export default function App() {
<Radio aria-hidden="true" className="size-5" />
Net Logger
</span>
<nav aria-label="Main" className="flex flex-wrap gap-1">
<Button asChild variant={current === "dashboard" ? "secondary" : "ghost"} size="sm">
<a href="#/" aria-current={current === "dashboard" ? "page" : undefined}>Dashboard</a>
</Button>
<Button asChild variant={current === "nets" || current.startsWith("net-") ? "secondary" : "ghost"} size="sm">
<a href="#/nets" aria-current={current === "nets" ? "page" : undefined}>Nets</a>
</Button>
<Button asChild variant={current === "stats" ? "secondary" : "ghost"} size="sm">
<a href="#/stats" aria-current={current === "stats" ? "page" : undefined}>Stats</a>
</Button>
{user.role === "admin" && (
<>
<Button asChild variant={current === "health" ? "secondary" : "ghost"} size="sm">
<a href="#/health" aria-current={current === "health" ? "page" : undefined}>Health</a>
</Button>
<Button asChild variant={current === "users" ? "secondary" : "ghost"} size="sm">
<a href="#/users" aria-current={current === "users" ? "page" : undefined}>Users</a>
</Button>
</>
)}
</nav>
<div className="ml-auto flex items-center gap-2 text-sm">
<span className="text-muted-foreground">
<span className="sr-only">Signed in as </span>{user.callsign || user.username}
<span className="sr-only">, {user.role}</span>
</span>
<PasswordDialog />
<Button variant="outline" size="sm" onClick={logout}>Log out</Button>
<Button ref={menuButton} type="button" variant="outline" className="ml-auto h-11 md:hidden"
aria-expanded={menuOpen} aria-controls="site-menu" onClick={() => setMenuOpen(!menuOpen)}>
{menuOpen ? <X aria-hidden="true" /> : <MenuIcon aria-hidden="true" />}Menu
</Button>
{/* Phones: a panel under the Menu button. Wider screens: always shown in the header row. */}
<div id="site-menu" onKeyDown={onMenuKey}
className={`${menuOpen ? "flex" : "hidden"} w-full flex-col gap-3 pb-1 md:flex md:w-auto md:flex-1 md:flex-row md:items-center md:gap-6 md:pb-0`}>
<nav aria-label="Main" className="flex flex-col gap-1 md:flex-row md:flex-wrap">
<Button asChild variant={current === "dashboard" ? "secondary" : "ghost"} size="sm" className="max-md:h-11 max-md:justify-start">
<a href="#/" aria-current={current === "dashboard" ? "page" : undefined}>Dashboard</a>
</Button>
<Button asChild variant={current === "nets" || current.startsWith("net-") ? "secondary" : "ghost"} size="sm" className="max-md:h-11 max-md:justify-start">
<a href="#/nets" aria-current={current === "nets" ? "page" : undefined}>Nets</a>
</Button>
<Button asChild variant={current === "stats" ? "secondary" : "ghost"} size="sm" className="max-md:h-11 max-md:justify-start">
<a href="#/stats" aria-current={current === "stats" ? "page" : undefined}>Stats</a>
</Button>
{user.role === "admin" && (
<>
<Button asChild variant={current === "health" ? "secondary" : "ghost"} size="sm" className="max-md:h-11 max-md:justify-start">
<a href="#/health" aria-current={current === "health" ? "page" : undefined}>Health</a>
</Button>
<Button asChild variant={current === "users" ? "secondary" : "ghost"} size="sm" className="max-md:h-11 max-md:justify-start">
<a href="#/users" aria-current={current === "users" ? "page" : undefined}>Users</a>
</Button>
</>
)}
</nav>
<div className="flex flex-wrap items-center gap-2 border-t pt-3 text-sm md:ml-auto md:border-0 md:pt-0">
<span className="text-muted-foreground max-md:w-full">
<span className="sr-only">Signed in as </span>{user.callsign || user.username}
<span className="sr-only">, {user.role}</span>
</span>
<PasswordDialog />
<Button variant="outline" size="sm" className="max-md:h-11" onClick={logout}>Log out</Button>
</div>
</div>
</div>
</header>
Expand All @@ -149,10 +166,10 @@ export default function App() {
</main>
<footer className="mx-auto flex max-w-6xl flex-wrap gap-x-4 gap-y-1 border-t px-4 py-4 text-sm text-muted-foreground">
<span>Net Logger, free software under the AGPL-3.0</span>
<a href={`${REPO}#readme`} target="_blank" rel="noreferrer" className="inline-flex min-h-6 items-center underline underline-offset-4 max-md:min-h-11">
<a href={`${REPO}#readme`} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-6 items-center underline underline-offset-4 max-md:min-h-11">
Help<span className="sr-only"> (opens in a new tab)</span>
</a>
<a href={REPO} target="_blank" rel="noreferrer" className="inline-flex min-h-6 items-center underline underline-offset-4 max-md:min-h-11">
<a href={REPO} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-6 items-center underline underline-offset-4 max-md:min-h-11">
Source code<span className="sr-only"> (opens in a new tab)</span>
</a>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/PasswordDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function PasswordDialog() {
return (
<Dialog open={open} onOpenChange={reset}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">Change password</Button>
<Button variant="ghost" size="sm" className="max-md:h-11">Change password</Button>
</DialogTrigger>
<DialogContent>
{done ? (
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/ui/alert-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ function AlertDialog(props: React.ComponentProps<typeof AlertDialogPrimitive.Roo
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}

// Net Logger change: modal dialogs don't need aria-expanded, and without a matching aria-controls
// (Radix only sets it while open) checkers read the button as a broken disclosure or menu toggle.
function AlertDialogTrigger(props: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} aria-expanded={undefined} />;
}

function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}

// Net Logger change: modal dialogs don't need aria-expanded, and without a matching aria-controls
// (Radix only sets it while open) checkers read the button as a broken disclosure or menu toggle.
function DialogTrigger(props: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} aria-expanded={undefined} />;
}

function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
Expand Down
6 changes: 6 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,9 @@
scroll-behavior: auto !important;
}
}

/* Visible 2px focus ring on links and other focusable things that don't draw their own */
a:focus-visible, [tabindex]:not(h1, h2, h3, main):focus-visible, summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
15 changes: 12 additions & 3 deletions web/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,19 @@ function CallTags({ c }: { c: Checkin }) {

function NetControls({ open, netName, onChange }: { open: boolean; netName?: string; onChange: () => void }) {
const [name, setName] = useState("");
const [error, setError] = useState("");

const openNet = async (e: FormEvent) => {
e.preventDefault();
await post("/api/net/open", { name: name.trim() });
setName("");
onChange();
try {
await post("/api/net/open", { name: name.trim() });
setError("");
setName("");
onChange();
} catch (err) {
setError((err as Error).message);
document.getElementById("net-name")?.focus();
}
};

return (
Expand Down Expand Up @@ -344,7 +351,9 @@ function NetControls({ open, netName, onChange }: { open: boolean; netName?: str
) : (
<form onSubmit={openNet} className="flex flex-col gap-2">
<Label htmlFor="net-name">Net name</Label>
<p id="net-error" role="alert" className="text-sm font-medium text-destructive empty:hidden">{error}</p>
<Input id="net-name" value={name} onChange={(e) => setName(e.target.value)}
aria-invalid={!!error || undefined} aria-describedby={error ? "net-error" : undefined}
placeholder="Tuesday Night Net" className="max-md:h-11" />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="max-md:h-11 max-md:flex-1">Open net</Button>
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/Health.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function Health() {
<h2 id="h-steps" className="text-xl font-semibold">Setup guide</h2>
<div className="flex flex-wrap items-center gap-4">
<Button asChild variant="link" className="h-auto p-0 max-md:h-11">
<a href={DOCS} target="_blank" rel="noreferrer">
<a href={DOCS} target="_blank" rel="noopener noreferrer">
Full guide on GitHub<ExternalLink aria-hidden="true" /><span className="sr-only"> (opens in a new tab)</span>
</a>
</Button>
Expand Down
Loading