840 lines
27 KiB
TypeScript
840 lines
27 KiB
TypeScript
import React from "react";
|
|
import ReactDOM from "react-dom/client";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import {
|
|
PhysicalPosition,
|
|
PhysicalSize,
|
|
currentMonitor,
|
|
getCurrentWindow,
|
|
monitorFromPoint
|
|
} from "@tauri-apps/api/window";
|
|
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
|
|
import "./styles.css";
|
|
|
|
type UiSettingsSnapshot = {
|
|
active_sprite_pack: string;
|
|
scale: number;
|
|
visible: boolean;
|
|
always_on_top: boolean;
|
|
tauri_animation_slowdown_factor: number;
|
|
};
|
|
|
|
type UiSpritePackOption = {
|
|
id: string;
|
|
pack_id_or_path: string;
|
|
};
|
|
|
|
type UiApiToken = {
|
|
id: string;
|
|
label: string;
|
|
token: string;
|
|
created_at_ms: number;
|
|
};
|
|
|
|
const WINDOW_PADDING = 16;
|
|
const WINDOW_WORKAREA_MARGIN = 80;
|
|
const MIN_WINDOW_SIZE = 64;
|
|
const SIZE_EPSILON = 0.5;
|
|
const SCALE_EPSILON = 0.0001;
|
|
const SCALE_MIN = 0.5;
|
|
const SCALE_MAX = 3.0;
|
|
const LOGICAL_BASE_FRAME_WIDTH = 512;
|
|
const LOGICAL_BASE_FRAME_HEIGHT = 512;
|
|
const SLOWDOWN_FACTOR_MIN = 1;
|
|
const SLOWDOWN_FACTOR_MAX = 20;
|
|
const SLOWDOWN_FACTOR_DEFAULT = 3;
|
|
|
|
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
|
|
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
|
|
}
|
|
|
|
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
|
|
return invoke<UiSnapshot>("set_scale", { scale });
|
|
}
|
|
|
|
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
|
|
return invoke<UiSnapshot>("set_visibility", { visible });
|
|
}
|
|
|
|
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
|
|
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
|
|
}
|
|
|
|
async function invokeAnimationSlowdownFactor(): Promise<number> {
|
|
return invoke<number>("tauri_animation_slowdown_factor");
|
|
}
|
|
|
|
async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number> {
|
|
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
|
|
}
|
|
|
|
async function invokeListApiTokens(): Promise<UiApiToken[]> {
|
|
return invoke<UiApiToken[]>("list_api_tokens");
|
|
}
|
|
|
|
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
|
|
return invoke<UiApiToken>("create_api_token", { label });
|
|
}
|
|
|
|
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
|
|
return invoke<UiApiToken[]>("rename_api_token", { id, label });
|
|
}
|
|
|
|
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
|
|
return invoke<UiApiToken[]>("revoke_api_token", { id });
|
|
}
|
|
|
|
function fittedWindowSize(
|
|
scale: number
|
|
): { width: number; height: number } {
|
|
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
const width = Math.round(
|
|
Math.max(LOGICAL_BASE_FRAME_WIDTH * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
|
|
);
|
|
const height = Math.round(
|
|
Math.max(LOGICAL_BASE_FRAME_HEIGHT * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE)
|
|
);
|
|
return { width, height };
|
|
}
|
|
|
|
function effectiveScaleForWindowSize(width: number, height: number): number {
|
|
const availableWidth = Math.max(width - WINDOW_PADDING, MIN_WINDOW_SIZE);
|
|
const availableHeight = Math.max(height - WINDOW_PADDING, MIN_WINDOW_SIZE);
|
|
const scaleByWidth = availableWidth / LOGICAL_BASE_FRAME_WIDTH;
|
|
const scaleByHeight = availableHeight / LOGICAL_BASE_FRAME_HEIGHT;
|
|
const scale = Math.min(scaleByWidth, scaleByHeight);
|
|
return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX));
|
|
}
|
|
|
|
async function fitWindowForScale(scale: number): Promise<number> {
|
|
const window = getCurrentWindow();
|
|
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
|
|
const target = fittedWindowSize(scale);
|
|
const centerX = outerPosition.x + innerSize.width / 2;
|
|
const centerY = outerPosition.y + innerSize.height / 2;
|
|
let targetWidth = target.width;
|
|
let targetHeight = target.height;
|
|
let targetX = centerX - targetWidth / 2;
|
|
let targetY = centerY - targetHeight / 2;
|
|
let monitor:
|
|
| {
|
|
position: { x: number; y: number };
|
|
size: { width: number; height: number };
|
|
workArea: { position: { x: number; y: number }; size: { width: number; height: number } };
|
|
}
|
|
| null = null;
|
|
|
|
try {
|
|
monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor());
|
|
} catch {
|
|
monitor = null;
|
|
}
|
|
|
|
if (monitor !== null) {
|
|
const widthCap = Math.max(
|
|
monitor.workArea.size.width - WINDOW_WORKAREA_MARGIN,
|
|
MIN_WINDOW_SIZE
|
|
);
|
|
const heightCap = Math.max(
|
|
monitor.workArea.size.height - WINDOW_WORKAREA_MARGIN,
|
|
MIN_WINDOW_SIZE
|
|
);
|
|
targetWidth = Math.min(targetWidth, widthCap);
|
|
targetHeight = Math.min(targetHeight, heightCap);
|
|
targetX = centerX - targetWidth / 2;
|
|
targetY = centerY - targetHeight / 2;
|
|
}
|
|
|
|
const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON;
|
|
const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON;
|
|
if (widthChanged || heightChanged) {
|
|
await window.setSize(new PhysicalSize(targetWidth, targetHeight));
|
|
if (monitor !== null) {
|
|
const minX = Math.round(monitor.workArea.position.x);
|
|
const minY = Math.round(monitor.workArea.position.y);
|
|
const maxX = Math.round(
|
|
monitor.workArea.position.x + monitor.workArea.size.width - targetWidth
|
|
);
|
|
const maxY = Math.round(
|
|
monitor.workArea.position.y + monitor.workArea.size.height - targetHeight
|
|
);
|
|
targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX);
|
|
targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY);
|
|
}
|
|
await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY)));
|
|
}
|
|
|
|
return effectiveScaleForWindowSize(targetWidth, targetHeight);
|
|
}
|
|
|
|
function MainOverlayWindow(): JSX.Element {
|
|
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
|
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
|
|
const activePackRef = React.useRef<UiSpritePack | null>(null);
|
|
const loadedPackKeyRef = React.useRef<string | null>(null);
|
|
const effectiveScaleSyncRef = React.useRef<number | null>(null);
|
|
const slowdownFactorRef = React.useRef<number>(SLOWDOWN_FACTOR_DEFAULT);
|
|
const loadingPackRef = React.useRef(false);
|
|
const mountedRef = React.useRef(false);
|
|
|
|
React.useEffect(() => {
|
|
mountedRef.current = true;
|
|
let unlisten: null | (() => void) = null;
|
|
|
|
const recreateRenderer = async (
|
|
pack: UiSpritePack,
|
|
nextSnapshot: UiSnapshot
|
|
): Promise<boolean> => {
|
|
if (!mountedRef.current || hostRef.current === null) {
|
|
return false;
|
|
}
|
|
const previousRenderer = rendererRef.current;
|
|
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
|
|
nextRenderer.setAnimationSlowdownFactor(slowdownFactorRef.current);
|
|
rendererRef.current = nextRenderer;
|
|
activePackRef.current = pack;
|
|
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
|
|
previousRenderer?.dispose();
|
|
return true;
|
|
};
|
|
|
|
const tryFitWindow = async (scale: number): Promise<number | null> => {
|
|
try {
|
|
return await fitWindowForScale(scale);
|
|
} catch (err) {
|
|
if (mountedRef.current) {
|
|
setError(String(err));
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise<void> => {
|
|
if (Math.abs(snapshotScale - effectiveScale) < SCALE_EPSILON) {
|
|
return;
|
|
}
|
|
if (
|
|
effectiveScaleSyncRef.current !== null &&
|
|
Math.abs(effectiveScaleSyncRef.current - effectiveScale) < SCALE_EPSILON
|
|
) {
|
|
return;
|
|
}
|
|
effectiveScaleSyncRef.current = effectiveScale;
|
|
try {
|
|
await invokeSetScale(effectiveScale);
|
|
} catch (err) {
|
|
if (mountedRef.current) {
|
|
setError(String(err));
|
|
}
|
|
}
|
|
};
|
|
|
|
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
if (
|
|
effectiveScaleSyncRef.current !== null &&
|
|
Math.abs(effectiveScaleSyncRef.current - value.scale) < SCALE_EPSILON
|
|
) {
|
|
effectiveScaleSyncRef.current = null;
|
|
}
|
|
setSnapshot(value);
|
|
rendererRef.current?.applySnapshot(value);
|
|
|
|
const activePack = activePackRef.current;
|
|
const needsReload =
|
|
activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack;
|
|
if (needsReload && !loadingPackRef.current) {
|
|
loadingPackRef.current = true;
|
|
let reloaded = false;
|
|
try {
|
|
const pack = await invoke<UiSpritePack>("load_active_sprite_pack");
|
|
reloaded = await recreateRenderer(pack, value);
|
|
if (reloaded) {
|
|
const effectiveScale = await tryFitWindow(value.scale);
|
|
if (effectiveScale !== null) {
|
|
await syncEffectiveScale(value.scale, effectiveScale);
|
|
}
|
|
if (mountedRef.current && effectiveScale !== null) {
|
|
setError(null);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (mountedRef.current) {
|
|
console.error("reload_pack_failed", err);
|
|
setError(String(err));
|
|
}
|
|
} finally {
|
|
loadingPackRef.current = false;
|
|
}
|
|
}
|
|
|
|
if (activePackRef.current === null) {
|
|
return;
|
|
}
|
|
const effectiveScale = await tryFitWindow(value.scale);
|
|
if (effectiveScale !== null) {
|
|
await syncEffectiveScale(value.scale, effectiveScale);
|
|
}
|
|
if (effectiveScale !== null && mountedRef.current) {
|
|
setError(null);
|
|
}
|
|
};
|
|
|
|
Promise.all([
|
|
invoke<UiSpritePack>("load_active_sprite_pack"),
|
|
invoke<UiSnapshot>("current_state"),
|
|
invoke<boolean>("debug_overlay_visible"),
|
|
invokeAnimationSlowdownFactor()
|
|
])
|
|
.then(async ([pack, initialSnapshot, showDebug, slowdownFactor]) => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
setDebugOverlayVisible(showDebug);
|
|
slowdownFactorRef.current = Math.min(
|
|
Math.max(Math.round(slowdownFactor), SLOWDOWN_FACTOR_MIN),
|
|
SLOWDOWN_FACTOR_MAX
|
|
);
|
|
await recreateRenderer(pack, initialSnapshot);
|
|
await processSnapshot(initialSnapshot);
|
|
|
|
const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
|
void processSnapshot(event.payload);
|
|
});
|
|
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
setDebugOverlayVisible(Boolean(event.payload));
|
|
});
|
|
const unlistenSlowdown = await listen<number>(
|
|
"runtime:animation-slowdown-factor",
|
|
(event) => {
|
|
if (!mountedRef.current) {
|
|
return;
|
|
}
|
|
slowdownFactorRef.current = Math.min(
|
|
Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN),
|
|
SLOWDOWN_FACTOR_MAX
|
|
);
|
|
rendererRef.current?.setAnimationSlowdownFactor(slowdownFactorRef.current);
|
|
}
|
|
);
|
|
unlisten = () => {
|
|
unlistenSnapshot();
|
|
unlistenDebug();
|
|
unlistenSlowdown();
|
|
};
|
|
})
|
|
.catch((err) => {
|
|
if (mountedRef.current) {
|
|
setError(String(err));
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (unlisten !== null) {
|
|
unlisten();
|
|
}
|
|
rendererRef.current?.dispose();
|
|
rendererRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
const toggleDebugOverlay = React.useCallback(async () => {
|
|
try {
|
|
const next = !debugOverlayVisible;
|
|
const persisted = await invoke<boolean>("set_debug_overlay_visible", {
|
|
visible: next
|
|
});
|
|
setDebugOverlayVisible(persisted);
|
|
} catch (err) {
|
|
setError(String(err));
|
|
}
|
|
}, [debugOverlayVisible]);
|
|
|
|
React.useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
if (!event.ctrlKey || !event.shiftKey || event.code !== "KeyD") {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
void toggleDebugOverlay();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [toggleDebugOverlay]);
|
|
|
|
const onMouseDown = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
if (event.button !== 0) {
|
|
return;
|
|
}
|
|
void getCurrentWindow().startDragging().catch((err) => {
|
|
setError(String(err));
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<main className="app overlay-app" onMouseDown={onMouseDown}>
|
|
<div className="canvas-host" ref={hostRef} />
|
|
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
|
|
{debugOverlayVisible ? (
|
|
<section className="debug-panel">
|
|
<h1>sprimo-tauri</h1>
|
|
<p className="hint">Toggle: Ctrl+Shift+D</p>
|
|
{error !== null ? <p className="error">{error}</p> : null}
|
|
{snapshot === null ? (
|
|
<p>Loading snapshot...</p>
|
|
) : (
|
|
<dl>
|
|
<dt>state</dt>
|
|
<dd>{snapshot.state}</dd>
|
|
<dt>animation</dt>
|
|
<dd>{snapshot.current_animation}</dd>
|
|
<dt>pack</dt>
|
|
<dd>{snapshot.active_sprite_pack}</dd>
|
|
<dt>position</dt>
|
|
<dd>
|
|
{snapshot.x}, {snapshot.y}
|
|
</dd>
|
|
<dt>scale</dt>
|
|
<dd>{snapshot.scale}</dd>
|
|
</dl>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function SettingsWindow(): JSX.Element {
|
|
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
|
|
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
|
|
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
|
|
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
|
|
const [newTokenLabel, setNewTokenLabel] = React.useState("");
|
|
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [pending, setPending] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
let unlisten: null | (() => void) = null;
|
|
let mounted = true;
|
|
Promise.all([
|
|
invoke<UiSettingsSnapshot>("settings_snapshot"),
|
|
invoke<UiSpritePackOption[]>("list_sprite_packs"),
|
|
invoke<UiSpritePack>("load_active_sprite_pack"),
|
|
invokeListApiTokens()
|
|
])
|
|
.then(async ([snapshot, options, pack, authTokens]) => {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setSettings(snapshot);
|
|
setPacks(options);
|
|
setActivePack(pack);
|
|
setTokens(authTokens);
|
|
setTokenDrafts(
|
|
authTokens.reduce<Record<string, string>>((acc, token) => {
|
|
acc[token.id] = token.label;
|
|
return acc;
|
|
}, {})
|
|
);
|
|
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
const payload = event.payload;
|
|
if (payload.active_sprite_pack !== activePack?.id) {
|
|
void invoke<UiSpritePack>("load_active_sprite_pack")
|
|
.then((nextPack) => {
|
|
if (mounted) {
|
|
setActivePack(nextPack);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Keep existing pack metadata if reload fails.
|
|
});
|
|
}
|
|
setSettings((prev) => {
|
|
if (prev === null) {
|
|
return prev;
|
|
}
|
|
return {
|
|
active_sprite_pack: payload.active_sprite_pack,
|
|
scale: payload.scale,
|
|
visible: payload.visible,
|
|
always_on_top: payload.always_on_top,
|
|
tauri_animation_slowdown_factor:
|
|
prev.tauri_animation_slowdown_factor ?? SLOWDOWN_FACTOR_DEFAULT
|
|
};
|
|
});
|
|
});
|
|
const unlistenSlowdown = await listen<number>("runtime:animation-slowdown-factor", (event) => {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
const factor = Math.min(
|
|
Math.max(Math.round(Number(event.payload)), SLOWDOWN_FACTOR_MIN),
|
|
SLOWDOWN_FACTOR_MAX
|
|
);
|
|
setSettings((prev) =>
|
|
prev === null ? prev : { ...prev, tauri_animation_slowdown_factor: factor }
|
|
);
|
|
});
|
|
const previousUnlisten = unlisten;
|
|
unlisten = () => {
|
|
previousUnlisten();
|
|
unlistenSlowdown();
|
|
};
|
|
})
|
|
.catch((err) => {
|
|
if (mounted) {
|
|
setError(String(err));
|
|
}
|
|
});
|
|
return () => {
|
|
mounted = false;
|
|
if (unlisten !== null) {
|
|
unlisten();
|
|
}
|
|
};
|
|
}, [activePack?.id]);
|
|
|
|
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
|
|
setPending(true);
|
|
setError(null);
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
setError(String(err));
|
|
return null;
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}, []);
|
|
|
|
const onPackChange = React.useCallback(
|
|
async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const value = event.target.value;
|
|
const next = await withPending(() => invokeSetSpritePack(value));
|
|
if (next === null) {
|
|
return;
|
|
}
|
|
const refreshedPack = await withPending(() => invoke<UiSpritePack>("load_active_sprite_pack"));
|
|
if (refreshedPack !== null) {
|
|
setActivePack(refreshedPack);
|
|
}
|
|
setSettings((prev) =>
|
|
prev === null
|
|
? prev
|
|
: {
|
|
...prev,
|
|
active_sprite_pack: next.active_sprite_pack
|
|
}
|
|
);
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
const onScaleChange = React.useCallback(
|
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isFinite(value)) {
|
|
return;
|
|
}
|
|
const next = await withPending(() => invokeSetScale(value));
|
|
if (next === null) {
|
|
return;
|
|
}
|
|
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
const onVisibleChange = React.useCallback(
|
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.target.checked;
|
|
const next = await withPending(() => invokeSetVisibility(value));
|
|
if (next === null) {
|
|
return;
|
|
}
|
|
setSettings((prev) => (prev === null ? prev : { ...prev, visible: value }));
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
const onAlwaysOnTopChange = React.useCallback(
|
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.target.checked;
|
|
const next = await withPending(() => invokeSetAlwaysOnTop(value));
|
|
if (next === null) {
|
|
return;
|
|
}
|
|
setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value }));
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
const onAnimationSlowdownFactorChange = React.useCallback(
|
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isFinite(value)) {
|
|
return;
|
|
}
|
|
const clamped = Math.min(
|
|
Math.max(Math.round(value), SLOWDOWN_FACTOR_MIN),
|
|
SLOWDOWN_FACTOR_MAX
|
|
);
|
|
const persisted = await withPending(() => invokeSetAnimationSlowdownFactor(clamped));
|
|
if (persisted === null) {
|
|
return;
|
|
}
|
|
setSettings((prev) =>
|
|
prev === null
|
|
? prev
|
|
: { ...prev, tauri_animation_slowdown_factor: Number(persisted) }
|
|
);
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
const onNewTokenLabelChange = React.useCallback(
|
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
setNewTokenLabel(event.target.value);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const onCreateToken = React.useCallback(async () => {
|
|
const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined));
|
|
if (created === null) {
|
|
return;
|
|
}
|
|
const refreshed = await withPending(() => invokeListApiTokens());
|
|
if (refreshed === null) {
|
|
return;
|
|
}
|
|
setTokens(refreshed);
|
|
setTokenDrafts(
|
|
refreshed.reduce<Record<string, string>>((acc, token) => {
|
|
acc[token.id] = token.label;
|
|
return acc;
|
|
}, {})
|
|
);
|
|
setNewTokenLabel("");
|
|
}, [newTokenLabel, withPending]);
|
|
|
|
const onTokenDraftChange = React.useCallback((id: string, value: string) => {
|
|
setTokenDrafts((prev) => ({
|
|
...prev,
|
|
[id]: value
|
|
}));
|
|
}, []);
|
|
|
|
const onRenameToken = React.useCallback(
|
|
async (id: string) => {
|
|
const nextLabel = tokenDrafts[id] ?? "";
|
|
const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel));
|
|
if (refreshed === null) {
|
|
return;
|
|
}
|
|
setTokens(refreshed);
|
|
setTokenDrafts(
|
|
refreshed.reduce<Record<string, string>>((acc, token) => {
|
|
acc[token.id] = token.label;
|
|
return acc;
|
|
}, {})
|
|
);
|
|
},
|
|
[tokenDrafts, withPending]
|
|
);
|
|
|
|
const onCopyToken = React.useCallback(async (token: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(token);
|
|
} catch (err) {
|
|
setError(String(err));
|
|
}
|
|
}, []);
|
|
|
|
const onRevokeToken = React.useCallback(
|
|
async (id: string) => {
|
|
if (!window.confirm("Revoke this API token?")) {
|
|
return;
|
|
}
|
|
const refreshed = await withPending(() => invokeRevokeApiToken(id));
|
|
if (refreshed === null) {
|
|
return;
|
|
}
|
|
setTokens(refreshed);
|
|
setTokenDrafts(
|
|
refreshed.reduce<Record<string, string>>((acc, token) => {
|
|
acc[token.id] = token.label;
|
|
return acc;
|
|
}, {})
|
|
);
|
|
},
|
|
[withPending]
|
|
);
|
|
|
|
return (
|
|
<main className="settings-root">
|
|
<section className="settings-card">
|
|
<h1>Settings</h1>
|
|
<p className="settings-subtitle">Character and window controls</p>
|
|
{error !== null ? <p className="settings-error">{error}</p> : null}
|
|
{settings === null ? (
|
|
<p>Loading settings...</p>
|
|
) : (
|
|
<>
|
|
<label className="field">
|
|
<span>Character</span>
|
|
<select
|
|
value={settings.active_sprite_pack}
|
|
disabled={pending}
|
|
onChange={onPackChange}
|
|
>
|
|
{packs.map((pack) => (
|
|
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
|
|
{pack.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>Scale: {settings.scale.toFixed(2)}x</span>
|
|
<input
|
|
type="range"
|
|
min={SCALE_MIN}
|
|
max={SCALE_MAX}
|
|
step={0.05}
|
|
value={settings.scale}
|
|
disabled={pending}
|
|
onChange={onScaleChange}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>
|
|
Animation Speed Factor: x{settings.tauri_animation_slowdown_factor}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={SLOWDOWN_FACTOR_MIN}
|
|
max={SLOWDOWN_FACTOR_MAX}
|
|
step={1}
|
|
value={settings.tauri_animation_slowdown_factor}
|
|
disabled={pending}
|
|
onChange={onAnimationSlowdownFactorChange}
|
|
/>
|
|
</label>
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={settings.visible}
|
|
disabled={pending}
|
|
onChange={onVisibleChange}
|
|
/>
|
|
<span>Visible</span>
|
|
</label>
|
|
<label className="toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={settings.always_on_top}
|
|
disabled={pending}
|
|
onChange={onAlwaysOnTopChange}
|
|
/>
|
|
<span>Always on top</span>
|
|
</label>
|
|
<section className="token-section">
|
|
<h2>API Tokens</h2>
|
|
<p className="token-help">
|
|
Use any listed token as `Authorization: Bearer <token>`.
|
|
</p>
|
|
<div className="token-create">
|
|
<input
|
|
type="text"
|
|
value={newTokenLabel}
|
|
placeholder="New token label"
|
|
disabled={pending}
|
|
onChange={onNewTokenLabelChange}
|
|
/>
|
|
<button type="button" disabled={pending} onClick={onCreateToken}>
|
|
Create token
|
|
</button>
|
|
</div>
|
|
<div className="token-list">
|
|
{tokens.map((entry) => (
|
|
<article className="token-item" key={entry.id}>
|
|
<label className="field">
|
|
<span>Label</span>
|
|
<input
|
|
type="text"
|
|
value={tokenDrafts[entry.id] ?? ""}
|
|
disabled={pending}
|
|
onChange={(event) =>
|
|
onTokenDraftChange(entry.id, event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>Token</span>
|
|
<input type="text" value={entry.token} readOnly />
|
|
</label>
|
|
<div className="token-actions">
|
|
<button
|
|
type="button"
|
|
disabled={pending}
|
|
onClick={() => onCopyToken(entry.token)}
|
|
>
|
|
Copy
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={pending}
|
|
onClick={() => onRenameToken(entry.id)}
|
|
>
|
|
Save label
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={pending || tokens.length <= 1}
|
|
onClick={() => onRevokeToken(entry.id)}
|
|
>
|
|
Revoke
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function AppRoot(): JSX.Element {
|
|
const windowLabel = getCurrentWindow().label;
|
|
if (windowLabel === "settings") {
|
|
return <SettingsWindow />;
|
|
}
|
|
return <MainOverlayWindow />;
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
<React.StrictMode>
|
|
<AppRoot />
|
|
</React.StrictMode>
|
|
);
|