Add channel and permission management, sounds, themes and the public server list

- Channels: create, edit, delete, link and move (drag and drop for channels and
  people), right-click on free space in the channel list
- Permission editor with inherited rules, allow/deny per permission, groups and
  members; menus grey out actions the server does not allow
- Notification sounds for connection, channel, chat, mute and push-to-talk events;
  custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB
- Color schemes: Dark, Light, Midnight, Frost and Windows 95
- Public server list with live UDP ping, fetched through Chromium's network stack
- Kick, ban and connection-lost card with Reconnect
- Right-click menus for text fields, links, images and message authors
- Formatting toolbar in the message box, shared with the description editor
- Resizable channel list, menu headers, registration from the self menu only
- Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly,
  so quick actions are no longer dropped silently

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:54:03 +02:00
co-authored by Claude Opus 5.5
parent aa679dddcc
commit 9a3aeb29ad
53 changed files with 2684 additions and 170 deletions
+3 -38
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import FormatToolbar from './FormatToolbar.svelte';
import { toMumbleHtml, formatBytes } from '../lib/html.ts';
import { makeInlineImage } from '../lib/upload.ts';
import { ui } from '../lib/ui.svelte.ts';
@@ -17,7 +17,6 @@
let sourceText = $state('');
let html = $state('');
let error = $state('');
let color = $state('#e8b23c');
const size = $derived(html.length);
const over = $derived(limit > 0 && size > limit);
@@ -96,34 +95,11 @@
if (cmd) { e.preventDefault(); exec(cmd); }
}
const tools = [
{ cmd: 'bold', label: 'Bold (Ctrl+B)', text: 'B', cls: 'b' },
{ cmd: 'italic', label: 'Italic (Ctrl+I)', text: 'I', cls: 'i' },
{ cmd: 'underline', label: 'Underline (Ctrl+U)', text: 'U', cls: 'u' },
{ cmd: 'strikeThrough', label: 'Strikethrough', text: 'S', cls: 's' }
];
</script>
<div class="rich" class:over>
<div class="toolbar" role="toolbar" aria-label="Formatting">
{#each tools as t (t.cmd)}
<button class="tool {t.cls}" title={t.label} aria-label={t.label} disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec(t.cmd)}>{t.text}</button>
{/each}
<span class="sep"></span>
<label class="tool color" title="Text color">
<span class="swatch" style:background={color}></span>
<input type="color" bind:value={color} disabled={source} onchange={() => exec('foreColor', color)} aria-label="Text color" />
</label>
<span class="sep"></span>
<button class="tool" title="Bulleted list" aria-label="Bulleted list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertUnorderedList')}><Icon name="list" size={16} /></button>
<button class="tool" title="Numbered list" aria-label="Numbered list" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('insertOrderedList')}><Icon name="list-ordered" size={16} /></button>
<button class="tool" title="Link" aria-label="Link" disabled={source} onmousedown={e => e.preventDefault()} onclick={link}><Icon name="link" size={16} /></button>
<button class="tool" title="Insert picture" aria-label="Insert picture" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => picker.click()}><Icon name="image" size={16} /></button>
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<button class="tool" title="Clear formatting" aria-label="Clear formatting" disabled={source} onmousedown={e => e.preventDefault()} onclick={() => exec('removeFormat')}><Icon name="eraser" size={16} /></button>
<span class="grow"></span>
<button class="tool" class:on={source} title="Edit HTML" aria-label="Edit HTML" aria-pressed={source} onclick={toggleSource}><Icon name="code" size={16} /></button>
</div>
<FormatToolbar {exec} onlink={link} onimage={() => picker.click()} {source} ontogglesource={toggleSource} />
<input bind:this={picker} type="file" accept="image/*" hidden onchange={() => { const f = picker.files?.[0]; picker.value = ''; if (f) insertImage(f); }} />
<div class="area" class:hidden={source} class:blank={!html} bind:this={editor} contenteditable="true" role="textbox" aria-multiline="true"
aria-label="Description" tabindex="0" data-placeholder={placeholder} oninput={sync} {onpaste} {onkeydown}></div>
@@ -142,18 +118,7 @@
.rich { border: 1px solid var(--line); background: var(--bg-0); }
.rich:focus-within { border-color: var(--accent); }
.rich.over { border-color: var(--danger); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 4px; border-bottom: 1px solid var(--line); background: var(--bg-2); }
.tool { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; cursor: pointer; }
.tool:hover:not(:disabled), .tool.on { background: var(--bg-hover); color: var(--text); }
.b { font-weight: 800; }
.i { font-style: italic; font-family: Georgia, serif; }
.u { text-decoration: underline; }
.s { text-decoration: line-through; }
.sep { width: 1px; height: 18px; background: var(--line); margin: 0 4px; }
.grow { flex: 1; }
.color { position: relative; margin: 0; }
.swatch { width: 14px; height: 14px; border: 1px solid var(--text-faint); }
.color input { position: absolute; inset: 0; opacity: 0; cursor: pointer; padding: 0; }
.area { position: relative; min-height: 140px; max-height: 360px; overflow-y: auto; padding: 8px 10px; outline: none; overflow-wrap: anywhere; line-height: 1.45; }
.area.hidden { display: none; }
.area :global(p) { margin: 0 0 6px; }