Add release builds, classic and stacked layouts
- electron-builder packaging: AppImage, deb and tar.gz for Linux, NSIS installer for Windows, app icon, desktop entry - Gitea workflow builds Linux and Windows on version tags and attaches the files to the release - Layout setting in Appearance: Modern, Classic (chat left, channels right) and Stacked (channels on top, chat below, resizable split), like the desktop Mumble client - No message box while not connected; Manage identities on the welcome page - Public list: a list that arrives after the source changed no longer replaces the new one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { store, THEMES, type ThemeId } from '../lib/settings.svelte.ts';
|
||||
import { store, THEMES, type ThemeId, type Arrangement } from '../lib/settings.svelte.ts';
|
||||
|
||||
const ARRANGEMENTS: { id: Arrangement; name: string; note: string }[] = [
|
||||
{ id: 'side', name: 'Modern', note: 'Channels left, members right' },
|
||||
{ id: 'classic', name: 'Classic', note: 'Chat left, channels right, like Mumble' },
|
||||
{ id: 'stacked', name: 'Stacked', note: 'Channels on top, chat below, like Mumble' }
|
||||
];
|
||||
|
||||
function arrange(id: Arrangement) {
|
||||
store.settings.arrangement = id;
|
||||
store.saveSettings();
|
||||
}
|
||||
|
||||
function pick(id: ThemeId) {
|
||||
store.settings.theme = id;
|
||||
@@ -28,7 +39,41 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="gap">Layout</h3>
|
||||
<div class="themes" role="radiogroup" aria-label="Layout">
|
||||
{#each ARRANGEMENTS as a (a.id)}
|
||||
{@const on = (store.settings.arrangement ?? 'side') === a.id}
|
||||
<button role="radio" aria-checked={on} class:active={on} onclick={() => arrange(a.id)} aria-label={a.name}>
|
||||
<span class="mini lay {a.id}" aria-hidden="true">
|
||||
<span class="m-rail"><span></span><span></span></span>
|
||||
{#if a.id === 'side'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
|
||||
{#if a.id === 'stacked'}
|
||||
<span class="m-col">
|
||||
<span class="m-side"><i></i><i class="sel"></i></span>
|
||||
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><span class="m-box"></span></span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="m-chat"><span class="m-head"></span><i class="a"></i><i></i><i class="short"></i><span class="m-box"></span></span>
|
||||
{/if}
|
||||
{#if a.id === 'classic'}<span class="m-side"><i></i><i class="sel"></i><i></i></span>{/if}
|
||||
{#if a.id === 'side'}<span class="m-members"><i></i><i></i></span>{/if}
|
||||
</span>
|
||||
<span class="name">{a.name}</span>
|
||||
<span class="note">{a.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="help">Narrow windows always use the phone layout.</p>
|
||||
|
||||
<style>
|
||||
.gap { margin-top: 22px; }
|
||||
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
|
||||
.m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.m-col .m-chat { flex: 1; }
|
||||
.m-col .m-side { width: auto; height: 34%; flex: none; padding: 5px; gap: 4px; border-bottom: 1px solid var(--line); }
|
||||
.m-members { width: 18%; background: var(--bg-1); display: flex; flex-direction: column; gap: 5px; padding: 8px 4px; border-left: 1px solid var(--line); }
|
||||
.m-members i { height: 4px; background: var(--text-faint); width: 80%; }
|
||||
.lay.classic .m-side { border-left: 1px solid var(--line); }
|
||||
h3 { margin: 8px 0 0; font-size: 14px; }
|
||||
.themes { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin-top: 10px; }
|
||||
.themes > button { display: flex; flex-direction: column; gap: 2px; padding: 8px; text-align: left; border: 1px solid var(--line); background: var(--bg-0); }
|
||||
|
||||
+8
-3
@@ -58,13 +58,18 @@
|
||||
{:else}
|
||||
<h2>mumh5</h2>
|
||||
<p>Choose a server to get started, or find one:</p>
|
||||
<button class="btn browse" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
|
||||
<div class="welcome-actions">
|
||||
<button class="btn" onclick={() => (ui.publicServers = true)}>Browse public servers</button>
|
||||
<button class="btn" onclick={() => { ui.settingsTab = 'identities'; ui.settingsOpen = true; }}>Manage identities</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChatLog>
|
||||
|
||||
<Composer target={session.view} placeholder={session.status === 'connected' ? `Message ${isDm ? '@' : '#'}${title}` : 'Not connected'} />
|
||||
{#if session.status === 'connected'}
|
||||
<Composer target={session.view} placeholder={`Message ${isDm ? '@' : '#'}${title}`} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -79,7 +84,7 @@
|
||||
.empty { padding: 32px 16px 8px; }
|
||||
.empty h2 { margin: 0 0 4px; font-size: 24px; }
|
||||
.empty p { margin: 0; color: var(--text-dim); }
|
||||
.browse { margin-top: 12px; }
|
||||
.welcome-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.ended { max-width: 460px; margin: 16px 16px 0; padding: 16px 18px; background: var(--bg-1); border: 1px solid var(--line); border-left: 4px solid var(--warn); }
|
||||
.ended.bad { border-left-color: var(--danger); }
|
||||
.ended h2 { margin: 0; font-size: 20px; }
|
||||
|
||||
@@ -26,17 +26,23 @@
|
||||
|
||||
async function load() {
|
||||
// A new list starts a new ping round; answers from the previous round are dropped
|
||||
generation++;
|
||||
// and a list that arrives after the source changed is ignored
|
||||
const round = ++generation;
|
||||
queue = [];
|
||||
running = 0;
|
||||
loading = true;
|
||||
error = '';
|
||||
let list: PublicServer[] = [];
|
||||
let failed = '';
|
||||
try {
|
||||
servers = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
|
||||
if (!native) error = 'The public list needs the desktop app.';
|
||||
list = (await native?.publicServers(store.settings.publicListUrl)) ?? [];
|
||||
if (!native) failed = 'The public list needs the desktop app.';
|
||||
} catch (e) {
|
||||
error = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||
failed = (e as Error).message.replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||
}
|
||||
if (round !== generation || !alive) return;
|
||||
servers = list;
|
||||
error = failed;
|
||||
loading = false;
|
||||
pingVisible();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
// Drag handle on one edge of a panel. `edge` is the side the handle sits on: a panel on
|
||||
// the right of the window is resized from its left edge and vice versa. Width is remembered.
|
||||
// the right of the window is resized from its left edge and vice versa; a panel at the
|
||||
// bottom from its top edge and one at the top from its bottom edge (then `width` is its
|
||||
// height). The size is remembered.
|
||||
let { width = $bindable(), edge, min, max, initial, storageKey, label }:
|
||||
{ width: number; edge: 'left' | 'right'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
|
||||
{ width: number; edge: 'left' | 'right' | 'top' | 'bottom'; min: number; max: () => number; initial: number; storageKey: string; label: string } = $props();
|
||||
|
||||
let resizing = $state(false);
|
||||
|
||||
@@ -18,7 +20,7 @@
|
||||
resizing = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
const box = (handle.parentElement as HTMLElement).getBoundingClientRect();
|
||||
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : ev.clientX - box.left);
|
||||
const move = (ev: PointerEvent) => setWidth(edge === 'left' ? box.right - ev.clientX : edge === 'top' ? box.bottom - ev.clientY : edge === 'bottom' ? ev.clientY - box.top : ev.clientX - box.left);
|
||||
const up = () => {
|
||||
resizing = false;
|
||||
document.body.style.userSelect = '';
|
||||
@@ -32,8 +34,8 @@
|
||||
}
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
const grow = edge === 'left' ? 'ArrowLeft' : 'ArrowRight';
|
||||
const shrink = edge === 'left' ? 'ArrowRight' : 'ArrowLeft';
|
||||
const grow = edge === 'left' ? 'ArrowLeft' : edge === 'top' ? 'ArrowUp' : edge === 'bottom' ? 'ArrowDown' : 'ArrowRight';
|
||||
const shrink = edge === 'left' ? 'ArrowRight' : edge === 'top' ? 'ArrowDown' : edge === 'bottom' ? 'ArrowUp' : 'ArrowLeft';
|
||||
if (e.key === grow) setWidth(width + 16);
|
||||
else if (e.key === shrink) setWidth(width - 16);
|
||||
else return;
|
||||
@@ -45,7 +47,7 @@
|
||||
|
||||
<!-- Focusable separator is the ARIA window-splitter pattern -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation="vertical" aria-label={label}
|
||||
<div class="resize {edge}" class:active={resizing} role="separator" aria-orientation={edge === 'top' || edge === 'bottom' ? 'horizontal' : 'vertical'} aria-label={label}
|
||||
aria-valuenow={width} aria-valuemin={min} aria-valuemax={max()} tabindex="0" title="Drag to resize, double-click to reset"
|
||||
onpointerdown={start} ondblclick={() => setWidth(initial)} {onkeydown}></div>
|
||||
|
||||
@@ -53,6 +55,13 @@
|
||||
.resize { position: absolute; z-index: 5; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; }
|
||||
.resize.left { left: -4px; }
|
||||
.resize.right { right: -4px; }
|
||||
.resize.top, .resize.bottom { left: 0; right: 0; width: auto; height: 8px; cursor: row-resize; }
|
||||
.resize.top { top: -4px; bottom: auto; }
|
||||
.resize.bottom { bottom: -4px; top: auto; }
|
||||
.resize.top:hover, .resize.top:focus-visible, .resize.top.active,
|
||||
.resize.bottom:hover, .resize.bottom:focus-visible, .resize.bottom.active {
|
||||
background: linear-gradient(to bottom, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
|
||||
}
|
||||
.resize:hover, .resize:focus-visible, .resize.active {
|
||||
background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px);
|
||||
outline: none;
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
import { voice } from '../lib/audio/voice.svelte.ts';
|
||||
|
||||
// resizable: false in the phone drawer, which keeps a fixed width
|
||||
let { onnavigate, resizable = true }: { onnavigate: () => void; resizable?: boolean } = $props();
|
||||
// handleEdge: which side the resize handle sits on (left when the list is on the right, classic layout)
|
||||
// fill: take the whole area (stacked layout, where the parent sizes it)
|
||||
let { onnavigate, resizable = true, handleEdge = 'right', fill = false }:
|
||||
{ onnavigate: () => void; resizable?: boolean; handleEdge?: 'left' | 'right'; fill?: boolean } = $props();
|
||||
|
||||
const MIN_W = 200, DEFAULT_W = 260;
|
||||
const maxW = () => Math.max(MIN_W, Math.min(520, window.innerWidth - 520));
|
||||
@@ -58,8 +61,8 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" style:width={resizable ? `${width}px` : undefined}>
|
||||
{#if resizable}<ResizeHandle bind:width edge="right" min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
|
||||
<aside class="sidebar" class:fill style:width={resizable && !fill ? `${width}px` : undefined}>
|
||||
{#if resizable && !fill}<ResizeHandle bind:width edge={handleEdge} min={MIN_W} max={maxW} initial={DEFAULT_W} storageKey="mumh5.sidebarWidth" label="Resize channel list" />{/if}
|
||||
<header class:trusted>
|
||||
<button class="title" onclick={showServerCert} disabled={!cert} title={cert ? (trusted ? 'Valid certificate. Click to view' : 'Self-signed or untrusted certificate. Click to view') : ''}>
|
||||
<strong>{#if cert}<Icon name={trusted ? 'lock' : 'shield'} size={14} />{/if}{session.server?.label ?? 'mumh5'}</strong>
|
||||
@@ -134,6 +137,7 @@
|
||||
|
||||
<style>
|
||||
.sidebar { position: relative; width: 260px; flex: none; display: flex; flex-direction: column; background: var(--bg-1); min-height: 0; }
|
||||
.sidebar.fill { width: 100%; height: 100%; }
|
||||
header { height: 52px; flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 0; border-bottom: 1px solid var(--line); }
|
||||
header.trusted { background: #1d5c38; border-bottom-color: #2a7a4c; }
|
||||
header.trusted .status, header.trusted .icon-btn { color: #cfeedd; }
|
||||
|
||||
Reference in New Issue
Block a user