setup wizard & macOS accessibility thing
This commit is contained in:
+29
@@ -107,3 +107,32 @@ input {
|
||||
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.bg-gridded {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
transparent 24%,
|
||||
var(--color-primary) 25%,
|
||||
var(--color-primary) 26%,
|
||||
transparent 27%,
|
||||
transparent 74%,
|
||||
var(--color-primary) 75%,
|
||||
var(--color-primary) 76%,
|
||||
transparent 77%,
|
||||
transparent
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 24%,
|
||||
var(--color-primary) 25%,
|
||||
var(--color-primary) 26%,
|
||||
transparent 27%,
|
||||
transparent 74%,
|
||||
var(--color-primary) 75%,
|
||||
var(--color-primary) 76%,
|
||||
transparent 77%,
|
||||
transparent
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@ async getSceneConfiguration() : Promise<SceneConfiguration> {
|
||||
async updateSceneConfiguration(configuration: SceneConfiguration) : Promise<SceneConfiguration> {
|
||||
return await TAURI_INVOKE("update_scene_configuration", { configuration });
|
||||
},
|
||||
async getOnboardingStatus() : Promise<OnboardingStatus> {
|
||||
return await TAURI_INVOKE("get_onboarding_status");
|
||||
},
|
||||
async completeOnboarding() : Promise<null> {
|
||||
return await TAURI_INVOKE("complete_onboarding");
|
||||
},
|
||||
async requestAccessibilityPermission() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("request_accessibility_permission");
|
||||
},
|
||||
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
|
||||
},
|
||||
@@ -99,6 +108,7 @@ friendInteractionReceived: FriendInteractionReceived,
|
||||
friendStatusesChanged: FriendStatusesChanged,
|
||||
friendsChanged: FriendsChanged,
|
||||
networkStatusChanged: NetworkStatusChanged,
|
||||
onboardingStatus: OnboardingStatus,
|
||||
profileChanged: ProfileChanged,
|
||||
puppetStatesChanged: PuppetStatesChanged,
|
||||
remotesChanged: RemotesChanged,
|
||||
@@ -111,6 +121,7 @@ friendInteractionReceived: "friend-interaction-received",
|
||||
friendStatusesChanged: "friend-statuses-changed",
|
||||
friendsChanged: "friends-changed",
|
||||
networkStatusChanged: "network-status-changed",
|
||||
onboardingStatus: "onboarding-status",
|
||||
profileChanged: "profile-changed",
|
||||
puppetStatesChanged: "puppet-states-changed",
|
||||
remotesChanged: "remotes-changed",
|
||||
@@ -153,6 +164,7 @@ 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 NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||
export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean }
|
||||
export type ProfileChanged = { profile: User }
|
||||
export type PuppetMovementMode = "free" | "bottom"
|
||||
export type PuppetState = { id: string; position: CursorPosition; isMoving: boolean }
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
kind = "info",
|
||||
children,
|
||||
}: {
|
||||
kind?: "info" | "error" | "warning";
|
||||
kind?: "info" | "success" | "error" | "warning";
|
||||
children: import("svelte").Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class:alert-info={kind === "info"}
|
||||
class:alert-success={kind === "success"}
|
||||
class:alert-error={kind === "error"}
|
||||
class:alert-warning={kind === "warning"}
|
||||
class="alert alert-soft min-h-0 px-2 py-1.5 text-xs"
|
||||
|
||||
@@ -301,34 +301,3 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-gridded {
|
||||
background-image:
|
||||
linear-gradient(
|
||||
0deg,
|
||||
transparent 24%,
|
||||
var(--color-primary) 25%,
|
||||
var(--color-primary) 26%,
|
||||
transparent 27%,
|
||||
transparent 74%,
|
||||
var(--color-primary) 75%,
|
||||
var(--color-primary) 76%,
|
||||
transparent 77%,
|
||||
transparent
|
||||
),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 24%,
|
||||
var(--color-primary) 25%,
|
||||
var(--color-primary) 26%,
|
||||
transparent 27%,
|
||||
transparent 74%,
|
||||
var(--color-primary) 75%,
|
||||
var(--color-primary) 76%,
|
||||
transparent 77%,
|
||||
transparent
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import {
|
||||
commands,
|
||||
events,
|
||||
type OnboardingStatus,
|
||||
type PuppetMovementMode,
|
||||
type RemoteInput,
|
||||
type SceneConfiguration,
|
||||
type User,
|
||||
} from "$lib/bindings";
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
import AccessibilityStep from "./components/accessibility-step.svelte";
|
||||
import CompleteStep from "./components/complete-step.svelte";
|
||||
import FriendStep from "./components/friend-step.svelte";
|
||||
import IdentityStep from "./components/identity-step.svelte";
|
||||
import MovementStep from "./components/movement-step.svelte";
|
||||
import ServerStep from "./components/server-step.svelte";
|
||||
import SkinStep from "./components/skin-step.svelte";
|
||||
import WelcomeStep from "./components/welcome-step.svelte";
|
||||
|
||||
type StepId =
|
||||
| "welcome"
|
||||
| "identity"
|
||||
| "skin"
|
||||
| "movement"
|
||||
| "server"
|
||||
| "friend"
|
||||
| "accessibility"
|
||||
| "complete";
|
||||
|
||||
const allSteps: Array<{
|
||||
id: StepId;
|
||||
title: string;
|
||||
}> = [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome to Friendolls!",
|
||||
},
|
||||
{
|
||||
id: "identity",
|
||||
title: "Choose your Display Name",
|
||||
},
|
||||
{
|
||||
id: "skin",
|
||||
title: "Choose your Puppet's Skin",
|
||||
},
|
||||
{
|
||||
id: "movement",
|
||||
title: "Choose your Movement Mode",
|
||||
},
|
||||
{
|
||||
id: "server",
|
||||
title: "Add a server",
|
||||
},
|
||||
{
|
||||
id: "friend",
|
||||
title: "Add a friend",
|
||||
},
|
||||
{
|
||||
id: "accessibility",
|
||||
title: "Allow cursor access",
|
||||
},
|
||||
{
|
||||
id: "complete",
|
||||
title: "Setup is complete",
|
||||
},
|
||||
];
|
||||
|
||||
let status = $state<OnboardingStatus | null>(null);
|
||||
let profile = $state<User | null>(null);
|
||||
let configuration = $state<SceneConfiguration>({
|
||||
puppetScale: 1,
|
||||
puppetOpacity: 1,
|
||||
puppetMovementMode: "free",
|
||||
});
|
||||
let remediation = $state(false);
|
||||
let stepIndex = $state(0);
|
||||
let busy = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
let displayName = $state("");
|
||||
let skinMode = $state<"current" | "default" | "custom">("default");
|
||||
let skinFile = $state<File | null>(null);
|
||||
let skinPreviewUrl = $state<string | null>(null);
|
||||
let movementMode = $state<PuppetMovementMode>("free");
|
||||
let serverName = $state("");
|
||||
let serverAddress = $state("");
|
||||
let serverPort = $state("");
|
||||
let savedServerId = $state<string | null>(null);
|
||||
let friendId = $state("");
|
||||
let savedFriendId = $state<string | null>(null);
|
||||
|
||||
let steps = $derived(
|
||||
remediation
|
||||
? allSteps.filter((candidate) => candidate.id === "accessibility")
|
||||
: allSteps.filter(
|
||||
(candidate) =>
|
||||
candidate.id !== "accessibility" ||
|
||||
status?.requiresAccessibilityPermission,
|
||||
),
|
||||
);
|
||||
let step = $derived(steps[stepIndex] ?? steps[0]);
|
||||
let permissionGranted = $derived(
|
||||
status?.macosAccessibilityPermissionGranted ?? false,
|
||||
);
|
||||
let isLastStep = $derived(stepIndex === steps.length - 1);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
unlisten = await events.onboardingStatus.listen((event) => {
|
||||
status = event.payload;
|
||||
});
|
||||
const [nextStatus, nextProfile, nextConfiguration] = await Promise.all([
|
||||
commands.getOnboardingStatus(),
|
||||
commands.getProfile(),
|
||||
commands.getSceneConfiguration(),
|
||||
]);
|
||||
if (disposed) return;
|
||||
|
||||
status = nextStatus;
|
||||
profile = nextProfile;
|
||||
configuration = nextConfiguration;
|
||||
displayName =
|
||||
nextProfile.displayName === "Anonymous"
|
||||
? ""
|
||||
: nextProfile.displayName;
|
||||
skinMode = nextProfile.skinHash ? "current" : "default";
|
||||
movementMode = nextConfiguration.puppetMovementMode;
|
||||
|
||||
const requestedStep = new URLSearchParams(window.location.search).get(
|
||||
"step",
|
||||
);
|
||||
remediation =
|
||||
requestedStep === "accessibility" && nextStatus.onboardingDone;
|
||||
if (!remediation && requestedStep) {
|
||||
const requestedIndex = steps.findIndex(
|
||||
(candidate) => candidate.id === requestedStep,
|
||||
);
|
||||
if (requestedIndex >= 0) stepIndex = requestedIndex;
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!disposed) error = String(cause);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
|
||||
});
|
||||
|
||||
function selectSkin(file: File | null) {
|
||||
if (skinPreviewUrl) URL.revokeObjectURL(skinPreviewUrl);
|
||||
skinFile = file;
|
||||
skinPreviewUrl = file ? URL.createObjectURL(file) : null;
|
||||
if (file) skinMode = "custom";
|
||||
error = "";
|
||||
}
|
||||
|
||||
function remoteInput(): RemoteInput | null {
|
||||
if (!serverAddress.trim()) return null;
|
||||
const port = serverPort ? Number(serverPort) : null;
|
||||
if (
|
||||
port !== null &&
|
||||
(!Number.isInteger(port) || port < 1 || port > 65535)
|
||||
) {
|
||||
throw new Error("Port must be a whole number from 1 to 65535.");
|
||||
}
|
||||
return {
|
||||
address: serverAddress.trim(),
|
||||
name: serverName.trim() || null,
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveCurrentStep() {
|
||||
if (step.id === "identity") {
|
||||
const nextName = displayName.trim();
|
||||
if (!nextName) throw new Error("Display name cannot be 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 (skinMode === "default" && profile.skinHash) {
|
||||
profile = await commands.resetProfileSkin();
|
||||
} else if (skinMode === "custom" && skinFile) {
|
||||
const skinData = Array.from(
|
||||
new Uint8Array(await skinFile.arrayBuffer()),
|
||||
);
|
||||
profile = await commands.updateProfile(profile.displayName, skinData);
|
||||
selectSkin(null);
|
||||
skinMode = "current";
|
||||
} else if (skinMode === "custom") {
|
||||
throw new Error("Choose a 64×64 PNG skin before continuing.");
|
||||
}
|
||||
} else if (step.id === "movement") {
|
||||
configuration = await commands.updateSceneConfiguration({
|
||||
...configuration,
|
||||
puppetMovementMode: movementMode,
|
||||
});
|
||||
} else if (step.id === "server") {
|
||||
const remote = remoteInput();
|
||||
if (remote) {
|
||||
if (savedServerId) {
|
||||
await commands.updateRemote(savedServerId, remote);
|
||||
} else {
|
||||
savedServerId = (await commands.createRemote(remote)).id;
|
||||
}
|
||||
}
|
||||
} else if (step.id === "friend") {
|
||||
const nextFriendId = friendId.trim();
|
||||
if (nextFriendId && nextFriendId !== savedFriendId) {
|
||||
const previousFriendId = savedFriendId;
|
||||
savedFriendId = (await commands.createFriend(nextFriendId)).id;
|
||||
if (previousFriendId) await commands.deleteFriend(previousFriendId);
|
||||
}
|
||||
} else if (step.id === "accessibility" && !permissionGranted) {
|
||||
throw new Error(
|
||||
"Turn on Friendolls in macOS Accessibility settings before continuing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
if (!step) return;
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
await saveCurrentStep();
|
||||
if (remediation) {
|
||||
await getCurrentWindow().close();
|
||||
} else if (isLastStep) {
|
||||
await commands.completeOnboarding();
|
||||
await getCurrentWindow().close();
|
||||
} else {
|
||||
stepIndex += 1;
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (stepIndex > 0 && !busy) {
|
||||
stepIndex -= 1;
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAccessibilityPermission() {
|
||||
busy = true;
|
||||
error = "";
|
||||
try {
|
||||
const granted = await commands.requestAccessibilityPermission();
|
||||
if (!granted) {
|
||||
error =
|
||||
"macOS opened Accessibility settings. Enable Friendolls there; this page will confirm automatically.";
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Friendolls Setup</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="mt-6 flex-1 space-y-2 text-[11px]">
|
||||
{#each steps as candidate, index (candidate.id)}
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-60"
|
||||
class:font-bold={index === stepIndex}
|
||||
class:opacity-100={index <= stepIndex}
|
||||
>
|
||||
<span
|
||||
class="grid size-4 place-content-center border border-primary-content text-[9px]"
|
||||
class:bg-primary-content={index < stepIndex}
|
||||
class:text-primary={index < stepIndex}
|
||||
>
|
||||
{index < stepIndex ? "✓" : index + 1}
|
||||
</span>
|
||||
<span>{candidate.title.replace("Choose your ", "")}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-[10px] opacity-70">Friendolls 0.1</p>
|
||||
</aside>
|
||||
|
||||
<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> -->
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if error}
|
||||
<div class="mb-3">
|
||||
<PanelMessage
|
||||
kind={step.id === "accessibility" && !permissionGranted
|
||||
? "warning"
|
||||
: "error"}>{error}</PanelMessage
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !status || !profile}
|
||||
<div class="space-y-3" aria-label="Loading setup">
|
||||
<div class="skeleton h-5 w-2/3"></div>
|
||||
<div class="skeleton h-24 w-full"></div>
|
||||
</div>
|
||||
{:else if step.id === "welcome"}
|
||||
<WelcomeStep
|
||||
requiresAccessibilityPermission={status.requiresAccessibilityPermission}
|
||||
/>
|
||||
{:else if step.id === "identity"}
|
||||
<IdentityStep bind:displayName {busy} />
|
||||
{:else if step.id === "skin"}
|
||||
<SkinStep
|
||||
{profile}
|
||||
{configuration}
|
||||
bind:skinMode
|
||||
{skinPreviewUrl}
|
||||
{busy}
|
||||
onselect={selectSkin}
|
||||
/>
|
||||
{:else if step.id === "movement"}
|
||||
<MovementStep bind:movementMode {busy} />
|
||||
{:else if step.id === "server"}
|
||||
<ServerStep
|
||||
bind:serverName
|
||||
bind:serverAddress
|
||||
bind:serverPort
|
||||
{busy}
|
||||
/>
|
||||
{:else if step.id === "friend"}
|
||||
<FriendStep bind:friendId {busy} />
|
||||
{:else if step.id === "accessibility"}
|
||||
<AccessibilityStep
|
||||
{permissionGranted}
|
||||
{busy}
|
||||
onrequest={requestAccessibilityPermission}
|
||||
/>
|
||||
{:else if step.id === "complete"}
|
||||
<CompleteStep />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex shrink-0 items-center justify-between border-t border-base-300 bg-base-200 px-4 py-3"
|
||||
>
|
||||
<span class="text-[10px] text-base-content/60">
|
||||
{remediation
|
||||
? "Permission repair"
|
||||
: `Step ${stepIndex + 1} of ${steps.length}`}
|
||||
</span>
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if !remediation}
|
||||
<button
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy || stepIndex === 0}
|
||||
onclick={back}>< Back</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
class="btn min-w-20"
|
||||
type="button"
|
||||
disabled={busy ||
|
||||
(step.id === "accessibility" && !permissionGranted)}
|
||||
onclick={next}
|
||||
>
|
||||
{busy
|
||||
? "Working…"
|
||||
: remediation
|
||||
? "Done"
|
||||
: isLastStep
|
||||
? "Finish"
|
||||
: "Next >"}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
|
||||
let {
|
||||
permissionGranted,
|
||||
busy,
|
||||
onrequest,
|
||||
}: { permissionGranted: boolean; busy: boolean; onrequest: () => void } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<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."}
|
||||
</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>
|
||||
<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>
|
||||
</ol>
|
||||
<button
|
||||
class="btn mt-4 w-fit"
|
||||
type="button"
|
||||
disabled={busy || permissionGranted}
|
||||
onclick={onrequest}
|
||||
>
|
||||
{permissionGranted ? "Access Granted" : "Open Accessibility Settings…"}
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
|
||||
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
|
||||
>
|
||||
<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>
|
||||
<textarea
|
||||
id="onboarding-friend-key"
|
||||
class="textarea h-24 w-full resize-none font-mono text-[10px]"
|
||||
bind:value={friendId}
|
||||
autocomplete="off"
|
||||
disabled={busy}></textarea>
|
||||
<p class="label">Verify this key with your friend before adding it.</p>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
<input
|
||||
id="onboarding-display-name"
|
||||
class="input w-full"
|
||||
bind:value={displayName}
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<p class="label">Up to 64 characters. You can change it later.</p>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { PuppetMovementMode } from "$lib/bindings";
|
||||
|
||||
let {
|
||||
movementMode = $bindable(),
|
||||
busy,
|
||||
}: { movementMode: PuppetMovementMode; busy: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Puppets reposition</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)}
|
||||
<label
|
||||
class="card cursor-pointer overflow-hidden border border-base-300 bg-base-200"
|
||||
class:border-primary={movementMode === option.id}
|
||||
>
|
||||
<img
|
||||
src={option.image}
|
||||
alt={option.alt}
|
||||
class="w-full bg-linear-to-b from-base-100 to-base-300 object-cover"
|
||||
/>
|
||||
<span class="card-body flex-row items-center gap-2 p-3">
|
||||
<input
|
||||
class="radio scale-50 radio-primary"
|
||||
type="radio"
|
||||
name="movement-mode"
|
||||
value={option.id}
|
||||
bind:group={movementMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span class="text-xs font-bold">{option.label}</span>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import PanelMessage from "$lib/components/control-panel/panel-message.svelte";
|
||||
|
||||
let {
|
||||
serverName = $bindable(),
|
||||
serverAddress = $bindable(),
|
||||
serverPort = $bindable(),
|
||||
busy,
|
||||
}: {
|
||||
serverName: string;
|
||||
serverAddress: string;
|
||||
serverPort: string;
|
||||
busy: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PanelMessage
|
||||
>If you do not have a server yet, leave this empty and continue. You can add
|
||||
one later.</PanelMessage
|
||||
>
|
||||
<fieldset class="fieldset mt-3 border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Server details</legend>
|
||||
<label class="label" for="onboarding-server-name"
|
||||
>Friendly name (optional)</label
|
||||
>
|
||||
<input
|
||||
id="onboarding-server-name"
|
||||
class="input w-full"
|
||||
bind:value={serverName}
|
||||
maxlength="64"
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<label class="label mt-1" for="onboarding-server-address">Address</label>
|
||||
<input
|
||||
id="onboarding-server-address"
|
||||
class="input w-full"
|
||||
bind:value={serverAddress}
|
||||
placeholder="example.net"
|
||||
autocomplete="off"
|
||||
disabled={busy}
|
||||
/>
|
||||
<label class="label mt-1" for="onboarding-server-port">Port (optional)</label>
|
||||
<input
|
||||
id="onboarding-server-port"
|
||||
class="input w-28"
|
||||
bind:value={serverPort}
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
inputmode="numeric"
|
||||
placeholder="Default"
|
||||
disabled={busy}
|
||||
/>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { SceneConfiguration, User } from "$lib/bindings";
|
||||
import PuppetPreview from "../../scene/components/renderer/puppet/preview.svelte";
|
||||
|
||||
let {
|
||||
profile,
|
||||
configuration,
|
||||
skinMode = $bindable(),
|
||||
skinPreviewUrl,
|
||||
busy,
|
||||
onselect,
|
||||
}: {
|
||||
profile: User;
|
||||
configuration: SceneConfiguration;
|
||||
skinMode: "current" | "default" | "custom";
|
||||
skinPreviewUrl: string | null;
|
||||
busy: boolean;
|
||||
onselect: (file: File | null) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-[9rem_minmax(0,1fr)] gap-4 items-center">
|
||||
<div class="bg-gridded aspect-square">
|
||||
<div
|
||||
class="aspect-square border border-primary bg-primary/5 shadow-[inset_0_0_10px] shadow-primary"
|
||||
>
|
||||
<PuppetPreview
|
||||
userId={profile.id}
|
||||
skinHash={skinMode === "default" ? null : profile.skinHash}
|
||||
skinSource={skinMode === "custom" ? skinPreviewUrl : null}
|
||||
scale={configuration.puppetScale}
|
||||
opacity={configuration.puppetOpacity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="fieldset border border-base-300 bg-base-100 p-4">
|
||||
<legend class="fieldset-legend px-1">Puppet skin</legend>
|
||||
{#if profile.skinHash}
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
class="radio radio-sm radio-primary"
|
||||
type="radio"
|
||||
name="skin-mode"
|
||||
value="current"
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Keep current skin
|
||||
</label>
|
||||
{/if}
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
class="radio radio-sm radio-primary"
|
||||
type="radio"
|
||||
name="skin-mode"
|
||||
value="default"
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Use default skin
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
class="radio radio-sm radio-primary"
|
||||
type="radio"
|
||||
name="skin-mode"
|
||||
value="custom"
|
||||
bind:group={skinMode}
|
||||
disabled={busy}
|
||||
/>
|
||||
Choose a PNG file
|
||||
</label>
|
||||
<input
|
||||
class="file-input file-input-sm mt-2 w-full"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
disabled={busy}
|
||||
onchange={(event) => onselect(event.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
<p class="label">Skins must be exactly 64×64 pixels.</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
requiresAccessibilityPermission,
|
||||
}: { requiresAccessibilityPermission: boolean } = $props();
|
||||
</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>
|
||||
</div>
|
||||
Reference in New Issue
Block a user