chinese localization!!

This commit is contained in:
2026-09-03 16:21:01 +08:00
parent 34f35ef208
commit b731ec50d0
49 changed files with 1404 additions and 295 deletions
+14 -2
View File
@@ -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
View File
@@ -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"
+138
View File
@@ -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();
}
}
+41 -7
View File
@@ -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}
+17 -9
View File
@@ -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>
+52 -38
View File
@@ -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}>&lt; 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 &amp; 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>
+10 -2
View File
@@ -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>