control panel ui

This commit is contained in:
2026-08-27 00:09:20 +08:00
parent daf374bf9e
commit 7088f68507
16 changed files with 1211 additions and 7 deletions
+127 -5
View File
@@ -1,9 +1,131 @@
<script lang="ts">
//
import { getCurrentWindow } from "@tauri-apps/api/window";
import ActivityPanel from "$lib/components/control-panel/activity-panel.svelte";
import FriendsPanel from "$lib/components/control-panel/friends-panel.svelte";
import GeneralPanel from "$lib/components/control-panel/general-panel.svelte";
import NetworkPanel from "$lib/components/control-panel/network-panel.svelte";
import type {
PanelActions,
PanelState,
} from "$lib/components/control-panel/types";
const tabs = [
{ id: "general", label: "General" },
{ id: "friends", label: "Friends" },
{ id: "network", label: "Network" },
{ id: "activity", label: "Activity" },
] as const;
type TabId = (typeof tabs)[number]["id"];
let activeTab = $state<TabId>("general");
let actions: Partial<Record<TabId, PanelActions>> = {};
let panelStates = $state<Record<TabId, PanelState>>({
general: { dirty: false, busy: false },
friends: { dirty: false, busy: false },
network: { dirty: false, busy: false },
activity: { dirty: false, busy: false },
});
let anyDirty = $derived(tabs.some((tab) => panelStates[tab.id].dirty));
let anyBusy = $derived(tabs.some((tab) => panelStates[tab.id].busy));
function register(
name: string,
nextActions: PanelActions,
state: PanelState,
) {
const id = name as TabId;
actions[id] = nextActions;
if (
panelStates[id].dirty !== state.dirty ||
panelStates[id].busy !== state.busy
) {
panelStates[id] = state;
}
}
async function applyAll() {
for (const tab of tabs) {
if (!panelStates[tab.id].dirty) continue;
if (!(await actions[tab.id]?.apply())) {
activeTab = tab.id;
return false;
}
}
return true;
}
async function hideWindow() {
try {
await getCurrentWindow().hide();
} catch (error) {
console.error("failed to hide control panel", error);
}
}
async function confirm() {
if (await applyAll()) await hideWindow();
}
function cancel() {
tabs.forEach((tab) => actions[tab.id]?.reset());
void hideWindow();
}
</script>
<section>
<div>
<p>Control panel</p>
<svelte:head><title>Wyd Properties</title></svelte:head>
<main
class="flex h-full min-h-0 flex-col overflow-hidden bg-base-100 p-2 text-base-content"
>
<div class="tabs tabs-lift min-h-0 flex-1">
{#each tabs as tab}
<input
id={`${tab.id}-tab`}
type="radio"
name="control_panel_tabs"
class="tab"
aria-label={`${tab.label}${panelStates[tab.id].dirty ? " *" : ""}`}
value={tab.id}
bind:group={activeTab}
/>
<div
class="tab-content h-[calc(100%-1.75rem)] w-full overflow-hidden border border-base-300 bg-base-100 p-3"
role="tabpanel"
aria-labelledby={`${tab.id}-tab`}
>
<div class="h-full">
{#if tab.id === "general"}
<GeneralPanel {register} />
{:else if tab.id === "friends"}
<FriendsPanel {register} />
{:else if tab.id === "network"}
<NetworkPanel {register} />
{:else}
<ActivityPanel {register} />
{/if}
</div>
</div>
{/each}
</div>
</section>
<div class="flex shrink-0 justify-end gap-1.5 pt-2">
<button
class="btn min-w-16"
type="button"
disabled={anyBusy}
onclick={confirm}>OK</button
>
<button
class="btn min-w-16"
type="button"
disabled={anyBusy}
onclick={cancel}>Cancel</button
>
<button
class="btn min-w-16"
type="button"
disabled={!anyDirty || anyBusy}
onclick={applyAll}>{anyBusy ? "Applying…" : "Apply"}</button
>
</div>
</main>
@@ -0,0 +1,96 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { commands } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
import Info from "$lib/icons/info.svelte";
let displayName = $state("");
let friendId = $state("");
let busy = $state(false);
let error = $state("");
async function closeWindow() {
await getCurrentWindow().close();
}
async function submit(event: SubmitEvent) {
event.preventDefault();
const name = displayName.trim();
const id = friendId.trim();
if (!name || !id) {
error = "Both display name and public key are required.";
return;
}
busy = true;
error = "";
try {
await commands.createFriend({ id, displayName: name });
await closeWindow();
} catch (cause) {
error = String(cause);
busy = false;
}
}
</script>
<svelte:head><title>Add Friend</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
<form
class="flex h-full flex-col bg-base-100 p-3 text-base-content"
onsubmit={submit}
>
<div class="min-h-0 flex-1 space-y-2">
<div>
<h1 class="text-sm font-bold">Add a friend</h1>
<p class="text-xs text-base-content/65">
Enter the identity your friend shared with you.
</p>
</div>
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Friend identity</legend>
<label class="fieldset-label" for="friend-name">Display name</label>
<input
id="friend-name"
class="input w-full"
bind:value={displayName}
maxlength="64"
autocomplete="off"
required
/>
<label class="fieldset-label mt-1" for="friend-key"
>Identification Key <div
class="tooltip tooltip-primary"
data-tip="Verify with your friend before adding."
>
<div class="*:size-3">
<Info />
</div>
</div></label
>
<textarea
id="friend-key"
class="textarea h-20 w-full resize-none font-mono text-[10px]"
bind:value={friendId}
required></textarea>
</fieldset>
</div>
<div class="flex shrink-0 justify-end gap-1.5 border-t border-base-300 pt-2">
<button class="btn min-w-16" type="submit" disabled={busy}
>{busy ? "Adding…" : "OK"}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
>
</div>
</form>
@@ -0,0 +1,121 @@
<script lang="ts">
import { getCurrentWindow } from "@tauri-apps/api/window";
import { commands, type RemoteInput } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
let name = $state("");
let address = $state("");
let port = $state("");
let busy = $state(false);
let error = $state("");
async function closeWindow() {
await getCurrentWindow().close();
}
function remoteInput(): RemoteInput | null {
const parsedPort = port ? Number(port) : null;
if (!address.trim()) {
error = "Server address is required.";
return null;
}
if (
parsedPort !== null &&
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
) {
error = "Port must be a whole number from 1 to 65535.";
return null;
}
return {
address: address.trim(),
name: name.trim() || null,
port: parsedPort,
};
}
async function submit(event: SubmitEvent) {
event.preventDefault();
error = "";
const remote = remoteInput();
if (!remote) return;
busy = true;
try {
await commands.createRemote(remote);
await closeWindow();
} catch (cause) {
error = String(cause);
busy = false;
}
}
</script>
<svelte:head><title>Add Server</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
<form
class="flex h-full flex-col bg-base-100 p-3 text-base-content"
onsubmit={submit}
>
<div class="min-h-0 flex-1 space-y-2">
<div>
<h1 class="text-sm font-bold">Add a server</h1>
<p class="text-xs text-base-content/65">
Configure a remote server used to connect with friends.
</p>
</div>
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Server details</legend>
<label class="fieldset-label" for="remote-name"
>Friendly name (optional)</label
>
<input
id="remote-name"
class="input w-full"
bind:value={name}
maxlength="64"
autocomplete="off"
/>
<label class="fieldset-label mt-1" for="remote-address">Address</label>
<input
id="remote-address"
class="input w-full"
bind:value={address}
placeholder="example.net"
autocomplete="off"
required
/>
<label class="fieldset-label mt-1" for="remote-port"
>Port (optional)</label
>
<input
id="remote-port"
class="input w-28"
bind:value={port}
type="number"
min="1"
max="65535"
inputmode="numeric"
placeholder="Default"
/>
<p class="fieldset-label">Leave port blank to use the server default.</p>
</fieldset>
</div>
<div class="flex shrink-0 justify-end gap-1.5 border-t border-base-300 pt-2">
<button class="btn min-w-16" type="submit" disabled={busy}
>{busy ? "Adding…" : "OK"}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
>
</div>
</form>
@@ -0,0 +1,172 @@
<script lang="ts">
import { page } from "$app/stores";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { onMount } from "svelte";
import { commands, type RemoteInput } from "$lib/bindings";
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
let name = $state("");
let address = $state("");
let port = $state("");
let loading = $state(true);
let busy = $state(false);
let available = $state(false);
let error = $state("");
const remoteId = $derived($page.params.id ?? "");
onMount(() => {
let active = true;
if (!remoteId) {
error = "No server was selected.";
loading = false;
return;
}
void commands
.getRemote(remoteId)
.then((remote) => {
if (!active) return;
if (!remote) {
error = "This server no longer exists.";
return;
}
name = remote.name ?? "";
address = remote.address;
port = remote.port?.toString() ?? "";
available = true;
})
.catch((cause) => {
if (active) error = String(cause);
})
.finally(() => {
if (active) loading = false;
});
return () => {
active = false;
};
});
async function closeWindow() {
await getCurrentWindow().close();
}
function remoteInput(): RemoteInput | null {
const parsedPort = port ? Number(port) : null;
if (!address.trim()) {
error = "Server address is required.";
return null;
}
if (
parsedPort !== null &&
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
) {
error = "Port must be a whole number from 1 to 65535.";
return null;
}
return {
address: address.trim(),
name: name.trim() || null,
port: parsedPort,
};
}
async function submit(event: SubmitEvent) {
event.preventDefault();
error = "";
const remote = remoteInput();
if (!remote || !available) return;
busy = true;
try {
const updated = await commands.updateRemote(remoteId, remote);
if (!updated) {
error = "This server no longer exists.";
available = false;
busy = false;
return;
}
await closeWindow();
} catch (cause) {
error = String(cause);
busy = false;
}
}
</script>
<svelte:head><title>Edit Server</title></svelte:head>
<svelte:window
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
/>
<form
class="flex h-full flex-col bg-base-100 p-3 text-base-content"
onsubmit={submit}
>
<div class="min-h-0 flex-1 space-y-2">
<div>
<h1 class="text-sm font-bold">Edit server</h1>
<p class="text-xs text-base-content/65">
Update how Wyd connects to this remote server.
</p>
</div>
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
<fieldset
class="fieldset border border-base-300 bg-base-100 p-3"
disabled={loading || busy || !available}
>
<legend class="fieldset-legend px-1">Server details</legend>
<label class="fieldset-label" for="remote-name"
>Friendly name (optional)</label
>
<input
id="remote-name"
class="input w-full"
bind:value={name}
maxlength="64"
autocomplete="off"
/>
<label class="fieldset-label mt-1" for="remote-address">Address</label>
<input
id="remote-address"
class="input w-full"
bind:value={address}
placeholder="example.net"
autocomplete="off"
required
/>
<label class="fieldset-label mt-1" for="remote-port"
>Port (optional)</label
>
<input
id="remote-port"
class="input w-28"
bind:value={port}
type="number"
min="1"
max="65535"
inputmode="numeric"
placeholder="Default"
/>
<p class="fieldset-label">Leave port blank to use the server default.</p>
</fieldset>
</div>
<div class="flex shrink-0 justify-end gap-1.5 border-t border-base-300 pt-2">
<button
class="btn min-w-16"
type="submit"
disabled={loading || busy || !available}>{busy ? "Saving…" : "OK"}</button
>
<button
class="btn min-w-16"
type="button"
disabled={busy}
onclick={closeWindow}>Cancel</button
>
</div>
</form>