Full-featured SIP router with multi-provider trunking, browser softphone via WebRTC, real-time Opus/G.722/PCM transcoding in Rust, RNNoise ML noise suppression, Kokoro neural TTS announcements, and a Lit-based web dashboard with live call monitoring and REST API.
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
import { DeesElement, customElement, html, css, cssManager, state, type TemplateResult } from '../plugins.js';
|
|
import { deesCatalog } from '../plugins.js';
|
|
import { appState, type IAppState, type IContact } from '../state/appstate.js';
|
|
import { appRouter } from '../router.js';
|
|
import { viewHostCss } from './shared/index.js';
|
|
import type { IStatsTile } from '@design.estate/dees-catalog';
|
|
|
|
@customElement('sipproxy-view-contacts')
|
|
export class SipproxyViewContacts extends DeesElement {
|
|
@state() accessor appData: IAppState = appState.getState();
|
|
@state() accessor saving = false;
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
viewHostCss,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
padding: 16px;
|
|
}
|
|
.view-section {
|
|
margin-bottom: 24px;
|
|
}
|
|
`,
|
|
];
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.rxSubscriptions.push({
|
|
unsubscribe: appState.subscribe((s) => { this.appData = s; }),
|
|
} as any);
|
|
}
|
|
|
|
// ---------- CRUD operations ----------
|
|
|
|
private async openAddModal() {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
const formData = { name: '', number: '', company: '', notes: '' };
|
|
|
|
await DeesModal.createAndShow({
|
|
heading: 'Add Contact',
|
|
content: html`
|
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
|
<dees-input-text
|
|
.label=${'Name'}
|
|
.required=${true}
|
|
.value=${''}
|
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Number'}
|
|
.required=${true}
|
|
.value=${''}
|
|
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Company'}
|
|
.value=${''}
|
|
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Notes'}
|
|
.value=${''}
|
|
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
action: async (modal) => { modal.destroy(); },
|
|
},
|
|
{
|
|
name: 'Add Contact',
|
|
action: async (modal) => {
|
|
const name = formData.name.trim();
|
|
const number = formData.number.trim();
|
|
|
|
if (!name || !number) {
|
|
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
|
return;
|
|
}
|
|
|
|
const newContact: IContact = {
|
|
id: `contact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name,
|
|
number,
|
|
company: formData.company.trim() || undefined,
|
|
notes: formData.notes.trim() || undefined,
|
|
};
|
|
|
|
await this.saveContacts([...this.appData.contacts, newContact]);
|
|
modal.destroy();
|
|
deesCatalog.DeesToast.info('Contact added');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async openEditModal(contact: IContact) {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
const formData = {
|
|
name: contact.name,
|
|
number: contact.number,
|
|
company: contact.company || '',
|
|
notes: contact.notes || '',
|
|
};
|
|
|
|
await DeesModal.createAndShow({
|
|
heading: 'Edit Contact',
|
|
content: html`
|
|
<div style="display:flex;flex-direction:column;gap:12px;padding:4px 0;">
|
|
<dees-input-text
|
|
.label=${'Name'}
|
|
.required=${true}
|
|
.value=${formData.name}
|
|
@input=${(e: Event) => { formData.name = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Number'}
|
|
.required=${true}
|
|
.value=${formData.number}
|
|
@input=${(e: Event) => { formData.number = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Company'}
|
|
.value=${formData.company}
|
|
@input=${(e: Event) => { formData.company = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
<dees-input-text
|
|
.label=${'Notes'}
|
|
.value=${formData.notes}
|
|
@input=${(e: Event) => { formData.notes = (e.target as any).value; }}
|
|
></dees-input-text>
|
|
</div>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
action: async (modal) => { modal.destroy(); },
|
|
},
|
|
{
|
|
name: 'Save Changes',
|
|
action: async (modal) => {
|
|
const name = formData.name.trim();
|
|
const number = formData.number.trim();
|
|
|
|
if (!name || !number) {
|
|
deesCatalog.DeesToast.error('Name and number are required', 3000);
|
|
return;
|
|
}
|
|
|
|
const updatedContacts = this.appData.contacts.map((c) =>
|
|
c.id === contact.id
|
|
? { ...c, name, number, company: formData.company.trim() || undefined, notes: formData.notes.trim() || undefined }
|
|
: c,
|
|
);
|
|
|
|
await this.saveContacts(updatedContacts);
|
|
modal.destroy();
|
|
deesCatalog.DeesToast.info('Contact updated');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async deleteContact(contact: IContact) {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
|
|
await DeesModal.createAndShow({
|
|
heading: 'Delete Contact',
|
|
content: html`
|
|
<p style="color: #e2e8f0; margin: 0 0 8px;">
|
|
Are you sure you want to delete <strong>${contact.name}</strong>?
|
|
</p>
|
|
<p style="color: #94a3b8; font-size: 0.85rem; margin: 0;">
|
|
This action cannot be undone.
|
|
</p>
|
|
`,
|
|
menuOptions: [
|
|
{
|
|
name: 'Cancel',
|
|
action: async (modal) => { modal.destroy(); },
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
action: async (modal) => {
|
|
const updatedContacts = this.appData.contacts.filter((c) => c.id !== contact.id);
|
|
await this.saveContacts(updatedContacts);
|
|
modal.destroy();
|
|
deesCatalog.DeesToast.info('Contact deleted');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
private async toggleStar(contact: IContact) {
|
|
const updatedContacts = this.appData.contacts.map((c) =>
|
|
c.id === contact.id ? { ...c, starred: !c.starred } : c,
|
|
);
|
|
await this.saveContacts(updatedContacts);
|
|
}
|
|
|
|
private async saveContacts(contacts: IContact[]) {
|
|
this.saving = true;
|
|
try {
|
|
const config = await appState.apiGetConfig();
|
|
config.contacts = contacts;
|
|
await appState.apiSaveConfig(config);
|
|
} catch (e: any) {
|
|
deesCatalog.DeesToast.error(`Save failed: ${e.message}`, 4000);
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
}
|
|
|
|
private getColumns() {
|
|
return [
|
|
{
|
|
key: 'starred',
|
|
header: '',
|
|
sortable: false,
|
|
renderer: (val: boolean | undefined, row: IContact) => {
|
|
const starred = val === true;
|
|
return html`
|
|
<span
|
|
style="cursor:pointer;font-size:1.2rem;color:${starred ? '#fbbf24' : '#475569'};transition:color 0.15s;"
|
|
@click=${(e: Event) => { e.stopPropagation(); this.toggleStar(row); }}
|
|
>${starred ? '\u2605' : '\u2606'}</span>
|
|
`;
|
|
},
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Name',
|
|
sortable: true,
|
|
},
|
|
{
|
|
key: 'number',
|
|
header: 'Number',
|
|
renderer: (val: string) =>
|
|
html`<span style="font-family:'JetBrains Mono',monospace;font-size:.85rem;letter-spacing:.02em">${val}</span>`,
|
|
},
|
|
{
|
|
key: 'company',
|
|
header: 'Company',
|
|
renderer: (val: string | undefined) => val || '-',
|
|
},
|
|
{
|
|
key: 'notes',
|
|
header: 'Notes',
|
|
renderer: (val: string | undefined) =>
|
|
html`<span style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;color:#94a3b8" title=${val || ''}>${val || '-'}</span>`,
|
|
},
|
|
];
|
|
}
|
|
|
|
private getDataActions() {
|
|
return [
|
|
{
|
|
name: 'Call',
|
|
iconName: 'lucide:phone' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IContact }) => {
|
|
appState.selectContact(item);
|
|
appRouter.navigateTo('phone' as any);
|
|
},
|
|
},
|
|
{
|
|
name: 'Star',
|
|
iconName: 'lucide:star' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IContact }) => {
|
|
await this.toggleStar(item);
|
|
},
|
|
},
|
|
{
|
|
name: 'Edit',
|
|
iconName: 'lucide:pencil' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IContact }) => {
|
|
await this.openEditModal(item);
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2' as any,
|
|
type: ['inRow'] as any,
|
|
actionFunc: async ({ item }: { item: IContact }) => {
|
|
await this.deleteContact(item);
|
|
},
|
|
},
|
|
{
|
|
name: 'Add Contact',
|
|
iconName: 'lucide:plus' as any,
|
|
type: ['header'] as any,
|
|
actionFunc: async () => {
|
|
await this.openAddModal();
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
// ---------- Render ----------
|
|
|
|
public render(): TemplateResult {
|
|
const contacts = this.appData.contacts || [];
|
|
const companies = new Set(
|
|
contacts.map((c) => c.company?.trim()).filter((c) => c && c.length > 0),
|
|
);
|
|
|
|
const tiles: IStatsTile[] = [
|
|
{
|
|
id: 'total',
|
|
title: 'Total Contacts',
|
|
value: contacts.length,
|
|
type: 'number',
|
|
icon: 'lucide:contactRound',
|
|
description: contacts.length === 1 ? '1 contact' : `${contacts.length} contacts`,
|
|
},
|
|
{
|
|
id: 'starred',
|
|
title: 'Starred',
|
|
value: contacts.filter((c) => c.starred).length,
|
|
type: 'number',
|
|
icon: 'lucide:star',
|
|
color: 'hsl(45 93% 47%)',
|
|
description: 'Quick-dial contacts',
|
|
},
|
|
{
|
|
id: 'companies',
|
|
title: 'Companies',
|
|
value: companies.size,
|
|
type: 'number',
|
|
icon: 'lucide:building2',
|
|
description: `${companies.size} unique`,
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<div class="view-section">
|
|
<dees-statsgrid .tiles=${tiles} .minTileWidth=${220} .gap=${16}></dees-statsgrid>
|
|
</div>
|
|
|
|
<div class="view-section">
|
|
<dees-table
|
|
heading1="Contacts"
|
|
heading2="${contacts.length} total"
|
|
dataName="contacts"
|
|
.data=${this.sortedContacts(contacts)}
|
|
.rowKey=${'id'}
|
|
.searchable=${true}
|
|
.columns=${this.getColumns()}
|
|
.dataActions=${this.getDataActions()}
|
|
></dees-table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private sortedContacts(contacts: IContact[]): IContact[] {
|
|
return [...contacts].sort((a, b) => {
|
|
if (a.starred && !b.starred) return -1;
|
|
if (!a.starred && b.starred) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
}
|