web: icons, grouped admin sidebar, Home cards, phone-style softphone
ci / check (push) Successful in 2m5s
ci / images (push) Successful in 3m7s

lucide-react (1.56.0) is the one icon set; it hides icons from screen
readers by default and only the icons used are bundled (+6 kB gzipped).
It replaces the ✕ ☀ ☾ ↑ ↓ ▶ ✓ ← characters and the hand-drawn menu and
phone icons.

The admin sidebar's 21 pages are grouped under Call routing, People,
Features, History and System, each with an icon; the current page gets a
left bar. The My Phone tabs have icons and a blue active state.

Home is a greeting and cards (new shared Panel): My status, Missed calls
with a call-back button, New voicemails, with friendly empty states.

Softphone: a status pill (dot and word), call controls as icon-over-label
buttons that are toggles with aria-pressed (Mute, Hold, Keypad, Transfer,
red Hang up), and a round keypad with the letters under the digits.
This commit is contained in:
iamdoubz committed 2026-10-28 10:00:29 -05:00
1 parent 5db4914fa7
commit 3d0f091bba
16 files changed
+499 -219

No files matched your search

+1
View File
@@ -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",
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
import { X } from 'lucide-react';
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from './ui.tsx';
@@ -43,7 +44,7 @@ export function Dialog({
{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>
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
import { Play } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId, useRef } from 'react';
import { soundsApi } from '../api/client.ts';
@@ -63,7 +64,7 @@ export function SoundPicker({
void a.play().catch(() => undefined);
}}
>
▶
<Play className="size-4" />
</Button>
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
{upload.isPending ? 'Uploading…' : 'Upload new'}
+2 -1
View File
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { toggleTheme } from '../theme.ts';
import { Button } from './ui.tsx';
@@ -12,7 +13,7 @@ export function ThemeToggle() {
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDark(toggleTheme())}
>
{dark ? '☀' : '☾'}
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
+26 -15
View File
@@ -3,7 +3,8 @@
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-focus';
export const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
const variants = {
primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
@@ -30,30 +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 pointer-coarse:min-h-11 pointer-coarse:min-w-11 ${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}
/>
);
}
/** Three lines, for the menu buttons on phone-width screens. */
export function MenuIcon() {
/** 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 (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="size-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
<section
aria-labelledby={id}
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 shadow-sm"
>
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
<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 ${focus} aria-invalid:border-red-500`;
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({
+9 -3
View File
@@ -2,6 +2,7 @@
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
import { Check } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -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} />
</>
);
@@ -465,7 +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-muted">{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>
@@ -562,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>
+112 -41
View File
@@ -1,36 +1,97 @@
// 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';
import { signOut, systemApi } from '../../api/client.ts';
import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Brand, Button, MenuIcon } from '../../components/ui.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,23 +113,33 @@ 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-primary-soft font-medium text-primary-ink' : 'hover:bg-subtle'} ${p.milestone ? 'text-muted' : ''}`
}
>
{p.label}
{p.milestone && <span className="text-[10px] text-muted">{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>
);
@@ -91,7 +162,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
<MenuIcon />
<Menu className="size-5" />
</Button>
<Brand className="md:hidden" />
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
+2 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Office hours (05 §2, 03 §6.10): when calls go one way or another. A weekly grid (click and drag
// half hours), the exact ranges as a list, holidays, and force open / closed / automatic.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -254,7 +255,7 @@ function RangeList({
aria-label={`Remove ${rangeText(r)}`}
onClick={() => onChange(ranges.filter((_, j) => j !== i))}
>
✕
<X className="size-4" />
</Button>
</li>
))}
+6 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Outbound rules (05 §2, 03 §6.12): ordered list (first match wins), form, and a test box.
// ponytail: ↑/↓ buttons instead of drag-to-reorder; keyboard-friendly, no drag library.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -80,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"
@@ -89,7 +90,7 @@ 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-muted">{i + 1}.</span>
@@ -340,7 +341,7 @@ function RouteDrawer({
onClick={() => patterns.remove(i)}
disabled={patterns.fields.length === 1}
>
✕
<X className="size-4" />
</Button>
</div>
))}
@@ -385,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>
))}
+5 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Queues (05 §2, 03 §6.8): callers wait with music until an agent is free. The list shows live
// counts; a queue's page shows who is waiting, the agents (pause/unpause) and today's numbers.
import { ArrowLeft, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useParams } from 'react-router';
@@ -152,8 +153,9 @@ function QueueDetail({ id }: { id: string }) {
return (
<section className="flex flex-col gap-6">
<div>
<Link className="text-sm text-muted 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}
@@ -449,7 +451,7 @@ function QueueForm({
setMembers((ms) => ms.filter((x) => x.extensionId !== m.extensionId))
}
>
✕
<X className="size-4" />
</Button>
</li>
))}
+4 -3
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Ring groups (05 §2, 03 §6.7): one number that rings several people, everyone at once or one at
// a time, then goes somewhere else when nobody answers.
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -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>
))}
+3 -2
View File
@@ -2,6 +2,7 @@
// Sounds & music (05 §2): recorded prompts for digital receptionists and announcements.
// Upload by picking or dropping a file; play, rename, delete. Music-on-hold classes and their
// files (03 §8) and announcements live here too.
import { ArrowDown, ArrowUp } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
@@ -337,7 +338,7 @@ function MohClassCard({
disabled={i === 0 || update.isPending}
onClick={() => move(i, -1)}
>
↑
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
@@ -346,7 +347,7 @@ function MohClassCard({
disabled={i === cls.files.length - 1 || update.isPending}
onClick={() => move(i, 1)}
>
↓
<ArrowDown className="size-4" />
</Button>
</span>
)}
+2 -1
View File
@@ -2,6 +2,7 @@
// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
// denies), click to call. useContactName() gives every page names for numbers.
import { X } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import {
@@ -378,7 +379,7 @@ function ContactDrawer(props: {
setForm({ ...form, numbers: form.numbers.filter((_, j) => j !== i) })
}
>
✕
<X className="size-4" />
</Button>
</div>
))}
+151 -74
View File
@@ -1,12 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
import {
BookUser,
House,
Info,
LogOut,
Menu,
Phone,
PhoneMissed,
Settings,
Shield,
Users,
Voicemail,
X,
} from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } 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, MenuIcon } 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';
@@ -45,7 +59,8 @@ export function MePage({ user }: { user: AuthUser }) {
}
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium pointer-coarse:py-3 ${isActive ? 'bg-subtle' : 'hover:bg-subtle'}`;
`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">
@@ -75,7 +90,7 @@ export function MePage({ user }: { user: AuthUser }) {
aria-label={offers > 0 ? `Menu, ${offers} shared with you` : 'Menu'}
onClick={() => setMenu((o) => !o)}
>
<MenuIcon />
<Menu className="size-5" />
{offers > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
)}
@@ -88,37 +103,45 @@ export function MePage({ user }: { user: AuthUser }) {
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} onClick={close}>
<House className={icon} />
Home
</NavLink>
<NavLink to="/me/calls" className={tab} onClick={close}>
<Phone className={icon} />
Calls
</NavLink>
<NavLink to="/me/voicemail" className={tab} onClick={close}>
<Voicemail className={icon} />
Voicemail
</NavLink>
<NavLink to="/me/contacts" className={tab} onClick={close}>
<BookUser className={icon} />
Contacts
{offers > 0 && (
<span className="ml-1 rounded-full bg-primary 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} 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`} 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">
@@ -126,6 +149,7 @@ export function MePage({ user }: { user: AuthUser }) {
<ThemeToggle />
</div>
<button type="button" className={`${row} text-left`} onClick={logout}>
<LogOut className={icon} />
Sign out
</button>
</div>
@@ -145,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>
))}
@@ -169,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-muted">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-muted">
{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
@@ -211,6 +246,42 @@ 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 ?? [];
@@ -236,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-xl border border-line bg-surface shadow-sm p-4"
<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-muted">
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}{' '}
<Link className={link} to="/me/settings">
Change
</Link>
{fwd.length ? `Forwarding: ${fwd.join(', ')}.` : 'Not forwarding.'}
</p>
</section>
</Panel>
);
}
@@ -307,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-muted">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-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-muted">{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>
);
}
+154 -68
View File
@@ -3,11 +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 { 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';
@@ -64,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, '');
@@ -195,8 +224,12 @@ export function SoftphoneDock() {
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' && (
@@ -216,6 +249,7 @@ export function SoftphoneDock() {
aria-controls="sp-panel"
onClick={() => setOpen((o) => !o)}
>
<Grid3x3 className="size-4" />
{open ? 'Hide keypad' : 'Keypad'}
</Button>
<Button
@@ -225,6 +259,7 @@ export function SoftphoneDock() {
run(softphone.stop().then(stepDown));
}}
>
<Power className="size-4" />
Turn off
</Button>
</>
@@ -258,19 +293,15 @@ export function SoftphoneDock() {
);
}
// Feather's phone; turned 135° it's the hang-up handset.
const PHONE =
'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z';
function RoundButton({
label,
color,
hangUp = false,
icon: Icon,
onClick,
}: {
label: string;
color: string;
hangUp?: boolean;
icon: LucideIcon;
onClick: () => void;
}) {
return (
@@ -282,18 +313,7 @@ function RoundButton({
<span
className={`grid size-16 place-items-center rounded-full text-white transition-transform group-active:scale-95 motion-reduce:transition-none ${color}`}
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={`size-7 ${hangUp ? 'rotate-[135deg]' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={PHONE} />
</svg>
<Icon className="size-7" />
</span>
{label}
</button>
@@ -341,21 +361,64 @@ function IncomingCall({
<CallerCheck call={call} />
</div>
<div className="flex gap-16">
<RoundButton label="Decline" color="bg-red-600" hangUp onClick={onDecline} />
<RoundButton label="Answer" color="bg-emerald-600" onClick={onAnswer} />
<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>
);
@@ -436,67 +499,90 @@ function CallCard({
<div
role="group"
aria-label={`${label}: ${name}`}
className="flex flex-col gap-2 rounded-md bg-subtle p-2"
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)} />
)}
+18
View File
@@ -117,6 +117,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.104.3
version: 5.104.3(react@19.3.1)
lucide-react:
specifier: ^1.56.0
version: 1.56.0(@types/react@19.3.0)(react@19.3.1)
react:
specifier: ^19.3.1
version: 19.3.1
@@ -2156,6 +2159,15 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.56.0:
resolution: {integrity: sha512-b+hqj30/trHBma5r1zlKb6yLewQp6tUtnz9loc3xdrYerPmvLYhlEbyPVCJ8DOg2/blBf6lwPLznftFVACka6A==}
peerDependencies:
'@types/react': '*'
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -4288,6 +4300,12 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.56.0(@types/react@19.3.0)(react@19.3.1):
dependencies:
react: 19.3.1
optionalDependencies:
'@types/react': 19.3.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0