Merge pull request 'Feat/ui refresh' (#6) from feat/ui-refresh into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 5s
release / images (release) Successful in 58s

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
iamdoubz committed 2026-10-28 10:22:30 -05:00
commit add90204c0
54 files changed
+1100 -660

No files matched your search

+19
View File
@@ -5,6 +5,25 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker.
## v0.0.11 — 2026-10-28
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
`sudo scripts/update.sh v0.0.11`.
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
into a menu button on narrow screens.
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
throughout.
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
voicemails.
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
## v0.0.10 — 2026-10-27
**Before you update:** this version changes the database (spam check); the api backs up first,
+1 -1
View File
@@ -7,7 +7,7 @@
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0 (v0.0.10), for testing. See [what's left for v1](docs/v1-checklist.md).
**Status:** pre-1.0 (v0.0.11), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/web",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.9.1",
"@selfphoneng/shared": "workspace:*",
"@tanstack/react-query": "^5.104.3",
"lucide-react": "^1.56.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
+1 -1
View File
@@ -26,7 +26,7 @@ export function RequireAuth({
}) {
const me = useMe();
const loc = useLocation();
if (me.isPending) return <p className="p-6 text-sm text-zinc-500">Loading…</p>;
if (me.isPending) return <p className="p-6 text-sm text-muted">Loading…</p>;
if (me.isError) {
if (me.error instanceof ApiError && me.error.status === 401)
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
@@ -143,7 +143,7 @@ export function DestinationPicker({
</p>
)}
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
<p className="text-xs text-muted">Nothing to pick yet ({what}).</p>
)}
</fieldset>
);
+6 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
import { X } from 'lucide-react';
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from './ui.tsx';
@@ -28,27 +29,27 @@ export function Dialog({
const shape = drawer
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md'
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-lg';
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl';
return (
<dialog
ref={ref}
onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()}
aria-labelledby="dialog-title"
className={`${shape} bg-white p-0 text-zinc-900 shadow-xl backdrop:bg-black/40 dark:bg-zinc-900 dark:text-zinc-100`}
className={`${shape} bg-surface p-0 text-fg shadow-xl backdrop:bg-black/40`}
>
<div className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<h2 id="dialog-title" className="text-base font-semibold">
{title}
</h2>
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
✕
<X className="size-4" />
</Button>
</header>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
<footer className="flex justify-end gap-2 border-t border-line px-4 py-3">
{footer}
</footer>
)}
+5 -5
View File
@@ -15,13 +15,13 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
Parked calls
</h2>
{parked.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p>
) : (
<ul
aria-label="Parked calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{parked.map((p) => (
<li
@@ -31,14 +31,14 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
<span className="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
<span className="text-muted">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-muted"> · parked by {p.parkedBy}</span>}
</span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && (
<button
type="button"
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
className="rounded-md border border-line-strong bg-surface px-2 py-1"
onClick={() => onPickUp(p.slot)}
>
Pick up {p.slot}
+3 -3
View File
@@ -14,7 +14,7 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
code: in the app, choose <em>Fetch remote configuration</em> (or <em>Scan QR code</em>) and
point the camera at the code below. On a computer, paste the link instead.
</p>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
<strong>MicroSIP</strong> (Windows) and <strong>Baresip</strong> (Linux) work too, set up by
hand: an admin can see the server, user name and password.
</p>
@@ -32,14 +32,14 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
className="rounded bg-white"
/>
<p className="font-mono text-xs break-all">{qr.data.url}</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
password: don't share it.
</p>
</div>
)}
{qr.error && <Alert>{qr.error.message}</Alert>}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
On a mobile phone, apps can miss calls while they're in the background (iPhones and
battery-saving Android phones stop them). For calls you can't miss, use a desk phone or this
browser, or forward unanswered calls to your mobile number.
+4 -4
View File
@@ -56,11 +56,11 @@ export function SignInCard() {
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Password</h3>
{ways.password ? (
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
<Link className="text-link underline" to="/change-password">
Change password
</Link>
) : (
<p className="text-zinc-500">
<p className="text-muted">
You have no password here. An admin can give you one with Reset password.
</p>
)}
@@ -92,7 +92,7 @@ export function SignInCard() {
</>
) : (
<>
<p className="text-zinc-500">
<p className="text-muted">
Connect your {oidc.label} account to sign in with it as well.
</p>
<div>
@@ -133,7 +133,7 @@ function DirectoryConnect() {
}}
>
<h3 className="font-medium">Company directory</h3>
<p className="text-zinc-500">
<p className="text-muted">
Connect your directory account to sign in with its password as well.
</p>
<Field label="Directory username or email">
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
import { Play } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId, useRef } from 'react';
import { soundsApi } from '../api/client.ts';
@@ -63,7 +64,7 @@ export function SoundPicker({
void a.play().catch(() => undefined);
}}
>
▶
<Play className="size-4" />
</Button>
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
{upload.isPending ? 'Uploading…' : 'Upload new'}
+1 -1
View File
@@ -44,7 +44,7 @@ export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
>
{high ? 'Likely spam' : 'Possible spam'}
</span>
<span className="text-zinc-600 dark:text-zinc-400">{spamLine(check)}</span>
<span className="text-muted">{spamLine(check)}</span>
</span>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { toggleTheme } from '../theme.ts';
import { Button } from './ui.tsx';
@@ -12,7 +13,7 @@ export function ThemeToggle() {
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDark(toggleTheme())}
>
{dark ? '☀' : '☾'}
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
+35 -9
View File
@@ -3,15 +3,14 @@
import { useId, type ComponentProps, type ReactNode } from 'react';
import { APP_NAME } from '@selfphoneng/shared';
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500';
export const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50',
secondary:
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
secondary: 'border border-line-strong bg-surface hover:bg-subtle',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50',
ghost: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
ghost: 'hover:bg-subtle',
};
/** The icon (public/icon-192.png, cut from public/logo.png) and the product name. */
@@ -32,13 +31,40 @@ export function Button({
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed ${variants[variant]} ${focus} ${className}`}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed pointer-coarse:min-h-11 pointer-coarse:min-w-11 ${variants[variant]} ${focusRing} ${className}`}
{...props}
/>
);
}
export const inputClass = `w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900 ${focus} aria-invalid:border-red-500`;
/** A titled card: heading, an optional link or button on the right, then the body. */
export function Panel({
title,
action,
children,
}: {
title: string;
action?: ReactNode;
children: ReactNode;
}) {
const id = useId();
return (
<section
aria-labelledby={id}
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
{action}
</div>
{children}
</section>
);
}
export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focusRing} aria-invalid:border-red-500`;
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
export function Field({
@@ -67,7 +93,7 @@ export function Field({
{(error || hint) && (
<p
id={msgId}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-zinc-500'}`}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-muted'}`}
>
{error ?? hint}
</p>
+64
View File
@@ -1,2 +1,66 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Theme colors: the logo's blue and navy. Pages use these names (bg-surface, text-muted,
border-line...), not palette colors, so light and dark are paired here once. Every text pair
is at least 4.5:1 in both themes. */
:root {
color-scheme: light;
--sp-page: #f8fafc;
--sp-surface: #ffffff;
--sp-subtle: #f1f5f9;
--sp-line: #e2e8f0;
--sp-line-strong: #cbd5e1;
--sp-fg: #0f172a;
--sp-muted: #475569;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #eff6ff;
--sp-primary-line: #bfdbfe;
--sp-primary-ink: #1e3a8a;
--sp-link: #1d4ed8;
--sp-focus: #2563eb;
}
.dark {
color-scheme: dark;
--sp-page: #0b1120;
--sp-surface: #131c2e;
--sp-subtle: #1e293b;
--sp-line: #253349;
--sp-line-strong: #334155;
--sp-fg: #f1f5f9;
--sp-muted: #94a3b8;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #172554;
--sp-primary-line: #1e3a8a;
--sp-primary-ink: #dbeafe;
--sp-link: #60a5fa;
--sp-focus: #60a5fa;
}
@theme inline {
--color-page: var(--sp-page);
--color-surface: var(--sp-surface);
--color-subtle: var(--sp-subtle);
--color-line: var(--sp-line);
--color-line-strong: var(--sp-line-strong);
--color-fg: var(--sp-fg);
--color-muted: var(--sp-muted);
--color-primary: var(--sp-primary);
--color-primary-hover: var(--sp-primary-hover);
--color-primary-soft: var(--sp-primary-soft);
--color-primary-line: var(--sp-primary-line);
--color-primary-ink: var(--sp-primary-ink);
--color-link: var(--sp-link);
--color-focus: var(--sp-focus);
}
/* iOS zooms into any field under 16px on focus and stays zoomed, which left the incoming call
dialog off screen. Unlayered, so it beats text-sm. */
@media (pointer: coarse) {
input,
select,
textarea {
font-size: 16px;
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ const router = createBrowserRouter([
const queryClient = new QueryClient();
document.body.className = 'bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100';
document.body.className = 'bg-page text-fg';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
+4 -4
View File
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
import { aboutApi } from '../api/client.ts';
import { Alert, Brand } from '../components/ui.tsx';
const linkClass = 'text-sky-700 underline dark:text-sky-400';
const linkClass = 'text-link underline';
export function AboutPage() {
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
@@ -44,18 +44,18 @@ export function AboutPage() {
<a
href="/api/about/source"
download="selfphone.tar.gz"
className="rounded-md bg-sky-600 px-4 py-2 font-medium text-white hover:bg-sky-700"
className="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover"
>
Code v{a.version}
</a>
<span className="text-zinc-500">
<span className="text-muted">
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
{new Date(a.source.builtAt).toLocaleString()}
</span>
</div>
) : (
a && (
<p className="text-zinc-500">
<p className="text-muted">
This is a development build: the source archive is only in builds made with Docker.
The project&apos;s repository is below.
</p>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ChangePasswordPage() {
return (
<AuthCard title="Change your password">
{forced && (
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<p className="mb-4 text-sm text-muted">
You signed in with a temporary password. Choose a new one to continue.
</p>
)}
+5 -5
View File
@@ -13,12 +13,12 @@ import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="flex min-h-dvh flex-col items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="w-full max-w-sm rounded-xl border border-line bg-surface p-6 shadow-sm">
<img src="/logo-512.png" alt={APP_NAME} className="mx-auto mb-4 size-32 rounded-xl" />
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children}
</div>
<Link to="/about" className="mt-3 text-xs text-zinc-500 hover:underline">
<Link to="/about" className="mt-3 text-xs text-muted hover:underline">
About SelfPhoneNG · source code
</Link>
</main>
@@ -73,12 +73,12 @@ export function LoginPage() {
{/* A full page load: the browser goes to the provider and comes back signed in. */}
<a
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
className="rounded-md bg-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-primary-hover"
>
Sign in with {oidc.label}
</a>
{showForm && (
<p className="text-center text-xs text-zinc-500" aria-hidden>
<p className="text-center text-xs text-muted" aria-hidden>
or with your password
</p>
)}
@@ -87,7 +87,7 @@ export function LoginPage() {
{!showForm && (
<button
type="button"
className="w-full text-center text-xs text-zinc-500 hover:underline"
className="w-full text-center text-xs text-muted hover:underline"
onClick={() => setAdminForm(true)}
>
Admin sign-in
+23 -18
View File
@@ -2,6 +2,7 @@
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
import { Check } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -70,7 +71,7 @@ export function SetupPage() {
type="button"
onClick={() => go(i + 1)}
aria-current={step === i + 1 ? 'step' : undefined}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-sky-600 text-white' : 'bg-zinc-100 dark:bg-zinc-800'}`}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-primary text-white' : 'bg-subtle'}`}
>
{i + 1}. {s}
</button>
@@ -109,7 +110,7 @@ function Nav({
hint?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
<Button variant="primary" onClick={next} disabled={!canNext}>
Next
</Button>
@@ -118,7 +119,7 @@ function Nav({
{skip}
</Button>
)}
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
{hint && <span className="text-sm text-muted">{hint}</span>}
</div>
);
}
@@ -127,7 +128,10 @@ function PasswordStep({ next }: { next: () => void }) {
// Signing in with the initial password already forced a change (RequireAuth).
return (
<>
<p className="text-sm">✓ The initial admin password has been changed.</p>
<p className="text-sm">
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
The initial admin password has been changed.
</p>
<Nav next={next} />
</>
);
@@ -196,7 +200,7 @@ function CompanyForm({
</select>
)}
</Field>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
Save and continue
</Button>
@@ -343,7 +347,7 @@ function NetworkForm(p: {
I&apos;ve done this
</label>
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
{save.isPending ? 'Saving…' : 'Save and continue'}
</Button>
@@ -399,7 +403,7 @@ function TrunkStep({ next }: { next: () => void }) {
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
<strong>Registered</strong>.
</p>
@@ -457,7 +461,7 @@ function NumbersStep({ next }: { next: () => void }) {
);
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
@@ -465,8 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
<ul className="text-sm">
{dids.data.map((d) => (
<li key={d.id}>
✓ {formatNumber(d.number)}{' '}
{d.label && <span className="text-zinc-500">{d.label}</span>}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{formatNumber(d.number)} {d.label && <span className="text-muted">{d.label}</span>}
</li>
))}
</ul>
@@ -554,7 +558,7 @@ function UsersStep({ next }: { next: () => void }) {
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the people who get a phone (up to 10 here; more later under Users &amp; Extensions).
Each gets the extension you type (any free number, like 1), or the next free one, and a
temporary password, shown once.
@@ -563,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
<ul className="text-sm">
{withExt.map((u) => (
<li key={u.id}>
✓ {u.extension!.number} {u.displayName}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{u.extension!.number} {u.displayName}
</li>
))}
</ul>
@@ -614,7 +619,7 @@ function UsersStep({ next }: { next: () => void }) {
</form>
{save.isError && <Alert>{save.error.message}</Alert>}
{created.length > 0 && (
<div className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<div className="rounded-md border border-line p-3 text-sm">
<p className="mb-2 font-medium">
Give each person their temporary password (shown only now):
</p>
@@ -624,7 +629,7 @@ function UsersStep({ next }: { next: () => void }) {
<tr key={c.email}>
<td>{c.number}</td>
<td>{c.name}</td>
<td className="text-zinc-500">{c.email}</td>
<td className="text-muted">{c.email}</td>
<td className="font-mono">{c.password}</td>
</tr>
))}
@@ -682,7 +687,7 @@ function RoutingStep({ next }: { next: () => void }) {
const incomplete = Object.values(dest).some((d) => d === null);
return (
<>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under
Digital receptionist later and pick it here or on the Phone numbers page.
</p>
@@ -715,7 +720,7 @@ function RoutingStep({ next }: { next: () => void }) {
})}
{everyone.isError && <Alert>{everyone.error.message}</Alert>}
{save.isError && <Alert>{save.error.message}</Alert>}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button
variant="primary"
onClick={() => save.mutate()}
@@ -740,7 +745,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
<li>Everything here can be changed later in the admin pages.</li>
</ul>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" onClick={onFinish} disabled={busy}>
Finish
</Button>
@@ -753,6 +758,6 @@ function Loading({ q }: { q: { isError: boolean; error: Error | null } }): React
return q.isError ? (
<Alert>{q.error!.message}</Alert>
) : (
<p className="text-sm text-zinc-500">Loading…</p>
<p className="text-sm text-muted">Loading…</p>
);
}
+2 -2
View File
@@ -73,7 +73,7 @@ function TwoStep() {
<h2 id="two-step-h" className="font-medium">
Two-step sign-in
</h2>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Besides your password, signing in asks for a code from an authenticator app on your phone
(any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
</p>
@@ -115,7 +115,7 @@ function TwoStep() {
height={240}
className="rounded bg-white"
/>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
</p>
<Field label="2. Enter the 6-digit code it shows">
+117 -51
View File
@@ -1,6 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
// Admin shell (05 §2): sidebar grouped by section; unbuilt pages say which milestone brings them.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Activity,
AudioLines,
Ban,
BellRing,
BookUser,
Cable,
ChartColumn,
Clock,
Hash,
Headset,
HistoryIcon,
LayoutDashboard,
Menu,
ListOrdered,
Megaphone,
MessageSquare,
MonitorSmartphone,
Music,
PhoneOutgoing,
Settings,
Split,
Users,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
@@ -9,28 +33,65 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
// Grouped by job rather than spec order, so the 21 pages scan as six short lists.
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: null },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: null },
{ path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
{ path: 'queues', label: 'Queues', milestone: null },
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: null },
{ path: 'contacts', label: 'Contacts', milestone: null },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: null },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
{ path: 'settings', label: 'Settings', milestone: null },
{ path: 'system', label: 'System', milestone: null },
{ path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
{ path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
{ path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
{
path: 'outbound',
label: 'Outbound rules',
milestone: null,
section: 'Call routing',
icon: PhoneOutgoing,
},
{
path: 'ring-groups',
label: 'Ring groups',
milestone: null,
section: 'Call routing',
icon: BellRing,
},
{ path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
{
path: 'ivrs',
label: 'Digital receptionist',
milestone: null,
section: 'Call routing',
icon: Split,
},
{ path: 'hours', label: 'Office hours', milestone: null, section: 'Call routing', icon: Clock },
{
path: 'blocked',
label: 'Blocked numbers',
milestone: null,
section: 'Call routing',
icon: Ban,
},
{ path: 'users', label: 'Users & Extensions', milestone: null, section: 'People', icon: Users },
{ path: 'devices', label: 'Phones', milestone: null, section: 'People', icon: MonitorSmartphone },
{ path: 'contacts', label: 'Contacts', milestone: null, section: 'People', icon: BookUser },
{
path: 'conferences',
label: 'Conferences',
milestone: null,
section: 'Features',
icon: Headset,
},
{ path: 'paging', label: 'Paging', milestone: null, section: 'Features', icon: Megaphone },
{ path: 'sounds', label: 'Sounds & music', milestone: null, section: 'Features', icon: Music },
{ path: 'sms', label: 'SMS', milestone: 'M9', section: 'Features', icon: MessageSquare },
{ path: 'calls', label: 'Call history', milestone: null, section: 'History', icon: HistoryIcon },
{
path: 'recordings',
label: 'Recordings',
milestone: null,
section: 'History',
icon: AudioLines,
},
{ path: 'reports', label: 'Reports', milestone: null, section: 'History', icon: ChartColumn },
{ path: 'settings', label: 'Settings', milestone: null, section: 'System', icon: Settings },
{ path: 'system', label: 'System', milestone: null, section: 'System', icon: Activity },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
@@ -52,29 +113,39 @@ export function AdminLayout({ user }: { user: AuthUser }) {
const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`flex items-center justify-between rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-sky-100 font-medium text-sky-900 dark:bg-sky-950 dark:text-sky-100' : 'hover:bg-zinc-200/60 dark:hover:bg-zinc-800'} ${p.milestone ? 'text-zinc-500' : ''}`
}
>
{p.label}
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
{[...new Set(ADMIN_PAGES.map((p) => p.section))].map((section) => (
<div key={section} className="mt-3 first:mt-0">
{section && (
<h2 className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted uppercase">
{section}
</h2>
)}
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.filter((p) => p.section === section).map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm pointer-coarse:py-2.5 ${isActive ? 'bg-primary-soft font-medium text-primary-ink before:absolute before:inset-y-1.5 before:left-0 before:w-1 before:rounded-full before:bg-primary' : 'hover:bg-subtle'} ${p.milestone ? 'text-muted' : ''}`
}
>
<p.icon className="size-4 shrink-0" />
<span className="flex-1">{p.label}</span>
{p.milestone && <span className="text-xs text-muted">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
);
return (
<div className="flex min-h-dvh">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-zinc-200 bg-zinc-50 md:flex dark:border-zinc-800 dark:bg-zinc-900">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-line bg-surface md:flex">
<p className="px-4 py-4 text-lg">
<Brand />
</p>
@@ -82,7 +153,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-1 border-b border-zinc-200 px-2 py-2 sm:gap-2 sm:px-4 dark:border-zinc-800">
<header className="flex items-center gap-1 border-b border-line px-2 py-2 sm:gap-2 sm:px-4">
<Button
variant="ghost"
className="md:hidden"
@@ -91,13 +162,11 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
☰
<Menu className="size-5" />
</Button>
<Brand className="md:hidden" />
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
<span className="hidden text-sm text-zinc-600 sm:inline dark:text-zinc-400">
{user.displayName}
</span>
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
<ThemeToggle />
{/* On phones this lives in the menu instead. */}
<span className="hidden sm:contents">
@@ -111,12 +180,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</div>
</header>
{navOpen && (
<div
id="mobile-nav"
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
>
<div id="mobile-nav" className="border-b border-line bg-surface pt-2 md:hidden">
{links}
<div className="border-t border-zinc-200 px-2 py-2 dark:border-zinc-800">
<div className="border-t border-line px-2 py-2">
<Button
variant="ghost"
className="w-full justify-start"
@@ -148,7 +214,7 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
return (
<section>
<h1 className="mb-2 text-xl font-semibold">{label}</h1>
<p className="text-sm text-zinc-500">Coming in {milestone}.</p>
<p className="text-sm text-muted">Coming in {milestone}.</p>
</section>
);
}
+4 -4
View File
@@ -28,7 +28,7 @@ export function BlockedPage() {
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Blocked numbers</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected.
</p>
@@ -67,13 +67,13 @@ export function BlockedPage() {
</Button>
</form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">Nobody is blocked.</p>}
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((b) => (
<li key={b.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
<span className="font-mono">{formatNumber(b.number)}</span>
<span className="flex-1 text-zinc-500">{b.reason}</span>
<span className="flex-1 text-muted">{b.reason}</span>
<Button
variant="ghost"
aria-label={`Unblock ${formatNumber(b.number)}`}
+11 -11
View File
@@ -21,10 +21,10 @@ export const fmtDuration = (s: number) =>
export const STATUS_LOOK: Record<CallDto['status'], string> = {
answered: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100',
missed: 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
voicemail: 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100',
voicemail: 'bg-primary-soft text-primary-ink',
abandoned: 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
busy: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
failed: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
busy: 'bg-line text-fg',
failed: 'bg-line text-fg',
};
export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' };
@@ -71,7 +71,7 @@ export function CallsPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Call history</h1>
<a
className="rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
href={callsApi.csvUrl(filter)}
>
Export CSV
@@ -148,10 +148,10 @@ export function CallsPage() {
</div>
</form>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls match.</p>}
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls match.</p>}
<ul
aria-label="Calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm">
@@ -161,15 +161,15 @@ export function CallsPage() {
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left"
onClick={() => setOpen(open === c.id ? null : c.id)}
>
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-14">{DIRECTION_TEXT[c.direction]}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span>
{c.fromName && (
<span className="text-zinc-500"> {fmtNumber(c.fromNumber)}</span>
<span className="text-muted"> {fmtNumber(c.fromNumber)}</span>
)} → {fmtNumber(c.toNumber)}
{c.answeredBy && (
<span className="text-zinc-500"> · answered by {c.answeredBy.name}</span>
<span className="text-muted"> · answered by {c.answeredBy.name}</span>
)}
<SpamNote check={c.spam} />
</span>
@@ -202,12 +202,12 @@ export function CallsPage() {
function Legs({ id }: { id: string }) {
const d = useQuery({ queryKey: ['call', id], queryFn: () => callsApi.get(id) });
if (!d.data) return <p className="text-xs text-zinc-500">Loading…</p>;
if (!d.data) return <p className="text-xs text-muted">Loading…</p>;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<caption className="sr-only">Call legs</caption>
<thead className="text-left text-zinc-500">
<thead className="text-left text-muted">
<tr>
<th className="pr-3 font-normal">Time</th>
<th className="pr-3 font-normal">From channel</th>
+3 -3
View File
@@ -35,13 +35,13 @@ export function ConferencesPage() {
</div>
{rooms.isError && <Alert>{rooms.error.message}</Alert>}
{rooms.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No conference rooms yet. A room is a number (like 800) that several people dial to talk
together, with an optional PIN.
</p>
)}
{rooms.data && rooms.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{rooms.data.map((c) => (
<li
key={c.id}
@@ -49,7 +49,7 @@ export function ConferencesPage() {
>
<span className="font-mono">{c.number}</span>
<span className="font-medium">{c.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{c.pin ? `PIN ${c.pin}` : 'No PIN'}
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
{c.record ? ' · recorded' : ''}
+15 -18
View File
@@ -15,8 +15,8 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h2 className="text-sm text-zinc-500">{title}</h2>
<div className="rounded-xl border border-line bg-surface shadow-sm p-4">
<h2 className="text-sm text-muted">{title}</h2>
<div className="mt-1 text-2xl font-semibold">{children}</div>
</div>
);
@@ -55,7 +55,7 @@ export function DashboardPage() {
<Card title="Telephony engine">
<span
className={
amiConnected ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
amiConnected ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}
>
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
@@ -72,19 +72,19 @@ export function DashboardPage() {
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No extensions yet. Give a user a number under Users & Extensions.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.map((e) => (
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
<StatusDot ext={e} />
<span className="w-14 font-mono">{e.number}</span>
<span className="flex-1 truncate">{e.name}</span>
<span className="text-zinc-500">{statusLabel(e)}</span>
<span className="text-muted">{statusLabel(e)}</span>
{e.contacts && (
<span className="hidden w-24 text-right text-zinc-500 sm:inline">
<span className="hidden w-24 text-right text-muted sm:inline">
{e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
</span>
)}
@@ -118,15 +118,12 @@ function QueueTiles() {
null,
);
return (
<li
key={q.number}
className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<Link className="text-sm text-zinc-500 hover:underline" to={`queues/${q.id}`}>
<li key={q.number} className="rounded-xl border border-line bg-surface shadow-sm p-4">
<Link className="text-sm text-muted hover:underline" to={`queues/${q.id}`}>
{q.number} {q.name}
</Link>
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div>
<div className="text-sm text-zinc-500">
<div className="text-sm text-muted">
Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
agents free
</div>
@@ -258,11 +255,11 @@ function LiveCalls() {
</p>
)}
{calls.length === 0 ? (
<p className="text-sm text-zinc-500">No calls right now.</p>
<p className="text-sm text-muted">No calls right now.</p>
) : (
<ul
aria-label="Calls now"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li
@@ -272,15 +269,15 @@ function LiveCalls() {
>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span>
{c.from.name && <span className="text-zinc-500"> {fmtNumber(c.from.number)}</span>}
{c.from.name && <span className="text-muted"> {fmtNumber(c.from.number)}</span>}
{' → '}
{fmtNumber(c.to.number)}
<span className="text-zinc-500">
<span className="text-muted">
{' '}
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
{c.recording && ' · recording'}
</span>
<span className="block font-mono text-xs text-zinc-500" data-channels>
<span className="block font-mono text-xs text-muted" data-channels>
{c.channels.join(' ')}
</span>
</span>
+7 -7
View File
@@ -42,14 +42,14 @@ export function IvrsPage() {
</div>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No menus yet. A menu greets callers ("Thanks for calling. For sales press 1…") and sends
them where they choose. Upload the greeting under Sounds &amp; music, or right in the
form.
</p>
)}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((r) => (
<li
key={r.id}
@@ -57,7 +57,7 @@ export function IvrsPage() {
>
<span className="font-mono">{r.number}</span>
<span className="font-medium">{r.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{r.options.map((o) => `${o.digit}: ${o.summary}`).join(' · ') || 'No keys yet'}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
@@ -230,10 +230,10 @@ function IvrForm({
aria-label={`Key ${d}`}
aria-pressed={selected === d}
onClick={() => setSelected(d)}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-sky-500 ring-2 ring-sky-500' : 'border-zinc-300 dark:border-zinc-700'} ${dest ? 'bg-sky-50 dark:bg-sky-950' : ''}`}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-primary ring-2 ring-primary' : 'border-line-strong'} ${dest ? 'bg-primary-soft' : ''}`}
>
<span className="text-lg font-semibold">{d}</span>
<span className="line-clamp-2 text-zinc-600 dark:text-zinc-400">
<span className="line-clamp-2 text-muted">
{dest ? describe(dest) : dest === null ? 'Not finished' : 'Unused'}
</span>
</button>
@@ -242,7 +242,7 @@ function IvrForm({
</div>
{errors.options && <Alert>{errors.options}</Alert>}
{selected && (
<div className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 dark:border-zinc-800">
<div className="flex flex-col gap-2 rounded-md border border-line p-3">
{keys[selected] === undefined ? (
<Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}>
Use key {selected}
@@ -281,7 +281,7 @@ function IvrForm({
/>
<span>
Callers may dial an extension directly (e.g. 101)
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
A key that is also the first digit of an extension waits 3 seconds for more digits.
</span>
</span>
+7 -7
View File
@@ -37,26 +37,26 @@ export function NumbersPage() {
</Button>
</div>
{noTrunk && (
<p className="text-sm text-zinc-500">Add a trunk first: numbers arrive through one.</p>
<p className="text-sm text-muted">Add a trunk first: numbers arrive through one.</p>
)}
{dids.isError && <Alert>{dids.error.message}</Alert>}
{dids.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{dids.isPending && <p className="text-sm text-muted">Loading…</p>}
{dids.data?.length === 0 && !noTrunk && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No numbers yet. Add the numbers (DIDs) you bought at voip.ms.
</p>
)}
{dids.data && dids.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{dids.data.map((d) => (
<li
key={d.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-mono">{formatNumber(d.number)}</span>
{d.label && <span className="text-zinc-500">{d.label}</span>}
{d.label && <span className="text-muted">{d.label}</span>}
<span className="flex-1 truncate">→ {d.destinationSummary}</span>
<span className="hidden text-zinc-500 sm:inline">{trunkName(d.trunkId)}</span>
<span className="hidden text-muted sm:inline">{trunkName(d.trunkId)}</span>
<Button onClick={() => setEditing(d)}>Edit</Button>
<Button
variant="ghost"
@@ -235,7 +235,7 @@ function DidDrawer({
<input type="checkbox" className="mt-1" {...form.register('recordInbound')} />
<span>
Record every call to this number
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
Callers hear the recording notice from Settings → Recording first, if you set one.
Check the consent rules where you and your callers are.
</span>
+17 -16
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -36,11 +37,11 @@ const rangeText = (r: OpenRange) =>
export function OfficeHoursPage() {
const [tab, setTab] = useState<'hours' | 'holidays'>('hours');
const tabClass = (on: boolean) =>
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
`rounded-md px-3 py-2 text-sm font-medium ${on ? 'bg-subtle' : 'hover:bg-subtle'}`;
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Office hours</h1>
<div role="tablist" className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800">
<div role="tablist" className="flex gap-1 border-b border-line pb-2">
<button
role="tab"
aria-selected={tab === 'hours'}
@@ -108,7 +109,7 @@ function HoursTab() {
{list.isError && <Alert>{list.error.message}</Alert>}
{override.error && <Alert>{override.error.message}</Alert>}
{list.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No office hours yet. Set your opening hours, then point your phone number at them: callers
go one way when you're open and another when you're closed.
</p>
@@ -117,13 +118,13 @@ function HoursTab() {
<article
key={tc.id}
aria-label={tc.name}
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
>
<div className="flex flex-wrap items-center gap-3">
<h2 className="flex-1 font-semibold">{tc.name}</h2>
<StatusChip tc={tc} />
</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
{tc.openRanges.map(rangeText).join('; ') || 'Never open'} · {tc.timezone}
{tc.useHolidays ? ' · closed on holidays' : ''}
{tc.overrideDigit !== null &&
@@ -199,7 +200,7 @@ function WeekGrid({
onPointerUp={() => setPaint(null)}
onPointerLeave={() => setPaint(null)}
>
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-zinc-500">
<div className="grid w-max grid-cols-[2.5rem_repeat(48,0.75rem)] gap-px text-[10px] text-muted">
<span />
{Array.from({ length: SLOTS }, (_, i) => (
<span key={i} className="text-center">
@@ -213,7 +214,7 @@ function WeekGrid({
<span
key={i}
data-cell={`${d}-${i}`}
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-zinc-200 dark:bg-zinc-800'}`}
className={`h-5 cursor-pointer touch-none rounded-sm ${on ? 'bg-green-500' : 'bg-line'}`}
onPointerDown={(e) => {
// Touch captures the pointer to this cell; let go so dragging paints the others.
const el = e.target as Element;
@@ -254,14 +255,14 @@ function RangeList({
aria-label={`Remove ${rangeText(r)}`}
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
{ranges.length === 0 && <li className="text-zinc-500">No open hours: always closed.</li>}
{ranges.length === 0 && <li className="text-muted">No open hours: always closed.</li>}
</ul>
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-zinc-200 p-2 dark:border-zinc-800">
<legend className="px-1 text-xs text-zinc-500">Add hours</legend>
<fieldset className="flex min-w-0 flex-wrap items-end gap-2 rounded-md border border-line p-2">
<legend className="px-1 text-xs text-muted">Add hours</legend>
{DAYS.map((d) => (
<label key={d} className="flex items-center gap-1 text-sm">
<input
@@ -417,7 +418,7 @@ function HoursForm({
</Field>
<fieldset className="flex min-w-0 flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Open hours</legend>
<p className="text-xs text-zinc-500">Click or drag across the grid; green is open.</p>
<p className="text-xs text-muted">Click or drag across the grid; green is open.</p>
<WeekGrid ranges={ranges} onChange={setRanges} />
{errors.openRanges && <Alert>{errors.openRanges}</Alert>}
<RangeList ranges={ranges} onChange={setRanges} />
@@ -544,18 +545,18 @@ function HolidaysTab() {
Add US federal holidays for {year}
</Button>
{importUs.data && (
<span role="status" className="text-sm text-zinc-500">
<span role="status" className="text-sm text-muted">
Added {importUs.data.added}.
</span>
)}
</div>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No holidays yet.</p>}
{list.data?.length === 0 && <p className="text-sm text-muted">No holidays yet.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((h) => (
<li key={h.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
<span className="flex-1 font-medium">{h.name}</span>
<span className="text-zinc-500">
<span className="text-muted">
{fmt(h.startDate)}
{h.endDate !== h.startDate && ` – ${fmt(h.endDate)}`}
{h.recurringYearly && ' · every year'}
+17 -18
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -56,19 +57,17 @@ export function OutboundPage() {
New rule
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
When someone dials an outside number, the first rule whose pattern matches decides how the
call goes out. Put more specific rules (like toll-free) above general ones.
</p>
{routes.isError && <Alert>{routes.error.message}</Alert>}
{move.isError && <Alert>{move.error.message}</Alert>}
{routes.data?.length === 0 && (
<p className="text-sm text-zinc-500">
No rules yet. They are created with your first trunk.
</p>
<p className="text-sm text-muted">No rules yet. They are created with your first trunk.</p>
)}
{routes.data && routes.data.length > 0 && (
<ol className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ol className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{routes.data.map((r, i) => (
<li
key={r.id}
@@ -82,7 +81,7 @@ export function OutboundPage() {
disabled={i === 0 || move.isPending}
onClick={() => move.mutate({ from: i, to: i - 1 })}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
@@ -91,18 +90,18 @@ export function OutboundPage() {
disabled={i === routes.data.length - 1 || move.isPending}
onClick={() => move.mutate({ from: i, to: i + 1 })}
>
↓
<ArrowDown className="size-4" />
</Button>
</span>
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-zinc-400 line-through'}`}>
<span className="w-5 text-muted">{i + 1}.</span>
<span className={`font-medium ${r.enabled ? '' : 'text-muted line-through'}`}>
{r.name}
</span>
{!r.enabled && <span className="text-xs text-zinc-500">off</span>}
<span className="flex-1 truncate font-mono text-xs text-zinc-500">
{!r.enabled && <span className="text-xs text-muted">off</span>}
<span className="flex-1 truncate font-mono text-xs text-muted">
{r.patterns.map((p) => p.match).join(' ')}
</span>
<span className="hidden text-zinc-500 sm:inline">
<span className="hidden text-muted sm:inline">
{CLASS_LABEL[r.requiredClass]} · {r.trunkIds.map(trunkName).join(' → ')}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
@@ -162,7 +161,7 @@ function TestBox() {
if (!exts.data?.length) return null;
return (
<form
className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
className="flex flex-col gap-3 rounded-xl border border-line bg-surface shadow-sm p-4"
onSubmit={(e) => {
e.preventDefault();
if (number.trim()) test.mutate();
@@ -303,7 +302,7 @@ function RouteDrawer({
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-medium">Patterns</legend>
<details className="text-xs text-zinc-500">
<details className="text-xs text-muted">
<summary className="cursor-pointer">Pattern help</summary>
<p className="mt-1">
<code>X</code> any digit, <code>Z</code> 1–9, <code>N</code> 2–9, <code>[1-5]</code>{' '}
@@ -342,7 +341,7 @@ function RouteDrawer({
onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -375,7 +374,7 @@ function RouteDrawer({
<ol className="flex flex-col gap-1">
{chosen.map((id, i) => (
<li key={id} className="flex items-center gap-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">{trunks.find((t) => t.id === id)?.name ?? id}</span>
<Button
variant="ghost"
@@ -387,14 +386,14 @@ function RouteDrawer({
setTrunks(ids);
}}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
aria-label="Remove trunk"
onClick={() => setTrunks(chosen.filter((x) => x !== id))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
+4 -4
View File
@@ -38,14 +38,14 @@ export function PagingPage() {
</div>
{groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No paging groups yet. Dialing a paging group's number (like 850) makes its desk phones
answer by themselves, so everyone near them hears you. To talk to one desk phone this way,
dial *80 and its extension (like *80101).
</p>
)}
{groups.data && groups.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{groups.data.map((g) => (
<li
key={g.id}
@@ -53,7 +53,7 @@ export function PagingPage() {
>
<span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{g.duplex ? 'Two-way' : 'One-way'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'}
</span>
@@ -193,7 +193,7 @@ function PagingForm({
{x.number} {x.name}
</label>
))}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Only desk phones are paged, not the browser phone, and phones already on a call are
skipped. Most Yealink, Grandstream, Polycom and Snom phones answer pages by themselves.
</p>
+13 -19
View File
@@ -47,7 +47,7 @@ export function PhonesPage() {
Add phone
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
Add a desk phone by its MAC address (on a sticker under the phone) and it sets itself up:
account, voicemail and pickup keys.
</p>
@@ -58,11 +58,11 @@ export function PhonesPage() {
</Alert>
)}
{phones.isError && <Alert>{phones.error.message}</Alert>}
{phones.data?.length === 0 && <p className="text-sm text-zinc-500">No phones yet.</p>}
{phones.data?.length === 0 && <p className="text-sm text-muted">No phones yet.</p>}
{phones.data && phones.data.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<div className="overflow-x-auto rounded-xl border border-line bg-surface shadow-sm">
<table className="w-full text-left text-sm">
<thead className="text-zinc-500">
<thead className="text-muted">
<tr>
<th className="px-3 py-2 font-medium">MAC</th>
<th className="px-3 py-2 font-medium">Phone</th>
@@ -71,18 +71,18 @@ export function PhonesPage() {
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody className="divide-y divide-line">
{phones.data.map((d) => (
<tr key={d.id}>
<td className="px-3 py-2 font-mono">{fmtMac(d.mac)}</td>
<td className="px-3 py-2">
{VENDOR[d.vendor].replace(' (set up by hand)', '')} {d.model}
{d.label && <span className="text-zinc-500"> · {d.label}</span>}
{d.label && <span className="text-muted"> · {d.label}</span>}
</td>
<td className="px-3 py-2">
{d.extensionNumber} {d.extensionName}
</td>
<td className="px-3 py-2 text-zinc-500">
<td className="px-3 py-2 text-muted">
{d.lastProvisionedAt
? `${new Date(d.lastProvisionedAt).toLocaleString()} from ${d.lastProvisionedIp}`
: 'Never'}
@@ -248,7 +248,7 @@ function PhoneDrawer({
)}
</Field>
{form.vendor === 'grandstream' && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Grandstream line keys differ by model, so busy-lamp keys aren't set: add them on the
phone.
</p>
@@ -273,16 +273,13 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
});
const row = (k: string, v: string) => (
<div className="contents">
<dt className="text-zinc-500">{k}</dt>
<dt className="text-muted">{k}</dt>
<dd className="font-mono break-all">{v}</dd>
</div>
);
if (phone.vendor === 'generic')
return (
<section
aria-label="Manual settings"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<section aria-label="Manual settings" className="rounded-md bg-subtle p-3 text-sm">
<p className="mb-2">Enter these on the phone:</p>
{creds.data && (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
@@ -298,10 +295,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
</section>
);
return (
<section
aria-label="Set up the phone"
className="rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900"
>
<section aria-label="Set up the phone" className="rounded-md bg-subtle p-3 text-sm">
<p className="mb-2">
In the phone's web page (Settings → Auto Provision), or with DHCP option 66, set:
</p>
@@ -310,7 +304,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{row('Username', phone.mac)}
{row('Password', phone.provisioningPassword)}
</dl>
<p className="mt-2 text-zinc-500">
<p className="mt-2 text-muted">
Then reboot the phone.{' '}
{phone.lastProvisionedAt
? 'It last fetched its settings ' +
@@ -327,7 +321,7 @@ function SetupInfo({ phone }: { phone: DeviceDto }) {
{preview && (
<pre
aria-label="Config preview"
className="mt-2 max-h-80 overflow-auto rounded bg-white p-2 text-xs dark:bg-zinc-950"
className="mt-2 max-h-80 overflow-auto rounded bg-surface p-2 text-xs"
>
{preview}
</pre>
+21 -19
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
import { ArrowLeft, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useParams } from 'react-router';
@@ -59,13 +60,13 @@ function QueueList() {
</div>
{queues.isError && <Alert>{queues.error.message}</Alert>}
{queues.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No queues yet. In a queue (like 550 for Support) callers wait with music until one of the
agents is free.
</p>
)}
{queues.data && queues.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{queues.data.map((q) => {
const l = live[q.number];
return (
@@ -77,7 +78,7 @@ function QueueList() {
<Link className="font-medium underline-offset-2 hover:underline" to={q.id}>
{q.name}
</Link>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{q.members.length} agent{q.members.length === 1 ? '' : 's'}
{l ? ` · ${available(l)} available · ${l.waiting.length} waiting` : ''}
</span>
@@ -152,13 +153,14 @@ function QueueDetail({ id }: { id: string }) {
return (
<section className="flex flex-col gap-6">
<div>
<Link className="text-sm text-zinc-500 hover:underline" to="..">
← Queues
<Link className="inline-flex items-center gap-1 text-sm text-muted hover:underline" to="..">
<ArrowLeft className="size-4" />
Queues
</Link>
<h1 className="text-xl font-semibold">
<span className="font-mono">{q.number}</span> {q.name}
</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{STRATEGY_LABEL[q.strategy]} · waits up to {q.maxWaitSeconds} s, then {q.timeoutSummary}
</p>
</div>
@@ -166,18 +168,18 @@ function QueueDetail({ id }: { id: string }) {
<div>
<h2 className="mb-2 font-medium">Waiting</h2>
{!live?.waiting.length ? (
<p className="text-sm text-zinc-500">Nobody is waiting.</p>
<p className="text-sm text-muted">Nobody is waiting.</p>
) : (
<ol
aria-label="Waiting callers"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{live.waiting.map((w, i) => (
<li key={w.linkedid} className="flex items-center gap-3 px-3 py-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">
{w.callerName ? `${w.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(w.callerNumber)}</span>
<span className="text-muted">{fmtNumber(w.callerNumber)}</span>
</span>
<span className="font-mono tabular-nums">{since(w.since, now)}</span>
</li>
@@ -190,11 +192,11 @@ function QueueDetail({ id }: { id: string }) {
<h2 className="mb-2 font-medium">Agents</h2>
{toggle.error && <Alert>{toggle.error.message}</Alert>}
{q.members.length === 0 ? (
<p className="text-sm text-zinc-500">No agents yet. Add some with Edit.</p>
<p className="text-sm text-muted">No agents yet. Add some with Edit.</p>
) : (
<ul
aria-label="Agents"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{q.members.map((m) => {
const e = exts.data?.find((x) => x.id === m.extensionId);
@@ -207,7 +209,7 @@ function QueueDetail({ id }: { id: string }) {
>
<span className="w-12 font-mono">{e?.number}</span>
<span className="min-w-0 flex-1 truncate">{e?.name}</span>
<span className="text-zinc-500">
<span className="text-muted">
{paused ? 'Paused' : STATE_LABEL[l?.state ?? 'unavailable']}
{l ? ` · ${l.callsTaken} call${l.callsTaken === 1 ? '' : 's'}` : ''}
</span>
@@ -222,7 +224,7 @@ function QueueDetail({ id }: { id: string }) {
})}
</ul>
)}
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
Agents can also log in and out from a phone with *45{q.number}.
</p>
</div>
@@ -246,8 +248,8 @@ function QueueDetail({ id }: { id: string }) {
],
] as const
).map(([k, v]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold">{v ?? '…'}</dd>
</div>
))}
@@ -418,7 +420,7 @@ function QueueForm({
{members.map((m) => (
<li key={m.extensionId} className="flex items-center gap-2 text-sm">
<span className="flex-1">{label(m.extensionId)}</span>
<label className="flex items-center gap-1 text-zinc-500">
<label className="flex items-center gap-1 text-muted">
Priority
<select
aria-label={`Priority of ${label(m.extensionId)}`}
@@ -449,7 +451,7 @@ function QueueForm({
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
}
>
✕
<X className="size-4" />
</Button>
</li>
))}
@@ -472,7 +474,7 @@ function QueueForm({
))}
</select>
)}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Priority 0 gets calls first; higher numbers only when the lower ones are all busy.
</p>
</fieldset>
+11 -14
View File
@@ -15,19 +15,19 @@ const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).
function Transcript({ id }: { id: string }) {
const t = useQuery({ queryKey: ['transcript', id], queryFn: () => recordingsApi.transcript(id) });
if (t.error) return <Alert>{t.error.message}</Alert>;
if (!t.data) return <p className="text-zinc-500">Loading…</p>;
if (!t.data) return <p className="text-muted">Loading…</p>;
return (
<ol aria-label="Transcript" className="mt-1 flex flex-col gap-1">
{t.data.lines.map((l, i) => (
<li key={i} className="flex gap-2">
<span className="w-10 shrink-0 text-zinc-500 tabular-nums">{mmss(l.at)}</span>
<span className="w-10 shrink-0 text-muted tabular-nums">{mmss(l.at)}</span>
<span className="w-20 shrink-0 font-medium">
{l.side === 'caller' ? 'Caller' : 'Other side'}
</span>
<span>{l.text}</span>
</li>
))}
{!t.data.lines.length && <li className="text-zinc-500">No words.</li>}
{!t.data.lines.length && <li className="text-muted">No words.</li>}
</ol>
);
}
@@ -55,7 +55,7 @@ export function RecordingsPage() {
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Recordings</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Calls are recorded when a user's record mode or a phone number says so. How long they're
kept is under Settings → Recording. With call transcripts on (Settings → AI), each call is
written out a minute or two after it ends.
@@ -90,21 +90,21 @@ export function RecordingsPage() {
</form>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data && items.length === 0 && (
<p className="text-sm text-zinc-500">{q ? 'No call mentions that.' : 'No recordings.'}</p>
<p className="text-sm text-muted">{q ? 'No call mentions that.' : 'No recordings.'}</p>
)}
<ul
aria-label="Recordings"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{items.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2 text-sm">
<span className="w-36 text-zinc-500">{new Date(r.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(r.startedAt).toLocaleString()}</span>
<span className="min-w-0 flex-1">
{r.call
? `${r.call.fromName ?? fmtNumber(r.call.fromNumber)} → ${fmtNumber(r.call.toNumber)}`
: 'Call not found'}
{r.durationMs !== null && (
<span className="text-zinc-500">
<span className="text-muted">
{' '}
· {fmtDuration(Math.round(r.durationMs / 1000))}
</span>
@@ -119,7 +119,7 @@ export function RecordingsPage() {
/>
<a
href={recordingsApi.audioUrl(r.id, true)}
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Download
</a>
@@ -127,15 +127,12 @@ export function RecordingsPage() {
Delete
</Button>
{r.summary && (
<p
aria-label="Summary"
className="w-full whitespace-pre-line text-zinc-700 dark:text-zinc-300"
>
<p aria-label="Summary" className="w-full whitespace-pre-line text-fg">
{r.summary}
</p>
)}
{r.aiError && (
<p className="w-full text-xs text-zinc-500">
<p className="w-full text-xs text-muted">
{r.hasTranscript ? '' : 'No transcript: '}
{r.aiError}
</p>
+14 -14
View File
@@ -45,14 +45,14 @@ function Bars({
{label}
{segments.length > 1 &&
segments.map(([k, text, color]) => (
<span key={k} className="flex items-center gap-1 text-xs font-normal text-zinc-500">
<span key={k} className="flex items-center gap-1 text-xs font-normal text-muted">
<span className={`inline-block size-2.5 rounded-sm ${color}`} />
{text}
</span>
))}
<span className="text-xs font-normal text-zinc-500">most in a day: {max}</span>
<span className="text-xs font-normal text-muted">most in a day: {max}</span>
</figcaption>
<div className="flex h-40 items-end gap-px border-b border-zinc-300 dark:border-zinc-700">
<div className="flex h-40 items-end gap-px border-b border-line-strong">
{days.map((d) => (
<div
key={d.date}
@@ -67,7 +67,7 @@ function Bars({
</div>
))}
</div>
<div className="flex justify-between text-xs text-zinc-500">
<div className="flex justify-between text-xs text-muted">
<span>{shortDate(days[0]!.date)}</span>
<span>{shortDate(days.at(-1)!.date)}</span>
</div>
@@ -84,7 +84,7 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tr>
<th />
{hours[0]!.map((_, h) => (
<th key={h} scope="col" className="w-6 font-normal text-zinc-500">
<th key={h} scope="col" className="w-6 font-normal text-muted">
{h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
</th>
))}
@@ -93,14 +93,14 @@ function Heatmap({ hours }: { hours: number[][] }) {
<tbody>
{hours.map((row, d) => (
<tr key={d}>
<th scope="row" className="pr-1 text-left font-normal text-zinc-500">
<th scope="row" className="pr-1 text-left font-normal text-muted">
{WEEKDAYS[d]}
</th>
{row.map((n, h) => (
<td
key={h}
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`}
className="h-6 w-6 rounded-sm bg-zinc-100 dark:bg-zinc-800"
className="h-6 w-6 rounded-sm bg-subtle"
style={
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
}
@@ -160,7 +160,7 @@ export function ReportsPage() {
Show
</Button>
{s && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{shortDate(s.from)} – {shortDate(s.to)}
{!range.from && !range.to && ' (last 30 days)'}
</p>
@@ -197,10 +197,10 @@ export function ReportsPage() {
),
] as const
).map(([k, v, note]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<div key={k} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<dt className="text-muted">{k}</dt>
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
{note && <dd className="text-xs text-zinc-500">{note}</dd>}
{note && <dd className="text-xs text-muted">{note}</dd>}
</div>
))}
</dl>
@@ -225,11 +225,11 @@ export function ReportsPage() {
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>}
{queues.data && !queues.data.length && <p className="text-sm text-muted">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<thead className="text-muted">
<tr>
{[
'Queue',
@@ -246,7 +246,7 @@ export function ReportsPage() {
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody className="divide-y divide-line">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
+9 -8
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
// a time, then goes somewhere else when nobody answers.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -40,13 +41,13 @@ export function RingGroupsPage() {
</div>
{groups.isError && <Alert>{groups.error.message}</Alert>}
{groups.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No ring groups yet. A ring group is one number (like 500 for Sales) that rings several
people.
</p>
)}
{groups.data && groups.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{groups.data.map((g) => (
<li
key={g.id}
@@ -54,7 +55,7 @@ export function RingGroupsPage() {
>
<span className="font-mono">{g.number}</span>
<span className="font-medium">{g.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{g.strategy === 'ringall' ? 'Everyone at once' : 'One at a time'}:{' '}
{g.memberIds.map(nameOf).join(', ') || 'nobody yet'} · then {g.noAnswerSummary}
</span>
@@ -238,7 +239,7 @@ function RingGroupForm({
<ol className="flex flex-col gap-1">
{members.map((m, i) => (
<li key={m} className="flex items-center gap-2 text-sm">
<span className="w-5 text-zinc-500">{i + 1}.</span>
<span className="w-5 text-muted">{i + 1}.</span>
<span className="flex-1">{label(m)}</span>
<Button
className="px-2 py-1"
@@ -246,7 +247,7 @@ function RingGroupForm({
disabled={i === 0}
onClick={() => move(i, -1)}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
className="px-2 py-1"
@@ -254,7 +255,7 @@ function RingGroupForm({
disabled={i === members.length - 1}
onClick={() => move(i, 1)}
>
↓
<ArrowDown className="size-4" />
</Button>
<Button
variant="ghost"
@@ -262,7 +263,7 @@ function RingGroupForm({
aria-label={`Remove ${label(m)}`}
onClick={() => setMembers((ms) => ms.filter((x) => x !== m))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
@@ -282,7 +283,7 @@ function RingGroupForm({
))}
</select>
)}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Members' call forwarding and voicemail don't apply here; Do not disturb does.
</p>
</fieldset>
+51 -54
View File
@@ -52,16 +52,13 @@ export function SettingsPage() {
return (
<section className="flex max-w-2xl flex-col gap-4">
<h1 className="text-xl font-semibold">Settings</h1>
<nav
aria-label="Settings"
className="flex flex-wrap gap-1 border-b border-zinc-200 dark:border-zinc-800"
>
<nav aria-label="Settings" className="flex flex-wrap gap-1 border-b border-line">
{TABS.map((t) => (
<NavLink
key={t.id}
to={`/admin/settings/${t.id}`}
className={({ isActive }) =>
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`
`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium ${isActive || (t.id === 'email' && tab === 'email') ? 'border-primary' : 'border-transparent hover:border-line-strong'}`
}
>
{t.label}
@@ -128,7 +125,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
});
}}
>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
The mail server used for emergency alerts and voicemail emails. Leave the server empty to
turn email off.
</p>
@@ -209,7 +206,7 @@ export function EmailTab({ s }: { s: SettingsDto }) {
</div>
{s.smtp && (
<div className="flex flex-col gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex flex-col gap-2 border-t border-line pt-4">
<div className="flex items-end gap-2">
<Field label="Send a test email to">
{(a) => (
@@ -317,7 +314,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
</Field>
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-sm font-medium">Ring these phones</legend>
{exts.data?.length === 0 && <p className="text-sm text-zinc-500">No extensions yet.</p>}
{exts.data?.length === 0 && <p className="text-sm text-muted">No extensions yet.</p>}
{exts.data?.map((x) => (
<label key={x.id} className="flex items-center gap-2 text-sm">
<input
@@ -340,7 +337,7 @@ export function EmergencyTab({ s }: { s: SettingsDto }) {
{test.isPending ? 'Testing…' : 'Test notifications'}
</Button>
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
The test sends the email and rings the phones above with &quot;TEST&quot; in front. It never
dials 911. Never test by calling 911.
</p>
@@ -398,7 +395,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
onChange={setNotice}
noneLabel="No notice"
/>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Played to callers of phone numbers set to record (Phone numbers → Record every call).
</p>
<Field label="Keep recordings for (days)" hint="0 keeps them forever">
@@ -427,7 +424,7 @@ function RecordingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -462,7 +459,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
save.mutate(Object.fromEntries(RANGES.map(([k]) => [k, v[k].map(Number)])));
}}
>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
Where <strong>Next free</strong> looks when you add a user, ring group, queue, digital
receptionist, conference room or paging group. You can always type any other free number of
1 to 6 digits instead, for example <strong>1</strong> for the first user: SelfPhoneNG checks
@@ -471,8 +468,8 @@ function NumberingTab({ s }: { s: SettingsDto }) {
</p>
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-2 text-sm">
<span />
<span className="text-zinc-500">From</span>
<span className="text-zinc-500">To</span>
<span className="text-muted">From</span>
<span className="text-muted">To</span>
{RANGES.map(([k, label]) => (
<Fragment key={k}>
<span>{label}</span>
@@ -495,7 +492,7 @@ function NumberingTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -539,7 +536,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={watch} onChange={(e) => setWatch(e.target.checked)} />
Re-check the public IP every 5 minutes (dynamic IP)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
When it changes, the new IP is saved and admins get an email asking for a telephony restart.
Asks api.ipify.org (or ifconfig.me) for the address.
</p>
@@ -562,7 +559,7 @@ function NetworkTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
</form>
@@ -638,7 +635,7 @@ function AiTab({ s }: { s: SettingsDto }) {
});
}}
>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
AI runs on this server or your local network only: an address must be localhost or a local
IP (127.x, 10.x, 172.16–31.x, 192.168.x). Calls and text never go anywhere else. Leave an
address empty to turn that part off.
@@ -648,7 +645,7 @@ function AiTab({ s }: { s: SettingsDto }) {
return (
<fieldset key={kind} className="flex flex-col gap-2">
<legend className="mb-1 font-medium">{title}</legend>
<p className="text-xs text-zinc-500">{hint}</p>
<p className="text-xs text-muted">{hint}</p>
<div className="flex flex-wrap gap-2">
<input
aria-label={`${title} address`}
@@ -700,7 +697,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={vmText} onChange={(e) => setVmText(e.target.checked)} />
Voicemail transcripts (speech to text)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
New messages are written out before the voicemail email goes, and the text is in the email
and on the voicemail page.
</p>
@@ -708,7 +705,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<input type="checkbox" checked={callText} onChange={(e) => setCallText(e.target.checked)} />
Call transcripts (speech to text)
</label>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Recorded calls are written out a minute or two after they end, the caller and the other side
apart, and can be searched on the Recordings page. Only calls recorded while this is on.
</p>
@@ -725,7 +722,7 @@ function AiTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -773,7 +770,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
>
<h2 className="font-medium">Desk phones</h2>
{box(restrict, setRestrict, 'Only accept desk phones from the networks below')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Local networks: {s['network.localNets'].join(', ')}. Phones anywhere else (at home, on a
mobile network) can't sign in while this is on. Browser phones still work from anywhere:
they come through this website.
@@ -792,7 +789,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
<h2 className="font-medium">Outside calls at once</h2>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Limits the damage if someone steals a phone's password. Forwarded calls count for the person
who set the forward. 911 is never limited. Callers over the limit hear "all circuits are
busy". 0 = no limit.
@@ -842,7 +839,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
{box(tcpOn, setTcpOn, 'Accept SIP over TCP')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
For phones and trunks set to TCP. Phones set up by this server use UDP.
</p>
<h2 className="font-medium">Encrypted SIP (TLS)</h2>
@@ -852,7 +849,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
: (cert.data?.error ?? '…')}
</p>
{box(tlsOn, setTlsOn, 'Accept SIP over TLS')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Needed for an encrypted voip.ms trunk. Turning it on or changing the port needs a telephony
restart. After renewing the certificate, run scripts/tls-cert.sh.
</p>
@@ -871,7 +868,7 @@ function SecurityTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -938,7 +935,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
)}
</Field>
{box(recs, setRecs, 'Include call recordings (they can be large)')}
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Backups hold the database, voicemail, sounds and music on hold, in data/backups on the
server. Copy them somewhere else too. Passwords inside are encrypted with APP_SECRET from
.env: keep a copy of .env, or a backup can't be restored. To restore, run
@@ -948,7 +945,7 @@ function BackupsTab({ s }: { s: SettingsDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -960,16 +957,16 @@ function BackupsTab({ s }: { s: SettingsDto }) {
</Button>
</div>
{now.error && <Alert>{now.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No backups yet.</p>}
<ul aria-label="Backups" className="divide-y divide-zinc-200 dark:divide-zinc-800">
{list.data?.length === 0 && <p className="text-sm text-muted">No backups yet.</p>}
<ul aria-label="Backups" className="divide-y divide-line">
{list.data?.map((b) => (
<li key={b.name} className="flex items-center justify-between gap-3 py-2 text-sm">
<span>
{new Date(b.createdAt).toLocaleString()}{' '}
<span className="text-zinc-500">({(b.size / 1048576).toFixed(1)} MB)</span>
<span className="text-muted">({(b.size / 1048576).toFixed(1)} MB)</span>
</span>
<a
className="text-sky-700 hover:underline dark:text-sky-400"
className="text-link hover:underline"
href={`/api/system/backups/${b.name}/download`}
download
>
@@ -1054,12 +1051,12 @@ function WebhooksTab({ s }: { s: SettingsDto }) {
/>
Allow addresses on private networks (10.x, 192.168.x, localhost …)
</label>
<p className="-mt-4 text-xs text-zinc-500">
<p className="-mt-4 text-xs text-muted">
Off by default, so a webhook can't reach services inside your network. Turn it on for a
receiver in your own network (home lab).
</p>
{save.error && <Alert>{save.error.message}</Alert>}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Each POST has the headers X-SelfPhoneNG-Event, X-SelfPhoneNG-Timestamp and
X-SelfPhoneNG-Signature: sha256= the hex HMAC-SHA256 of timestamp + &quot;.&quot; + body
with the webhook&apos;s secret. A failed POST is tried again after 1 and 5 minutes, half an
@@ -1084,12 +1081,12 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
});
const remove = useMutation({ mutationFn: () => webhooksApi.remove(w.id), onSuccess: onChange });
return (
<li className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<li className="flex flex-col gap-2 rounded-md border border-line p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium break-all">{w.url}</span>
<span className="text-zinc-500">{w.enabled ? 'On' : 'Off'}</span>
<span className="text-muted">{w.enabled ? 'On' : 'Off'}</span>
</div>
<p className="text-zinc-500">{w.events.join(', ')}</p>
<p className="text-muted">{w.events.join(', ')}</p>
<p>
Secret: <code className="break-all">{w.secret}</code>
</p>
@@ -1113,7 +1110,7 @@ function WebhookItem({ w, onChange }: { w: WebhookDto; onChange: () => void }) {
</p>
)}
{showLog && (
<ul aria-label="Deliveries" className="text-xs text-zinc-600 dark:text-zinc-400">
<ul aria-label="Deliveries" className="text-xs text-muted">
{log.data?.length === 0 && <li>Nothing sent yet.</li>}
{log.data?.map((d) => (
<li key={d.id}>
@@ -1195,19 +1192,19 @@ function TokensTab() {
</div>
)}
</form>
<p className="-mt-2 text-xs text-zinc-500">
<p className="-mt-2 text-xs text-muted">
Send it as the header Authorization: Bearer &lt;token&gt;. A token can do everything its
admin can, except manage tokens. It stops working when revoked or when the admin is
disabled.
</p>
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-zinc-200 dark:divide-zinc-800">
{list.data?.length === 0 && <p className="text-sm text-muted">No tokens.</p>}
<ul aria-label="API tokens" className="divide-y divide-line">
{list.data?.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<span>
<span className="font-medium">{t.name}</span>{' '}
<code className="text-zinc-500">{t.tokenPrefix}…</code>
<span className="block text-xs text-zinc-500">
<code className="text-muted">{t.tokenPrefix}…</code>
<span className="block text-xs text-muted">
By {t.createdBy}, last used{' '}
{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}
</span>
@@ -1353,7 +1350,7 @@ export function ContactsTab({ s }: { s: SettingsDto }) {
</span>
)}
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Each person then connects their own Contrack account in My settings, Contacts. If the AI
inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
it off in Contrack, or point it at your local AI.
@@ -1409,7 +1406,7 @@ function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) {
});
return (
<div className="flex flex-col gap-1 text-sm">
<p className="text-zinc-500">
<p className="text-muted">
{st.ftcCursor
? `Downloading: at the complaints of ${st.ftcCursor.day}.`
: st.ftcLastSyncAt
@@ -1511,7 +1508,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
else submit();
}}
>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
When an outside caller from the US isn&rsquo;t in anyone&rsquo;s contacts, look the number
up and show what&rsquo;s known about them while it rings. Information only: calls are never
blocked or sent elsewhere. IPQualityScore answers first when it&rsquo;s set up; the FTC list
@@ -1588,7 +1585,7 @@ function SpamTab({ s }: { s: SettingsDto }) {
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Desk phones</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Shown in front of the caller&rsquo;s name when the answer comes in before the phone rings.
Letters, digits, spaces and . &apos; &amp; @ - only.
</p>
@@ -1742,16 +1739,16 @@ function SignInTab({ s }: { s: SettingsDto }) {
</fieldset>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">OpenID Connect</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Keycloak, Authentik, Authelia, Zitadel, Entra ID, Google and others. At the provider, make
a confidential client using the standard (authorization code) flow, and add these:
</p>
<dl className="flex flex-col gap-2 text-sm">
{uris.map(([label, uri]) => (
<div key={label} className="flex flex-wrap items-center gap-2">
<dt className="w-48 text-zinc-500">{label}</dt>
<dt className="w-48 text-muted">{label}</dt>
<dd className="flex items-center gap-2">
<code className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-800">{uri}</code>
<code className="rounded bg-subtle px-2 py-1">{uri}</code>
<Button onClick={() => void navigator.clipboard?.writeText(uri)}>Copy</Button>
</dd>
</div>
@@ -1834,7 +1831,7 @@ function SignInTab({ s }: { s: SettingsDto }) {
)}
{test.error && <Alert>{test.error.message}</Alert>}
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Then connect your own account in My account, and sign in from a private window.
</p>
</fieldset>
@@ -1910,12 +1907,12 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
>
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 font-medium">Directory (LDAP)</legend>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
When a password isn&apos;t the one set here, SelfPhoneNG asks the directory. People sign
in with their directory username or email; the first time, they&apos;re matched to their
user here by email. Nobody gets a user just for being in the directory.
</p>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Every hour, someone no longer in the directory (or no longer matched by the user filter)
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
that on Users.
+17 -16
View File
@@ -2,6 +2,7 @@
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
// files (03 §8) and announcements live here too.
import { ArrowDown, ArrowUp } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -68,7 +69,7 @@ export function SoundsPage() {
<h1 className="text-xl font-semibold">Sounds &amp; music</h1>
<form
aria-label="Upload a sound"
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-sky-500 bg-sky-50 dark:bg-sky-950' : 'border-zinc-300 dark:border-zinc-700'}`}
className={`flex flex-col gap-3 rounded-lg border-2 border-dashed p-4 ${dragging ? 'border-primary bg-primary-soft' : 'border-line-strong'}`}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
@@ -122,19 +123,19 @@ export function SoundsPage() {
{add.isPending ? 'Converting…' : 'Upload'}
</Button>
</div>
{file && <p className="text-sm text-zinc-500">Selected: {file.name}</p>}
{file && <p className="text-sm text-muted">Selected: {file.name}</p>}
{tooBig && <Alert>That file is larger than 20 MB.</Alert>}
{add.error && <Alert>{add.error.message}</Alert>}
</form>
{sounds.isError && <Alert>{sounds.error.message}</Alert>}
{sounds.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No sounds yet. Upload greetings for your digital receptionist and announcements.
</p>
)}
{sounds.data && sounds.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{sounds.data.map((s) => (
<li
key={s.id}
@@ -142,7 +143,7 @@ export function SoundsPage() {
>
<span className="min-w-0 flex-1">
<span className="font-medium">{s.name}</span>{' '}
<span className="text-zinc-500">{secs(s.durationMs)}</span>
<span className="text-muted">{secs(s.durationMs)}</span>
</span>
<audio
controls
@@ -210,7 +211,7 @@ function MusicOnHold() {
<h2 id="moh-h" className="text-lg font-semibold">
Music on hold
</h2>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
What callers hear on hold or waiting in a queue. Default music is built in. Make your own
set here, then choose it for a user (Phone tab) or a queue.
</p>
@@ -297,7 +298,7 @@ function MohClassCard({
<div
aria-label={`Music on hold ${cls.name}`}
role="group"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 font-medium">{cls.name}</span>
@@ -306,7 +307,7 @@ function MohClassCard({
</Button>
</div>
{cls.files.length === 0 && (
<p className="text-sm text-zinc-500">No music yet: callers hear the default music.</p>
<p className="text-sm text-muted">No music yet: callers hear the default music.</p>
)}
{cls.files.length > 1 && (
<label className="flex items-center gap-2 text-sm">
@@ -317,7 +318,7 @@ function MohClassCard({
onChange={(e) => update.mutate({ shuffle: e.target.checked })}
/>
Shuffle
<span className="text-zinc-500">
<span className="text-muted">
{cls.shuffle
? '(plays in random order)'
: '(each caller on hold starts at the top, in this order)'}
@@ -329,7 +330,7 @@ function MohClassCard({
<li key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{cls.files.length > 1 && !cls.shuffle && (
<span className="flex items-center gap-0.5">
<span className="w-5 text-right text-zinc-500 tabular-nums">{i + 1}.</span>
<span className="w-5 text-right text-muted tabular-nums">{i + 1}.</span>
<Button
variant="ghost"
aria-label={`Move ${f.name} up`}
@@ -337,7 +338,7 @@ function MohClassCard({
disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
@@ -346,12 +347,12 @@ function MohClassCard({
disabled={i === cls.files.length - 1 || update.isPending}
onClick={() => move(i, 1)}
>
↓
<ArrowDown className="size-4" />
</Button>
</span>
)}
<span className="min-w-0 flex-1">
{f.name} <span className="text-zinc-500">{secs(f.durationMs)}</span>
{f.name} <span className="text-muted">{secs(f.durationMs)}</span>
</span>
<audio
controls
@@ -425,19 +426,19 @@ function Announcements() {
</h2>
<Button onClick={() => setEditing('new')}>Add announcement</Button>
</div>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
An announcement plays a sound ("Our hours are…") and then sends the caller on, e.g. back to
a menu. Use them from phone numbers, menus and office hours.
</p>
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((a) => (
<li
key={a.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="font-medium">{a.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
plays "{a.soundName}", then {a.destinationSummary}
</span>
<Button onClick={() => setEditing(a)}>Edit</Button>
+25 -28
View File
@@ -33,12 +33,9 @@ function Panel({
children: ReactNode;
}) {
return (
<section
aria-label={title}
className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900/60"
>
<header className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<span className="text-sky-600 dark:text-sky-400">{icon(path)}</span>
<section aria-label={title} className="rounded-xl border border-line bg-surface shadow-sm">
<header className="flex items-center gap-2 border-b border-line px-4 py-3">
<span className="text-link">{icon(path)}</span>
<h2 className="flex-1 font-medium">{title}</h2>
{action}
</header>
@@ -54,7 +51,7 @@ function Copy({ text, label }: { text: string; label: string }) {
type="button"
aria-label={`Copy ${label}`}
title={done ? 'Copied' : `Copy ${label}`}
className="rounded p-0.5 text-sky-600 hover:bg-zinc-100 dark:text-sky-400 dark:hover:bg-zinc-800"
className="rounded p-0.5 text-link hover:bg-subtle"
onClick={() =>
void navigator.clipboard.writeText(text).then(() => {
setDone(true);
@@ -70,8 +67,8 @@ function Copy({ text, label }: { text: string; label: string }) {
/** One "label value" cell of the information grid. */
function Item({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-zinc-100 py-2 dark:border-zinc-800">
<dt className="text-zinc-500">{label}</dt>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-line py-2">
<dt className="text-muted">{label}</dt>
<dd className="flex items-center gap-1.5 font-semibold">{children}</dd>
</div>
);
@@ -87,7 +84,7 @@ const STATUS: Record<DiagnosticCheck['status'], [string, string]> = {
function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] }) {
return (
<ul aria-label={label} className="divide-y divide-zinc-100 dark:divide-zinc-800">
<ul aria-label={label} className="divide-y divide-line">
{checks.map((c) => (
<li key={c.id} className="flex gap-3 px-4 py-2 text-sm">
<span
@@ -97,9 +94,9 @@ function Checks({ label, checks }: { label: string; checks: DiagnosticCheck[] })
<div className="min-w-0 flex-1">
<p>
<span className="font-medium">{c.label}</span>
<span className="text-zinc-500"> · {STATUS[c.status][0]}</span>
<span className="text-muted"> · {STATUS[c.status][0]}</span>
</p>
<p className="break-words text-zinc-600 dark:text-zinc-400">{c.detail}</p>
<p className="break-words text-muted">{c.detail}</p>
{c.fix && (
<p className="mt-1 rounded-md bg-amber-50 px-2 py-1 break-words text-amber-900 dark:bg-amber-950/50 dark:text-amber-100">
<strong>How to fix:</strong> {c.fix}
@@ -128,12 +125,12 @@ function Diagnostics() {
}
>
<h3 className="px-4 pt-3 text-sm font-medium">This server</h3>
{inside.isPending && <p className="px-4 py-2 text-sm text-zinc-500">Checking…</p>}
{inside.isPending && <p className="px-4 py-2 text-sm text-muted">Checking…</p>}
{inside.error && <Alert>{inside.error.message}</Alert>}
{inside.data && <Checks label="Server checks" checks={inside.data.checks} />}
<div className="flex flex-col gap-2 border-t border-zinc-200 px-4 py-3 text-sm dark:border-zinc-800">
<div className="flex flex-col gap-2 border-t border-line px-4 py-3 text-sm">
<h3 className="font-medium">From the internet</h3>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
The network test probe connects back to this server from outside, the way a phone on
mobile data or your SIP provider does. Quick: ports 80, 443 and SIP. Complete: also SIP
over TLS, the browser phone and the call audio ports.
@@ -151,16 +148,16 @@ function Diagnostics() {
</Button>
</div>
{inside.data && !hasProbe && (
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Set a probe first:{' '}
<Link to="/admin/settings/network" className="text-sky-700 underline dark:text-sky-400">
<Link to="/admin/settings/network" className="text-link underline">
Settings → Network
</Link>
.
</p>
)}
{test.isPending && (
<p role="status" className="text-zinc-500">
<p role="status" className="text-muted">
Testing… (up to half a minute)
</p>
)}
@@ -203,7 +200,7 @@ export function SystemPage() {
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">System</h1>
<Link to="/about" className="text-sm text-sky-700 hover:underline dark:text-sky-400">
<Link to="/about" className="text-sm text-link hover:underline">
About and source code
</Link>
</div>
@@ -222,7 +219,7 @@ export function SystemPage() {
<Item label="Company">{s.companyName}</Item>
<Item label="Public IP">
{s.publicIp ?? 'not set'}
<span className="font-normal text-zinc-500">
<span className="font-normal text-muted">
{s.publicIpDynamic ? 'dynamic, re-checked' : 'static'}
</span>
{s.publicIp && <Copy text={s.publicIp} label="public IP" />}
@@ -231,7 +228,7 @@ export function SystemPage() {
<Item label="Ports">
<span>
SIP {p.sip}
<span className="font-normal text-zinc-500">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
<span className="font-normal text-muted">{p.sipTcp ? ' UDP+TCP' : ' UDP'}</span>
{p.sips && ` · SIPS ${p.sips}`} · Media {p.rtp} · HTTPS {p.https}
</span>
</Item>
@@ -266,13 +263,13 @@ export function SystemPage() {
<div className="flex items-center gap-2">
<pre
aria-label="Restart command"
className="flex-1 overflow-x-auto rounded-md bg-zinc-100 px-3 py-2 font-mono dark:bg-zinc-950"
className="flex-1 overflow-x-auto rounded-md bg-subtle px-3 py-2 font-mono"
>
{RESTART}
</pre>
<Copy text={RESTART} label="restart command" />
</div>
<p className="text-zinc-500">
<p className="text-muted">
Calls in progress drop, so do it when nobody is on the phone. Phones and trunks sign in
again by themselves within a minute; the web app, voicemail and history aren&apos;t
affected.
@@ -288,7 +285,7 @@ export function SystemPage() {
action={
<a
href="/api/system/events.csv"
className="flex items-center gap-1.5 rounded-md border border-zinc-300 px-2.5 py-1 text-sm hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="flex items-center gap-1.5 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-sm hover:bg-subtle"
>
{icon(DOWNLOAD)} Download
</a>
@@ -296,12 +293,12 @@ export function SystemPage() {
>
{events.error && <Alert>{events.error.message}</Alert>}
{events.data && !rows.length && (
<p className="px-4 py-3 text-sm text-zinc-500">Nothing logged yet.</p>
<p className="px-4 py-3 text-sm text-muted">Nothing logged yet.</p>
)}
{!!rows.length && (
<div className="overflow-x-auto">
<table aria-label="Events" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<thead className="text-muted">
<tr>
{['Severity', 'Event', 'Time', 'Source', 'Details'].map((h) => (
<th key={h} scope="col" className="px-4 py-2 font-medium whitespace-nowrap">
@@ -310,7 +307,7 @@ export function SystemPage() {
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
<tbody className="divide-y divide-line">
{rows.map((e) => (
<tr key={e.id}>
<td className="px-4 py-1.5 whitespace-nowrap">
@@ -332,7 +329,7 @@ export function SystemPage() {
</div>
)}
{events.hasNextPage && (
<div className="border-t border-zinc-100 py-2 text-center dark:border-zinc-800">
<div className="border-t border-line py-2 text-center">
<Button
onClick={() => void events.fetchNextPage()}
disabled={events.isFetchingNextPage}
+15 -16
View File
@@ -20,8 +20,7 @@ import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { useLive } from '../../live/store.ts';
export function trunkStatus(t: Pick<TrunkDto, 'enabled' | 'authMode'>, live?: TrunkLive) {
if (!t.enabled)
return { label: 'Off', color: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' };
if (!t.enabled) return { label: 'Off', color: 'bg-line text-fg' };
const ok = 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200';
const bad = 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200';
const wait = 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200';
@@ -65,22 +64,22 @@ export function TrunksPage() {
New trunk
</Button>
</div>
<p className="max-w-prose text-sm text-zinc-500">
<p className="max-w-prose text-sm text-muted">
A trunk connects the phone system to your phone company (voip.ms or any SIP provider), so
calls can go to and come from the outside world.
</p>
{trunks.isError && <Alert>{trunks.error.message}</Alert>}
{trunks.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{trunks.isPending && <p className="text-sm text-muted">Loading…</p>}
{trunks.data?.length === 0 && (
<p className="text-sm text-zinc-500">No trunks yet. Add your voip.ms sub-account.</p>
<p className="text-sm text-muted">No trunks yet. Add your voip.ms sub-account.</p>
)}
{trunks.data && trunks.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{trunks.data.map((t) => (
<li key={t.id} className="flex flex-wrap items-center gap-3 px-3 py-2 text-sm">
<StatusPill t={t} />
<span className="font-medium">{t.name}</span>
<span className="flex-1 truncate text-zinc-500">
<span className="flex-1 truncate text-muted">
{t.username ? `${t.username}@` : ''}
{t.serverHost}
</span>
@@ -404,7 +403,7 @@ export function TrunkForm({
Enabled
</label>
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<details className="rounded-md border border-line p-3 text-sm">
<summary className="cursor-pointer font-medium">Advanced</summary>
<div className="mt-3 flex flex-col gap-4">
<label className="flex items-center gap-2 text-sm">
@@ -434,10 +433,10 @@ export function TrunkForm({
</details>
{voipms && (
<details className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<details className="rounded-md border border-line p-3 text-sm">
<summary className="cursor-pointer font-medium">voip.ms API (optional)</summary>
<div className="mt-3 flex flex-col gap-4">
<p className="text-zinc-500">
<p className="text-muted">
Needed for SMS and importing your numbers. In the voip.ms portal: Main Menu → SOAP and
REST/JSON API. Enable it, set an API password, and allow this server's public IP
address.
@@ -506,9 +505,9 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
return (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 font-medium">Your voip.ms numbers</legend>
{list.isPending && <p className="text-zinc-500">Asking voip.ms…</p>}
{list.isPending && <p className="text-muted">Asking voip.ms…</p>}
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && <p className="text-zinc-500">voip.ms lists no numbers.</p>}
{list.data?.length === 0 && <p className="text-muted">voip.ms lists no numbers.</p>}
{list.data?.map((d) => (
<label key={d.number} className="flex items-center gap-2">
<input
@@ -522,8 +521,8 @@ export function ImportNumbers({ trunk }: { trunk: TrunkDto }) {
}
/>
{formatNumber(d.number)}
{d.description && <span className="text-zinc-500">{d.description}</span>}
{d.added && <span className="text-zinc-500">(added)</span>}
{d.description && <span className="text-muted">{d.description}</span>}
{d.added && <span className="text-muted">(added)</span>}
</label>
))}
{fresh.length > 0 && (
@@ -558,7 +557,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
const live = useLive((s) => s.trunks[trunk.id]);
const status = trunkStatus(trunk, live);
return (
<div className="flex flex-col gap-2 rounded-md bg-zinc-50 p-3 text-sm dark:bg-zinc-900">
<div className="flex flex-col gap-2 rounded-md bg-subtle p-3 text-sm">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
{status.label}
@@ -582,7 +581,7 @@ function TestPanel({ trunk }: { trunk: TrunkDto }) {
/** What the customer sets in the voip.ms portal (06 §1). */
function VoipmsChecklist() {
return (
<details className="rounded-md border border-sky-200 bg-sky-50 p-3 text-sm dark:border-sky-900 dark:bg-sky-950/40">
<details className="rounded-md border border-primary-line bg-primary-soft p-3 text-sm">
<summary className="cursor-pointer font-medium">Set up voip.ms first (checklist)</summary>
<ol className="mt-2 flex list-decimal flex-col gap-1.5 pl-5">
<li>
+11 -11
View File
@@ -82,7 +82,7 @@ export function UserDrawer({
<div
role="tablist"
aria-label="User settings"
className="mb-4 flex gap-1 border-b border-zinc-200 dark:border-zinc-800"
className="mb-4 flex gap-1 border-b border-line"
>
{tabs.map((t) => (
<button
@@ -93,7 +93,7 @@ export function UserDrawer({
disabled={!t.enabled}
title={t.enabled ? undefined : 'Give the user an extension number first'}
onClick={() => setTab(t.id)}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-zinc-400 ${tab === t.id ? 'border-sky-600' : 'border-transparent hover:border-zinc-300'}`}
className={`-mb-px border-b-2 px-3 py-1.5 text-sm font-medium disabled:cursor-not-allowed disabled:text-muted ${tab === t.id ? 'border-primary' : 'border-transparent hover:border-line-strong'}`}
>
{t.label}
</button>
@@ -243,9 +243,9 @@ function GeneralTab({
</label>
)}
{user && (
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button onClick={() => onResetPassword(user)}>Reset password</Button>
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
Creates a new temporary password and signs them out everywhere.
</p>
{user.twoStep && <ResetTwoStep user={user} />}
@@ -413,7 +413,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
};
const row = (label: string, value: ReactNode) => (
<div className="flex flex-col gap-0.5 sm:flex-row sm:gap-3">
<dt className="w-28 shrink-0 text-zinc-500">{label}</dt>
<dt className="w-28 shrink-0 text-muted">{label}</dt>
<dd className="font-mono break-all">{value}</dd>
</div>
);
@@ -421,7 +421,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
return (
<div className="flex flex-col gap-4 text-sm">
{error && <Alert>{error}</Alert>}
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Use these to set up a desk phone or softphone by hand. Viewing the password is recorded in
the audit log.
</p>
@@ -436,7 +436,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
{creds && row('Dial plan', creds.dialPlan)}
</dl>
{creds && (
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
The dial plan is for analog phone adapters (Grandstream HT80x and HT81x): numbers that
match go out at once, the rest after a pause or #.
</p>
@@ -446,7 +446,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
Reveal password
</Button>
)}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
{!confirming ? (
<Button onClick={() => setConfirming(true)}>New SIP password</Button>
) : (
@@ -462,7 +462,7 @@ function CredentialsTab({ ext }: { ext: ExtensionDto }) {
)}
{done && <p className="mt-2 text-green-700 dark:text-green-400">New password saved.</p>}
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<h3 className="mb-2 font-medium">Phone app</h3>
<PhoneAppSetup load={() => extensionsApi.softphoneQr(ext.id)} />
</div>
@@ -614,7 +614,7 @@ function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap'
<p>
<span className="font-medium">{label}</span>: {oidc.email ?? 'connected'}
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
</p>
</>
@@ -650,7 +650,7 @@ function ResetTwoStep({ user }: { user: UserDto }) {
Turn off two-step sign-in
</Button>
)}
<p className="mt-1 text-xs text-zinc-500">
<p className="mt-1 text-xs text-muted">
For a lost phone: they sign in with their password alone and can turn it on again.
</p>
{reset.error && <Alert>{reset.error.message}</Alert>}
+12 -14
View File
@@ -45,7 +45,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
</div>
{users.isError && <Alert>{users.error.message}</Alert>}
{users.isPending && <p className="text-sm text-zinc-500">Loading…</p>}
{users.isPending && <p className="text-sm text-muted">Loading…</p>}
{users.data && (
<UsersTable
users={users.data}
@@ -105,7 +105,7 @@ export function UsersPage({ currentUserId }: { currentUserId: string }) {
they must change it when they first sign in.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md bg-zinc-100 px-3 py-2 font-mono text-base dark:bg-zinc-800">
<code className="flex-1 rounded-md bg-subtle px-3 py-2 font-mono text-base">
{tempPassword?.password}
</code>
<Button
@@ -131,7 +131,7 @@ function UsersTable({
}) {
const live = useLive((s) => s.extensions);
const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return <p className="text-sm text-zinc-500">No users yet.</p>;
if (users.length === 0) return <p className="text-sm text-muted">No users yet.</p>;
const ext = (u: UserDto) =>
u.extension ? (
<span className="inline-flex items-center gap-1.5 font-mono">
@@ -144,13 +144,11 @@ function UsersTable({
const badge = (u: UserDto) => (
<span className="flex flex-wrap gap-1">
{u.role === 'admin' && (
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-950 dark:text-sky-200">
<span className="rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
admin
</span>
)}
{!u.isActive && (
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs dark:bg-zinc-700">inactive</span>
)}
{!u.isActive && <span className="rounded bg-line px-1.5 py-0.5 text-xs">inactive</span>}
</span>
);
// 12 §5: every way this user can sign in.
@@ -173,18 +171,18 @@ function UsersTable({
{/* Stacked cards on small screens (05 §1). */}
<ul className="flex flex-col gap-2 md:hidden">
{users.map((u) => (
<li key={u.id} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<li key={u.id} className="rounded-xl border border-line bg-surface shadow-sm p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">
{u.displayName}
{u.extension && <span className="ml-2 text-sm text-zinc-500">{ext(u)}</span>}
{u.extension && <span className="ml-2 text-sm text-muted">{ext(u)}</span>}
</p>
<p className="truncate text-sm text-zinc-500">{u.email}</p>
<p className="truncate text-sm text-muted">{u.email}</p>
</div>
{badge(u)}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-zinc-500">
<div className="mt-2 flex items-center justify-between text-xs text-muted">
<span>
Last login: {fmtDate(u.lastLoginAt)} · {ways(u)}
</span>
@@ -194,7 +192,7 @@ function UsersTable({
))}
</ul>
<table className="hidden w-full text-left text-sm md:table">
<thead className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<thead className="border-b border-line text-muted">
<tr>
<th className="py-2 font-medium">Ext.</th>
<th className="py-2 font-medium">Name</th>
@@ -209,13 +207,13 @@ function UsersTable({
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-100 dark:border-zinc-800/60">
<tr key={u.id} className="border-b border-line">
<td className="py-2">{ext(u)}</td>
<td className="py-2 font-medium">{u.displayName}</td>
<td className="py-2">{u.email}</td>
<td className="py-2">{badge(u)}</td>
<td className="py-2">{ways(u)}</td>
<td className="py-2 text-zinc-500">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-muted">{fmtDate(u.lastLoginAt)}</td>
<td className="py-2 text-right">{actions(u)}</td>
</tr>
))}
+19 -27
View File
@@ -2,6 +2,7 @@
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
// denies), click to call. useContactName() gives every page names for numbers.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
@@ -133,7 +134,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
href={ctk.publicUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="inline-flex items-center rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Import in Contrack
</a>
@@ -146,7 +147,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
</div>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -161,11 +162,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
{ctk?.connected && ctk.toCopy > 0 && <CopyToContrack base={base} count={ctk.toCopy} />}
{list.data && shown.length === 0 && (
<p className="text-sm text-zinc-500">
{q ? 'No contact matches that.' : 'No contacts yet.'}
</p>
<p className="text-sm text-muted">{q ? 'No contact matches that.' : 'No contacts yet.'}</p>
)}
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{shown.map((c) => (
<li key={c.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm">
{canShare && (
@@ -184,9 +183,9 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
<span className="min-w-40 flex-1">
<span className="font-medium">{c.name}</span>
{c.company && <span className="text-zinc-500"> · {c.company}</span>}
{c.company && <span className="text-muted"> · {c.company}</span>}
{base === MINE && c.book === 'company' && (
<span className="ml-2 rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<span className="ml-2 rounded bg-subtle px-1.5 py-0.5 text-xs text-muted">
Company
</span>
)}
@@ -196,22 +195,17 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
<button
key={n.number}
type="button"
className="text-sky-700 underline dark:text-sky-400"
className="text-link underline"
aria-label={`Call ${c.name} ${n.label} ${show(n.number)}`}
onClick={() => call(n.number)}
>
{n.label && <span className="text-zinc-500 no-underline">{n.label} </span>}
{n.label && <span className="text-muted no-underline">{n.label} </span>}
{show(n.number)}
</button>
))}
</span>
{c.url && (
<a
href={c.url}
target="_blank"
rel="noreferrer"
className="text-sky-700 underline dark:text-sky-400"
>
<a href={c.url} target="_blank" rel="noreferrer" className="text-link underline">
Open in Contrack
</a>
)}
@@ -272,7 +266,7 @@ function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
return (
<div
role="status"
className="flex flex-wrap items-center gap-2 rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950"
className="flex flex-wrap items-center gap-2 rounded-md bg-primary-soft p-3 text-sm"
>
<span className="flex-1">
{count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
@@ -385,7 +379,7 @@ function ContactDrawer(props: {
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -454,7 +448,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
}
>
<div className="flex flex-col gap-3 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
a header row: name, company, email, label1, number1, label2, number2…
</p>
@@ -474,7 +468,7 @@ function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => voi
if (read.length) run.mutate(false);
}}
/>
{run.isPending && <p className="text-zinc-500">Reading…</p>}
{run.isPending && <p className="text-muted">Reading…</p>}
{preview && (
<div role="status" className="flex flex-col gap-1">
<p>
@@ -538,7 +532,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
</p>
) : (
<div className="flex flex-col gap-2 text-sm">
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Each person gets a copy to accept into their own contacts, or deny.
</p>
<label className="flex items-center gap-2 font-medium">
@@ -564,7 +558,7 @@ function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean)
setTo(next);
}}
/>
{p.name} <span className="text-zinc-500">({p.number})</span>
{p.name} <span className="text-muted">({p.number})</span>
</label>
))}
{share.error && <Alert>{share.error.message}</Alert>}
@@ -604,7 +598,7 @@ function SharedWithYou() {
return (
<section
aria-labelledby="shared-h"
className="flex flex-col gap-3 rounded-lg border border-sky-200 bg-sky-50 p-3 dark:border-sky-900 dark:bg-sky-950"
className="flex flex-col gap-3 rounded-lg border border-primary-line bg-primary-soft p-3"
>
<h2 id="shared-h" className="text-lg font-semibold">
Shared with you
@@ -643,11 +637,9 @@ function SharedWithYou() {
<li key={s.id} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1">
{s.contact.name}
{s.contact.company && (
<span className="text-zinc-500"> · {s.contact.company}</span>
)}
{s.contact.company && <span className="text-muted"> · {s.contact.company}</span>}
{s.contact.numbers.map((n) => (
<span key={n.number} className="text-zinc-500">
<span key={n.number} className="text-muted">
{' '}
· {show(n.number)}
</span>
+207 -96
View File
@@ -1,12 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
import {
BookUser,
House,
Info,
LogOut,
Menu,
Phone,
PhoneMissed,
Settings,
Shield,
Users,
Voicemail,
X,
} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
import { meApi, signOut } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Alert, Brand, Button } from '../../components/ui.tsx';
import { Alert, Brand, Button, Panel } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
import { SoftphoneDock } from '../../softphone/SoftphoneDock.tsx';
import { PresencePanel } from '../../softphone/PresencePanel.tsx';
@@ -17,7 +31,7 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
const link = 'text-link underline';
export function MePage({ user }: { user: AuthUser }) {
const qc = useQueryClient();
@@ -36,15 +50,25 @@ export function MePage({ user }: { user: AuthUser }) {
const toasts = useLive((s) => s.toasts);
const offers = useContactShares().data?.length ?? 0;
const [menu, setMenu] = useState(false);
const close = () => setMenu(false);
async function logout() {
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
qc.clear();
nav('/login', { replace: true });
}
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-primary-soft text-primary-ink' : 'hover:bg-subtle'}`;
const icon = 'size-4 shrink-0';
const row = tab({ isActive: false });
return (
<main className="mx-auto flex min-h-dvh max-w-6xl flex-col gap-6 p-4 sm:p-8">
<header className="flex flex-wrap items-center justify-between gap-2">
<header className="flex items-center justify-between gap-2">
<h1 className="text-xl">
<Brand />
</h1>
<div className="flex items-center gap-2">
<div className="hidden items-center gap-2 md:flex">
{user.role === 'admin' && (
<Link className={`${link} text-sm`} to="/admin">
Admin
@@ -54,47 +78,81 @@ export function MePage({ user }: { user: AuthUser }) {
About
</Link>
<ThemeToggle />
<Button
variant="ghost"
onClick={async () => {
await signOut(); // may leave for the OpenID provider's sign-out page (12 §3.5)
qc.clear();
nav('/login', { replace: true });
}}
>
<Button variant="ghost" onClick={logout}>
Sign out
</Button>
</div>
<Button
variant="ghost"
className="relative md:hidden"
aria-expanded={menu}
aria-controls="me-nav"
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
onClick={() => setMenu((o) => !o)}
>
<Menu className="size-5" />
{offers > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
)}
</Button>
</header>
{/* On phones the tabs and the header links fold into the menu button. */}
<nav
id="me-nav"
aria-label="My phone"
className="flex gap-1 border-b border-zinc-200 pb-2 dark:border-zinc-800"
className={`${menu ? 'flex' : 'hidden'} flex-col gap-1 border-b border-line pb-2 md:flex md:flex-row`}
>
<NavLink to="/me" end className={tab}>
<NavLink to="/me" end className={tab} onClick={close}>
<House className={icon} />
Home
</NavLink>
<NavLink to="/me/calls" className={tab}>
<NavLink to="/me/calls" className={tab} onClick={close}>
<Phone className={icon} />
Calls
</NavLink>
<NavLink to="/me/voicemail" className={tab}>
<NavLink to="/me/voicemail" className={tab} onClick={close}>
<Voicemail className={icon} />
Voicemail
</NavLink>
<NavLink to="/me/contacts" className={tab}>
<NavLink to="/me/contacts" className={tab} onClick={close}>
<BookUser className={icon} />
Contacts
{offers > 0 && (
<span className="ml-1 rounded-full bg-sky-600 px-1.5 text-xs text-white">
<span className="rounded-full bg-primary px-1.5 text-xs text-white">
<span className="sr-only">, shared with you: </span>
{offers}
</span>
)}
</NavLink>
<NavLink to="/me/settings" className={tab}>
<NavLink to="/me/settings" className={tab} onClick={close}>
<Settings className={icon} />
Settings
</NavLink>
{/* People is a right-hand column on wide screens, a tab on small ones (05 §4.1). */}
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`}>
<NavLink to="/me/people" className={(s) => `${tab(s)} lg:hidden`} onClick={close}>
<Users className={icon} />
People
</NavLink>
<div className="mt-1 flex flex-col gap-1 border-t border-line pt-2 md:hidden">
{user.role === 'admin' && (
<Link className={row} to="/admin">
<Shield className={icon} />
Admin
</Link>
)}
<Link className={row} to="/about">
<Info className={icon} />
About
</Link>
<div className="flex items-center justify-between pl-3 text-sm font-medium">
Theme
<ThemeToggle />
</div>
<button type="button" className={`${row} text-left`} onClick={logout}>
<LogOut className={icon} />
Sign out
</button>
</div>
</nav>
{toasts.map((t) => (
<div
@@ -111,7 +169,7 @@ export function MePage({ user }: { user: AuthUser }) {
useLive.setState((s) => ({ toasts: s.toasts.filter((x) => x.id !== t.id) }))
}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -135,39 +193,50 @@ export function MePage({ user }: { user: AuthUser }) {
);
}
const seeAll = 'text-sm font-medium text-link hover:underline';
const when = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
/** One line in a Home card: a round icon, two lines of text, and an optional button. */
function Row({
icon,
title,
detail,
children,
}: {
icon: ReactNode;
title: string;
detail: string;
children?: ReactNode;
}) {
return (
<li className="flex items-center gap-3 py-2">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-subtle">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title}</span>
<span className="block text-xs text-muted">{detail}</span>
</span>
{children}
</li>
);
}
function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return (
<div className="flex flex-col gap-6">
<p>Hi {user.displayName}.</p>
<p className="text-2xl font-semibold">Hi {user.displayName}</p>
<SharedNotice />
{user.extension && <StatusCard ext={user.extension.number} />}
{user.extension && <MissedCalls />}
<div className="grid gap-4 md:grid-cols-2">
{user.extension && <MissedCalls />}
<NewVoicemails />
</div>
{user.extension && <ParkedOnHome />}
<section aria-labelledby="new-vm" className="flex flex-col gap-2">
<h2 id="new-vm" className="text-lg font-semibold">
New voicemails
</h2>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data && fresh.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
<ul className="flex flex-col gap-1">
{fresh.slice(0, 5).map((m) => (
<li key={m.id} className="text-sm">
<span className="font-semibold">{nameOf(m.callerNumber) ?? callerOf(m)}</span>{' '}
<span className="text-zinc-500">
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</span>
</li>
))}
</ul>
{list.data && list.data.length > 0 && (
<Link className={`${link} text-sm`} to="/me/voicemail">
Open voicemail
</Link>
)}
</section>
<p className="text-sm">
<Link className={link} to="/change-password">
Change password
@@ -177,13 +246,49 @@ function Home({ user }: { user: AuthUser }) {
);
}
function NewVoicemails() {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
const nameOf = useContactName();
return (
<Panel
title="New voicemails"
action={
list.data &&
list.data.length > 0 && (
<Link className={seeAll} to="/me/voicemail">
Open voicemail
</Link>
)
}
>
{list.error && <Alert>{list.error.message}</Alert>}
{list.data && fresh.length === 0 && (
<p className="text-sm text-muted">You're all caught up.</p>
)}
{fresh.length > 0 && (
<ul className="-my-2 divide-y divide-line">
{fresh.slice(0, 5).map((m) => (
<Row
key={m.id}
icon={<Voicemail className="size-4 text-link" />}
title={nameOf(m.callerNumber) ?? callerOf(m)}
detail={`${when(m.receivedAt)} · ${mmss(m.durationS)}`}
/>
))}
</ul>
)}
</Panel>
);
}
/** "Dan shared 2 contacts with you": they wait in Contacts for Accept or Deny (11 §2.1). */
function SharedNotice() {
const offers = useContactShares().data ?? [];
if (!offers.length) return null;
const from = [...new Set(offers.map((o) => o.from?.name ?? 'Someone'))].join(', ');
return (
<p role="status" className="rounded-md bg-sky-50 p-3 text-sm dark:bg-sky-950">
<p role="status" className="rounded-md bg-primary-soft p-3 text-sm">
{from} shared {offers.length} contact{offers.length === 1 ? '' : 's'} with you.{' '}
<Link className={link} to="/me/contacts">
Accept or deny
@@ -202,19 +307,20 @@ function StatusCard({ ext }: { ext: string }) {
me.data.cfna && `no answer → ${showTarget(me.data.cfna)}`,
].filter(Boolean);
return (
<section
aria-label="My status"
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
<Panel
title="My status"
action={
<Link className={seeAll} to="/me/settings">
Change
</Link>
}
>
<DndSwitch me={me.data} />
<QueueLogins ext={ext} />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
<Link className={link} to="/me/settings">
Change
</Link>
<p className="text-sm text-muted">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
</p>
</section>
</Panel>
);
}
@@ -242,7 +348,7 @@ function QueueLogins({ ext }: { ext: string }) {
onChange={(e) => toggle.mutate({ id: q.id, paused: !e.target.checked })}
/>
Taking calls from queue {q.name} ({q.number})
<span className="text-xs text-zinc-500">(also *45{q.number} from your phone)</span>
<span className="text-xs text-muted">(also *45{q.number} from your phone)</span>
</label>
);
})}
@@ -258,7 +364,7 @@ function ParkedOnHome() {
<div className="flex flex-col gap-1">
<ParkedCalls onPickUp={call} />
{note && (
<p role="status" className="text-sm text-zinc-500">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -273,44 +379,49 @@ function MissedCalls() {
const missed = calls.data?.items.filter((c) => c.missedByMe).slice(0, 5) ?? [];
const nameOf = useContactName();
return (
<section aria-labelledby="missed-h" className="flex flex-col gap-2">
<h2 id="missed-h" className="text-lg font-semibold">
Missed calls
</h2>
{calls.data && missed.length === 0 && <p className="text-sm text-zinc-500">None.</p>}
<Panel
title="Missed calls"
action={
calls.data &&
calls.data.items.length > 0 && (
<Link className={seeAll} to="/me/calls">
All my calls
</Link>
)
}
>
{calls.data && missed.length === 0 && <p className="text-sm text-muted">No missed calls.</p>}
{note && (
<p role="status" className="text-sm text-zinc-500">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
<ul className="flex flex-col gap-1 text-sm">
{missed.map((c) => {
const o = otherParty(c);
return (
<li key={c.id}>
<span className="font-semibold">
{nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
</span>{' '}
<span className="text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>{' '}
{o.number && (
<button
type="button"
className={link}
onClick={() => call(o.number!)}
aria-label={`Call back ${fmtNumber(o.number)}`}
>
Call back
</button>
)}
</li>
);
})}
</ul>
{calls.data && calls.data.items.length > 0 && (
<Link className={`${link} text-sm`} to="/me/calls">
All my calls
</Link>
{missed.length > 0 && (
<ul className="-my-2 divide-y divide-line">
{missed.map((c) => {
const o = otherParty(c);
return (
<Row
key={c.id}
icon={<PhoneMissed className="size-4 text-red-600 dark:text-red-400" />}
title={nameOf(o.number) ?? o.name ?? fmtNumber(o.number)}
detail={when(c.startedAt)}
>
{o.number && (
<Button
variant="ghost"
className="text-link"
onClick={() => call(o.number!)}
aria-label={`Call back ${fmtNumber(o.number)}`}
>
<Phone className="size-4" />
</Button>
)}
</Row>
);
})}
</ul>
)}
</section>
</Panel>
);
}
+13 -18
View File
@@ -37,7 +37,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
onChange={(e) => save.mutate({ dnd: e.target.checked })}
/>
Do not disturb
<span className="text-xs text-zinc-500">(also *78 on, *79 off from your phone)</span>
<span className="text-xs text-muted">(also *78 on, *79 off from your phone)</span>
</label>
);
}
@@ -45,7 +45,7 @@ export function DndSwitch({ me }: { me: MeDto }) {
export function MeSettingsPage() {
const me = useMe();
if (me.error) return <Alert>{me.error.message}</Alert>;
if (!me.data) return <p className="text-sm text-zinc-500">Loading…</p>;
if (!me.data) return <p className="text-sm text-muted">Loading…</p>;
// ponytail: the forms start from what was loaded; a *72 from the phone while this page is open
// shows after a reload (remounting on every change would also wipe "Saved." and errors).
return <SettingsForms me={me.data} />;
@@ -119,7 +119,7 @@ function Forwarding({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save forwarding
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -188,7 +188,7 @@ function Voicemail({ me }: { me: MeDto }) {
<Button type="submit" variant="primary" disabled={save.isPending}>
Save voicemail settings
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -218,7 +218,7 @@ function CallReportForm({ me }: { me: MeDto }) {
<h2 id="report-h" className="text-lg font-semibold">
Call report
</h2>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
An email listing your calls: who, which numbers, how long, and if they were recorded. No
calls, no email. It goes out a few minutes after each period ends, in the company time zone.
</p>
@@ -285,14 +285,14 @@ function CallReportForm({ me }: { me: MeDto }) {
onChange={(e) => setForm({ ...form, callReportSummaries: e.target.checked })}
/>
Add a summary of each recorded call
<span className="text-xs text-zinc-500">(when your admin has call summaries on)</span>
<span className="text-xs text-muted">(when your admin has call summaries on)</span>
</label>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" disabled={save.isPending}>
Save call report
</Button>
{save.isSuccess && <span className="text-sm text-zinc-500">Saved.</span>}
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
</div>
{save.error && <Alert>{save.error.message}</Alert>}
</form>
@@ -313,12 +313,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? (
<a
className="text-sky-700 underline dark:text-sky-400"
href={contrack.publicUrl}
target="_blank"
rel="noreferrer"
>
<a className="text-link underline" href={contrack.publicUrl} target="_blank" rel="noreferrer">
Contrack
</a>
) : (
@@ -365,9 +360,9 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
<span>
With the summary and action items of each recorded call
{!contrack.summariesAvailable && (
<span className="text-zinc-500"> (when your admin turns on call summaries)</span>
<span className="text-muted"> (when your admin turns on call summaries)</span>
)}
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
reach that provider.
</span>
@@ -376,7 +371,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable<MeDto['contrack']
</div>
)}
{!contrack.tokenPrefix && (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<p className="text-sm text-muted">
Your contacts live in your own {where} account. There, open Settings → Account → API
tokens → Create token, with Read and write (so contacts shared with you can be added) and
Expires: Never, and paste it here. Only you see your contacts unless you share them.
@@ -448,7 +443,7 @@ function MobileApp() {
<li key={d.id} className="flex flex-wrap items-center gap-3">
<span>
{d.name}{' '}
<span className="text-zinc-500">
<span className="text-muted">
({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '}
{new Date(d.createdAt).toLocaleDateString()}
{d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`})
@@ -485,7 +480,7 @@ function MobileApp() {
Or type it in the app: server <span className="font-mono">{pair.data.server}</span>,
code <span className="font-mono">{pair.data.code}</span>
</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it
gets into your account: don't share it.
</p>
+6 -6
View File
@@ -47,12 +47,12 @@ export function MyCallsPage() {
</h2>
{list.isError && <Alert>{list.error.message}</Alert>}
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls yet.</p>}
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls yet.</p>}
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{calls.map((c) => {
const other = otherParty(c);
const known = nameOf(other.number);
@@ -61,7 +61,7 @@ export function MyCallsPage() {
key={c.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-10">{c.myRole === 'out' ? 'Out' : 'In'}</span>
<span
className={`min-w-0 flex-1 ${c.missedByMe ? 'font-semibold text-red-700 dark:text-red-400' : ''}`}
@@ -70,7 +70,7 @@ export function MyCallsPage() {
{other.number ? (
<button
type="button"
className="text-sky-700 underline dark:text-sky-400"
className="text-link underline"
aria-label={`Call back ${fmtNumber(other.number)}`}
onClick={() => call(other.number!)}
>
@@ -80,7 +80,7 @@ export function MyCallsPage() {
'Unknown'
)}
{c.direction !== 'internal' && (
<span className="text-zinc-500"> · {DIRECTION_TEXT[c.direction]}</span>
<span className="text-muted"> · {DIRECTION_TEXT[c.direction]}</span>
)}
{!known && other.number && c.direction !== 'internal' && (
<AddToContacts number={other.number} name={other.name} />
+11 -11
View File
@@ -68,32 +68,32 @@ function Messages() {
onChange={(e) => setQ(e.target.value)}
/>
)}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">No messages.</p>}
{list.data?.length === 0 && <p className="text-sm text-muted">No messages.</p>}
{!!list.data?.length && shown?.length === 0 && (
<p className="text-sm text-zinc-500">No message mentions that.</p>
<p className="text-sm text-muted">No message mentions that.</p>
)}
<ul className="flex flex-col gap-2">
{shown?.map((m) => (
<li
key={m.id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 sm:flex-row sm:items-center dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<p className={isNew(m) ? 'font-semibold' : ''}>
{who(m)}
{isNew(m) && (
<span className="ml-2 rounded bg-sky-100 px-1.5 py-0.5 text-xs text-sky-800 dark:bg-sky-900 dark:text-sky-200">
<span className="ml-2 rounded bg-primary-soft px-1.5 py-0.5 text-xs text-primary-ink">
New
</span>
)}
</p>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
{m.callerName && m.callerNumber ? `${formatNumber(m.callerNumber)} · ` : ''}
{new Date(m.receivedAt).toLocaleString()} · {mmss(m.durationS)}
</p>
{m.transcript !== null && (
<p className="mt-1 text-sm" title="Automatic transcript, may have mistakes">
{m.transcript || <span className="text-zinc-500">(no words)</span>}
{m.transcript || <span className="text-muted">(no words)</span>}
</p>
)}
</div>
@@ -108,7 +108,7 @@ function Messages() {
<div className="flex gap-2">
<a
href={meApi.audioUrl(m.id, true)}
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800"
className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-subtle"
>
Download
</a>
@@ -217,7 +217,7 @@ function Greeting(props: {
<div
role="group"
aria-labelledby={id}
className="flex flex-col gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-800"
className="flex flex-col gap-2 rounded-xl border border-line bg-surface shadow-sm p-3"
>
<p id={id} className="font-medium">
{label}
@@ -231,7 +231,7 @@ function Greeting(props: {
className="w-full sm:w-80"
/>
) : (
<p className="text-sm text-zinc-500">Not recorded. {props.fallback}</p>
<p className="text-sm text-muted">Not recorded. {props.fallback}</p>
)}
<div className="flex flex-wrap gap-2">
{recording ? (
@@ -265,8 +265,8 @@ function Greeting(props: {
)}
</div>
{recording && <p className="text-sm text-red-600 dark:text-red-400">Recording…</p>}
{upload.isPending && <p className="text-sm text-zinc-500">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-zinc-500">Saved.</p>}
{upload.isPending && <p className="text-sm text-muted">Saving…</p>}
{upload.isSuccess && !upload.isPending && <p className="text-sm text-muted">Saved.</p>}
{(micError ?? upload.error?.message ?? remove.error?.message) && (
<Alert>{micError ?? upload.error?.message ?? remove.error?.message}</Alert>
)}
+3 -3
View File
@@ -54,7 +54,7 @@ export function PresencePanel({ me }: { me: string | null }) {
onChange={(e) => setQ(e.target.value)}
/>
{note && (
<p role="status" className="text-sm text-zinc-600 dark:text-zinc-400">
<p role="status" className="text-sm text-muted">
{note}
</p>
)}
@@ -63,7 +63,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<li
key={e.number}
data-number={e.number}
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-900"
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-subtle"
onDragOver={(ev) => {
if (active && ev.dataTransfer.types.includes(CALL_DRAG)) ev.preventDefault();
}}
@@ -75,7 +75,7 @@ export function PresencePanel({ me }: { me: string | null }) {
<StatusDot ext={e} />
<span className="min-w-0 flex-1 truncate text-sm">
<span className="font-medium">{e.name}</span>{' '}
<span className="text-zinc-500">
<span className="text-muted">
{e.number} · {statusLabel(e)}
</span>
</span>
+235 -78
View File
@@ -3,12 +3,22 @@
// dialpad and audio devices; call cards with mute/hold/keypad/transfer; the incoming call dialog.
// Not yet: DND toggle (M6), Record via *1 and recent calls (M7).
import { useQuery } from '@tanstack/react-query';
import {
ArrowRightLeft,
Grid3x3,
Mic,
MicOff,
Pause,
Phone,
PhoneOff,
Power,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { meApi } from '../api/client.ts';
import { Dialog } from '../components/Dialog.tsx';
import { SpamNote } from '../components/SpamNote.tsx';
import { Button, inputClass } from '../components/ui.tsx';
import { Button, focusRing, inputClass } from '../components/ui.tsx';
import { useLive } from '../live/store.ts';
import { onTakeOver, stepDown, takeOver, tryLead } from './leader.ts';
import { CALL_DRAG, softphone, type CallInfo } from './SoftphoneEngine.ts';
@@ -65,6 +75,24 @@ const STATUS = {
error: 'Error',
elsewhere: 'Active in another tab',
};
const DOT = {
off: 'bg-line-strong',
connecting: 'bg-amber-500 animate-pulse motion-reduce:animate-none',
registered: 'bg-green-500',
error: 'bg-red-500',
elsewhere: 'bg-line-strong',
};
// The letters on a phone's keys, as a hint only (screen readers get the digit).
const LETTERS: Record<string, string> = {
'2': 'ABC',
'3': 'DEF',
'4': 'GHI',
'5': 'JKL',
'6': 'MNO',
'7': 'PQRS',
'8': 'TUV',
'9': 'WXYZ',
};
const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'];
const clean = (s: string) => s.replace(/[^0-9*#+]/g, '');
@@ -193,11 +221,15 @@ export function SoftphoneDock() {
return (
<section
aria-label="Softphone"
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-zinc-200 bg-white py-3 dark:border-zinc-800 dark:bg-zinc-950"
className="sticky bottom-0 mt-auto flex flex-col gap-2 border-t border-line bg-page py-3"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
Softphone: <span role="status">{STATUS[reg]}</span>
<span className="inline-flex items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-sm font-medium">
<span aria-hidden className={`size-2 rounded-full ${DOT[reg]}`} />
Softphone<span className="sr-only">:</span>
<span role="status" className="text-muted">
{STATUS[reg]}
</span>
</span>
<span className="flex-1" />
{reg === 'off' && (
@@ -217,6 +249,7 @@ export function SoftphoneDock() {
aria-controls="sp-panel"
onClick={() => setOpen((o) => !o)}
>
<Grid3x3 className="size-4" />
{open ? 'Hide keypad' : 'Keypad'}
</Button>
<Button
@@ -226,6 +259,7 @@ export function SoftphoneDock() {
run(softphone.stop().then(stepDown));
}}
>
<Power className="size-4" />
Turn off
</Button>
</>
@@ -247,44 +281,144 @@ export function SoftphoneDock() {
<Devices />
</div>
)}
<Dialog
open={dialogCall !== undefined}
onClose={() => dialogCall && run(softphone.decline(dialogCall.id))}
title="Incoming call"
footer={
dialogCall && (
<>
<Button onClick={() => run(softphone.decline(dialogCall.id))}>Decline</Button>
<Button variant="primary" onClick={() => run(softphone.answer(dialogCall.id))}>
Answer
</Button>
</>
)
}
>
{dialogCall && (
<p className="text-lg">
<span className="font-semibold">{nameOf(dialogCall)}</span>
{nameOf(dialogCall) !== dialogCall.remote && (
<span className="block text-sm text-zinc-500">
{dialogCall.remote.length > 6 ? formatNumber(dialogCall.remote) : dialogCall.remote}
</span>
)}
</p>
)}
{dialogCall && <CallerCheck call={dialogCall} />}
</Dialog>
{dialogCall && (
<IncomingCall
call={dialogCall}
name={nameOf(dialogCall)}
onAnswer={() => run(softphone.answer(dialogCall.id))}
onDecline={() => run(softphone.decline(dialogCall.id))}
/>
)}
</section>
);
}
function RoundButton({
label,
color,
icon: Icon,
onClick,
}: {
label: string;
color: string;
icon: LucideIcon;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="group flex flex-col items-center gap-2 rounded-lg text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-focus"
>
<span
className={`grid size-16 place-items-center rounded-full text-white transition-transform group-active:scale-95 motion-reduce:transition-none ${color}`}
>
<Icon className="size-7" />
</span>
{label}
</button>
);
}
/** The ringing screen: full screen on a phone, a card on wider screens. Esc declines. */
function IncomingCall({
call,
name,
onAnswer,
onDecline,
}: {
call: CallInfo;
name: string;
onAnswer: () => void;
onDecline: () => void;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const d = ref.current;
if (d && !d.open) d.showModal();
}, []);
return (
<dialog
ref={ref}
aria-labelledby="ring-title"
onCancel={(e) => {
e.preventDefault();
onDecline();
}}
className="m-0 h-dvh max-h-none w-full max-w-none bg-surface p-0 text-fg backdrop:bg-black/60 sm:m-auto sm:h-auto sm:max-w-sm sm:rounded-2xl sm:shadow-xl"
>
<div className="flex h-full flex-col items-center justify-between gap-10 px-6 py-16 text-center sm:py-8">
<div className="flex flex-col items-center gap-2">
<h2 id="ring-title" className="text-sm font-medium text-muted">
Incoming call
</h2>
<p className="text-3xl font-semibold break-words">{name}</p>
{name !== call.remote && (
<p className="text-muted tabular-nums">
{call.remote.length > 6 ? formatNumber(call.remote) : call.remote}
</p>
)}
<CallerCheck call={call} />
</div>
<div className="flex gap-16">
<RoundButton label="Decline" color="bg-red-600" icon={PhoneOff} onClick={onDecline} />
<RoundButton label="Answer" color="bg-emerald-600" icon={Phone} onClick={onAnswer} />
</div>
</div>
</dialog>
);
}
const TONE = {
normal: 'border border-line bg-surface hover:bg-subtle',
pressed: 'bg-primary text-white hover:bg-primary-hover',
answer: 'bg-emerald-700 text-white hover:bg-emerald-800',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
/** A call control: icon over a short label, phone style. `pressed` makes it a toggle. */
function Control({
icon: Icon,
label,
pressed,
tone = 'normal',
onClick,
}: {
icon: LucideIcon;
label: string;
pressed?: boolean;
tone?: 'normal' | 'answer' | 'danger';
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={`flex min-h-14 min-w-16 flex-col items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none ${focusRing} ${TONE[pressed ? 'pressed' : tone]}`}
>
<Icon className="size-5" />
{label}
</button>
);
}
function Keypad({ onKey, label }: { onKey: (k: string) => void; label: string }) {
return (
<div role="group" aria-label={label} className="grid w-56 grid-cols-3 gap-1">
<div role="group" aria-label={label} className="grid w-fit grid-cols-3 gap-3">
{KEYS.map((k) => (
<Button key={k} onClick={() => onKey(k)} className="text-lg">
{k}
</Button>
<button
key={k}
type="button"
aria-label={k}
onClick={() => onKey(k)}
className={`flex size-14 flex-col items-center justify-center rounded-full border border-line bg-surface transition-colors hover:bg-subtle active:bg-line ${focusRing}`}
>
<span className="text-xl leading-none font-medium">{k}</span>
<span aria-hidden className="h-3 text-[0.625rem] leading-3 tracking-widest text-muted">
{LETTERS[k]}
</span>
</button>
))}
</div>
);
@@ -365,67 +499,90 @@ function CallCard({
<div
role="group"
aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-zinc-100 p-2 dark:bg-zinc-900"
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-3 shadow-sm"
// Drop it on a person in People to transfer it there.
draggable={call.state === 'active'}
onDragStart={(e) => e.dataTransfer.setData(CALL_DRAG, call.id)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1 text-sm">
<span className="font-medium">{name}</span> · {label}
{call.answeredAt && <> · {elapsed(call.answeredAt, now)}</>}
<div className="flex flex-col gap-1">
<p className="flex flex-wrap items-baseline gap-x-2">
<span className="font-semibold">{name}</span>
<span className="text-sm text-muted">
{label}
{call.answeredAt && (
<span className="tabular-nums"> · {elapsed(call.answeredAt, now)}</span>
)}
</span>
{call.poorQuality && (
<span className="ml-2 text-amber-700 dark:text-amber-400">Poor connection</span>
<span className="text-sm text-amber-700 dark:text-amber-400">Poor connection</span>
)}
<CallerCheck call={call} />
</span>
</p>
<CallerCheck call={call} />
</div>
<div className="flex flex-wrap gap-2">
{call.state === 'incoming' ? (
<>
<Button variant="primary" onClick={() => run(softphone.answer(call.id))}>
Answer
</Button>
<Button variant="ghost" onClick={() => run(softphone.decline(call.id))}>
Decline
</Button>
<Control
icon={Phone}
label="Answer"
tone="answer"
onClick={() => run(softphone.answer(call.id))}
/>
<Control
icon={PhoneOff}
label="Decline"
tone="danger"
onClick={() => run(softphone.decline(call.id))}
/>
</>
) : (
<>
{call.held && other && !consultId && (
<Button
variant="primary"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{call.state === 'active' && (
<>
<Button
aria-pressed={call.muted}
<Control
icon={call.muted ? MicOff : Mic}
label="Mute"
pressed={call.muted}
onClick={() => softphone.mute(call.id, !call.muted)}
>
{call.muted ? 'Unmute' : 'Mute'}
</Button>
<Button
aria-pressed={call.held}
/>
<Control
icon={Pause}
label="Hold"
pressed={call.held}
onClick={() => run(softphone.hold(call.id, !call.held))}
>
{call.held ? 'Resume' : 'Hold'}
</Button>
<Button aria-pressed={keypad} onClick={() => setKeypad((k) => !k)}>
Keypad
</Button>
<Button aria-pressed={transfer} onClick={() => setTransfer((t) => !t)}>
Transfer
</Button>
/>
<Control
icon={Grid3x3}
label="Keypad"
pressed={keypad}
onClick={() => setKeypad((k) => !k)}
/>
<Control
icon={ArrowRightLeft}
label="Transfer"
pressed={transfer}
onClick={() => setTransfer((t) => !t)}
/>
</>
)}
<Button variant="danger" onClick={() => run(softphone.hangup(call.id))}>
Hang up
</Button>
<Control
icon={PhoneOff}
label="Hang up"
tone="danger"
onClick={() => run(softphone.hangup(call.id))}
/>
</>
)}
</div>
{call.held && other && !consultId && (
<Button
variant="primary"
className="self-start"
onClick={() => run(softphone.attendedTransfer(call.id, other.id))}
>
Complete transfer
</Button>
)}
{keypad && call.state === 'active' && (
<Keypad label="Send tones" onKey={(k) => softphone.dtmf(call.id, k)} />
)}
@@ -551,7 +708,7 @@ function Devices() {
</Button>
)}
{perm === 'denied' && (
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Call notifications are blocked. To get them, allow notifications for this site in your
browser’s site settings (the icon left of the address), then reload the page.
</p>
+1 -1
View File
@@ -89,7 +89,7 @@ needs inside (`selfphoneng-probe-linux-x64`, or `-arm64` for ARM machines), next
With Node 22 or newer installed you can take the small `selfphoneng-probe.cjs` instead.
```sh
v=v0.0.10 # the release
v=v0.0.11 # the release
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/selfphoneng-probe-linux-x64
curl -fLO https://git.dou.bet/spng/selfphoneng/releases/download/$v/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "selfphoneng",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/shared",
"version": "0.0.10",
"version": "0.0.11",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+18
View File
@@ -117,6 +117,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.104.3
version: 5.104.3(react@19.3.1)
lucide-react:
specifier: ^1.56.0
version: 1.56.0(@types/react@19.3.0)(react@19.3.1)
react:
specifier: ^19.3.1
version: 19.3.1
@@ -2156,6 +2159,15 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.56.0:
resolution: {integrity: sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4288,6 +4300,12 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.56.0(@types/react@19.3.0)(react@19.3.1):
dependencies:
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0