web: icons, grouped admin sidebar, Home cards, phone-style softphone
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:
1 parent
5db4914fa7
commit
3d0f091bba
16 files changed
+499
-219
No files matched your search
@@ -13,6 +13,7 @@
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.3",
|
||||
"lucide-react": "^1.56.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.90.0",
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
|
||||
import { Play } from 'lucide-react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useId, useRef } from 'react';
|
||||
import { soundsApi } from '../api/client.ts';
|
||||
@@ -63,7 +64,7 @@ export function SoundPicker({
|
||||
void a.play().catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
▶
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
|
||||
{upload.isPending ? 'Uploading…' : 'Upload new'}
|
||||
|
||||
@@ -1,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,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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
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