control panel ui
This commit is contained in:
@@ -2,9 +2,11 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for Wyd UI windows",
|
"description": "Capability for Wyd UI windows",
|
||||||
"windows": ["debug", "scene"],
|
"windows": ["control-panel", "control-panel-action-*", "debug", "scene"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
"opener:default"
|
"opener:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
|||||||
images::pick_and_send_image,
|
images::pick_and_send_image,
|
||||||
images::send_image_bytes,
|
images::send_image_bytes,
|
||||||
interactions::send_interaction,
|
interactions::send_interaction,
|
||||||
|
ui::control_panel::open_action_window,
|
||||||
ui::scene::update_scene_hitboxes,
|
ui::scene::update_scene_hitboxes,
|
||||||
])
|
])
|
||||||
.events(tauri_specta::collect_events![
|
.events(tauri_specta::collect_events![
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use tauri::{AppHandle, Manager, WebviewUrl};
|
use tauri::{AppHandle, Manager, WebviewUrl};
|
||||||
|
|
||||||
const WINDOW_LABEL: &str = "control-panel";
|
const WINDOW_LABEL: &str = "control-panel";
|
||||||
|
const ACTION_WINDOW_PREFIX: &str = "control-panel-action-";
|
||||||
|
|
||||||
pub fn init(handle: &AppHandle) {
|
pub fn init(handle: &AppHandle) {
|
||||||
if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
|
if let Some(window) = handle.get_webview_window(WINDOW_LABEL) {
|
||||||
@@ -33,3 +34,51 @@ pub fn init(handle: &AppHandle) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn open_action_window(
|
||||||
|
handle: AppHandle,
|
||||||
|
name: String,
|
||||||
|
title: String,
|
||||||
|
page_url: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| !name
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_alphanumeric() || character == '-')
|
||||||
|
{
|
||||||
|
return Err("Action window name must contain only letters, numbers, or hyphens.".into());
|
||||||
|
}
|
||||||
|
if title.trim().is_empty() || title.chars().count() > 80 {
|
||||||
|
return Err("Action window title must contain between 1 and 80 characters.".into());
|
||||||
|
}
|
||||||
|
if !(page_url.starts_with("/control-panel/add/")
|
||||||
|
|| page_url.starts_with("/control-panel/edit/"))
|
||||||
|
|| page_url.contains("..")
|
||||||
|
|| page_url.contains(['?', '#'])
|
||||||
|
{
|
||||||
|
return Err("Action windows can only open control-panel add or edit pages.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = format!("{ACTION_WINDOW_PREFIX}{name}");
|
||||||
|
if let Some(window) = handle.get_webview_window(&label) {
|
||||||
|
window.show().map_err(|error| error.to_string())?;
|
||||||
|
window.center().map_err(|error| error.to_string())?;
|
||||||
|
window.set_focus().map_err(|error| error.to_string())?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tauri::WebviewWindowBuilder::new(&handle, label, WebviewUrl::App(page_url.into()))
|
||||||
|
.title(title)
|
||||||
|
.inner_size(300.0, 450.0)
|
||||||
|
.min_inner_size(300.0, 450.0)
|
||||||
|
.resizable(false)
|
||||||
|
.minimizable(false)
|
||||||
|
.maximizable(false)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
|
|
||||||
mod control_panel;
|
pub mod control_panel;
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
mod debug;
|
mod debug;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ async sendImageBytes(recipientId: string, bytes: number[]) : Promise<null> {
|
|||||||
async sendInteraction(recipientId: string, content: InteractionContent) : Promise<null> {
|
async sendInteraction(recipientId: string, content: InteractionContent) : Promise<null> {
|
||||||
return await TAURI_INVOKE("send_interaction", { recipientId, content });
|
return await TAURI_INVOKE("send_interaction", { recipientId, content });
|
||||||
},
|
},
|
||||||
|
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
|
||||||
|
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
|
||||||
|
},
|
||||||
async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise<null> {
|
async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise<null> {
|
||||||
return await TAURI_INVOKE("update_scene_hitboxes", { hitboxes });
|
return await TAURI_INVOKE("update_scene_hitboxes", { hitboxes });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { friends } from "$lib/listeners/friends";
|
||||||
|
import {
|
||||||
|
liveMetadata,
|
||||||
|
liveMetadataListenerError,
|
||||||
|
} from "$lib/listeners/live-metadata";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import PanelMessage from "./panel-message.svelte";
|
||||||
|
import type { RegisterPanel } from "./types";
|
||||||
|
|
||||||
|
let { register }: { register: RegisterPanel } = $props();
|
||||||
|
|
||||||
|
const actions = { apply: async () => true, reset: () => undefined };
|
||||||
|
onMount(() => register("activity", actions, { dirty: false, busy: false }));
|
||||||
|
|
||||||
|
function userIds() {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
$liveMetadata.localId,
|
||||||
|
...Object.keys($liveMetadata.cursorPositions),
|
||||||
|
...$liveMetadata.foregroundApps.keys(),
|
||||||
|
]),
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameFor(id: string) {
|
||||||
|
if (id === $liveMetadata.localId) return "You";
|
||||||
|
return (
|
||||||
|
$friends.find((friend) => friend.id === id)?.displayName ??
|
||||||
|
"Unknown friend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactId(id: string) {
|
||||||
|
return id.length > 18 ? `${id.slice(0, 9)}…${id.slice(-6)}` : id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-2">
|
||||||
|
{#if $liveMetadataListenerError}
|
||||||
|
<PanelMessage kind="error">{$liveMetadataListenerError}</PanelMessage>
|
||||||
|
{:else if userIds().length === 0}
|
||||||
|
<PanelMessage
|
||||||
|
>Waiting for activity data. This usually appears after Wyd has started
|
||||||
|
monitoring.</PanelMessage
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto border border-base-300 bg-base-100"
|
||||||
|
>
|
||||||
|
{#each userIds() as userId (userId)}
|
||||||
|
{@const cursor = $liveMetadata.cursorPositions[userId]}
|
||||||
|
{@const app = $liveMetadata.foregroundApps.get(userId)}
|
||||||
|
<article class="border-b border-base-300 p-2 last:border-b-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if app?.ico}
|
||||||
|
<img
|
||||||
|
class="pixelated size-8 border border-base-300 bg-base-200 p-0.5"
|
||||||
|
src={`data:image/png;base64,${app.ico}`}
|
||||||
|
alt=""
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid size-8 shrink-0 place-content-center border border-base-300 bg-base-200 text-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
▣
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<strong class="truncate text-xs">{nameFor(userId)}</strong>
|
||||||
|
{#if userId === $liveMetadata.localId}<span class="badge badge-xs"
|
||||||
|
>Local</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="truncate font-mono text-[9px] text-base-content/55"
|
||||||
|
title={userId}
|
||||||
|
>
|
||||||
|
{compactId(userId)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl
|
||||||
|
class="mt-2 grid grid-cols-[5rem_1fr] gap-x-2 gap-y-0.5 text-[10px]"
|
||||||
|
>
|
||||||
|
<dt class="text-base-content/60">Foreground app</dt>
|
||||||
|
<dd class="truncate">
|
||||||
|
{app?.local ?? app?.unlocal ?? "Not available"}
|
||||||
|
</dd>
|
||||||
|
<dt class="text-base-content/60">Cursor</dt>
|
||||||
|
<dd>
|
||||||
|
{cursor
|
||||||
|
? `${Math.round(cursor.raw.x)}, ${Math.round(cursor.raw.y)} px`
|
||||||
|
: "Not available"}
|
||||||
|
</dd>
|
||||||
|
<dt class="text-base-content/60">Mapped</dt>
|
||||||
|
<dd>
|
||||||
|
{cursor
|
||||||
|
? `${cursor.mapped.x.toFixed(3)}, ${cursor.mapped.y.toFixed(3)}`
|
||||||
|
: "Not available"}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { commands } from "$lib/bindings";
|
||||||
|
import {
|
||||||
|
friendStatusesListenerError,
|
||||||
|
onlineFriendIds,
|
||||||
|
} from "$lib/listeners/friend-statuses";
|
||||||
|
import { friends, friendsListenerError } from "$lib/listeners/friends";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import PanelMessage from "./panel-message.svelte";
|
||||||
|
import type { RegisterPanel } from "./types";
|
||||||
|
|
||||||
|
let { register }: { register: RegisterPanel } = $props();
|
||||||
|
|
||||||
|
let selectedId = $state<string | null>(null);
|
||||||
|
let mode = $state<"browse" | "remove">("browse");
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
let dirty = $derived(mode !== "browse");
|
||||||
|
let selected = $derived(
|
||||||
|
$friends.find((friend) => friend.id === selectedId) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => register("friends", { apply, reset }, { dirty, busy }));
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedId && !$friends.some((friend) => friend.id === selectedId))
|
||||||
|
selectedId = null;
|
||||||
|
});
|
||||||
|
onMount(() => register("friends", { apply, reset }, { dirty, busy }));
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
mode = "browse";
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddActionWindow() {
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await commands.openActionWindow(
|
||||||
|
"friend",
|
||||||
|
"Add Friend",
|
||||||
|
"/control-panel/add/friend",
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (mode === "browse") return true;
|
||||||
|
if (mode === "remove" && !selected) {
|
||||||
|
error = "The selected friend no longer exists.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
if (selected) {
|
||||||
|
await commands.deleteFriend(selected.id);
|
||||||
|
selectedId = null;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-2">
|
||||||
|
{#if error || $friendsListenerError || $friendStatusesListenerError}
|
||||||
|
<PanelMessage kind="error"
|
||||||
|
>{error ||
|
||||||
|
$friendsListenerError ||
|
||||||
|
$friendStatusesListenerError}</PanelMessage
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === "remove" && selected}
|
||||||
|
<PanelMessage kind="warning">
|
||||||
|
Applying will remove <strong>{selected.displayName}</strong>. They will
|
||||||
|
immediately go offline and stop receiving your activity.
|
||||||
|
</PanelMessage>
|
||||||
|
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||||
|
>Keep friend</button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col border border-base-300 bg-base-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_4.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
|
||||||
|
>
|
||||||
|
<span>Name</span><span>Status</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Friends"
|
||||||
|
>
|
||||||
|
{#if $friends.length === 0}
|
||||||
|
<div
|
||||||
|
class="grid h-full min-h-36 place-content-center px-8 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-bold">No friends yet</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Add someone using their public key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each $friends as friend (friend.id)}
|
||||||
|
{@const online = $onlineFriendIds.has(friend.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedId === friend.id}
|
||||||
|
class="grid w-full grid-cols-[1fr_4.5rem] items-center border-b border-base-200 px-2 py-1.5 text-left text-xs hover:bg-base-200 aria-selected:bg-primary aria-selected:text-primary-content"
|
||||||
|
onclick={() => (selectedId = friend.id)}
|
||||||
|
ondblclick={() => (selectedId = friend.id)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<strong class="block truncate">{friend.displayName}</strong>
|
||||||
|
<span class="block truncate font-mono text-[9px] opacity-65"
|
||||||
|
>{friend.id}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class:status-success={online} class="status shadow-none"
|
||||||
|
></span>
|
||||||
|
{online ? "Online" : "Offline"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={openAddActionWindow}>Add…</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
disabled={!selected}
|
||||||
|
onclick={() => (mode = "remove")}>Remove</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { commands } from "$lib/bindings";
|
||||||
|
import { profile, profileListenerError } from "$lib/listeners/profile";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import PanelMessage from "./panel-message.svelte";
|
||||||
|
import type { RegisterPanel } from "./types";
|
||||||
|
import Info from "$lib/icons/info.svelte";
|
||||||
|
|
||||||
|
let { register }: { register: RegisterPanel } = $props();
|
||||||
|
|
||||||
|
let displayName = $state("");
|
||||||
|
let dirty = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!dirty) displayName = $profile?.displayName ?? "";
|
||||||
|
register("general", { apply, reset }, { dirty, busy });
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => register("general", { apply, reset }, { dirty, busy }));
|
||||||
|
|
||||||
|
function updateDirty() {
|
||||||
|
dirty = displayName.trim() !== ($profile?.displayName ?? "");
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
displayName = $profile?.displayName ?? "";
|
||||||
|
dirty = false;
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
const nextName = displayName.trim();
|
||||||
|
if (!dirty) return true;
|
||||||
|
if (!nextName) {
|
||||||
|
error = "Display name cannot be empty.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await commands.updateProfile(nextName);
|
||||||
|
displayName = nextName;
|
||||||
|
dirty = false;
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#if error || $profileListenerError}
|
||||||
|
<PanelMessage kind="error">{error || $profileListenerError}</PanelMessage>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||||
|
<legend class="fieldset-legend px-1">Identity</legend>
|
||||||
|
|
||||||
|
<label class="fieldset-label" for="display-name">Display name</label>
|
||||||
|
<input
|
||||||
|
id="display-name"
|
||||||
|
class="input w-full"
|
||||||
|
bind:value={displayName}
|
||||||
|
oninput={updateDirty}
|
||||||
|
maxlength="64"
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={!$profile || busy}
|
||||||
|
aria-describedby="display-name-help"
|
||||||
|
/>
|
||||||
|
<p id="display-name-help" class="fieldset-label">
|
||||||
|
This name can be changed at any time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="fieldset-label mt-2" for="public-key"
|
||||||
|
>Identification Key
|
||||||
|
<div
|
||||||
|
class="tooltip tooltip-primary"
|
||||||
|
data-tip="Share only with people you trust."
|
||||||
|
>
|
||||||
|
<div class="*:size-3">
|
||||||
|
<Info />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{#if $profile}
|
||||||
|
<div class="join w-full">
|
||||||
|
<input
|
||||||
|
id="public-key"
|
||||||
|
class="input join-item min-w-0 flex-1 font-mono text-[10px]"
|
||||||
|
value={$profile.id}
|
||||||
|
readonly
|
||||||
|
aria-label="Public key"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn join-item"
|
||||||
|
type="button"
|
||||||
|
onclick={() => navigator.clipboard.writeText($profile?.id ?? "")}
|
||||||
|
title="Copy public key">Copy</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else if $profileListenerError}
|
||||||
|
<input
|
||||||
|
id="public-key"
|
||||||
|
class="input w-full"
|
||||||
|
value="Unavailable"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="skeleton h-7 w-full" aria-label="Loading public key"></div>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { commands } from "$lib/bindings";
|
||||||
|
import {
|
||||||
|
connectionStatuses,
|
||||||
|
connectionStatusesListenerError,
|
||||||
|
} from "$lib/listeners/connection-status";
|
||||||
|
import { remotes, remotesListenerError } from "$lib/listeners/remotes";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import PanelMessage from "./panel-message.svelte";
|
||||||
|
import type { RegisterPanel } from "./types";
|
||||||
|
|
||||||
|
let { register }: { register: RegisterPanel } = $props();
|
||||||
|
|
||||||
|
let selectedId = $state<string | null>(null);
|
||||||
|
let mode = $state<"browse" | "remove">("browse");
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
let dirty = $derived(mode !== "browse");
|
||||||
|
let selected = $derived(
|
||||||
|
$remotes.find((remote) => remote.id === selectedId) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => register("network", { apply, reset }, { dirty, busy }));
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedId && !$remotes.some((remote) => remote.id === selectedId))
|
||||||
|
selectedId = null;
|
||||||
|
});
|
||||||
|
onMount(() => register("network", { apply, reset }, { dirty, busy }));
|
||||||
|
|
||||||
|
function statusFor(remoteId: string) {
|
||||||
|
return (
|
||||||
|
$connectionStatuses.find((status) => status.remoteId === remoteId)
|
||||||
|
?.state ?? "disconnected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddActionWindow() {
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await commands.openActionWindow(
|
||||||
|
"network",
|
||||||
|
"Add Server",
|
||||||
|
"/control-panel/add/network",
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditWindow(id: string) {
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
await commands.openActionWindow(
|
||||||
|
`network-edit-${id}`,
|
||||||
|
"Edit Server",
|
||||||
|
`/control-panel/edit/network/${encodeURIComponent(id)}`,
|
||||||
|
);
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
mode = "browse";
|
||||||
|
error = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (mode === "browse") return true;
|
||||||
|
if (mode === "remove" && !selected) {
|
||||||
|
error = "The selected server no longer exists.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
if (selected) {
|
||||||
|
await commands.deleteRemote(selected.id);
|
||||||
|
selectedId = null;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
return true;
|
||||||
|
} catch (cause) {
|
||||||
|
error = String(cause);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full min-h-0 flex-col gap-2">
|
||||||
|
{#if error || $remotesListenerError || $connectionStatusesListenerError}
|
||||||
|
<PanelMessage kind="error"
|
||||||
|
>{error ||
|
||||||
|
$remotesListenerError ||
|
||||||
|
$connectionStatusesListenerError}</PanelMessage
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mode === "remove" && selected}
|
||||||
|
<PanelMessage kind="warning">
|
||||||
|
Applying will remove <strong>{selected.name ?? selected.address}</strong>.
|
||||||
|
Existing connections through this server may stop.
|
||||||
|
</PanelMessage>
|
||||||
|
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||||
|
>Keep server</button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col border border-base-300 bg-base-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_5.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
|
||||||
|
>
|
||||||
|
<span>Server</span><span>Connection</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Configured servers"
|
||||||
|
>
|
||||||
|
{#if $remotes.length === 0}
|
||||||
|
<div
|
||||||
|
class="grid h-full min-h-36 place-content-center px-8 text-center"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-bold">No servers configured</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
Add a server to connect with friends.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each $remotes as remote (remote.id)}
|
||||||
|
{@const state = statusFor(remote.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedId === remote.id}
|
||||||
|
class="grid w-full grid-cols-[1fr_5.5rem] items-center border-b border-base-200 px-2 py-1.5 text-left text-xs hover:bg-base-200 aria-selected:bg-primary aria-selected:text-primary-content"
|
||||||
|
onclick={() => (selectedId = remote.id)}
|
||||||
|
ondblclick={() => openEditWindow(remote.id)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<strong class="block truncate"
|
||||||
|
>{remote.name ?? remote.address}</strong
|
||||||
|
>
|
||||||
|
<span class="block truncate text-[10px] opacity-65"
|
||||||
|
>{remote.address}{remote.port !== null
|
||||||
|
? `:${remote.port}`
|
||||||
|
: ""}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 capitalize">
|
||||||
|
<span
|
||||||
|
class:status-success={state === "connected"}
|
||||||
|
class:status-warning={state === "connecting"}
|
||||||
|
class="status shadow-none"
|
||||||
|
></span>
|
||||||
|
{state}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={openAddActionWindow}>Add…</button
|
||||||
|
>
|
||||||
|
<div class="flex flex-row gap-1.5">
|
||||||
|
<button
|
||||||
|
class="btn join-item"
|
||||||
|
type="button"
|
||||||
|
disabled={!selected || busy}
|
||||||
|
onclick={() => selected && openEditWindow(selected.id)}>Edit…</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn join-item"
|
||||||
|
type="button"
|
||||||
|
disabled={!selected}
|
||||||
|
onclick={() => (mode = "remove")}>Remove</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
kind = "info",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
kind?: "info" | "error" | "warning";
|
||||||
|
children: import("svelte").Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class:alert-info={kind === "info"}
|
||||||
|
class:alert-error={kind === "error"}
|
||||||
|
class:alert-warning={kind === "warning"}
|
||||||
|
class="alert alert-soft min-h-0 px-2 py-1.5 text-xs"
|
||||||
|
role={kind === "error" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type PanelState = {
|
||||||
|
dirty: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PanelActions = {
|
||||||
|
apply: () => Promise<boolean>;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterPanel = (
|
||||||
|
name: string,
|
||||||
|
actions: PanelActions,
|
||||||
|
state: PanelState,
|
||||||
|
) => void;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-info-icon lucide-info"
|
||||||
|
><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path
|
||||||
|
d="M12 8h.01"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
After Width: | Height: | Size: 339 B |
@@ -1,9 +1,131 @@
|
|||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<section>
|
<svelte:head><title>Wyd Properties</title></svelte:head>
|
||||||
<div>
|
|
||||||
<p>Control panel</p>
|
<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>
|
</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>
|
||||||
Reference in New Issue
Block a user