chinese localization!!
This commit is contained in:
+14
-2
@@ -87,14 +87,26 @@ input {
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "W95F";
|
||||
font-family: "w95f";
|
||||
src: url("/w95f.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "ark-pixel-12px-proportional-zh_cn";
|
||||
src: url("/ark-pixel-12px-proportional-zh_cn.ttf.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-custom: "W95F", sans-serif;
|
||||
--font-en: "w95f", "Cascadia Code";
|
||||
--font-zh-cn:
|
||||
"w95f", "ark-pixel-12px-proportional-zh_cn", "PingFang SC",
|
||||
"Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
+10
-1
@@ -89,6 +89,12 @@ async completeOnboarding() : Promise<null> {
|
||||
async requestAccessibilityPermission() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("request_accessibility_permission");
|
||||
},
|
||||
async getLocaleSettings() : Promise<LocaleChanged> {
|
||||
return await TAURI_INVOKE("get_locale_settings");
|
||||
},
|
||||
async setLocalePreference(preference: string) : Promise<LocaleChanged> {
|
||||
return await TAURI_INVOKE("set_locale_preference", { preference });
|
||||
},
|
||||
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
|
||||
},
|
||||
@@ -107,6 +113,7 @@ friendForegroundAppChanged: FriendForegroundAppChanged,
|
||||
friendInteractionReceived: FriendInteractionReceived,
|
||||
friendStatusesChanged: FriendStatusesChanged,
|
||||
friendsChanged: FriendsChanged,
|
||||
localeChanged: LocaleChanged,
|
||||
networkStatusChanged: NetworkStatusChanged,
|
||||
onboardingStatus: OnboardingStatus,
|
||||
profileChanged: ProfileChanged,
|
||||
@@ -120,6 +127,7 @@ friendForegroundAppChanged: "friend-foreground-app-changed",
|
||||
friendInteractionReceived: "friend-interaction-received",
|
||||
friendStatusesChanged: "friend-statuses-changed",
|
||||
friendsChanged: "friends-changed",
|
||||
localeChanged: "locale-changed",
|
||||
networkStatusChanged: "network-status-changed",
|
||||
onboardingStatus: "onboarding-status",
|
||||
profileChanged: "profile-changed",
|
||||
@@ -163,6 +171,7 @@ export type FriendStatusesChanged = { friendIds: string[] }
|
||||
export type FriendsChanged = { friends: Friend[] }
|
||||
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
||||
export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
|
||||
export type LocaleChanged = { preference: string; locale: string }
|
||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||
export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean }
|
||||
export type ProfileChanged = { profile: User }
|
||||
@@ -179,7 +188,7 @@ export type SceneHitbox = { x: number; y: number; width: number; height: number
|
||||
* Public user identity exchanged with peers and remote servers. `id` is the
|
||||
* user's Ed25519 public key and is not persisted as local profile metadata.
|
||||
*/
|
||||
export type User = { id: string; displayName: string; skinHash: string | null }
|
||||
export type User = { id: string; displayName: string; displayNameConfigured: boolean; skinHash: string | null }
|
||||
|
||||
/** tauri-specta globals **/
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import PanelMessage from "./panel-message.svelte";
|
||||
import type { RegisterPanel } from "./types";
|
||||
import Info from "$lib/icons/info.svelte";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let { register }: { register: RegisterPanel } = $props();
|
||||
|
||||
@@ -44,11 +45,11 @@
|
||||
try {
|
||||
await commands.openActionWindow(
|
||||
"friend",
|
||||
"Add Friend",
|
||||
$messages.action_add_friend_title(),
|
||||
"/control-panel/add/friend",
|
||||
);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -57,7 +58,7 @@
|
||||
async function apply() {
|
||||
if (mode === "browse") return true;
|
||||
if (mode === "remove" && !selected) {
|
||||
error = "The selected friend no longer exists.";
|
||||
error = $messages.error_friend_missing();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,7 +72,7 @@
|
||||
reset();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
return false;
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -82,20 +83,20 @@
|
||||
<div class="flex h-full min-h-0 flex-col gap-2">
|
||||
{#if error || $friendsListenerError || $friendStatusesListenerError}
|
||||
<PanelMessage kind="error"
|
||||
>{error ||
|
||||
$friendsListenerError ||
|
||||
$friendStatusesListenerError}</PanelMessage
|
||||
>{errorMessage(
|
||||
error || $friendsListenerError || $friendStatusesListenerError,
|
||||
)}</PanelMessage
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if mode === "remove" && selected}
|
||||
<PanelMessage kind="warning">
|
||||
Applying will remove <strong
|
||||
>{friendName(selected, "Unknown friend")}</strong
|
||||
>. They will immediately go offline and stop receiving your activity.
|
||||
{$messages.friends_remove_prefix()}<strong
|
||||
>{friendName(selected, $messages.common_unknown_friend())}</strong
|
||||
>{$messages.friends_remove_suffix()}
|
||||
</PanelMessage>
|
||||
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||
>Keep friend</button
|
||||
>{$messages.friends_keep()}</button
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
@@ -104,20 +105,22 @@
|
||||
<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>
|
||||
<span>{$messages.common_name()}</span><span
|
||||
>{$messages.common_status()}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto"
|
||||
role="listbox"
|
||||
aria-label="Friends"
|
||||
aria-label={$messages.friends_list_label()}
|
||||
>
|
||||
{#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="text-xs font-bold">{$messages.friends_empty()}</p>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Add someone using their public key.
|
||||
{$messages.friends_empty_help()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -136,12 +139,15 @@
|
||||
<strong
|
||||
class:italic={!friend.displayName}
|
||||
class="block truncate"
|
||||
>{friendName(friend, "Unknown friend")}</strong
|
||||
>{friendName(
|
||||
friend,
|
||||
$messages.common_unknown_friend(),
|
||||
)}</strong
|
||||
>
|
||||
{#if !friend.displayName}
|
||||
<div
|
||||
class="tooltip tooltip-primary"
|
||||
data-tip={"Will be resolved when they're online"}
|
||||
data-tip={$messages.friends_resolve_online()}
|
||||
>
|
||||
<div class="*:size-3 opacity-50">
|
||||
<Info />
|
||||
@@ -149,14 +155,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="block truncate font-mono text-[9px] opacity-65"
|
||||
<span class="block truncate 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"}
|
||||
{online
|
||||
? $messages.common_online()
|
||||
: $messages.common_offline()}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
@@ -169,13 +177,13 @@
|
||||
class="btn"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={openAddActionWindow}>Add…</button
|
||||
onclick={openAddActionWindow}>{$messages.common_add()}</button
|
||||
>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
disabled={!selected}
|
||||
onclick={() => (mode = "remove")}>Remove</button
|
||||
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
import PanelMessage from "./panel-message.svelte";
|
||||
import type { RegisterPanel } from "./types";
|
||||
import Info from "$lib/icons/info.svelte";
|
||||
import {
|
||||
errorMessage,
|
||||
languageOptions,
|
||||
localePreference,
|
||||
messages,
|
||||
setLanguagePreference,
|
||||
} from "$lib/i18n";
|
||||
|
||||
let { register }: { register: RegisterPanel } = $props();
|
||||
|
||||
@@ -12,6 +19,21 @@
|
||||
let dirty = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
let languageBusy = $state(false);
|
||||
|
||||
async function changeLanguage(event: Event) {
|
||||
languageBusy = true;
|
||||
error = "";
|
||||
try {
|
||||
await setLanguagePreference(
|
||||
(event.currentTarget as HTMLSelectElement).value,
|
||||
);
|
||||
} catch (cause) {
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
languageBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!dirty) displayName = $profile?.displayName ?? "";
|
||||
@@ -35,7 +57,7 @@
|
||||
const nextName = displayName.trim();
|
||||
if (!dirty) return true;
|
||||
if (!nextName) {
|
||||
error = "Display name cannot be empty.";
|
||||
error = $messages.error_display_name_empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -47,7 +69,7 @@
|
||||
dirty = false;
|
||||
return true;
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
return false;
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -57,13 +79,17 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if error || $profileListenerError}
|
||||
<PanelMessage kind="error">{error || $profileListenerError}</PanelMessage>
|
||||
<PanelMessage kind="error"
|
||||
>{errorMessage(error || $profileListenerError)}</PanelMessage
|
||||
>
|
||||
{/if}
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||
<legend class="fieldset-legend px-1">Identity</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.account_identity()}</legend>
|
||||
|
||||
<label class="fieldset-label" for="display-name">Display name</label>
|
||||
<label class="fieldset-label" for="display-name"
|
||||
>{$messages.common_display_name()}</label
|
||||
>
|
||||
<input
|
||||
id="display-name"
|
||||
class="input w-full"
|
||||
@@ -75,14 +101,14 @@
|
||||
aria-describedby="display-name-help"
|
||||
/>
|
||||
<p id="display-name-help" class="fieldset-label">
|
||||
This name can be changed at any time.
|
||||
{$messages.account_name_help()}
|
||||
</p>
|
||||
|
||||
<label class="fieldset-label mt-2" for="public-key"
|
||||
>Identification Key
|
||||
>{$messages.common_identification_key()}
|
||||
<div
|
||||
class="tooltip tooltip-primary"
|
||||
data-tip="Share only with people you trust."
|
||||
data-tip={$messages.account_key_trust()}
|
||||
>
|
||||
<div class="*:size-3">
|
||||
<Info />
|
||||
@@ -93,27 +119,48 @@
|
||||
<div class="join w-full">
|
||||
<input
|
||||
id="public-key"
|
||||
class="input join-item min-w-0 flex-1 font-mono text-[10px]"
|
||||
class="input join-item min-w-0 flex-1 text-[10px]"
|
||||
value={$profile.id}
|
||||
readonly
|
||||
aria-label="Public key"
|
||||
aria-label={$messages.account_public_key()}
|
||||
/>
|
||||
<button
|
||||
class="btn join-item"
|
||||
type="button"
|
||||
onclick={() => navigator.clipboard.writeText($profile?.id ?? "")}
|
||||
title="Copy public key">Copy</button
|
||||
title={$messages.account_copy_key()}>{$messages.common_copy()}</button
|
||||
>
|
||||
</div>
|
||||
{:else if $profileListenerError}
|
||||
<input
|
||||
id="public-key"
|
||||
class="input w-full"
|
||||
value="Unavailable"
|
||||
value={$messages.common_unavailable()}
|
||||
disabled
|
||||
/>
|
||||
{:else}
|
||||
<div class="skeleton h-7 w-full" aria-label="Loading public key"></div>
|
||||
<div
|
||||
class="skeleton h-7 w-full"
|
||||
aria-label={$messages.account_loading_key()}
|
||||
></div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||
<legend class="fieldset-legend px-1">{$messages.account_ui_label()}</legend>
|
||||
<label class="fieldset-label" for="language"
|
||||
>{$messages.language_label()}</label
|
||||
>
|
||||
<select
|
||||
id="language"
|
||||
class="select w-full"
|
||||
value={$localePreference}
|
||||
disabled={languageBusy}
|
||||
onchange={changeLanguage}
|
||||
>
|
||||
{#each languageOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label($messages)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import PanelMessage from "./panel-message.svelte";
|
||||
import type { RegisterPanel } from "./types";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let { register }: { register: RegisterPanel } = $props();
|
||||
|
||||
@@ -41,11 +42,11 @@
|
||||
try {
|
||||
await commands.openActionWindow(
|
||||
"network",
|
||||
"Add Server",
|
||||
$messages.action_add_server_title(),
|
||||
"/control-panel/add/network",
|
||||
);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -57,11 +58,11 @@
|
||||
try {
|
||||
await commands.openActionWindow(
|
||||
`network-edit-${id}`,
|
||||
"Edit Server",
|
||||
$messages.action_edit_server_title(),
|
||||
`/control-panel/edit/network/${encodeURIComponent(id)}`,
|
||||
);
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -79,7 +80,7 @@
|
||||
try {
|
||||
await commands.reorderRemotes(ordered.map((remote) => remote.id));
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -93,7 +94,7 @@
|
||||
async function apply() {
|
||||
if (mode === "browse") return true;
|
||||
if (mode === "remove" && !selected) {
|
||||
error = "The selected server no longer exists.";
|
||||
error = $messages.error_server_missing();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@
|
||||
reset();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
return false;
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -118,19 +119,20 @@
|
||||
<div class="flex h-full min-h-0 flex-col gap-2">
|
||||
{#if error || $remotesListenerError || $connectionStatusesListenerError}
|
||||
<PanelMessage kind="error"
|
||||
>{error ||
|
||||
$remotesListenerError ||
|
||||
$connectionStatusesListenerError}</PanelMessage
|
||||
>{errorMessage(
|
||||
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.
|
||||
{$messages.network_remove_prefix()}<strong
|
||||
>{selected.name ?? selected.address}</strong
|
||||
>{$messages.network_existing_connections_stop()}
|
||||
</PanelMessage>
|
||||
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
|
||||
>Keep server</button
|
||||
>{$messages.network_keep()}</button
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
@@ -139,20 +141,22 @@
|
||||
<div
|
||||
class="grid grid-cols-[1fr_5.5rem_3.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><span>Priority</span>
|
||||
<span>{$messages.network_server()}</span><span
|
||||
>{$messages.network_connection()}</span
|
||||
><span>{$messages.network_priority()}</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto"
|
||||
role="group"
|
||||
aria-label="Configured servers"
|
||||
aria-label={$messages.network_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="text-xs font-bold">{$messages.network_no_servers()}</p>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Add a server to connect with friends.
|
||||
{$messages.network_empty_help()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -185,22 +189,26 @@
|
||||
class:status-warning={state === "connecting"}
|
||||
class="status shadow-none"
|
||||
></span>
|
||||
{state}
|
||||
{state === "connected"
|
||||
? $messages.network_connected()
|
||||
: state === "connecting"
|
||||
? $messages.network_connecting()
|
||||
: $messages.network_disconnected()}
|
||||
</span>
|
||||
<span class="flex justify-end gap-0.5">
|
||||
<button
|
||||
class="btn btn-xs px-1"
|
||||
type="button"
|
||||
aria-label={`Increase priority for ${remote.name ?? remote.address}`}
|
||||
title="Move up"
|
||||
aria-label={`${$messages.network_move_up()}: ${remote.name ?? remote.address}`}
|
||||
title={$messages.network_move_up()}
|
||||
disabled={busy || index === 0}
|
||||
onclick={() => moveRemote(remote.id, -1)}>↑</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-xs px-1"
|
||||
type="button"
|
||||
aria-label={`Decrease priority for ${remote.name ?? remote.address}`}
|
||||
title="Move down"
|
||||
aria-label={`${$messages.network_move_down()}: ${remote.name ?? remote.address}`}
|
||||
title={$messages.network_move_down()}
|
||||
disabled={busy || index === $remotes.length - 1}
|
||||
onclick={() => moveRemote(remote.id, 1)}>↓</button
|
||||
>
|
||||
@@ -216,20 +224,20 @@
|
||||
class="btn"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={openAddActionWindow}>Add…</button
|
||||
onclick={openAddActionWindow}>{$messages.common_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
|
||||
onclick={() => selected && openEditWindow(selected.id)}>{$messages.common_edit()}</button
|
||||
>
|
||||
<button
|
||||
class="btn join-item"
|
||||
type="button"
|
||||
disabled={!selected}
|
||||
onclick={() => (mode = "remove")}>Remove</button
|
||||
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import PuppetPreview from "../../../routes/scene/components/renderer/puppet/preview.svelte";
|
||||
import PanelMessage from "./panel-message.svelte";
|
||||
import type { RegisterPanel } from "./types";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let { register }: { register: RegisterPanel } = $props();
|
||||
|
||||
@@ -114,7 +115,7 @@
|
||||
} catch (cause) {
|
||||
dirty =
|
||||
hasConfigurationChanges() || skinFile !== null || pendingSkinReset;
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
return false;
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -125,7 +126,9 @@
|
||||
<div class="space-y-1">
|
||||
{#if error || $sceneConfigurationListenerError || $profileListenerError}
|
||||
<PanelMessage kind="error">
|
||||
{error || $sceneConfigurationListenerError || $profileListenerError}
|
||||
{errorMessage(
|
||||
error || $sceneConfigurationListenerError || $profileListenerError,
|
||||
)}
|
||||
</PanelMessage>
|
||||
{/if}
|
||||
|
||||
@@ -142,8 +145,8 @@
|
||||
class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1"
|
||||
>
|
||||
<div class="text-start flex flex-col">
|
||||
<p>Scale</p>
|
||||
<p>Opacity</p>
|
||||
<p>{$messages.scene_scale()}</p>
|
||||
<p>{$messages.scene_opacity()}</p>
|
||||
</div>
|
||||
<div class="text-end flex flex-col">
|
||||
<p>{(puppetScale * 100).toFixed(0)}%</p>
|
||||
@@ -164,7 +167,7 @@
|
||||
{:else}
|
||||
<div
|
||||
class="skeleton aspect-square size-full"
|
||||
aria-label="Loading puppet preview"
|
||||
aria-label={$messages.scene_loading_preview()}
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -172,9 +175,13 @@
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
|
||||
<legend class="fieldset-legend px-1">All puppets</legend>
|
||||
<legend class="fieldset-legend px-1"
|
||||
>{$messages.scene_all_puppets()}</legend
|
||||
>
|
||||
<div>
|
||||
<label class="fieldset-label" for="puppet-scale">Scale</label>
|
||||
<label class="fieldset-label" for="puppet-scale"
|
||||
>{$messages.scene_scale()}</label
|
||||
>
|
||||
<input
|
||||
id="puppet-scale"
|
||||
class="range range-sm"
|
||||
@@ -190,7 +197,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
|
||||
<label class="fieldset-label" for="puppet-opacity"
|
||||
>{$messages.scene_opacity()}</label
|
||||
>
|
||||
<input
|
||||
id="puppet-opacity"
|
||||
class="range range-sm"
|
||||
@@ -212,7 +221,7 @@
|
||||
disabled={!$profile || busy}
|
||||
onclick={() => skinInput?.click()}
|
||||
>
|
||||
Choose a skin
|
||||
{$messages.scene_choose_skin()}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
@@ -220,7 +229,7 @@
|
||||
disabled={!$profile || busy || (!$profile.skinHash && !skinFile)}
|
||||
onclick={resetSkin}
|
||||
>
|
||||
Reset
|
||||
{$messages.common_reset()}
|
||||
</button>
|
||||
<input
|
||||
id="skin-file"
|
||||
@@ -235,7 +244,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
|
||||
<legend class="fieldset-legend px-1">Puppets reposition</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.puppet_movement()}</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
class:border-primary={puppetMovementMode === "free"}
|
||||
@@ -243,7 +252,7 @@
|
||||
>
|
||||
<img
|
||||
src="/puppet-movement-free.svg"
|
||||
alt="Puppets moving freely across the full viewport"
|
||||
alt={$messages.puppet_free_alt()}
|
||||
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
|
||||
class:from-primary-content={puppetMovementMode === "free"}
|
||||
/>
|
||||
@@ -252,7 +261,7 @@
|
||||
class:bg-primary-content={puppetMovementMode === "free"}
|
||||
class:text-primary={puppetMovementMode === "free"}
|
||||
>
|
||||
<span class="text-xs font-medium">Freeroam around</span>
|
||||
<span class="text-xs font-medium">{$messages.puppet_free()}</span>
|
||||
<input
|
||||
class="hidden"
|
||||
type="radio"
|
||||
@@ -275,7 +284,7 @@
|
||||
>
|
||||
<img
|
||||
src="/puppet-movement-bottom.svg"
|
||||
alt="Puppets moving along the bottom of the viewport"
|
||||
alt={$messages.puppet_bottom_alt()}
|
||||
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
|
||||
class:from-primary-content={puppetMovementMode === "bottom"}
|
||||
/>
|
||||
@@ -283,7 +292,7 @@
|
||||
class="card-body items-center gap-2 p-2 text-center"
|
||||
class:bg-primary-content={puppetMovementMode === "bottom"}
|
||||
>
|
||||
<span class="text-xs font-medium">Stay on the ground</span>
|
||||
<span class="text-xs font-medium">{$messages.puppet_bottom()}</span>
|
||||
<input
|
||||
class="hidden"
|
||||
type="radio"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { derived, writable } from "svelte/store";
|
||||
import { commands, events, type LocaleChanged } from "$lib/bindings";
|
||||
import * as compiledMessages from "../../paraglide/messages.js";
|
||||
import {
|
||||
baseLocale,
|
||||
getLocale,
|
||||
isLocale,
|
||||
setLocale,
|
||||
type Locale,
|
||||
} from "../../paraglide/runtime.js";
|
||||
|
||||
export type MessageCatalog = typeof compiledMessages;
|
||||
|
||||
export const locale = writable<Locale>(baseLocale);
|
||||
export const localePreference = writable("system");
|
||||
|
||||
// A new object is emitted for each locale so Svelte reevaluates every message
|
||||
// call without requiring a provider in every Tauri window.
|
||||
export const messages = derived(locale, () => ({ ...compiledMessages }));
|
||||
|
||||
export const languageOptions = [
|
||||
{
|
||||
value: "system",
|
||||
label: (catalog: MessageCatalog) => catalog.language_system(),
|
||||
},
|
||||
{
|
||||
value: "en",
|
||||
label: (catalog: MessageCatalog) => catalog.language_english(),
|
||||
},
|
||||
{
|
||||
value: "zh-CN",
|
||||
label: (catalog: MessageCatalog) => catalog.language_chinese(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function applyLocaleSettings(settings: LocaleChanged) {
|
||||
const nextLocale = isLocale(settings.locale) ? settings.locale : baseLocale;
|
||||
await setLocale(nextLocale, { reload: false });
|
||||
localePreference.set(settings.preference);
|
||||
locale.set(nextLocale);
|
||||
document.documentElement.lang = nextLocale;
|
||||
}
|
||||
|
||||
export async function initLocalization(): Promise<() => void> {
|
||||
const unlisten = await events.localeChanged.listen((event) => {
|
||||
void applyLocaleSettings(event.payload);
|
||||
});
|
||||
|
||||
try {
|
||||
await applyLocaleSettings(await commands.getLocaleSettings());
|
||||
return unlisten;
|
||||
} catch (error) {
|
||||
unlisten();
|
||||
const fallback = getLocale();
|
||||
locale.set(fallback);
|
||||
document.documentElement.lang = fallback;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLanguagePreference(preference: string) {
|
||||
await applyLocaleSettings(await commands.setLocalePreference(preference));
|
||||
}
|
||||
|
||||
export function errorMessage(cause: unknown): string {
|
||||
const value = cause instanceof Error ? cause.message : String(cause);
|
||||
switch (value) {
|
||||
case "Display name cannot be empty.":
|
||||
return compiledMessages.error_display_name_empty();
|
||||
case "Server address is required.":
|
||||
return compiledMessages.error_server_address_required();
|
||||
case "Port must be a whole number from 1 to 65535.":
|
||||
return compiledMessages.error_port_invalid();
|
||||
case "Identification key is required.":
|
||||
return compiledMessages.error_friend_key_required();
|
||||
case "No server was selected.":
|
||||
return compiledMessages.error_server_not_selected();
|
||||
case "This server no longer exists.":
|
||||
return compiledMessages.error_server_missing();
|
||||
case "The selected friend no longer exists.":
|
||||
return compiledMessages.error_friend_missing();
|
||||
case "Your profile is still loading.":
|
||||
return compiledMessages.error_profile_loading();
|
||||
case "Choose a 64×64 PNG skin before continuing.":
|
||||
return compiledMessages.error_skin_required();
|
||||
case "Turn on Friendolls in macOS Accessibility settings before continuing.":
|
||||
return compiledMessages.error_accessibility_required();
|
||||
case "Identification key is not valid base64url.":
|
||||
return compiledMessages.error_key_invalid_base64();
|
||||
case "Identification key must encode a 32-byte public key.":
|
||||
return compiledMessages.error_key_invalid_length();
|
||||
case "Identification key is not a valid Ed25519 public key.":
|
||||
return compiledMessages.error_key_invalid_ed25519();
|
||||
case "You cannot add your own identification key.":
|
||||
return compiledMessages.error_own_key();
|
||||
case "The selected image is not a local file":
|
||||
return compiledMessages.error_image_not_local();
|
||||
case "The selected image could not be read":
|
||||
return compiledMessages.error_image_unreadable();
|
||||
case "Choose an image file":
|
||||
return compiledMessages.error_image_choose();
|
||||
case "The selected image could not be decoded":
|
||||
return compiledMessages.error_image_decode();
|
||||
case "Image compression failed":
|
||||
return compiledMessages.error_image_compression();
|
||||
case "Image is still too detailed after compression":
|
||||
return compiledMessages.error_image_too_detailed();
|
||||
case "No connected relay can currently reach this friend":
|
||||
return compiledMessages.error_friend_unreachable();
|
||||
case "Friend is busy; try again":
|
||||
return compiledMessages.error_friend_busy();
|
||||
case "Relay rejected the interaction":
|
||||
return compiledMessages.error_interaction_rejected();
|
||||
case "Friend is no longer available":
|
||||
return compiledMessages.error_friend_unavailable();
|
||||
case "Skin must be a valid PNG image":
|
||||
return compiledMessages.error_skin_invalid_png();
|
||||
default:
|
||||
{
|
||||
const imageLimit =
|
||||
/^The selected image must be at most (\d+) MiB$/u.exec(value);
|
||||
if (imageLimit) {
|
||||
return compiledMessages.error_image_too_large({
|
||||
max: Number(imageLimit[1]),
|
||||
});
|
||||
}
|
||||
const skinDimensions = /^Skin must be (\d+)×(\d+) pixels$/u.exec(value);
|
||||
if (skinDimensions) {
|
||||
return compiledMessages.error_skin_dimensions({
|
||||
width: Number(skinDimensions[1]),
|
||||
height: Number(skinDimensions[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (getLocale() !== "en" && /[\u3400-\u9fff]/u.test(value)) return value;
|
||||
return getLocale() === "en" ? value : compiledMessages.error_unexpected();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,42 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onMount } from "svelte";
|
||||
import { initLocalization, locale } from "$lib/i18n";
|
||||
import { initAppListeners } from "$lib/listeners";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
let ready = $state(false);
|
||||
|
||||
onMount(initAppListeners);
|
||||
onMount(() => {
|
||||
let disposeLocalization: (() => void) | undefined;
|
||||
let disposeListeners: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
void initLocalization()
|
||||
.then((dispose) => {
|
||||
if (disposed) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
disposeLocalization = dispose;
|
||||
disposeListeners = initAppListeners();
|
||||
ready = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("failed to initialize localization", error);
|
||||
if (!disposed) {
|
||||
disposeListeners = initAppListeners();
|
||||
ready = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
disposeListeners?.();
|
||||
disposeLocalization?.();
|
||||
};
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
document.addEventListener("contextmenu", (e) => {
|
||||
@@ -15,8 +45,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-screen h-screen max-w-[100vw] max-h-screen *:size-full font-custom"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if ready}
|
||||
<div
|
||||
class="w-screen h-screen max-w-[100vw] max-h-screen *:size-full"
|
||||
class:font-en={$locale !== "zh-CN"}
|
||||
class:font-zh-cn={$locale === "zh-CN"}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
} from "$lib/components/control-panel/types";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { onMount } from "svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
const tabs = [
|
||||
{ id: "scene", label: "Scene" },
|
||||
{ id: "general", label: "Account" },
|
||||
{ id: "friends", label: "Friends" },
|
||||
{ id: "network", label: "Network" },
|
||||
{ id: "scene", label: "control_tab_scene" },
|
||||
{ id: "general", label: "control_tab_account" },
|
||||
{ id: "friends", label: "control_tab_friends" },
|
||||
{ id: "network", label: "control_tab_network" },
|
||||
] as const;
|
||||
type TabId = (typeof tabs)[number]["id"];
|
||||
|
||||
@@ -31,6 +32,10 @@
|
||||
let anyBusy = $derived(tabs.some((tab) => panelStates[tab.id].busy));
|
||||
let appVersion = $state("");
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.control_window_title());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
getVersion().then((v) => {
|
||||
appVersion = v;
|
||||
@@ -81,7 +86,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Friendolls Properties</title></svelte:head>
|
||||
<svelte:head><title>{$messages.control_window_title()}</title></svelte:head>
|
||||
|
||||
<main
|
||||
class="relative flex h-full min-h-0 flex-col overflow-hidden bg-base-100 p-2 text-base-content"
|
||||
@@ -96,7 +101,7 @@
|
||||
type="radio"
|
||||
name="control_panel_tabs"
|
||||
class="tab text-xs"
|
||||
aria-label={tab.label}
|
||||
aria-label={$messages[tab.label]()}
|
||||
value={tab.id}
|
||||
bind:group={activeTab}
|
||||
/>
|
||||
@@ -127,19 +132,22 @@
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={anyBusy}
|
||||
onclick={confirm}>OK</button
|
||||
onclick={confirm}>{$messages.common_ok()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={anyBusy}
|
||||
onclick={cancel}>Cancel</button
|
||||
onclick={cancel}>{$messages.common_cancel()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={!anyDirty || anyBusy}
|
||||
onclick={applyAll}>{anyBusy ? "Applying…" : "Apply"}</button
|
||||
onclick={applyAll}
|
||||
>{anyBusy
|
||||
? $messages.common_applying()
|
||||
: $messages.common_apply()}</button
|
||||
>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { commands } from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import Info from "$lib/icons/info.svelte";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let friendId = $state("");
|
||||
let busy = $state(false);
|
||||
@@ -15,6 +16,10 @@
|
||||
let lookupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let lookupGeneration = 0;
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.action_add_friend_title());
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
lookupGeneration += 1;
|
||||
if (lookupTimer) clearTimeout(lookupTimer);
|
||||
@@ -28,7 +33,7 @@
|
||||
event.preventDefault();
|
||||
const id = friendId.trim();
|
||||
if (!id) {
|
||||
error = "Identification key is required.";
|
||||
error = $messages.error_friend_key_required();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +43,7 @@
|
||||
await commands.createFriend(id);
|
||||
await closeWindow();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
@@ -52,7 +57,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
preview = { status: "loading", text: "Resolving…" };
|
||||
preview = { status: "loading", text: $messages.common_resolving() };
|
||||
lookupTimer = setTimeout(
|
||||
() => void resolveProfilePreview(id, generation),
|
||||
250,
|
||||
@@ -65,16 +70,19 @@
|
||||
if (generation !== lookupGeneration) return;
|
||||
preview = displayName
|
||||
? { status: "resolved", text: displayName }
|
||||
: { status: "unresolved", text: "Unresolved user" };
|
||||
: { status: "unresolved", text: $messages.common_unresolved_user() };
|
||||
} catch {
|
||||
if (generation === lookupGeneration) {
|
||||
preview = { status: "unavailable", text: "Unresolved" };
|
||||
preview = {
|
||||
status: "unavailable",
|
||||
text: $messages.common_unresolved(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Add Friend</title></svelte:head>
|
||||
<svelte:head><title>{$messages.action_add_friend_title()}</title></svelte:head>
|
||||
<svelte:window
|
||||
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
|
||||
/>
|
||||
@@ -87,16 +95,18 @@
|
||||
{#if error}<PanelMessage kind="error">{error}</PanelMessage>{/if}
|
||||
|
||||
<PanelMessage>
|
||||
Their display name will appear after Friendolls learns their profile from
|
||||
a shared server.
|
||||
{$messages.action_friend_profile_help()}
|
||||
</PanelMessage>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
|
||||
<legend class="fieldset-legend px-1">Friend identity</legend>
|
||||
<legend class="fieldset-legend px-1"
|
||||
>{$messages.common_friend_identity()}</legend
|
||||
>
|
||||
<label class="fieldset-label" for="friend-key"
|
||||
>Identification Key <div
|
||||
>{$messages.common_identification_key()}
|
||||
<div
|
||||
class="tooltip tooltip-primary"
|
||||
data-tip="Verify with your friend before adding."
|
||||
data-tip={$messages.action_friend_verify()}
|
||||
>
|
||||
<div class="*:size-3">
|
||||
<Info />
|
||||
@@ -105,13 +115,13 @@
|
||||
>
|
||||
<textarea
|
||||
id="friend-key"
|
||||
class="textarea h-20 w-full resize-none font-mono text-[10px]"
|
||||
class="textarea h-20 w-full resize-none text-[10px]"
|
||||
bind:value={friendId}
|
||||
autocomplete="off"
|
||||
oninput={scheduleProfilePreview}
|
||||
required></textarea>
|
||||
<label class="fieldset-label mt-1" for="friend-display-name"
|
||||
>Display name</label
|
||||
>{$messages.common_display_name()}</label
|
||||
>
|
||||
<input
|
||||
id="friend-display-name"
|
||||
@@ -126,13 +136,13 @@
|
||||
|
||||
<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
|
||||
>{busy ? $messages.action_adding() : $messages.common_ok()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={closeWindow}>Cancel</button
|
||||
onclick={closeWindow}>{$messages.common_cancel()}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { commands, type RemoteInput } from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let name = $state("");
|
||||
let address = $state("");
|
||||
@@ -9,6 +10,10 @@
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.action_add_server_title());
|
||||
});
|
||||
|
||||
async function closeWindow() {
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
@@ -16,14 +21,14 @@
|
||||
function remoteInput(): RemoteInput | null {
|
||||
const parsedPort = port ? Number(port) : null;
|
||||
if (!address.trim()) {
|
||||
error = "Server address is required.";
|
||||
error = $messages.error_server_address_required();
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsedPort !== null &&
|
||||
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
|
||||
) {
|
||||
error = "Port must be a whole number from 1 to 65535.";
|
||||
error = $messages.error_port_invalid();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -44,13 +49,13 @@
|
||||
await commands.createRemote(remote);
|
||||
await closeWindow();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Add Server</title></svelte:head>
|
||||
<svelte:head><title>{$messages.action_add_server_title()}</title></svelte:head>
|
||||
<svelte:window
|
||||
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
|
||||
/>
|
||||
@@ -63,9 +68,9 @@
|
||||
{#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>
|
||||
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
|
||||
<label class="fieldset-label" for="remote-name"
|
||||
>Friendly name (optional)</label
|
||||
>{$messages.common_optional_friendly_name()}</label
|
||||
>
|
||||
<input
|
||||
id="remote-name"
|
||||
@@ -74,7 +79,7 @@
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="fieldset-label mt-1" for="remote-address">Address</label>
|
||||
<label class="fieldset-label mt-1" for="remote-address">{$messages.common_address()}</label>
|
||||
<input
|
||||
id="remote-address"
|
||||
class="input w-full"
|
||||
@@ -84,7 +89,7 @@
|
||||
required
|
||||
/>
|
||||
<label class="fieldset-label mt-1" for="remote-port"
|
||||
>Port (optional)</label
|
||||
>{$messages.common_optional_port()}</label
|
||||
>
|
||||
<input
|
||||
id="remote-port"
|
||||
@@ -101,13 +106,13 @@
|
||||
|
||||
<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
|
||||
>{busy ? $messages.action_adding() : $messages.common_ok()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={closeWindow}>Cancel</button
|
||||
onclick={closeWindow}>{$messages.common_cancel()}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import { commands, type RemoteInput } from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
let name = $state("");
|
||||
let address = $state("");
|
||||
@@ -15,11 +16,15 @@
|
||||
|
||||
const remoteId = $derived($page.params.id ?? "");
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.action_edit_server_title());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let active = true;
|
||||
|
||||
if (!remoteId) {
|
||||
error = "No server was selected.";
|
||||
error = $messages.error_server_not_selected();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
@@ -29,7 +34,7 @@
|
||||
.then((remote) => {
|
||||
if (!active) return;
|
||||
if (!remote) {
|
||||
error = "This server no longer exists.";
|
||||
error = $messages.error_server_missing();
|
||||
return;
|
||||
}
|
||||
name = remote.name ?? "";
|
||||
@@ -38,7 +43,7 @@
|
||||
available = true;
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (active) error = String(cause);
|
||||
if (active) error = errorMessage(cause);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) loading = false;
|
||||
@@ -56,14 +61,14 @@
|
||||
function remoteInput(): RemoteInput | null {
|
||||
const parsedPort = port ? Number(port) : null;
|
||||
if (!address.trim()) {
|
||||
error = "Server address is required.";
|
||||
error = $messages.error_server_address_required();
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
parsedPort !== null &&
|
||||
(!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)
|
||||
) {
|
||||
error = "Port must be a whole number from 1 to 65535.";
|
||||
error = $messages.error_port_invalid();
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -83,20 +88,20 @@
|
||||
try {
|
||||
const updated = await commands.updateRemote(remoteId, remote);
|
||||
if (!updated) {
|
||||
error = "This server no longer exists.";
|
||||
error = $messages.error_server_missing();
|
||||
available = false;
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
await closeWindow();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Edit Server</title></svelte:head>
|
||||
<svelte:head><title>{$messages.action_edit_server_title()}</title></svelte:head>
|
||||
<svelte:window
|
||||
onkeydown={(event) => event.key === "Escape" && !busy && void closeWindow()}
|
||||
/>
|
||||
@@ -107,9 +112,9 @@
|
||||
>
|
||||
<div class="min-h-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<h1 class="text-sm font-bold">Edit server</h1>
|
||||
<h1 class="text-sm font-bold">{$messages.action_edit_server_heading()}</h1>
|
||||
<p class="text-xs text-base-content/65">
|
||||
Update how Friendolls connects to this remote server.
|
||||
{$messages.action_edit_server_help()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,9 +124,9 @@
|
||||
class="fieldset border border-base-300 bg-base-100 p-3"
|
||||
disabled={loading || busy || !available}
|
||||
>
|
||||
<legend class="fieldset-legend px-1">Server details</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
|
||||
<label class="fieldset-label" for="remote-name"
|
||||
>Friendly name (optional)</label
|
||||
>{$messages.common_optional_friendly_name()}</label
|
||||
>
|
||||
<input
|
||||
id="remote-name"
|
||||
@@ -130,7 +135,7 @@
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label class="fieldset-label mt-1" for="remote-address">Address</label>
|
||||
<label class="fieldset-label mt-1" for="remote-address">{$messages.common_address()}</label>
|
||||
<input
|
||||
id="remote-address"
|
||||
class="input w-full"
|
||||
@@ -140,7 +145,7 @@
|
||||
required
|
||||
/>
|
||||
<label class="fieldset-label mt-1" for="remote-port"
|
||||
>Port (optional)</label
|
||||
>{$messages.common_optional_port()}</label
|
||||
>
|
||||
<input
|
||||
id="remote-port"
|
||||
@@ -159,13 +164,15 @@
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="submit"
|
||||
disabled={loading || busy || !available}>{busy ? "Saving…" : "OK"}</button
|
||||
disabled={loading || busy || !available}>{busy
|
||||
? $messages.common_saving()
|
||||
: $messages.common_ok()}</button
|
||||
>
|
||||
<button
|
||||
class="btn min-w-16"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={closeWindow}>Cancel</button
|
||||
onclick={closeWindow}>{$messages.common_cancel()}</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import ServerStep from "./components/server-step.svelte";
|
||||
import SkinStep from "./components/skin-step.svelte";
|
||||
import WelcomeStep from "./components/welcome-step.svelte";
|
||||
import { errorMessage, messages, type MessageCatalog } from "$lib/i18n";
|
||||
|
||||
type StepId =
|
||||
| "welcome"
|
||||
@@ -32,39 +33,48 @@
|
||||
|
||||
const allSteps: Array<{
|
||||
id: StepId;
|
||||
title: string;
|
||||
title: (catalog: MessageCatalog) => string;
|
||||
navigationLabel: (catalog: MessageCatalog) => string;
|
||||
}> = [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome to Friendolls!",
|
||||
title: (catalog) => catalog.onboarding_welcome_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_welcome_nav(),
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
title: "Choose your Display Name",
|
||||
title: (catalog) => catalog.onboarding_identity_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_identity_nav(),
|
||||
},
|
||||
{
|
||||
id: "skin",
|
||||
title: "Choose your Puppet's Skin",
|
||||
title: (catalog) => catalog.onboarding_skin_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_skin_nav(),
|
||||
},
|
||||
{
|
||||
id: "movement",
|
||||
title: "Choose your Movement Mode",
|
||||
title: (catalog) => catalog.onboarding_movement_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_movement_nav(),
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
title: "Add a server",
|
||||
title: (catalog) => catalog.onboarding_server_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_server_nav(),
|
||||
},
|
||||
{
|
||||
id: "friend",
|
||||
title: "Add a friend",
|
||||
title: (catalog) => catalog.onboarding_friend_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_friend_nav(),
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
title: "Allow cursor access",
|
||||
title: (catalog) => catalog.onboarding_accessibility_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_accessibility_nav(),
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
title: "Setup is complete",
|
||||
title: (catalog) => catalog.onboarding_complete_title(),
|
||||
navigationLabel: (catalog) => catalog.onboarding_complete_nav(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,6 +117,10 @@
|
||||
);
|
||||
let isLastStep = $derived(stepIndex === steps.length - 1);
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.onboarding_window_title());
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
@@ -126,10 +140,9 @@
|
||||
status = nextStatus;
|
||||
profile = nextProfile;
|
||||
configuration = nextConfiguration;
|
||||
displayName =
|
||||
nextProfile.displayName === "Anonymous"
|
||||
? ""
|
||||
: nextProfile.displayName;
|
||||
displayName = nextProfile.displayNameConfigured
|
||||
? nextProfile.displayName
|
||||
: "";
|
||||
skinMode = nextProfile.skinHash ? "current" : "default";
|
||||
movementMode = nextConfiguration.puppetMovementMode;
|
||||
|
||||
@@ -145,7 +158,7 @@
|
||||
if (requestedIndex >= 0) stepIndex = requestedIndex;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!disposed) error = String(cause);
|
||||
if (!disposed) error = errorMessage(cause);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -174,7 +187,7 @@
|
||||
port !== null &&
|
||||
(!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
) {
|
||||
throw new Error("Port must be a whole number from 1 to 65535.");
|
||||
throw new Error($messages.error_port_invalid());
|
||||
}
|
||||
return {
|
||||
address: serverAddress.trim(),
|
||||
@@ -186,11 +199,11 @@
|
||||
async function saveCurrentStep() {
|
||||
if (step.id === "identity") {
|
||||
const nextName = displayName.trim();
|
||||
if (!nextName) throw new Error("Display name cannot be empty.");
|
||||
if (!nextName) throw new Error($messages.error_display_name_empty());
|
||||
profile = await commands.updateProfile(nextName, null);
|
||||
displayName = profile.displayName;
|
||||
} else if (step.id === "skin") {
|
||||
if (!profile) throw new Error("Your profile is still loading.");
|
||||
if (!profile) throw new Error($messages.error_profile_loading());
|
||||
if (skinMode === "default" && profile.skinHash) {
|
||||
profile = await commands.resetProfileSkin();
|
||||
} else if (skinMode === "custom" && skinFile) {
|
||||
@@ -201,7 +214,7 @@
|
||||
selectSkin(null);
|
||||
skinMode = "current";
|
||||
} else if (skinMode === "custom") {
|
||||
throw new Error("Choose a 64×64 PNG skin before continuing.");
|
||||
throw new Error($messages.error_skin_required());
|
||||
}
|
||||
} else if (step.id === "movement") {
|
||||
configuration = await commands.updateSceneConfiguration({
|
||||
@@ -225,9 +238,7 @@
|
||||
if (previousFriendId) await commands.deleteFriend(previousFriendId);
|
||||
}
|
||||
} else if (step.id === "accessibility" && !permissionGranted) {
|
||||
throw new Error(
|
||||
"Turn on Friendolls in macOS Accessibility settings before continuing.",
|
||||
);
|
||||
throw new Error($messages.error_accessibility_required());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +257,7 @@
|
||||
stepIndex += 1;
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -265,25 +276,26 @@
|
||||
try {
|
||||
const granted = await commands.requestAccessibilityPermission();
|
||||
if (!granted) {
|
||||
error =
|
||||
"macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.";
|
||||
error = $messages.accessibility_settings_opened();
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Friendolls Setup</title></svelte:head>
|
||||
<svelte:head><title>{$messages.onboarding_window_title()}</title></svelte:head>
|
||||
|
||||
<main class="flex h-full min-h-0 flex-col bg-base-200 text-base-content">
|
||||
<div class="grid min-h-0 flex-1 grid-cols-[11rem_minmax(0,1fr)]">
|
||||
<aside
|
||||
class="flex flex-col bg-linear-to-b from-primary to-primary/50 p-5 text-primary-content"
|
||||
>
|
||||
<div class="text-lg font-bold leading-tight">Friendolls<br />Setup</div>
|
||||
<div class="text-lg font-bold leading-tight">
|
||||
{$messages.onboarding_brand()}
|
||||
</div>
|
||||
<div class="mt-6 flex-1 space-y-2 text-[11px]">
|
||||
{#each steps as candidate, index (candidate.id)}
|
||||
<div
|
||||
@@ -298,7 +310,7 @@
|
||||
>
|
||||
{index < stepIndex ? "✓" : index + 1}
|
||||
</span>
|
||||
<span>{candidate.title.replace("Choose your ", "")}</span>
|
||||
<span>{candidate.navigationLabel($messages)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -308,8 +320,7 @@
|
||||
<section class="flex min-h-0 flex-col bg-base-100">
|
||||
{#if step}
|
||||
<header class="bg-base-100 px-6 pt-4">
|
||||
<p class="font-bold text-2xl">{step.title}</p>
|
||||
<!-- <p class="text-xs text-base-content/65">{step.description}</p> -->
|
||||
<p class="font-bold text-2xl">{step.title($messages)}</p>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
@@ -324,7 +335,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !status || !profile}
|
||||
<div class="space-y-3" aria-label="Loading setup">
|
||||
<div class="space-y-3" aria-label={$messages.onboarding_loading()}>
|
||||
<div class="skeleton h-5 w-2/3"></div>
|
||||
<div class="skeleton h-24 w-full"></div>
|
||||
</div>
|
||||
@@ -370,8 +381,11 @@
|
||||
>
|
||||
<span class="text-[10px] text-base-content/60">
|
||||
{remediation
|
||||
? "Permission repair"
|
||||
: `Step ${stepIndex + 1} of ${steps.length}`}
|
||||
? $messages.onboarding_permission_repair()
|
||||
: $messages.onboarding_step_count({
|
||||
current: stepIndex + 1,
|
||||
total: steps.length,
|
||||
})}
|
||||
</span>
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if !remediation}
|
||||
@@ -379,7 +393,7 @@
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy || stepIndex === 0}
|
||||
onclick={back}>< Back</button
|
||||
onclick={back}>{$messages.onboarding_back()}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
@@ -390,12 +404,12 @@
|
||||
onclick={next}
|
||||
>
|
||||
{busy
|
||||
? "Working…"
|
||||
? $messages.onboarding_working()
|
||||
: remediation
|
||||
? "Done"
|
||||
? $messages.onboarding_done()
|
||||
: isLastStep
|
||||
? "Finish"
|
||||
: "Next >"}
|
||||
? $messages.onboarding_finish()
|
||||
: $messages.onboarding_next()}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
permissionGranted,
|
||||
@@ -12,20 +13,16 @@
|
||||
<div class="space-y-4">
|
||||
<PanelMessage kind={permissionGranted ? "success" : "warning"}>
|
||||
{permissionGranted
|
||||
? "Accessibility access is granted. Puppets can now follow your cursor."
|
||||
: "Accessibility access is still waiting for your approval."}
|
||||
? $messages.accessibility_granted()
|
||||
: $messages.accessibility_waiting()}
|
||||
</PanelMessage>
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Why Friendolls needs this</legend>
|
||||
<p class="text-xs leading-relaxed">
|
||||
Friendolls reads the system cursor position so your puppet can follow you
|
||||
and your friends can see that movement. It does not use Accessibility
|
||||
access to read typed text, click buttons, or control other apps.
|
||||
</p>
|
||||
<legend class="fieldset-legend px-1">{$messages.accessibility_why()}</legend>
|
||||
<p class="text-xs leading-relaxed">{$messages.accessibility_explanation()}</p>
|
||||
<ol class="mt-3 list-decimal space-y-1 pl-5 text-xs">
|
||||
<li>Click <strong>Open Accessibility Settings</strong>.</li>
|
||||
<li>Turn on Friendolls in Privacy & Security → Accessibility.</li>
|
||||
<li>Return here; this wizard confirms the change automatically.</li>
|
||||
<li>{$messages.accessibility_step_open()}</li>
|
||||
<li>{$messages.accessibility_step_enable()}</li>
|
||||
<li>{$messages.accessibility_step_return()}</li>
|
||||
</ol>
|
||||
<button
|
||||
class="btn mt-4 w-fit"
|
||||
@@ -33,7 +30,9 @@
|
||||
disabled={busy || permissionGranted}
|
||||
onclick={onrequest}
|
||||
>
|
||||
{permissionGranted ? "Access Granted" : "Open Accessibility Settings…"}
|
||||
{permissionGranted
|
||||
? $messages.accessibility_access_granted()
|
||||
: $messages.accessibility_open_settings()}
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { messages } from "$lib/i18n";
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 text-sm">
|
||||
<div class="grid size-14 place-content-center border-2 border-success bg-success/10 text-3xl text-success">✓</div>
|
||||
<p>
|
||||
Click <strong>Finish</strong> to save setup and launch Friendolls. The
|
||||
control panel remains available from the menu bar or system tray.
|
||||
</p>
|
||||
<p>{$messages.onboarding_complete_body()}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let { friendId = $bindable(), busy }: { friendId: string; busy: boolean } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<PanelMessage
|
||||
>If no one has shared a key with you yet, leave this empty and continue. You
|
||||
can add friends anytime afterwards.</PanelMessage
|
||||
>
|
||||
<PanelMessage>{$messages.onboarding_friend_skip()}</PanelMessage>
|
||||
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Friend identity</legend>
|
||||
<label class="label" for="onboarding-friend-key">Identification Key</label>
|
||||
<legend class="fieldset-legend px-1"
|
||||
>{$messages.common_friend_identity()}</legend
|
||||
>
|
||||
<label class="label" for="onboarding-friend-key"
|
||||
>{$messages.common_identification_key()}</label
|
||||
>
|
||||
<textarea
|
||||
id="onboarding-friend-key"
|
||||
class="textarea h-24 w-full resize-none font-mono text-[10px]"
|
||||
class="textarea h-24 w-full resize-none text-[10px]"
|
||||
bind:value={friendId}
|
||||
autocomplete="off"
|
||||
disabled={busy}></textarea>
|
||||
<p class="label">Verify this key with your friend before adding it.</p>
|
||||
<p class="label">{$messages.friend_key_verify()}</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { messages } from "$lib/i18n";
|
||||
let { displayName = $bindable(), busy }: { displayName: string; busy: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Identity</legend>
|
||||
<label class="label" for="onboarding-display-name">Display name</label>
|
||||
<legend class="fieldset-legend px-1">{$messages.onboarding_identity_legend()}</legend>
|
||||
<label class="label" for="onboarding-display-name">{$messages.common_display_name()}</label>
|
||||
<input
|
||||
id="onboarding-display-name"
|
||||
class="input w-full"
|
||||
@@ -13,5 +14,5 @@
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<p class="label">Up to 64 characters. You can change it later.</p>
|
||||
<p class="label">{$messages.onboarding_identity_help()}</p>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PuppetMovementMode } from "$lib/bindings";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
movementMode = $bindable(),
|
||||
@@ -8,9 +9,9 @@
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Puppets reposition</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.puppet_movement()}</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each [{ id: "free" as const, image: "/puppet-movement-free.svg", alt: "Puppets moving freely across the full viewport", label: "Freeroam around" }, { id: "bottom" as const, image: "/puppet-movement-bottom.svg", alt: "Puppets moving along the bottom of the viewport", label: "Stay on the ground" }] as option (option.id)}
|
||||
{#each [{ id: "free" as const, image: "/puppet-movement-free.svg", alt: $messages.puppet_free_alt(), label: $messages.puppet_free() }, { id: "bottom" as const, image: "/puppet-movement-bottom.svg", alt: $messages.puppet_bottom_alt(), label: $messages.puppet_bottom() }] as option (option.id)}
|
||||
<label
|
||||
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
|
||||
class:border-primary={movementMode === option.id}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
serverName = $bindable(),
|
||||
@@ -14,14 +15,11 @@
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PanelMessage
|
||||
>If you do not have a server yet, leave this empty and continue. You can add
|
||||
one later.</PanelMessage
|
||||
>
|
||||
<PanelMessage>{$messages.onboarding_server_skip()}</PanelMessage>
|
||||
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Server details</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.common_server_details()}</legend>
|
||||
<label class="label" for="onboarding-server-name"
|
||||
>Friendly name (optional)</label
|
||||
>{$messages.common_optional_friendly_name()}</label
|
||||
>
|
||||
<input
|
||||
id="onboarding-server-name"
|
||||
@@ -31,7 +29,7 @@
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<label class="label mt-1" for="onboarding-server-address">Address</label>
|
||||
<label class="label mt-1" for="onboarding-server-address">{$messages.common_address()}</label>
|
||||
<input
|
||||
id="onboarding-server-address"
|
||||
class="input w-full"
|
||||
@@ -40,7 +38,7 @@
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<label class="label mt-1" for="onboarding-server-port">Port (optional)</label>
|
||||
<label class="label mt-1" for="onboarding-server-port">{$messages.common_optional_port()}</label>
|
||||
<input
|
||||
id="onboarding-server-port"
|
||||
class="input w-28"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { SceneConfiguration, User } from "$lib/bindings";
|
||||
import PuppetPreview from "../../scene/components/renderer/puppet/preview.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let {
|
||||
profile,
|
||||
@@ -34,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Puppet skin</legend>
|
||||
<legend class="fieldset-legend px-1">{$messages.onboarding_skin_legend()}</legend>
|
||||
{#if profile.skinHash}
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
@@ -45,7 +46,7 @@
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Keep current skin
|
||||
{$messages.onboarding_skin_keep()}
|
||||
</label>
|
||||
{/if}
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
@@ -57,7 +58,7 @@
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Use default skin
|
||||
{$messages.onboarding_skin_default()}
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
@@ -68,7 +69,7 @@
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Choose a PNG file
|
||||
{$messages.onboarding_skin_choose_png()}
|
||||
</label>
|
||||
<input
|
||||
class="file-input file-input-sm mt-2 w-full"
|
||||
@@ -77,6 +78,6 @@
|
||||
disabled={busy}
|
||||
onchange={(event) => onselect(event.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<p class="label">Skins must be exactly 64×64 pixels.</p>
|
||||
<p class="label">{$messages.onboarding_skin_help()}</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
errorMessage,
|
||||
languageOptions,
|
||||
localePreference,
|
||||
messages,
|
||||
setLanguagePreference,
|
||||
} from "$lib/i18n";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
|
||||
let {
|
||||
requiresAccessibilityPermission,
|
||||
}: { requiresAccessibilityPermission: boolean } = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
async function changeLanguage(event: Event) {
|
||||
busy = true;
|
||||
try {
|
||||
error = "";
|
||||
await setLanguagePreference(
|
||||
(event.currentTarget as HTMLSelectElement).value,
|
||||
);
|
||||
} catch (cause) {
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 text-sm">
|
||||
<p>
|
||||
Welcome to the Friendolls setup wizard, which will guide you through the
|
||||
process of setting up Friendolls.
|
||||
</p>
|
||||
<p>
|
||||
Friendolls turns your friends into desktop puppets that follow their cursors
|
||||
and appear when they are online.
|
||||
</p>
|
||||
<p>{$messages.onboarding_welcome_intro()}</p>
|
||||
<p>{$messages.onboarding_welcome_description()}</p>
|
||||
{#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">{$messages.language_label()}</legend>
|
||||
<select
|
||||
class="select w-full"
|
||||
aria-label={$messages.language_label()}
|
||||
value={$localePreference}
|
||||
disabled={busy}
|
||||
onchange={changeLanguage}
|
||||
>
|
||||
{#each languageOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label($messages)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import Popover from "$lib/components/popover.svelte";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import { onlineFriendIds } from "$lib/listeners/friend-statuses";
|
||||
@@ -14,6 +15,7 @@
|
||||
import SceneImageViewer from "./popovers/image-viewer.svelte";
|
||||
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
|
||||
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let lockedPopoverUserId = $state<string | null>(null);
|
||||
@@ -39,6 +41,10 @@
|
||||
),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void getCurrentWindow().setTitle($messages.scene_window_title());
|
||||
});
|
||||
|
||||
onMount(startHitboxSync);
|
||||
|
||||
$effect(() => {
|
||||
@@ -66,7 +72,7 @@
|
||||
? $profile.displayName
|
||||
: friendName(
|
||||
$friends.find((friend) => friend.id === userId),
|
||||
"Unknown user",
|
||||
$messages.common_unknown_user(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +106,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{$messages.scene_window_title()}</title></svelte:head>
|
||||
|
||||
<div class="relative size-full">
|
||||
<Renderer
|
||||
puppets={visiblePuppets}
|
||||
@@ -147,7 +155,7 @@
|
||||
|
||||
<Popover
|
||||
id={popoverId}
|
||||
label="Show live user information"
|
||||
label={$messages.scene_show_user()}
|
||||
labelledBy={`${popoverId}-title`}
|
||||
open={selectedUserId === userId}
|
||||
onOpenChange={(open) => setPopoverOpen(userId, open)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { messages } from "$lib/i18n";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import * as THREE from "three";
|
||||
import { PuppetVisual, SOUTH_WEST_ROTATION_Y } from "./visual";
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
<div
|
||||
class="aspect-square size-full overflow-hidden"
|
||||
aria-label="Live puppet preview"
|
||||
aria-label={$messages.scene_loading_preview()}
|
||||
role="img"
|
||||
bind:this={container}
|
||||
></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
@@ -26,12 +27,12 @@
|
||||
class="relative grid max-h-full max-w-full place-items-center border border-white/20 bg-black/70 p-2 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Image from ${senderName}`}
|
||||
aria-label={$messages.image_from({ sender: senderName })}
|
||||
transition:scale={{ start: 0.96, duration: 180 }}
|
||||
>
|
||||
<img
|
||||
src={source}
|
||||
alt={`Image sent by ${senderName}`}
|
||||
alt={$messages.image_sent_by({ sender: senderName })}
|
||||
class="max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)] object-contain"
|
||||
/>
|
||||
<div
|
||||
@@ -42,7 +43,7 @@
|
||||
bind:this={closeButton}
|
||||
type="button"
|
||||
class="grid size-6 place-items-center text-lg leading-none hover:bg-white/15 focus-visible:outline focus-visible:outline-white"
|
||||
aria-label="Close image"
|
||||
aria-label={$messages.image_close()}
|
||||
onclick={onClose}
|
||||
>
|
||||
×
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import type { FriendInteractionReceived } from "$lib/bindings";
|
||||
import { messages } from "$lib/i18n";
|
||||
|
||||
type Props = {
|
||||
interaction: FriendInteractionReceived;
|
||||
@@ -30,7 +31,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-5 place-items-center text-xs text-base-content/50 transition-colors hover:text-base-content"
|
||||
aria-label="Dismiss interaction"
|
||||
aria-label={$messages.interaction_dismiss()}
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
@@ -43,7 +44,7 @@
|
||||
class="origin-bottom-right animate-[wiggle_650ms_ease-in-out_2] text-xl"
|
||||
>👋</span
|
||||
>
|
||||
<span>waved</span>
|
||||
<span>{$messages.interaction_waved()}</span>
|
||||
</div>
|
||||
{:else if interaction.content.type === "text"}
|
||||
<p
|
||||
@@ -55,16 +56,16 @@
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full cursor-zoom-in bg-base-200"
|
||||
aria-label={`Open image from ${senderName}`}
|
||||
aria-label={$messages.interaction_open_image({ sender: senderName })}
|
||||
onclick={() => onOpenImage(imageSource)}
|
||||
>
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={`Preview sent by ${senderName}`}
|
||||
alt={$messages.interaction_preview_image({ sender: senderName })}
|
||||
class="h-24 w-full object-cover"
|
||||
/>
|
||||
<span class="block px-2 py-1 text-[0.6rem] text-base-content/60">
|
||||
Click to enlarge
|
||||
{$messages.interaction_enlarge()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
} from "$lib/bindings";
|
||||
import { friendName, friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
import { errorMessage, messages } from "$lib/i18n";
|
||||
|
||||
type Props = {
|
||||
titleId: string;
|
||||
@@ -35,7 +36,7 @@
|
||||
? $profile.displayName
|
||||
: friendName(
|
||||
$friends.find((friend) => friend.id === userId),
|
||||
"Unknown user",
|
||||
$messages.common_unknown_user(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -54,7 +55,7 @@
|
||||
mode = null;
|
||||
onSent();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
@@ -95,7 +96,7 @@
|
||||
onSent();
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
error = errorMessage(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
@@ -115,7 +116,7 @@
|
||||
{#if mode !== null}
|
||||
<button
|
||||
class="absolute right-1 top-1 grid size-6 place-items-center text-lg leading-none btn btn-square btn-ghost"
|
||||
aria-label="Close interaction popover"
|
||||
aria-label={$messages.scene_close_interaction()}
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
@@ -123,7 +124,7 @@
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
|
||||
<h3 class="text-xs text-base-content/50">{$messages.scene_currently_enjoying()}</h3>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
@@ -136,7 +137,7 @@
|
||||
<p class="truncate text-sm">
|
||||
{foregroundApp?.local ??
|
||||
foregroundApp?.unlocal ??
|
||||
"Waiting for data"}
|
||||
$messages.scene_waiting_data()}
|
||||
</p>
|
||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||
<p class="truncate text-xs text-base-content/50">
|
||||
@@ -158,7 +159,7 @@
|
||||
aria-pressed={mode === "message"}
|
||||
onclick={() => selectMode("message")}
|
||||
>
|
||||
Message
|
||||
{$messages.scene_message()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -166,7 +167,7 @@
|
||||
disabled={sending}
|
||||
onclick={() => sendContent({ type: "wave" })}
|
||||
>
|
||||
Wave
|
||||
{$messages.scene_wave()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -176,7 +177,7 @@
|
||||
aria-pressed={mode === "image"}
|
||||
onclick={() => selectMode("image")}
|
||||
>
|
||||
Image
|
||||
{$messages.scene_image()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -193,8 +194,8 @@
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
class="textarea textarea-xs w-full resize-none bg-base-100"
|
||||
placeholder={`Write to ${username}`}
|
||||
aria-label={`Message ${username}`}></textarea>
|
||||
placeholder={$messages.scene_write_to({ username })}
|
||||
aria-label={$messages.scene_message_user({ username })}></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[0.6rem] text-base-content/45">
|
||||
{message.length}/500
|
||||
@@ -204,7 +205,7 @@
|
||||
class="btn btn-primary btn-xs"
|
||||
disabled={sending || !message.trim()}
|
||||
>
|
||||
{sending ? "Sending…" : "Send"}
|
||||
{sending ? $messages.scene_sending() : $messages.scene_send()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -213,9 +214,9 @@
|
||||
class="mt-2 grid min-h-24 place-items-center border border-primary/60 bg-primary/5 p-2 text-center"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs">Choose or paste an image</p>
|
||||
<p class="text-xs">{$messages.scene_choose_or_paste()}</p>
|
||||
<p class="mt-0.5 text-[0.6rem] text-base-content/45">
|
||||
Compressed to 480 px · 150 KiB
|
||||
{$messages.scene_compression_note()}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -223,7 +224,9 @@
|
||||
disabled={sending}
|
||||
onclick={sendImage}
|
||||
>
|
||||
{sending ? "Compressing…" : "Choose image"}
|
||||
{sending
|
||||
? $messages.scene_compressing()
|
||||
: $messages.scene_choose_image()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user