feat(App): add Reality Override Admin page and integrate into app navigation
This commit is contained in:
parent
0df2b9cae5
commit
92009c1a63
|
|
@ -11,6 +11,7 @@ import ToolsPage from './components/ToolsPage';
|
||||||
import SearchOverlay from './components/SearchOverlay';
|
import SearchOverlay from './components/SearchOverlay';
|
||||||
import MediaManager from './components/MediaManager';
|
import MediaManager from './components/MediaManager';
|
||||||
import RealityOverridePage from './components/RealityOverridePage';
|
import RealityOverridePage from './components/RealityOverridePage';
|
||||||
|
import RealityOverrideAdminPage from './components/RealityOverrideAdminPage';
|
||||||
import logoSvg from '../imports/logo.svg';
|
import logoSvg from '../imports/logo.svg';
|
||||||
import { useSettings } from './settings';
|
import { useSettings } from './settings';
|
||||||
|
|
||||||
|
|
@ -37,7 +38,8 @@ type AppId =
|
||||||
| 'petscii-editor'
|
| 'petscii-editor'
|
||||||
| 'idle-animation'
|
| 'idle-animation'
|
||||||
| 'loading-animation'
|
| 'loading-animation'
|
||||||
| 'reality-override';
|
| 'reality-override'
|
||||||
|
| 'reality-override-admin';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('status');
|
const [currentPage, setCurrentPage] = useState<Page>('status');
|
||||||
|
|
@ -108,6 +110,7 @@ export default function App() {
|
||||||
<AppCard icon={<Activity className="w-7 h-7" />} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} />
|
<AppCard icon={<Activity className="w-7 h-7" />} label="Idle Animation" onClick={() => setCurrentPage('idle-animation')} />
|
||||||
<AppCard icon={<Loader2 className="w-7 h-7" />} label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} />
|
<AppCard icon={<Loader2 className="w-7 h-7" />} label="Loading Animation" onClick={() => setCurrentPage('loading-animation')} />
|
||||||
<AppCard icon={<Wifi className="w-7 h-7" />} label="Reality Override" onClick={() => setCurrentPage('reality-override')} />
|
<AppCard icon={<Wifi className="w-7 h-7" />} label="Reality Override" onClick={() => setCurrentPage('reality-override')} />
|
||||||
|
<AppCard icon={<Wifi className="w-7 h-7" />} label="Override Admin" onClick={() => setCurrentPage('reality-override-admin')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,7 +148,8 @@ export default function App() {
|
||||||
'petscii-editor': <AppPage title="Petscii Editor" onBack={() => setCurrentPage('apps')} />,
|
'petscii-editor': <AppPage title="Petscii Editor" onBack={() => setCurrentPage('apps')} />,
|
||||||
'idle-animation': <AppPage title="Idle Animation" onBack={() => setCurrentPage('apps')} />,
|
'idle-animation': <AppPage title="Idle Animation" onBack={() => setCurrentPage('apps')} />,
|
||||||
'loading-animation': <AppPage title="Loading Animation" onBack={() => setCurrentPage('apps')} />,
|
'loading-animation': <AppPage title="Loading Animation" onBack={() => setCurrentPage('apps')} />,
|
||||||
'reality-override': <RealityOverridePage onBack={() => setCurrentPage('apps')} />
|
'reality-override': <RealityOverridePage onBack={() => setCurrentPage('apps')} />,
|
||||||
|
'reality-override-admin': <RealityOverrideAdminPage onBack={() => setCurrentPage('apps')} />
|
||||||
};
|
};
|
||||||
|
|
||||||
// AppCard component for app grid
|
// AppCard component for app grid
|
||||||
|
|
|
||||||
223
src/app/components/RealityOverrideAdminPage.tsx
Normal file
223
src/app/components/RealityOverrideAdminPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronLeft, Loader2, Wifi, WifiOff, Send,
|
||||||
|
Image as ImageIcon, Film, Music,
|
||||||
|
Crop, RotateCcw, Minimize2,
|
||||||
|
Scissors, FileOutput, LayoutGrid,
|
||||||
|
Mic, Clapperboard, Wand2, FlipHorizontal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// ── Command definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Cmd { id: string; label: string; icon: React.ReactNode; payload: string; }
|
||||||
|
interface Group { id: string; label: string; color: string; border: string; icon: React.ReactNode; commands: Cmd[]; }
|
||||||
|
|
||||||
|
const img = (cls = 'w-4 h-4') => <ImageIcon className={cls} />;
|
||||||
|
const aud = (cls = 'w-4 h-4') => <Music className={cls} />;
|
||||||
|
const vid = (cls = 'w-4 h-4') => <Film className={cls} />;
|
||||||
|
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
id: 'image', label: 'Image Conversion',
|
||||||
|
color: 'text-violet-400', border: 'border-violet-800/50',
|
||||||
|
icon: <ImageIcon className="w-5 h-5" />,
|
||||||
|
commands: [
|
||||||
|
{ id: 'img-jpg', label: '→ JPEG', icon: img(), payload: 'CONVERT IMAGE → JPEG' },
|
||||||
|
{ id: 'img-png', label: '→ PNG', icon: img(), payload: 'CONVERT IMAGE → PNG' },
|
||||||
|
{ id: 'img-webp', label: '→ WebP', icon: img(), payload: 'CONVERT IMAGE → WEBP' },
|
||||||
|
{ id: 'img-gif', label: '→ GIF', icon: img(), payload: 'CONVERT IMAGE → GIF' },
|
||||||
|
{ id: 'img-bmp', label: '→ BMP', icon: img(), payload: 'CONVERT IMAGE → BMP' },
|
||||||
|
{ id: 'img-avif', label: '→ AVIF', icon: img(), payload: 'CONVERT IMAGE → AVIF' },
|
||||||
|
{ id: 'img-resize', label: 'Resize', icon: <Minimize2 className="w-4 h-4" />, payload: 'IMAGE: RESIZE' },
|
||||||
|
{ id: 'img-crop', label: 'Crop', icon: <Crop className="w-4 h-4" />, payload: 'IMAGE: CROP' },
|
||||||
|
{ id: 'img-rotate', label: 'Rotate 90°', icon: <RotateCcw className="w-4 h-4" />, payload: 'IMAGE: ROTATE 90°' },
|
||||||
|
{ id: 'img-fliph', label: 'Flip H', icon: <FlipHorizontal className="w-4 h-4" />, payload: 'IMAGE: FLIP HORIZONTAL' },
|
||||||
|
{ id: 'img-magic', label: 'Auto Enhance', icon: <Wand2 className="w-4 h-4" />, payload: 'IMAGE: AUTO ENHANCE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audio', label: 'Audio Conversion',
|
||||||
|
color: 'text-teal-400', border: 'border-teal-800/50',
|
||||||
|
icon: <Music className="w-5 h-5" />,
|
||||||
|
commands: [
|
||||||
|
{ id: 'aud-mp3', label: '→ MP3', icon: aud(), payload: 'CONVERT AUDIO → MP3' },
|
||||||
|
{ id: 'aud-wav', label: '→ WAV', icon: aud(), payload: 'CONVERT AUDIO → WAV' },
|
||||||
|
{ id: 'aud-ogg', label: '→ OGG', icon: aud(), payload: 'CONVERT AUDIO → OGG' },
|
||||||
|
{ id: 'aud-flac', label: '→ FLAC', icon: aud(), payload: 'CONVERT AUDIO → FLAC' },
|
||||||
|
{ id: 'aud-aac', label: '→ AAC', icon: aud(), payload: 'CONVERT AUDIO → AAC' },
|
||||||
|
{ id: 'aud-opus', label: '→ Opus', icon: aud(), payload: 'CONVERT AUDIO → OPUS' },
|
||||||
|
{ id: 'aud-norm', label: 'Normalize', icon: <Mic className="w-4 h-4" />, payload: 'AUDIO: NORMALIZE' },
|
||||||
|
{ id: 'aud-extract', label: 'Extract from Video', icon: <Scissors className="w-4 h-4" />, payload: 'AUDIO: EXTRACT FROM VIDEO' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'video', label: 'Video Conversion',
|
||||||
|
color: 'text-rose-400', border: 'border-rose-800/50',
|
||||||
|
icon: <Film className="w-5 h-5" />,
|
||||||
|
commands: [
|
||||||
|
{ id: 'vid-mp4', label: '→ MP4', icon: vid(), payload: 'CONVERT VIDEO → MP4' },
|
||||||
|
{ id: 'vid-webm', label: '→ WebM', icon: vid(), payload: 'CONVERT VIDEO → WEBM' },
|
||||||
|
{ id: 'vid-avi', label: '→ AVI', icon: vid(), payload: 'CONVERT VIDEO → AVI' },
|
||||||
|
{ id: 'vid-mkv', label: '→ MKV', icon: vid(), payload: 'CONVERT VIDEO → MKV' },
|
||||||
|
{ id: 'vid-mov', label: '→ MOV', icon: vid(), payload: 'CONVERT VIDEO → MOV' },
|
||||||
|
{ id: 'vid-gif', label: '→ GIF', icon: vid(), payload: 'CONVERT VIDEO → GIF' },
|
||||||
|
{ id: 'vid-audio', label: 'Extract Audio', icon: <Music className="w-4 h-4" />, payload: 'VIDEO: EXTRACT AUDIO' },
|
||||||
|
{ id: 'vid-frames', label: 'Extract Frames', icon: <LayoutGrid className="w-4 h-4" />, payload: 'VIDEO: EXTRACT FRAMES' },
|
||||||
|
{ id: 'vid-clip', label: 'Trim Clip', icon: <Scissors className="w-4 h-4" />, payload: 'VIDEO: TRIM CLIP' },
|
||||||
|
{ id: 'vid-thumb', label: 'Thumbnail', icon: <Clapperboard className="w-4 h-4" />, payload: 'VIDEO: GENERATE THUMBNAIL' },
|
||||||
|
{ id: 'vid-export', label: 'Export Subtitles',icon: <FileOutput className="w-4 h-4" />, payload: 'VIDEO: EXPORT SUBTITLES' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Color maps keyed by group id ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GROUP_BTN: Record<string, string> = {
|
||||||
|
image: 'border-violet-800/40 hover:border-violet-600/70 hover:bg-violet-900/20 active:bg-violet-800/30',
|
||||||
|
audio: 'border-teal-800/40 hover:border-teal-600/70 hover:bg-teal-900/20 active:bg-teal-800/30',
|
||||||
|
video: 'border-rose-800/40 hover:border-rose-600/70 hover:bg-rose-900/20 active:bg-rose-800/30',
|
||||||
|
};
|
||||||
|
const GROUP_ICON: Record<string, string> = {
|
||||||
|
image: 'text-violet-400', audio: 'text-teal-400', video: 'text-rose-400',
|
||||||
|
};
|
||||||
|
const GROUP_HEAD: Record<string, string> = {
|
||||||
|
image: 'text-violet-300 border-violet-800/50',
|
||||||
|
audio: 'text-teal-300 border-teal-800/50',
|
||||||
|
video: 'text-rose-300 border-rose-800/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function RealityOverrideAdminPage({ onBack }: { onBack: () => void }) {
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||||
|
const [lastSent, setLastSent] = useState<string | null>(null);
|
||||||
|
const [flashId, setFlashId] = useState(0);
|
||||||
|
const [freeform, setFreeform] = useState('');
|
||||||
|
|
||||||
|
// ── WebSocket ───────────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
|
||||||
|
wsRef.current = ws;
|
||||||
|
setWsStatus('connecting');
|
||||||
|
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
|
||||||
|
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
|
||||||
|
ws.onerror = () => ws?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
return () => { cancelled = true; ws?.close(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Send command ────────────────────────────────────────────────────────────
|
||||||
|
const send = (payload: string) => {
|
||||||
|
if (wsRef.current?.readyState !== WebSocket.OPEN) return;
|
||||||
|
wsRef.current.send(payload);
|
||||||
|
setLastSent(payload);
|
||||||
|
setFlashId(n => n + 1);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setLastSent(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-neutral-950 text-white">
|
||||||
|
|
||||||
|
{/* CSS for sent flash */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes sent-flash {
|
||||||
|
0% { opacity: 0; transform: translateY(-6px); }
|
||||||
|
12% { opacity: 1; transform: translateY(0); }
|
||||||
|
70% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.sent-flash { animation: sent-flash 3s ease-out forwards; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-3 border-b border-neutral-800 bg-neutral-900">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-1 text-neutral-400 hover:text-white text-sm"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" /> Back
|
||||||
|
</button>
|
||||||
|
<span className="flex-1 font-mono text-sm tracking-widest text-neutral-300 uppercase">
|
||||||
|
Reality Override — Admin
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting…</span></>}
|
||||||
|
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
|
||||||
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Offline</span></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Freeform input ── */}
|
||||||
|
<div className="flex-shrink-0 flex gap-2 px-4 py-2 border-b border-neutral-800 bg-neutral-900">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={freeform}
|
||||||
|
onChange={e => setFreeform(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && freeform.trim()) { send(freeform.trim()); setFreeform(''); } }}
|
||||||
|
placeholder="Send custom command…"
|
||||||
|
disabled={wsStatus !== 'connected'}
|
||||||
|
className="flex-1 bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm font-mono text-neutral-200 placeholder-neutral-600 focus:outline-none focus:border-neutral-500 disabled:opacity-40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (freeform.trim()) { send(freeform.trim()); setFreeform(''); } }}
|
||||||
|
disabled={wsStatus !== 'connected' || !freeform.trim()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-neutral-700 hover:bg-neutral-600 text-neutral-200 text-sm disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sent indicator ── */}
|
||||||
|
<div className="relative h-8 flex-shrink-0">
|
||||||
|
{lastSent && (
|
||||||
|
<div key={flashId} className="sent-flash absolute inset-x-0 top-1 flex justify-center pointer-events-none">
|
||||||
|
<span className="text-xs font-mono text-green-400 bg-green-950/60 border border-green-800/40 px-3 py-1 rounded">
|
||||||
|
✓ Sent: {lastSent}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Command palette ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-6 space-y-6">
|
||||||
|
{GROUPS.map(group => (
|
||||||
|
<section key={group.id}>
|
||||||
|
<div className={`flex items-center gap-2 pb-2 mb-3 border-b ${GROUP_HEAD[group.id]}`}>
|
||||||
|
<span className={GROUP_ICON[group.id]}>{group.icon}</span>
|
||||||
|
<h2 className="font-mono text-sm tracking-wider uppercase">{group.label}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||||
|
{group.commands.map(cmd => (
|
||||||
|
<button
|
||||||
|
key={cmd.id}
|
||||||
|
onClick={() => send(cmd.payload)}
|
||||||
|
disabled={wsStatus !== 'connected'}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 px-3 py-2.5 rounded border text-left text-sm
|
||||||
|
bg-neutral-900 text-neutral-300
|
||||||
|
transition-colors disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
|
${GROUP_BTN[group.id]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span className={`flex-shrink-0 ${GROUP_ICON[group.id]}`}>{cmd.icon}</span>
|
||||||
|
<span className="truncate font-mono text-xs">{cmd.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user