Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add90204c0 | ||
|
|
4369dd5ae1 | ||
|
|
3d0f091bba | ||
|
|
5db4914fa7 | ||
|
|
fd3f06c167 |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/probe",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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's repository is below.
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "TEST" 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 + "." + body
|
||||
with the webhook'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 <token>. 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’t in anyone’s contacts, look the number
|
||||
up and show what’s known about them while it rings. Information only: calls are never
|
||||
blocked or sent elsewhere. IPQualityScore answers first when it’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’s name when the answer comes in before the phone rings.
|
||||
Letters, digits, spaces and . ' & @ - 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't the one set here, SelfPhoneNG asks the directory. People sign
|
||||
in with their directory username or email; the first time, they'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.
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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'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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@selfphoneng/shared",
|
||||
"version": "0.0.10",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
Generated
+18
@@ -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
|
||||
|
||||
Reference in new issue
Block a user