mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-09-04 16:23:17 +00:00
Release 1.0.7.0: Remote Web Panel & Stability Fixes
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { CategorySection } from '@/features/remote-panel/components/CategorySection';
|
||||
import { ConnectionPanel } from '@/features/remote-panel/components/ConnectionPanel';
|
||||
import { DeckHeader } from '@/features/remote-panel/components/DeckHeader';
|
||||
import { EmptyDeck } from '@/features/remote-panel/components/EmptyDeck';
|
||||
import { TrainerOverview } from '@/features/remote-panel/components/TrainerOverview';
|
||||
import { buildPinnedGroup, filterGroups, groupCheatsByCategory } from '@/features/remote-panel/category';
|
||||
import { handleProtocolMessage } from '@/features/remote-panel/message-handler';
|
||||
import { normalizeOutgoingValue, type CheatSchema, type TrainerMetaPayload } from '@/features/remote-panel/protocol';
|
||||
import {
|
||||
getPinnedStorageKey,
|
||||
loadPinnedTargets,
|
||||
savePinnedTargets,
|
||||
} from '@/features/remote-panel/pinned-storage';
|
||||
import { PanelSocketClient } from '@/features/remote-panel/socket-client';
|
||||
import { createInitialPanelState, panelReducer } from '@/features/remote-panel/state';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function App() {
|
||||
const [state, dispatch] = useReducer(panelReducer, createInitialPanelState());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const clientRef = useRef<PanelSocketClient | null>(null);
|
||||
const trainerMetaRef = useRef<TrainerMetaPayload | null>(state.trainerMeta);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
trainerMetaRef.current = state.trainerMeta;
|
||||
}, [state.trainerMeta]);
|
||||
|
||||
const groups = useMemo(() => groupCheatsByCategory(state.trainerMeta), [state.trainerMeta]);
|
||||
const pinnedGroup = useMemo(
|
||||
() => buildPinnedGroup(state.trainerMeta, state.pinnedTargets),
|
||||
[state.trainerMeta, state.pinnedTargets],
|
||||
);
|
||||
const filteredGroups = useMemo(() => filterGroups(groups, searchQuery), [groups, searchQuery]);
|
||||
const filteredPinnedGroup = useMemo(
|
||||
() => (pinnedGroup ? filterGroups([pinnedGroup], searchQuery)[0] ?? null : null),
|
||||
[pinnedGroup, searchQuery],
|
||||
);
|
||||
const pinnedStorageKey = useMemo(
|
||||
() => getPinnedStorageKey(state.trainerMeta?.trainer ?? null),
|
||||
[state.trainerMeta?.trainer],
|
||||
);
|
||||
const activeTrainer = state.trainerMeta?.trainer ?? null;
|
||||
const cheatCount = state.trainerMeta?.schema.cheats.length ?? 0;
|
||||
const controlsDisabled = Boolean(activeTrainer?.trainerLoading || activeTrainer?.isTimeLimitExpired);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'setPinnedTargets', pinned: loadPinnedTargets(pinnedStorageKey) });
|
||||
}, [pinnedStorageKey]);
|
||||
|
||||
function connect(): void {
|
||||
clientRef.current?.disconnect();
|
||||
|
||||
const wsUrl = state.wsUrl.trim();
|
||||
if (!wsUrl) {
|
||||
dispatch({ type: 'error', message: 'Enter a WebSocket URL first.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const nextClient = new PanelSocketClient(wsUrl, {
|
||||
onConnecting: () => dispatch({ type: 'connecting' }),
|
||||
onOpen: () => dispatch({ type: 'connected' }),
|
||||
onMessage: (message) => handleProtocolMessage(dispatch, message, trainerMetaRef.current),
|
||||
onClose: () => dispatch({ type: 'error', message: 'The WebSocket connection closed.' }),
|
||||
onError: (message) => dispatch({ type: 'error', message }),
|
||||
});
|
||||
|
||||
clientRef.current = nextClient;
|
||||
nextClient.connect();
|
||||
}
|
||||
|
||||
function handleCheatChange(cheat: CheatSchema, nextValue: unknown): void {
|
||||
const normalizedValue = normalizeOutgoingValue(cheat, nextValue);
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: true });
|
||||
dispatch({ type: 'valueChanged', target: cheat.target, value: normalizedValue });
|
||||
|
||||
if (state.connectionStatus !== 'connected' || !state.trainerMeta || !clientRef.current) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const sent = clientRef.current.setValue(state.trainerMeta.trainer.trainerId, cheat.target, normalizedValue, cheat.uuid);
|
||||
if (!sent) {
|
||||
dispatch({ type: 'setPending', target: cheat.target, pending: false });
|
||||
dispatch({ type: 'error', message: 'The bridge socket is not open.' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleTogglePin(cheat: CheatSchema): void {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[cheat.target]) {
|
||||
delete next[cheat.target];
|
||||
} else {
|
||||
next[cheat.target] = true;
|
||||
}
|
||||
dispatch({ type: 'togglePinnedTarget', target: cheat.target });
|
||||
savePinnedTargets(pinnedStorageKey, next);
|
||||
}
|
||||
|
||||
async function loadDebugSession(): Promise<void> {
|
||||
if (!import.meta.env.DEV) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientRef.current?.disconnect();
|
||||
const debugSession = await import('@/features/remote-panel/debug-session');
|
||||
debugSession.loadDebugSession(dispatch);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
void import('@/features/remote-panel/debug-session').then((debugSession) => {
|
||||
if (debugSession.isDebugSessionRequested()) {
|
||||
debugSession.loadDebugSession(dispatch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.wsUrl.trim()) {
|
||||
connect();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.wsUrl.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="min-h-svh overflow-hidden bg-background px-2 py-2 text-foreground sm:px-5 sm:py-3 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 sm:gap-4">
|
||||
<DeckHeader connectionStatus={state.connectionStatus} remoteUrl={state.remoteUrl} />
|
||||
|
||||
<div className="grid gap-3 sm:gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<aside className="space-y-3 sm:space-y-4">
|
||||
<ConnectionPanel
|
||||
status={state.connectionStatus}
|
||||
wsUrl={state.wsUrl}
|
||||
lastError={state.lastError}
|
||||
onConnect={connect}
|
||||
onDebugSession={import.meta.env.DEV ? loadDebugSession : undefined}
|
||||
onWsUrlChange={(wsUrl) => dispatch({ type: 'setWsUrl', wsUrl })}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section className="space-y-4 sm:space-y-5">
|
||||
{activeTrainer ? (
|
||||
<>
|
||||
<TrainerOverview trainer={activeTrainer} cheatCount={cheatCount} categoryCount={groups.length} />
|
||||
<div className="relative">
|
||||
<Icon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" name="search" />
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onInput={(event) => setSearchQuery((event.target as HTMLInputElement).value)}
|
||||
placeholder="Search cheats, categories, targets..."
|
||||
className="h-9 pl-8 pr-8 text-sm"
|
||||
/>
|
||||
{searchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-white"
|
||||
>
|
||||
<Icon className="size-3.5" name="x" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-4 sm:space-y-7">
|
||||
{filteredPinnedGroup ? (
|
||||
<CategorySection
|
||||
key="__pinned__"
|
||||
group={filteredPinnedGroup}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
) : null}
|
||||
{filteredGroups.map((group) => (
|
||||
<CategorySection
|
||||
key={group.id}
|
||||
group={group}
|
||||
values={state.values}
|
||||
pendingTargets={state.pendingTargets}
|
||||
pinnedTargets={state.pinnedTargets}
|
||||
disabled={controlsDisabled}
|
||||
onCheatChange={handleCheatChange}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
))}
|
||||
{searchQuery && filteredGroups.length === 0 && !filteredPinnedGroup ? (
|
||||
<p className="rounded-[8px] border border-white/10 bg-white/4.5 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No cheats match "{searchQuery}".
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyDeck />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type BadgeVariant = "default" | "outline"
|
||||
|
||||
const BADGE_BASE = "inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap"
|
||||
|
||||
const BADGE_VARIANTS: Record<BadgeVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline: "border-border bg-input/20 text-foreground",
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ComponentProps<"span"> & { variant?: BadgeVariant }) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(BADGE_BASE, BADGE_VARIANTS[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge }
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ButtonVariant = "default" | "outline"
|
||||
type ButtonSize = "default" | "icon"
|
||||
|
||||
const BUTTON_BASE = "inline-flex shrink-0 items-center justify-center rounded-md border border-transparent text-xs font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50"
|
||||
|
||||
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline: "border-border hover:bg-input/50 hover:text-foreground",
|
||||
}
|
||||
|
||||
const BUTTON_SIZES: Record<ButtonSize, string> = {
|
||||
default: "h-7 gap-1 px-2",
|
||||
icon: "size-7",
|
||||
}
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ComponentProps<"button"> & { variant?: ButtonVariant; size?: ButtonSize }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="button"
|
||||
className={cn(BUTTON_BASE, BUTTON_VARIANTS[variant], BUTTON_SIZES[size], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs text-card-foreground ring-1 ring-foreground/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"grid auto-rows-min items-start gap-1 rounded-t-lg px-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("font-heading text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-xs/relaxed text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ReactNode, SVGProps } from 'react';
|
||||
|
||||
export type IconName =
|
||||
| 'activity'
|
||||
| 'alert'
|
||||
| 'atom'
|
||||
| 'backpack'
|
||||
| 'bolt'
|
||||
| 'box'
|
||||
| 'boxes'
|
||||
| 'car'
|
||||
| 'category'
|
||||
| 'chart'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'flame'
|
||||
| 'flask'
|
||||
| 'gamepad'
|
||||
| 'hammer'
|
||||
| 'heart-broken'
|
||||
| 'loader'
|
||||
| 'map-pin'
|
||||
| 'package'
|
||||
| 'pin'
|
||||
| 'pin-off'
|
||||
| 'plug'
|
||||
| 'play'
|
||||
| 'radar'
|
||||
| 'refresh'
|
||||
| 'shield-bolt'
|
||||
| 'sparkles'
|
||||
| 'search'
|
||||
| 'swords'
|
||||
| 'trophy'
|
||||
| 'user'
|
||||
| 'wifi'
|
||||
| 'world'
|
||||
| 'x';
|
||||
|
||||
const ICON_PATHS: Record<IconName, ReactNode> = {
|
||||
activity: <path d="M3 12h4l2-6 4 12 2-6h6" />,
|
||||
alert: <><path d="M12 3 2.8 20h18.4z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
|
||||
atom: <><circle cx="12" cy="12" r="1.5" /><path d="M4 12c2-4 14-4 16 0" /><path d="M4 12c2 4 14 4 16 0" /><path d="M12 4c4 2 4 14 0 16" /></>,
|
||||
backpack: <><path d="M8 8V7a4 4 0 0 1 8 0v1" /><path d="M6 9h12v11H6z" /><path d="M9 14h6" /></>,
|
||||
bolt: <path d="m13 2-8 12h6l-1 8 8-12h-6z" />,
|
||||
box: <><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9z" /><path d="m4 7.5 8 4.5 8-4.5" /><path d="M12 12v9" /></>,
|
||||
boxes: <><path d="M4 7h7v7H4z" /><path d="M13 10h7v7h-7z" /><path d="M7 16h7v5H7z" /></>,
|
||||
car: <><path d="M5 13 7 7h10l2 6" /><path d="M5 13h14v5H5z" /><path d="M8 18v2" /><path d="M16 18v2" /></>,
|
||||
category: <><path d="M4 4h7v7H4z" /><path d="M13 4h7v7h-7z" /><path d="M4 13h7v7H4z" /><path d="M13 13h7v7h-7z" /></>,
|
||||
chart: <><path d="M4 19V5" /><path d="M4 19h16" /><path d="M8 15v-4" /><path d="M12 15V8" /><path d="M16 15v-6" /></>,
|
||||
'chevron-left': <path d="m15 6-6 6 6 6" />,
|
||||
'chevron-right': <path d="m9 6 6 6-6 6" />,
|
||||
flame: <path d="M12 22c4 0 7-3 7-7 0-3-2-5-5-8 0 3-2 4-4 5 0-3-1-5-3-7 0 5-3 7-3 10 0 4 3 7 8 7z" />,
|
||||
flask: <><path d="M9 3h6" /><path d="M10 3v5l-5 9a3 3 0 0 0 2.6 4h8.8a3 3 0 0 0 2.6-4l-5-9V3" /><path d="M8 15h8" /></>,
|
||||
gamepad: <><path d="M6 11h12a4 4 0 0 1 4 4v1a3 3 0 0 1-5.2 2L15 16H9l-1.8 2A3 3 0 0 1 2 16v-1a4 4 0 0 1 4-4z" /><path d="M7 15h4" /><path d="M9 13v4" /><path d="M16.5 14.5h.01" /><path d="M18.5 16.5h.01" /></>,
|
||||
hammer: <><path d="M14 5 5 14" /><path d="m4 15 5 5" /><path d="M12 3h5l4 4-3 3-4-4" /></>,
|
||||
'heart-broken': <path d="M20 8.5c0 6-8 11.5-8 11.5S4 14.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5zM12 6l-2 4 4 2-2 4" />,
|
||||
loader: <><path d="M12 3a9 9 0 1 0 9 9" /><path d="M21 12a9 9 0 0 0-9-9" /></>,
|
||||
'map-pin': <><path d="M12 21s7-5.2 7-11a7 7 0 1 0-14 0c0 5.8 7 11 7 11z" /><circle cx="12" cy="10" r="2" /></>,
|
||||
package: <><path d="M5 8h14v11H5z" /><path d="m8 8 2-4h4l2 4" /><path d="M12 8v11" /></>,
|
||||
pin: <path fill="currentColor" stroke="none" d="M16,12V4H17V2H7V4H8V12L6,14V16H11.2V22H12.8V16H18V14L16,12Z" />,
|
||||
'pin-off': <path fill="currentColor" stroke="none" d="M2,5.27L3.28,4L20,20.72L18.73,22L12.8,16.07V22H11.2V16H6V14L8,12V11.27L2,5.27M16,12L18,14V16H17.82L8,6.18V4H7V2H17V4H16V12Z" />,
|
||||
plug: <><path d="M8 2v6" /><path d="M16 2v6" /><path d="M7 8h10v4a5 5 0 0 1-10 0z" /><path d="M12 17v5" /></>,
|
||||
play: <path d="m8 5 11 7-11 7z" />,
|
||||
radar: <><circle cx="12" cy="12" r="2" /><path d="M12 4a8 8 0 0 1 8 8" /><path d="M4 12a8 8 0 0 1 8-8" /><path d="M12 20a8 8 0 0 1-8-8" /><path d="M12 12l6-6" /></>,
|
||||
refresh: <><path d="M20 6v5h-5" /><path d="M4 18v-5h5" /><path d="M18 11a6 6 0 0 0-10-4L4 11" /><path d="M6 13a6 6 0 0 0 10 4l4-4" /></>,
|
||||
'shield-bolt': <><path d="M12 3 20 6v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /><path d="m13 7-4 6h3l-1 4 4-6h-3z" /></>,
|
||||
sparkles: <><path d="m12 3 1.6 5.4L19 10l-5.4 1.6L12 17l-1.6-5.4L5 10l5.4-1.6z" /><path d="m5 16 .8 2.2L8 19l-2.2.8L5 22l-.8-2.2L2 19l2.2-.8z" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
swords: <><path d="M14 6 20 0" /><path d="m14 6 4 4" /><path d="M4 20 14 10" /><path d="M10 6 4 0" /><path d="m10 6-4 4" /><path d="M20 20 10 10" /></>,
|
||||
trophy: <><path d="M8 4h8v4a4 4 0 0 1-8 0z" /><path d="M8 6H4a4 4 0 0 0 4 4" /><path d="M16 6h4a4 4 0 0 1-4 4" /><path d="M12 12v5" /><path d="M8 21h8" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
|
||||
wifi: <><path d="M4 9a12 12 0 0 1 16 0" /><path d="M7 12a7 7 0 0 1 10 0" /><path d="M10 15a3 3 0 0 1 4 0" /><path d="M12 19h.01" /></>,
|
||||
world: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18" /><path d="M12 3a15 15 0 0 0 0 18" /></>,
|
||||
x: <><path d="M6 6l12 12" /><path d="M18 6 6 18" /></>,
|
||||
};
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, 'stroke'> & {
|
||||
name: IconName;
|
||||
stroke?: number | string;
|
||||
};
|
||||
|
||||
export function Icon({ name, className, stroke = 1.8, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={stroke}
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
{ICON_PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
disabled,
|
||||
onValueChange,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
...props
|
||||
}: Omit<ComponentProps<"input">, "defaultValue" | "onChange" | "type" | "value"> & {
|
||||
defaultValue?: number
|
||||
onValueChange?: (value: number) => void
|
||||
value?: number
|
||||
}) {
|
||||
const currentValue = Number(value ?? defaultValue ?? min)
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
className={cn("h-2 w-full cursor-pointer accent-primary disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(event) => onValueChange?.(Number(event.currentTarget.value))}
|
||||
{...props}
|
||||
aria-valuenow={currentValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
checked = false,
|
||||
className,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
size = "default",
|
||||
...props
|
||||
}: Omit<ComponentProps<"button">, "onChange"> & {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-checked={checked}
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
disabled={disabled}
|
||||
role="switch"
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
size === "sm" ? "h-3.5 w-6" : "h-4 w-7",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
className
|
||||
)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform",
|
||||
size === "sm" ? "size-3" : "size-3.5",
|
||||
checked ? "translate-x-[calc(100%-2px)]" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Icon, type IconName } from '@/components/ui/icon';
|
||||
import type { CheatSchema, TrainerMetaPayload, TrainerSummary } from './protocol';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
challenge: 'Challenge',
|
||||
character: 'Character',
|
||||
cheats: 'Cheats',
|
||||
crafting: 'Crafting',
|
||||
enemies: 'Enemies',
|
||||
game: 'Game',
|
||||
inventory: 'Inventory',
|
||||
items: 'Items',
|
||||
physics: 'Physics',
|
||||
pinned: 'Pinned',
|
||||
player: 'Player',
|
||||
resources: 'Resources',
|
||||
stats: 'Stats',
|
||||
teleport: 'Teleport',
|
||||
vehicles: 'Vehicles',
|
||||
weapons: 'Weapons',
|
||||
world: 'World',
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, IconName> = {
|
||||
challenge: 'flame',
|
||||
character: 'user',
|
||||
cheats: 'sparkles',
|
||||
crafting: 'hammer',
|
||||
enemies: 'heart-broken',
|
||||
game: 'gamepad',
|
||||
inventory: 'backpack',
|
||||
items: 'package',
|
||||
physics: 'atom',
|
||||
pinned: 'bolt',
|
||||
player: 'user',
|
||||
resources: 'box',
|
||||
stats: 'chart',
|
||||
teleport: 'map-pin',
|
||||
vehicles: 'car',
|
||||
weapons: 'swords',
|
||||
world: 'world',
|
||||
};
|
||||
|
||||
const CATEGORY_ACCENTS: Record<string, string> = {
|
||||
challenge: 'text-orange-300 bg-orange-500/12 ring-orange-300/30',
|
||||
enemies: 'text-red-300 bg-red-500/12 ring-red-300/30',
|
||||
inventory: 'text-amber-200 bg-amber-500/12 ring-amber-200/30',
|
||||
physics: 'text-cyan-200 bg-cyan-500/12 ring-cyan-200/30',
|
||||
player: 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30',
|
||||
stats: 'text-fuchsia-200 bg-fuchsia-500/12 ring-fuchsia-200/30',
|
||||
teleport: 'text-sky-200 bg-sky-500/12 ring-sky-200/30',
|
||||
vehicles: 'text-lime-200 bg-lime-500/12 ring-lime-200/30',
|
||||
weapons: 'text-rose-200 bg-rose-500/12 ring-rose-200/30',
|
||||
world: 'text-teal-200 bg-teal-500/12 ring-teal-200/30',
|
||||
};
|
||||
|
||||
export type CategoryGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
cheats: CheatSchema[];
|
||||
};
|
||||
|
||||
export function formatCategoryName(category: string): string {
|
||||
const key = category.toLowerCase();
|
||||
if (CATEGORY_LABELS[key]) {
|
||||
return CATEGORY_LABELS[key];
|
||||
}
|
||||
|
||||
return category
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
export function groupCheatsByCategory(trainerMeta: TrainerMetaPayload | null): CategoryGroup[] {
|
||||
if (!trainerMeta) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grouped = new Map<string, CheatSchema[]>();
|
||||
for (const cheat of trainerMeta.schema.cheats) {
|
||||
const bucket = grouped.get(cheat.category) ?? [];
|
||||
bucket.push(cheat);
|
||||
grouped.set(cheat.category, bucket);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([id, cheats]) => ({ id, label: formatCategoryName(id), cheats }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
export const PINNED_CATEGORY_ID = 'pinned';
|
||||
|
||||
export function buildPinnedGroup(
|
||||
trainerMeta: TrainerMetaPayload | null,
|
||||
pinnedTargets: Record<string, true>,
|
||||
): CategoryGroup | null {
|
||||
if (!trainerMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pinnedCheats = trainerMeta.schema.cheats.filter((cheat) => pinnedTargets[cheat.target]);
|
||||
if (pinnedCheats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: PINNED_CATEGORY_ID,
|
||||
label: formatCategoryName(PINNED_CATEGORY_ID),
|
||||
cheats: pinnedCheats,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterGroups(groups: CategoryGroup[], query: string): CategoryGroup[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const result: CategoryGroup[] = [];
|
||||
for (const group of groups) {
|
||||
const matchesGroup = group.label.toLowerCase().includes(normalized) || group.id.toLowerCase().includes(normalized);
|
||||
const cheats = matchesGroup
|
||||
? group.cheats
|
||||
: group.cheats.filter((cheat) => cheatMatchesQuery(cheat, normalized));
|
||||
|
||||
if (cheats.length > 0) {
|
||||
result.push({ ...group, cheats });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function cheatMatchesQuery(cheat: CheatSchema, query: string): boolean {
|
||||
if (cheat.name?.toLowerCase().includes(query)) return true;
|
||||
if (cheat.target?.toLowerCase().includes(query)) return true;
|
||||
if (cheat.category?.toLowerCase().includes(query)) return true;
|
||||
if (cheat.description?.toLowerCase().includes(query)) return true;
|
||||
if (cheat.type?.toLowerCase().includes(query)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getTrainerDisplayName(trainer: TrainerSummary): string {
|
||||
return trainer.displayName?.trim() || trainer.gameId || trainer.titleId || trainer.trainerId;
|
||||
}
|
||||
|
||||
export function getCategoryAccent(category: string): string {
|
||||
return CATEGORY_ACCENTS[category.toLowerCase()] ?? 'text-emerald-200 bg-emerald-500/12 ring-emerald-200/30';
|
||||
}
|
||||
|
||||
export function CategoryIcon({ category, className }: { category: string; className?: string }) {
|
||||
return <Icon className={className} name={CATEGORY_ICONS[category.toLowerCase()] ?? 'gamepad'} stroke={1.8} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryIcon, type CategoryGroup, getCategoryAccent } from '../category';
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { CheatTile } from './CheatTile';
|
||||
|
||||
type CategorySectionProps = {
|
||||
group: CategoryGroup;
|
||||
values: Record<string, unknown>;
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
disabled: boolean;
|
||||
onCheatChange: (cheat: CheatSchema, nextValue: unknown) => void;
|
||||
onTogglePin: (cheat: CheatSchema) => void;
|
||||
};
|
||||
|
||||
export function CategorySection({
|
||||
group,
|
||||
values,
|
||||
pendingTargets,
|
||||
pinnedTargets,
|
||||
disabled,
|
||||
onCheatChange,
|
||||
onTogglePin,
|
||||
}: CategorySectionProps) {
|
||||
return (
|
||||
<section className="space-y-2 sm:space-y-3">
|
||||
<header className="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className={cn('flex size-8 items-center justify-center rounded-[8px] ring-1 sm:size-10', getCategoryAccent(group.id))}>
|
||||
<CategoryIcon category={group.id} className="size-4 sm:size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white sm:text-xl">{group.label}</h3>
|
||||
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground sm:text-xs">{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">
|
||||
{group.cheats.length} nodes
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-2 sm:gap-3 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{group.cheats.map((cheat) => (
|
||||
<CheatTile
|
||||
key={cheat.uuid}
|
||||
cheat={cheat}
|
||||
value={values[cheat.target]}
|
||||
pending={Boolean(pendingTargets[cheat.target])}
|
||||
pinned={Boolean(pinnedTargets[cheat.target])}
|
||||
disabled={disabled}
|
||||
onChange={(nextValue) => onCheatChange(cheat, nextValue)}
|
||||
onTogglePin={() => onTogglePin(cheat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CheatSchema } from '../protocol';
|
||||
import { CheatControl } from '../controls/CheatControl';
|
||||
|
||||
type CheatTileProps = {
|
||||
cheat: CheatSchema;
|
||||
value: unknown;
|
||||
pending: boolean;
|
||||
disabled: boolean;
|
||||
pinned: boolean;
|
||||
onChange: (nextValue: unknown) => void;
|
||||
onTogglePin: () => void;
|
||||
};
|
||||
|
||||
export function CheatTile({ cheat, value, pending, disabled, pinned, onChange, onTogglePin }: CheatTileProps) {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/20 transition-colors hover:border-emerald-300/25">
|
||||
<CardHeader className="gap-1.5 p-3 sm:gap-2 sm:p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-[0.95rem] font-bold text-white sm:text-base">{cheat.name}</CardTitle>
|
||||
{cheat.description ? <p className="mt-0.5 line-clamp-2 text-[0.78rem] text-muted-foreground sm:text-sm">{cheat.description}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{pending ? <Icon className="size-4 animate-spin text-emerald-200" name="loader" /> : null}
|
||||
<Badge className="hidden border-white/10 bg-black/20 text-white sm:inline-flex" variant="outline">{cheat.type}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePin}
|
||||
aria-label={pinned ? 'Unpin cheat' : 'Pin cheat'}
|
||||
title={pinned ? 'Unpin' : 'Pin to top'}
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-md border border-white/10 transition-colors',
|
||||
pinned
|
||||
? 'bg-amber-300/20 text-amber-200 hover:bg-amber-300/30'
|
||||
: 'bg-white/5 text-muted-foreground hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" name={pinned ? 'pin-off' : 'pin'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{cheat.instructions ? <p className="rounded-[8px] border border-amber-300/20 bg-amber-300/10 p-2 text-[0.72rem] text-amber-100 sm:text-xs">{cheat.instructions}</p> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 sm:p-4 sm:pt-0">
|
||||
<CheatControl cheat={cheat} value={value} pending={pending} disabled={disabled} onChange={onChange} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DEFAULT_REMOTE_PORT } from '../constants';
|
||||
import type { ConnectionStatus } from '../state';
|
||||
|
||||
type ConnectionPanelProps = {
|
||||
status: ConnectionStatus;
|
||||
wsUrl: string;
|
||||
lastError: string | null;
|
||||
onConnect: () => void;
|
||||
onDebugSession?: () => void;
|
||||
onWsUrlChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ConnectionPanel({ status, wsUrl, lastError, onConnect, onDebugSession, onWsUrlChange }: ConnectionPanelProps) {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5 shadow-xl shadow-black/25">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold text-white">Bridge uplink</CardTitle>
|
||||
<CardDescription className="mt-1 text-muted-foreground">Default relay port {DEFAULT_REMOTE_PORT}</CardDescription>
|
||||
</div>
|
||||
<Badge className="border border-white/10 bg-white/5 text-white" variant="outline">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-url" className="text-[0.7rem] uppercase tracking-[0.12em] text-muted-foreground">
|
||||
WebSocket endpoint
|
||||
</Label>
|
||||
<Input
|
||||
id="ws-url"
|
||||
type="url"
|
||||
value={wsUrl}
|
||||
placeholder={`ws://127.0.0.1:${DEFAULT_REMOTE_PORT}/remote/ws`}
|
||||
className="h-9 rounded-[8px] border-white/10 bg-black/25 font-mono text-[0.8rem] text-white placeholder:text-white/30"
|
||||
onChange={(event) => onWsUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="h-9 rounded-[8px] bg-emerald-300 text-black hover:bg-emerald-200" onClick={onConnect}>
|
||||
<Icon className="size-4" name="plug" />
|
||||
Connect
|
||||
</Button>
|
||||
{import.meta.env.DEV && onDebugSession ? (
|
||||
<Button className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white hover:bg-white/10" variant="outline" onClick={onDebugSession}>
|
||||
<Icon className="size-4" name="flask" />
|
||||
Debug session
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastError ? (
|
||||
<div className="flex items-start gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
|
||||
<Icon className="mt-0.5 size-4 shrink-0" name="alert" />
|
||||
<span>{lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConnectionStatus } from '../state';
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionStatus, string> = {
|
||||
idle: 'Standby',
|
||||
connecting: 'Linking',
|
||||
connected: 'Live',
|
||||
error: 'Fault',
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<ConnectionStatus, string> = {
|
||||
idle: 'border-amber-300/30 bg-amber-500/10 text-amber-200',
|
||||
connecting: 'border-sky-300/30 bg-sky-500/10 text-sky-200',
|
||||
connected: 'border-emerald-300/30 bg-emerald-500/10 text-emerald-200',
|
||||
error: 'border-red-300/30 bg-red-500/10 text-red-200',
|
||||
};
|
||||
|
||||
export function DeckHeader({ connectionStatus, remoteUrl }: { connectionStatus: ConnectionStatus; remoteUrl: string }) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4 rounded-[8px] border border-white/10 bg-white/[0.035] p-4 shadow-2xl shadow-black/30 backdrop-blur md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-[8px] border border-emerald-300/30 bg-emerald-300/10 text-emerald-200 shadow-lg shadow-emerald-500/10">
|
||||
<Icon className="size-6" name="shield-bolt" stroke={1.7} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-normal text-white">Wand Control Deck</h1>
|
||||
<Badge className="border border-lime-300/30 bg-lime-300/10 text-lime-200" variant="outline">
|
||||
beta
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><Icon className="size-3.5" name="wifi" /> local link</span>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="inline-flex min-w-0 items-center gap-1"><Icon className="size-3.5" name="plug" /> <span className="truncate">{remoteUrl.replace(/\/$/, '')}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn('flex w-fit items-center gap-2 rounded-[8px] border px-3 py-2 text-xs font-semibold uppercase tracking-[0.08em]', STATUS_CLASSES[connectionStatus])}>
|
||||
<Icon className="size-4" name="activity" />
|
||||
{STATUS_LABELS[connectionStatus]}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
|
||||
export function EmptyDeck() {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-dashed border-white/15 bg-white/[0.035]">
|
||||
<CardContent className="flex min-h-64 flex-col items-center justify-center gap-3 py-10 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-[8px] border border-cyan-300/25 bg-cyan-500/10 text-cyan-200">
|
||||
<Icon className="size-8" name="radar" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">No trainer signal</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">Connect the local bridge to stream trainer controls.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { getTrainerDisplayName } from '../category';
|
||||
import type { TrainerSummary } from '../protocol';
|
||||
|
||||
type TrainerOverviewProps = {
|
||||
trainer: TrainerSummary;
|
||||
cheatCount: number;
|
||||
categoryCount: number;
|
||||
};
|
||||
|
||||
export function TrainerOverview({ trainer, cheatCount, categoryCount }: TrainerOverviewProps) {
|
||||
return (
|
||||
<section className="grid gap-2 sm:gap-3 lg:grid-cols-[minmax(0,1.6fr)_repeat(2,minmax(160px,0.7fr))]">
|
||||
<Card className="rounded-[8px] border-emerald-300/20 bg-emerald-300/8 shadow-xl shadow-emerald-950/20">
|
||||
<CardContent className="flex items-center gap-2.5 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-[8px] border border-emerald-300/25 bg-black/25 text-emerald-200 sm:size-12">
|
||||
<Icon className="size-5 sm:size-7" name="gamepad" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-emerald-100/70 sm:text-[0.68rem]">Active trainer</p>
|
||||
<h2 className="truncate text-lg font-bold text-white sm:text-2xl">{getTrainerDisplayName(trainer)}</h2>
|
||||
<div className="mt-1 flex flex-wrap gap-1 sm:gap-1.5">
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.gameVersion ?? 'unknown build'}</Badge>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">{trainer.language ?? 'n/a'}</Badge>
|
||||
<Badge className="border-white/10 bg-white/5 text-white" variant="outline">#{trainer.trainerId}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-3 lg:contents">
|
||||
<StatCard icon={<Icon className="size-5" name="boxes" />} label="Cheats" value={cheatCount} />
|
||||
<StatCard icon={<Icon className="size-5" name="category" />} label="Loadouts" value={categoryCount} />
|
||||
</div>
|
||||
|
||||
{trainer.needsCompatibilityWarning ? (
|
||||
<Card className="rounded-[8px] border-orange-300/25 bg-orange-500/10 lg:col-span-3">
|
||||
<CardContent className="flex items-center gap-2 px-3 py-2.5 text-orange-100 sm:px-4 sm:py-3">
|
||||
<Icon className="size-4" name="trophy" />
|
||||
Compatibility warning active
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<Card className="rounded-[8px] border-white/10 bg-white/4.5">
|
||||
<CardContent className="flex items-center justify-between gap-2 px-3 py-3 sm:gap-3 sm:px-4 sm:py-4">
|
||||
<div>
|
||||
<p className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground sm:text-[0.68rem]">{label}</p>
|
||||
<p className="text-2xl font-bold text-white sm:text-3xl">{value}</p>
|
||||
</div>
|
||||
<div className="flex size-9 items-center justify-center rounded-[8px] border border-amber-300/20 bg-amber-300/10 text-amber-200 sm:size-10">
|
||||
{icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const DEFAULT_REMOTE_PORT = 3223;
|
||||
export const REMOTE_BASE_PATH = '/remote/';
|
||||
export const REMOTE_WS_PATH = '/remote/ws';
|
||||
export const CLIENT_VERSION = '0.2.0';
|
||||
export const WS_QUERY_PARAM = 'ws';
|
||||
|
||||
const DEV_SERVER_PORTS = new Set(['4173', '5173']);
|
||||
|
||||
function protocolForWebSocket(): 'ws' | 'wss' {
|
||||
return window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
}
|
||||
|
||||
function isServedByRemoteBridge(): boolean {
|
||||
return window.location.pathname.startsWith(REMOTE_BASE_PATH) && !DEV_SERVER_PORTS.has(window.location.port);
|
||||
}
|
||||
|
||||
export function readInitialRemoteUrl(): string {
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${window.location.protocol}//${window.location.host}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_BASE_PATH}`;
|
||||
}
|
||||
|
||||
export function readInitialWebSocketUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const explicitUrl = params.get(WS_QUERY_PARAM)?.trim();
|
||||
if (explicitUrl) {
|
||||
return explicitUrl;
|
||||
}
|
||||
|
||||
if (isServedByRemoteBridge()) {
|
||||
return `${protocolForWebSocket()}://${window.location.host}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
|
||||
return `ws://127.0.0.1:${DEFAULT_REMOTE_PORT}${REMOTE_WS_PATH}`;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CheatSchema, CheatOption } from '../protocol';
|
||||
import { resolveOption } from '../protocol';
|
||||
|
||||
type CheatControlProps = {
|
||||
cheat: CheatSchema;
|
||||
value: unknown;
|
||||
pending: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (nextValue: unknown) => void;
|
||||
};
|
||||
|
||||
function renderValue(value: unknown, postfix?: string): string {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'On' : 'Off';
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return `${String(value)}${postfix ?? ''}`;
|
||||
}
|
||||
|
||||
function numericValue(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function optionKey(option: CheatOption): string {
|
||||
return String(option.value);
|
||||
}
|
||||
|
||||
function findOption(options: CheatOption[], value: string): CheatOption | undefined {
|
||||
return options.find((option) => String(option.value) === value);
|
||||
}
|
||||
|
||||
function isSameOption(left: unknown, right: unknown): boolean {
|
||||
return String(left) === String(right);
|
||||
}
|
||||
|
||||
export function CheatControl({ cheat, value, pending, disabled, onChange }: CheatControlProps) {
|
||||
const commonDisabled = disabled || pending;
|
||||
const options = cheat.args.options?.map(resolveOption) ?? [];
|
||||
|
||||
if (cheat.type === 'toggle') {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<span className="text-sm font-semibold text-white">{renderValue(value)}</span>
|
||||
<Switch checked={Boolean(value)} disabled={commonDisabled} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'slider') {
|
||||
const min = cheat.args.min ?? 0;
|
||||
const max = cheat.args.max ?? 100;
|
||||
const currentValue = numericValue(value, min);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">Range</span>
|
||||
<span className="rounded-[6px] border border-emerald-300/25 bg-emerald-300/10 px-2 py-1 font-mono text-sm text-emerald-100">
|
||||
{renderValue(currentValue, cheat.args.postfix)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={cheat.args.step ?? 1}
|
||||
value={currentValue}
|
||||
disabled={commonDisabled}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'number') {
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 rounded-[8px] border border-white/10 bg-black/20 p-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={cheat.args.min}
|
||||
max={cheat.args.max}
|
||||
step={cheat.args.step ?? 1}
|
||||
value={String(value ?? '')}
|
||||
disabled={commonDisabled}
|
||||
className="h-9 rounded-[8px] border-white/10 bg-white/5 text-white"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
<span className="flex min-w-16 items-center justify-center rounded-[8px] border border-amber-300/25 bg-amber-300/10 px-2 font-mono text-sm text-amber-100">
|
||||
{renderValue(value, cheat.args.postfix)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'button') {
|
||||
return (
|
||||
<Button className="h-10 w-full rounded-[8px] bg-amber-300 text-black hover:bg-amber-200" disabled={commonDisabled} onClick={() => onChange(1)}>
|
||||
<Icon className="size-4" name="play" />
|
||||
{typeof cheat.args.button === 'string' ? cheat.args.button : 'Apply'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'selection') {
|
||||
const selectedValue = String(value ?? options[0]?.value ?? '');
|
||||
|
||||
return (
|
||||
<select
|
||||
value={selectedValue}
|
||||
disabled={commonDisabled}
|
||||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onChange={(event) => onChange(findOption(options, event.currentTarget.value)?.value ?? event.currentTarget.value)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={`${cheat.uuid}-${optionKey(option)}`} value={optionKey(option)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'scalar') {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2 sm:grid-cols-4">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={`${cheat.uuid}-${optionKey(option)}`}
|
||||
className={cn('h-9 rounded-[8px] border-white/10', isSameOption(value, option.value) ? 'bg-emerald-300 text-black hover:bg-emerald-200' : 'bg-white/5 text-white hover:bg-white/10')}
|
||||
disabled={commonDisabled}
|
||||
variant={isSameOption(value, option.value) ? 'default' : 'outline'}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (cheat.type === 'incremental') {
|
||||
const currentIndex = options.findIndex((option) => isSameOption(option.value, value));
|
||||
const previous = currentIndex > 0 ? options[currentIndex - 1] : null;
|
||||
const next = currentIndex >= 0 && currentIndex < options.length - 1 ? options[currentIndex + 1] : null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-2 rounded-[8px] border border-white/10 bg-black/20 p-2">
|
||||
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !previous} onClick={() => previous && onChange(previous.value)}>
|
||||
<Icon className="size-4" name="chevron-left" />
|
||||
</Button>
|
||||
<span className="truncate text-center text-sm font-semibold text-white">{renderValue(options[currentIndex]?.label ?? value)}</span>
|
||||
<Button size="icon" variant="outline" className="rounded-[8px] border-white/10 bg-white/5 text-white" disabled={commonDisabled || !next} onClick={() => next && onChange(next.value)}>
|
||||
<Icon className="size-4" name="chevron-right" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-[8px] border border-red-300/25 bg-red-500/10 p-3 text-sm text-red-100">
|
||||
<Icon className="size-4" name="refresh" /> Unsupported cheat type: {cheat.type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mockTrainerMeta, mockTrainerValues } from './mock-data';
|
||||
import type { PanelAction } from './state';
|
||||
|
||||
const MOCK_QUERY_PARAM = 'mock';
|
||||
|
||||
type PanelDispatch = (action: PanelAction) => void;
|
||||
|
||||
export function isDebugSessionRequested(): boolean {
|
||||
return new URLSearchParams(window.location.search).get(MOCK_QUERY_PARAM) === '1';
|
||||
}
|
||||
|
||||
export function loadDebugSession(dispatch: PanelDispatch): void {
|
||||
dispatch({ type: 'trainerMeta', payload: mockTrainerMeta });
|
||||
dispatch({ type: 'trainerValues', payload: mockTrainerValues.values });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IncomingMessage, TrainerMetaPayload } from './protocol';
|
||||
import { normalizeIncomingValue } from './protocol';
|
||||
import type { PanelAction } from './state';
|
||||
|
||||
type Dispatch = (action: PanelAction) => void;
|
||||
|
||||
export function handleProtocolMessage(dispatch: Dispatch, message: IncomingMessage, trainerMeta: TrainerMetaPayload | null): void {
|
||||
switch (message.type) {
|
||||
case 'hello_ack':
|
||||
handleHelloAck(dispatch, message.payload.accepted, message.payload.remoteUrl);
|
||||
return;
|
||||
case 'trainer_meta':
|
||||
dispatch({ type: 'trainerMeta', payload: message.payload });
|
||||
return;
|
||||
case 'trainer_values':
|
||||
dispatch({ type: 'trainerValues', payload: message.payload.values });
|
||||
return;
|
||||
case 'value_changed':
|
||||
handleValueChanged(dispatch, message, trainerMeta);
|
||||
return;
|
||||
case 'trainer_changed':
|
||||
dispatch({ type: 'trainerChanged' });
|
||||
return;
|
||||
case 'set_value_result':
|
||||
if (!message.payload.ok) {
|
||||
dispatch({ type: 'error', message: message.payload.error?.message ?? 'The trainer rejected the requested value.' });
|
||||
}
|
||||
return;
|
||||
case 'error':
|
||||
dispatch({ type: 'error', message: message.payload.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function handleHelloAck(dispatch: Dispatch, accepted: boolean, remoteUrl?: string): void {
|
||||
if (!accepted) {
|
||||
dispatch({ type: 'error', message: 'The desktop bridge rejected the connection.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteUrl) {
|
||||
dispatch({ type: 'setRemoteUrl', remoteUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function handleValueChanged(dispatch: Dispatch, message: Extract<IncomingMessage, { type: 'value_changed' }>, trainerMeta: TrainerMetaPayload | null): void {
|
||||
const cheat = trainerMeta?.schema.cheats.find((item) => item.target === message.payload.target || item.uuid === message.payload.cheatId);
|
||||
const nextValue = cheat ? normalizeIncomingValue(cheat, message.payload.value) : message.payload.value;
|
||||
dispatch({ type: 'valueChanged', target: message.payload.target, value: nextValue });
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import demoSession from '../../../fixtures/demo-session.json';
|
||||
import type { TrainerMetaPayload, TrainerValuesPayload } from './protocol';
|
||||
|
||||
export const mockTrainerMeta = demoSession.trainerMeta as TrainerMetaPayload;
|
||||
export const mockTrainerValues = demoSession.trainerValues as TrainerValuesPayload;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TrainerSummary } from './protocol';
|
||||
|
||||
const STORAGE_PREFIX = 'wand-remote.pinned-cheats.v1:';
|
||||
|
||||
export function getPinnedStorageKey(trainer: TrainerSummary | null | undefined): string | null {
|
||||
if (!trainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = trainer.gameId?.trim() || trainer.titleId?.trim() || trainer.trainerId?.trim();
|
||||
return id ? `${STORAGE_PREFIX}${id}` : null;
|
||||
}
|
||||
|
||||
export function loadPinnedTargets(storageKey: string | null): Record<string, true> {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const next: Record<string, true> = {};
|
||||
for (const target of parsed) {
|
||||
if (typeof target === 'string' && target.length > 0) {
|
||||
next[target] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePinnedTargets(storageKey: string | null, pinned: Record<string, true>): void {
|
||||
if (!storageKey || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const targets = Object.keys(pinned);
|
||||
if (targets.length === 0) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(targets));
|
||||
} catch {
|
||||
// Ignore quota / serialization errors – pinning is a non-critical UX nicety.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export type CheatType =
|
||||
| 'slider'
|
||||
| 'number'
|
||||
| 'toggle'
|
||||
| 'button'
|
||||
| 'selection'
|
||||
| 'scalar'
|
||||
| 'incremental';
|
||||
|
||||
export interface CheatOption {
|
||||
label?: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export type CheatOptionLike = CheatOption | string | number;
|
||||
|
||||
export interface CheatArgs {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
options?: CheatOptionLike[];
|
||||
button?: string | boolean;
|
||||
postfix?: string;
|
||||
default?: string | number | boolean;
|
||||
}
|
||||
|
||||
export interface CheatSchema {
|
||||
uuid: string;
|
||||
target: string;
|
||||
type: CheatType;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
instructions?: string | null;
|
||||
category: string;
|
||||
parent?: string | null;
|
||||
flags?: number;
|
||||
hotkeys?: string[][];
|
||||
args: CheatArgs;
|
||||
}
|
||||
|
||||
export interface TrainerSummary {
|
||||
trainerId: string;
|
||||
gameId: string;
|
||||
displayName?: string | null;
|
||||
titleId?: string | null;
|
||||
gameVersion?: string | null;
|
||||
trainerLoading: boolean;
|
||||
gameInstalled: boolean;
|
||||
needsCompatibilityWarning: boolean;
|
||||
language?: string;
|
||||
themeId?: string;
|
||||
isTimeLimitExpired: boolean;
|
||||
notesReadHash?: string | null;
|
||||
}
|
||||
|
||||
export interface TrainerMetaPayload {
|
||||
session: {
|
||||
instanceId: string;
|
||||
};
|
||||
trainer: TrainerSummary;
|
||||
schema: {
|
||||
categories: string[];
|
||||
cheats: CheatSchema[];
|
||||
};
|
||||
}
|
||||
|
||||
export type TrainerValuesPayload = {
|
||||
trainerId: string;
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ValueChangedPayload = {
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
oldValue?: unknown;
|
||||
source?: string;
|
||||
cheatId?: string;
|
||||
};
|
||||
|
||||
export type TrainerChangedPayload = {
|
||||
previousTrainerId?: string | null;
|
||||
trainerId: string;
|
||||
};
|
||||
|
||||
export type SetValuePayload = {
|
||||
trainerId: string;
|
||||
target: string;
|
||||
value: unknown;
|
||||
cheatId?: string;
|
||||
};
|
||||
|
||||
export type SetValueResultPayload = {
|
||||
ok: boolean;
|
||||
trainerId: string;
|
||||
target: string;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ErrorPayload = {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface MessageEnvelope<TType extends string, TPayload> {
|
||||
type: TType;
|
||||
version: number;
|
||||
requestId: string | null;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
export type HelloMessage = MessageEnvelope<
|
||||
'hello',
|
||||
{
|
||||
client: 'mobile-web';
|
||||
clientVersion: string;
|
||||
pairingToken?: string;
|
||||
capabilities: {
|
||||
supportsDeltaValues: boolean;
|
||||
supportsTrainerSwitch: boolean;
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
export type HelloAckMessage = MessageEnvelope<
|
||||
'hello_ack',
|
||||
{
|
||||
sessionId: string;
|
||||
accepted: boolean;
|
||||
serverVersion: string;
|
||||
protocolVersion: number;
|
||||
remoteUrl?: string;
|
||||
advertisedUrls?: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
export type TrainerMetaMessage = MessageEnvelope<'trainer_meta', TrainerMetaPayload>;
|
||||
export type TrainerValuesMessage = MessageEnvelope<'trainer_values', TrainerValuesPayload>;
|
||||
export type ValueChangedMessage = MessageEnvelope<'value_changed', ValueChangedPayload>;
|
||||
export type TrainerChangedMessage = MessageEnvelope<'trainer_changed', TrainerChangedPayload>;
|
||||
export type SetValueMessage = MessageEnvelope<'set_value', SetValuePayload>;
|
||||
export type SetValueResultMessage = MessageEnvelope<'set_value_result', SetValueResultPayload>;
|
||||
export type ErrorMessage = MessageEnvelope<'error', ErrorPayload>;
|
||||
|
||||
export type IncomingMessage =
|
||||
| HelloAckMessage
|
||||
| TrainerMetaMessage
|
||||
| TrainerValuesMessage
|
||||
| ValueChangedMessage
|
||||
| TrainerChangedMessage
|
||||
| SetValueResultMessage
|
||||
| ErrorMessage;
|
||||
|
||||
export type OutgoingMessage = HelloMessage | SetValueMessage;
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
export function isIncomingMessage(value: unknown): value is IncomingMessage {
|
||||
return isRecord(value) && typeof value.type === 'string' && typeof value.version === 'number' && 'payload' in value;
|
||||
}
|
||||
|
||||
export function resolveOption(option: CheatOptionLike): CheatOption {
|
||||
if (typeof option === 'string' || typeof option === 'number') {
|
||||
return { label: String(option), value: option };
|
||||
}
|
||||
|
||||
return {
|
||||
label: option.label ?? String(option.value),
|
||||
value: option.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIncomingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === 'toggle') {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingValue(cheat: CheatSchema, value: unknown): unknown {
|
||||
if (cheat.type === 'toggle') {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
if (cheat.type !== 'slider' && cheat.type !== 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
if (!trimmedValue) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Number(trimmedValue);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { CLIENT_VERSION } from './constants';
|
||||
import {
|
||||
type HelloMessage,
|
||||
type IncomingMessage,
|
||||
type OutgoingMessage,
|
||||
PROTOCOL_VERSION,
|
||||
type SetValueMessage,
|
||||
isIncomingMessage,
|
||||
} from './protocol';
|
||||
|
||||
type SocketHandlers = {
|
||||
onConnecting: () => void;
|
||||
onOpen: () => void;
|
||||
onMessage: (message: IncomingMessage) => void;
|
||||
onClose: () => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
export class PanelSocketClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private intentionalDisconnect = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly handlers: SocketHandlers,
|
||||
) {}
|
||||
|
||||
connect(pairingToken?: string): void {
|
||||
this.disconnect();
|
||||
this.intentionalDisconnect = false;
|
||||
this.handlers.onConnecting();
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.handlers.onOpen();
|
||||
this.send(this.createHelloMessage(pairingToken));
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
if (!this.intentionalDisconnect) {
|
||||
this.handlers.onClose();
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
this.handlers.onError('WebSocket connection failed.');
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.intentionalDisconnect = true;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
send(message: OutgoingMessage): boolean {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.socket.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
|
||||
setValue(trainerId: string, target: string, value: unknown, cheatId?: string): boolean {
|
||||
const message: SetValueMessage = {
|
||||
type: 'set_value',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `set_${target}_${Date.now()}`,
|
||||
payload: {
|
||||
trainerId,
|
||||
target,
|
||||
value,
|
||||
cheatId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
private createHelloMessage(pairingToken?: string): HelloMessage {
|
||||
return {
|
||||
type: 'hello',
|
||||
version: PROTOCOL_VERSION,
|
||||
requestId: `hello_${Date.now()}`,
|
||||
payload: {
|
||||
client: 'mobile-web',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
pairingToken,
|
||||
capabilities: {
|
||||
supportsDeltaValues: true,
|
||||
supportsTrainerSwitch: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(String(event.data)) as unknown;
|
||||
if (!isIncomingMessage(parsed)) {
|
||||
this.handlers.onError('Received an invalid protocol message.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onMessage(parsed);
|
||||
} catch (error) {
|
||||
this.handlers.onError(error instanceof Error ? error.message : 'Failed to parse websocket message.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { readInitialRemoteUrl, readInitialWebSocketUrl } from './constants';
|
||||
import type { TrainerMetaPayload } from './protocol';
|
||||
|
||||
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type PanelState = {
|
||||
connectionStatus: ConnectionStatus;
|
||||
wsUrl: string;
|
||||
remoteUrl: string;
|
||||
trainerMeta: TrainerMetaPayload | null;
|
||||
values: Record<string, unknown>;
|
||||
pendingTargets: Record<string, boolean>;
|
||||
pinnedTargets: Record<string, true>;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type PanelAction =
|
||||
| { type: 'setWsUrl'; wsUrl: string }
|
||||
| { type: 'setRemoteUrl'; remoteUrl: string }
|
||||
| { type: 'connecting' }
|
||||
| { type: 'connected' }
|
||||
| { type: 'trainerMeta'; payload: TrainerMetaPayload }
|
||||
| { type: 'trainerValues'; payload: Record<string, unknown> }
|
||||
| { type: 'valueChanged'; target: string; value: unknown }
|
||||
| { type: 'setPending'; target: string; pending: boolean }
|
||||
| { type: 'trainerChanged' }
|
||||
| { type: 'setPinnedTargets'; pinned: Record<string, true> }
|
||||
| { type: 'togglePinnedTarget'; target: string }
|
||||
| { type: 'error'; message: string | null };
|
||||
|
||||
export function createInitialPanelState(): PanelState {
|
||||
return {
|
||||
connectionStatus: 'idle',
|
||||
wsUrl: readInitialWebSocketUrl(),
|
||||
remoteUrl: readInitialRemoteUrl(),
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function panelReducer(state: PanelState, action: PanelAction): PanelState {
|
||||
switch (action.type) {
|
||||
case 'setWsUrl':
|
||||
return {
|
||||
...state,
|
||||
wsUrl: action.wsUrl,
|
||||
};
|
||||
case 'setRemoteUrl':
|
||||
return {
|
||||
...state,
|
||||
remoteUrl: action.remoteUrl,
|
||||
};
|
||||
case 'connecting':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: 'connecting',
|
||||
lastError: null,
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: 'connected',
|
||||
lastError: null,
|
||||
};
|
||||
case 'trainerMeta':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: action.payload,
|
||||
pendingTargets: {},
|
||||
};
|
||||
case 'trainerValues':
|
||||
return {
|
||||
...state,
|
||||
values: action.payload,
|
||||
};
|
||||
case 'valueChanged':
|
||||
return {
|
||||
...state,
|
||||
values: {
|
||||
...state.values,
|
||||
[action.target]: action.value,
|
||||
},
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: false,
|
||||
},
|
||||
};
|
||||
case 'setPending':
|
||||
return {
|
||||
...state,
|
||||
pendingTargets: {
|
||||
...state.pendingTargets,
|
||||
[action.target]: action.pending,
|
||||
},
|
||||
};
|
||||
case 'trainerChanged':
|
||||
return {
|
||||
...state,
|
||||
trainerMeta: null,
|
||||
values: {},
|
||||
pendingTargets: {},
|
||||
pinnedTargets: {},
|
||||
};
|
||||
case 'setPinnedTargets':
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: action.pinned,
|
||||
};
|
||||
case 'togglePinnedTarget': {
|
||||
const next = { ...state.pinnedTargets };
|
||||
if (next[action.target]) {
|
||||
delete next[action.target];
|
||||
} else {
|
||||
next[action.target] = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
pinnedTargets: next,
|
||||
};
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
connectionStatus: action.message ? 'error' : state.connectionStatus,
|
||||
lastError: action.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme inline {
|
||||
--font-heading: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.122 0.022 255);
|
||||
--foreground: oklch(0.968 0.016 96);
|
||||
--card: oklch(0.18 0.028 252);
|
||||
--card-foreground: oklch(0.968 0.016 96);
|
||||
--popover: oklch(0.16 0.026 252);
|
||||
--popover-foreground: oklch(0.968 0.016 96);
|
||||
--primary: oklch(0.812 0.184 142);
|
||||
--primary-foreground: oklch(0.13 0.032 145);
|
||||
--secondary: oklch(0.251 0.046 252);
|
||||
--secondary-foreground: oklch(0.92 0.025 94);
|
||||
--muted: oklch(0.235 0.031 252);
|
||||
--muted-foreground: oklch(0.72 0.025 248);
|
||||
--accent: oklch(0.742 0.153 55);
|
||||
--accent-foreground: oklch(0.16 0.028 50);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.32 0.042 252);
|
||||
--input: oklch(0.255 0.038 252);
|
||||
--ring: oklch(0.812 0.184 142);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply min-h-svh bg-background text-foreground antialiased;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
type ClassValue = string | number | false | null | undefined | ClassValue[] | Record<string, boolean | null | undefined>
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
const classes: string[] = []
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!input) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof input === "string" || typeof input === "number") {
|
||||
classes.push(String(input))
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
const value = cn(...input)
|
||||
if (value) {
|
||||
classes.push(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [key, enabled] of Object.entries(input)) {
|
||||
if (enabled) {
|
||||
classes.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return classes.join(" ")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app';
|
||||
import './index.css';
|
||||
|
||||
const root = document.getElementById('root') ?? document.getElementById('app');
|
||||
|
||||
if (!root) {
|
||||
throw new Error('App root not found.');
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user