Files
kid_dash/webapp/src/App.jsx
T
Elias Journal 30f9148adb Initial commit: KiddOs v3 React kiosk
- React 18 + Vite 5 hot-reload OS (800x480 Nord theme)
- 7 apps: Journal, Calculator, Drawing, CodeLab, Clock, Files, Scratch
- Cage + Cog Wayland kiosk, offline localStorage
- Scripts: dev.sh, run-kiosk.sh, run-prod.sh
2026-08-24 21:53:02 -04:00

269 lines
18 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import Journal from './apps/Journal.jsx'
import Calculator from './apps/Calculator.jsx'
import Drawing from './apps/Drawing.jsx'
import CodeLab from './apps/CodeLab.jsx'
import Clock from './apps/Clock.jsx'
import Files from './apps/Files.jsx'
import Scratch from './apps/Scratch.jsx'
// LOCAL ONLY - per parent boundary: no external IPs, only localhost + file://
// Allowed: http://localhost:5173, http://127.0.0.1:5173, file://
// Blocked: https://scratch.mit.edu etc. Scratch app now offline-only.
const APPS = [
{ name: 'Journal', icon: '/icons/journal.png', emoji:'📓', color: '#88C0D0', category: 'Create', desc: 'Write daily adventure', comp: 'journal', order: 10 },
{ name: 'Code', icon: '/icons/code.png', emoji:'💻', color: '#5E81AC', category: 'Create', desc: 'Make something', comp: 'code', order: 15 },
{ name: 'Scratch', icon: '/icons/scratch.png', emoji:'🧱', color: '#88C0D0', category: 'Create', desc: 'Blocks • offline', comp: 'scratch', order: 16 },
{ name: 'Calculator', icon: '/icons/calculator.png', emoji:'🧮', color: '#A3BE8C', category: 'Learn', desc: 'Do math', comp: 'calculator', order: 20 },
{ name: 'Clock', icon: '/icons/clock.png', emoji:'⏰', color: '#EBCB8B', category: 'System', desc: 'Time & timer', comp: 'clock', order: 22 },
{ name: 'Drawing', icon: '/icons/play.png', emoji:'🎨', color: '#9D7BD8', category: 'Play', desc: 'Draw & paint', comp: 'drawing', order: 25 },
{ name: 'Play', icon: '/icons/play.png', emoji:'🎮', color: '#9D7BD8', category: 'Play', desc: 'Play ground', comp: 'drawing', order: 26 },
{ name: 'Files', icon: '/icons/files.png', emoji:'📁', color: '#5E81AC', category: 'System', desc: 'Browse creations', comp: 'files', order: 30 },
]
const CATS = ['All','Create','Learn','Play','System']
const CAT_COLORS = {All:'#D8DEE9', Create:'#88C0D0', Learn:'#A3BE8C', Play:'#9D7BD8', System:'#5E81AC'}
const CAT_ICONS = {All:'☰', Create:'✏', Learn:'✈', Play:'▶', System:'⚙'}
const COMPS = {
journal: Journal,
calculator: Calculator,
drawing: Drawing,
code: CodeLab,
clock: Clock,
files: Files,
scratch: Scratch,
}
export default function App(){
const [cat, setCat] = useState(()=> localStorage.getItem('kiddos_cat') || 'All')
const [clock, setClock] = useState(new Date())
const [showSettings, setShowSettings] = useState(false)
const [currentApp, setCurrentApp] = useState(null)
const [recents, setRecents] = useState(()=> JSON.parse(localStorage.getItem('kiddos_recents')||'[]'))
const [companionMsg, setCompanionMsg] = useState(()=> localStorage.getItem('kiddos_companion_message')||'')
const [stats, setStats] = useState(()=> {
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_'))
return {entries: keys.length}
})
useEffect(()=>{
const t=setInterval(()=>setClock(new Date()),1000)
return ()=>clearInterval(t)
},[])
// Local-only stats polling. Companion is local file ~/launcher_config/companion_message.txt
// We mirror via localStorage key kiddos_companion_message if parent sets it.
// No external fetch.
useEffect(()=>{
const id=setInterval(()=>{
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_'))
setStats({entries: keys.length})
const msg = localStorage.getItem('kiddos_companion_message')
if(msg) setCompanionMsg(msg)
},3000)
return ()=>clearInterval(id)
},[])
useEffect(()=>{
localStorage.setItem('kiddos_cat', cat)
},[cat])
const saveRecent = (name)=>{
const r=[name, ...recents.filter(n=>n!==name)].slice(0,8)
setRecents(r)
localStorage.setItem('kiddos_recents', JSON.stringify(r))
}
const openApp = (app)=>{
saveRecent(app.name)
setCurrentApp(app)
}
const filtered = (cat==='All'? APPS : APPS.filter(a=>a.category===cat)).sort((a,b)=>a.order-b.order)
const favs = JSON.parse(localStorage.getItem('kiddos_favs')||'["Journal","Code","Calculator"]')
const favApps = APPS.filter(a=>favs.includes(a.name))
const recentApps = recents.map(n=>APPS.find(a=>a.name===n)).filter(Boolean).filter(a=>!favs.includes(a.name))
const favToShow = [...favApps, ...recentApps].slice(0,10)
if(currentApp){
const Comp = COMPS[currentApp.comp]
return (
<div style={{height:'100vh', display:'flex', flexDirection:'column', background:'#2E3440'}}>
<header style={{height:'52px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<div style={{display:'flex', gap:'10px', alignItems:'center'}}>
<button onClick={()=>setCurrentApp(null)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', padding:'0 10px', height:'36px', cursor:'pointer'}}>🏠 KiddOs</button>
<span style={{fontSize:'16px'}}>{currentApp.emoji}</span>
<strong style={{fontSize:'14px'}}>{currentApp.name}</strong>
<span style={{fontSize:'11px', color:'#8E99A8', background:'#3B4252', padding:'2px 6px', borderRadius:'8px', border:'1px solid #4C566A'}}>{currentApp.category}</span>
</div>
<div style={{display:'flex', gap:'8px', alignItems:'center'}}>
<span style={{fontSize:'15px', color:'#88C0D0', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', fontWeight:'800', background:'#232A36', padding:'4px 10px', borderRadius:'10px', border:'1px solid #88C0D0', letterSpacing:'0.5px'}}>{clock.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} • {stats.entries} entries</span>
<button onClick={()=>setShowSettings(true)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>⚙</button>
</div>
</header>
<div style={{flex:1, overflow:'hidden', position:'relative'}}>
{Comp ? <Comp /> : <div style={{padding:20, color:'#ECEFF4'}}>App {currentApp.name} coming soon</div>}
</div>
{showSettings && <div onClick={()=>setShowSettings(false)} style={{position:'fixed', inset:0, background:'rgba(0,0,0,0.6)', zIndex:40}}></div>}
<SettingsDrawer open={showSettings} onClose={()=>setShowSettings(false)} stats={stats} favs={favs} onFavsChange={(f)=>{localStorage.setItem('kiddos_favs', JSON.stringify(f))}} recents={recents} />
</div>
)
}
return (
<div style={{display:'flex', flexDirection:'column', height:'100vh', background:'#2E3440', overflow:'hidden'}}>
<header style={{height:'56px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<div style={{display:'flex', gap:'10px', alignItems:'center'}}>
<div style={{width:'36px', height:'36px', background:'#434C5E', borderRadius:'10px', display:'flex', alignItems:'center', justifyContent:'center', fontWeight:'bold', fontSize:'16px', border:'1px solid #4C566A'}}>E</div>
<div>
<div style={{fontWeight:'600', fontSize:'14px', display:'flex', alignItems:'center', gap:'6px'}}>Hey, Elias! <span style={{background:'#88C0D0', color:'#000', padding:'2px 6px', borderRadius:'10px', fontSize:'10px'}}>React v3 ⚡</span> <span style={{fontSize:'10px', color:'#8E99A8', background:'#232A36', border:'1px solid #4C566A', padding:'2px 6px', borderRadius:'8px'}}>{stats.entries} entries</span></div>
<div style={{fontSize:'11px', color:'#8E99A8', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis', maxWidth:'320px'}}>{companionMsg ? `✨ ${companionMsg}` : 'Offline • localhost:5173 only • No internet • Hot reload'}</div>
</div>
</div>
<div style={{color:'#88C0D0', fontSize:'18px', fontWeight:'800', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', textAlign:'center', minWidth:'175px', background:'#232A36', border:'1px solid #88C0D0', borderRadius:'10px', padding:'5px 10px', letterSpacing:'0.5px', boxShadow:'0 0 0 2px rgba(136,192,208,0.15)' }}>{clock.toLocaleString(undefined,{weekday:'short', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'})}</div>
<div style={{display:'flex', gap:'8px'}}>
<button onClick={()=>location.reload()} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>↻</button>
<button onClick={()=>setShowSettings(true)} style={{background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'40px', height:'36px', cursor:'pointer'}}>⚙</button>
</div>
</header>
<main style={{padding:'12px', display:'flex', flexDirection:'column', gap:'12px', height:'calc(100vh - 56px)', overflowY:'auto'}}>
<section>
<div style={{fontSize:'12px', marginBottom:'6px', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
<span>⭐ Favorites & Recent (tap → hot reload on save)</span>
<span style={{fontSize:'10px', color:'#A3BE8C'}}>🔒 localhost only • file:// • offline</span>
</div>
<div style={{display:'flex', gap:'10px', overflowX:'auto', paddingBottom:'4px'}}>
{favToShow.map(app=>(
<div key={app.name} onClick={()=>openApp(app)} style={{minWidth:'110px', height:'80px', background:'#232A36', border:'1px solid #EBCB8B', borderRadius:'12px', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'4px', cursor:'pointer', flexShrink:0}}>
<img src={app.icon} alt="" onError={e=>{e.target.style.display='none'; e.target.nextSibling.style.display='block'}} style={{width:'32px', height:'32px', borderRadius:'6px'}}/>
<span style={{display:'none', fontSize:'20px'}}>{app.emoji}</span>
<span style={{fontSize:'12px', color:'#ECEFF4'}}>{app.name}</span>
</div>
))}
{favToShow.length===0 && <span style={{fontSize:'12px', color:'#8E99A8'}}>No favorites yet — tap apps below</span>}
</div>
</section>
<section style={{display:'flex', gap:'8px', overflowX:'auto'}}>
{CATS.map(c=>(
<button key={c} onClick={()=>setCat(c)} style={{padding:'6px 14px', borderRadius:'16px', border:'1px solid #4C566A', background: cat===c ? CAT_COLORS[c] : '#3B4252', color: cat===c ? '#000' : '#ECEFF4', fontSize:'12px', whiteSpace:'nowrap', cursor:'pointer', fontWeight: cat===c ? '700' : '400'}}>
{CAT_ICONS[c]} {c}
</button>
))}
</section>
<section style={{display:'flex', flexWrap:'wrap', gap:'10px'}}>
{filtered.map(app=>(
<div key={app.name} onClick={()=>openApp(app)} style={{width:'150px', minHeight:'132px', background:'#3B4252', border:'1px solid #4C566A', borderRadius:'16px', padding:'10px', display:'flex', flexDirection:'column', alignItems:'center', gap:'6px', cursor:'pointer'}}>
<div style={{width:'100%', height:'4px', borderRadius:'2px', background: CAT_COLORS[app.category] || '#4C566A'}}></div>
<div style={{position:'relative', width:'48px', height:'48px', display:'flex', alignItems:'center', justifyContent:'center'}}>
<img src={app.icon} alt="" onError={e=>{e.target.style.display='none'; e.target.parentElement.querySelector('.emoji-fallback').style.display='block'}} style={{width:'48px', height:'48px', borderRadius:'8px'}}/>
<span className="emoji-fallback" style={{display:'none', fontSize:'32px'}}>{app.emoji}</span>
</div>
<div style={{fontSize:'13px', fontWeight:'600', textAlign:'center', color:'#ECEFF4'}}>{app.name}</div>
<div style={{fontSize:'11px', color:'#8E99A8', textAlign:'center'}}>{app.desc}</div>
</div>
))}
</section>
<section style={{display:'flex', gap:'10px', flexWrap:'wrap', marginTop:'8px'}}>
<div style={{background:'#232A36', border:'1px solid #5E81AC', borderRadius:'10px', padding:'8px 10px', fontSize:'11px', color:'#8E99A8'}}>
<strong style={{color:'#D8DEE9'}}>DSI 800×480</strong> • cage + cog wl • FT5x06 touch • Vite HMR • 🔒 localhost:5173 only
</div>
<div style={{background:'#232A36', border:'1px solid #A3BE8C', borderRadius:'10px', padding:'8px 10px', fontSize:'11px', color:'#A3BE8C'}}>
✅ Offline • No external URLs • localStorage • file:// fallback works
</div>
</section>
</main>
{showSettings && <div onClick={()=>setShowSettings(false)} style={{position:'fixed', inset:0, background:'rgba(0,0,0,0.6)', zIndex:10, display:'block'}}></div>}
<SettingsDrawer open={showSettings} onClose={()=>setShowSettings(false)} stats={stats} favs={favs} onFavsChange={(f)=>{localStorage.setItem('kiddos_favs', JSON.stringify(f)); location.reload()}} recents={recents} />
</div>
)
}
function SettingsDrawer({open, onClose, stats, favs, onFavsChange, recents}){
const [allApps] = useState(()=>APPS.map(a=>a.name))
return (
<div style={{position:'fixed', top:0, right:0, width:'360px', maxWidth:'85vw', height:'100%', background:'#3B4252', borderLeft:'1px solid #4C566A', transform: open ? 'translateX(0)' : 'translateX(100%)', transition:'transform 0.25s ease', overflowY:'auto', zIndex:50, display:'flex', flexDirection:'column'}}>
<div style={{height:'52px', background:'#1E222E', borderBottom:'1px solid #4C566A', display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 12px', flexShrink:0}}>
<strong>⚙ KiddOs v3 React</strong>
<button onClick={onClose} style={{background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4', borderRadius:'8px', width:'32px', height:'32px', cursor:'pointer'}}>✕</button>
</div>
<div style={{padding:'12px', display:'flex', flexDirection:'column', gap:'12px', overflowY:'auto'}}>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>📊 Stats (local)</h3>
<div style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.6'}}>
<div>Journal entries: {stats.entries}</div>
<div>Recents: {recents.join(', ')||'none'}</div>
<div>Touch: {'ontouchstart' in window ? 'Yes ✅' : 'No — try cage may need libinput debug'}</div>
<div>DSI: 800×480 • DRM card1 connector 49</div>
<div>UserAgent: {navigator.userAgent.slice(0,80)}</div>
</div>
</div>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>⭐ Favorites (local only)</h3>
<div style={{display:'flex', flexDirection:'column', gap:'6px'}}>
{allApps.map(name=>{
const isFav = favs.includes(name)
return (
<label key={name} style={{display:'flex', alignItems:'center', gap:'8px', fontSize:'12px', cursor:'pointer'}}>
<input type="checkbox" checked={isFav} onChange={()=>{
const next = isFav ? favs.filter(f=>f!==name) : [...favs, name]
onFavsChange(next)
}} />
<span>{name}</span>
</label>
)
})}
</div>
<div style={{fontSize:'10px', color:'#8E99A8', marginTop:'6px'}}>Offline • Saved in localStorage</div>
</div>
<div style={{background:'#232A36', border:'1px solid #5E81AC', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>🔒 Network lock — localhost only</h3>
<p style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.4'}}>Per safety boundary: browser may only load localhost or file:// — no external IPs. This means:</p>
<ul style={{fontSize:'11px', color:'#8E99A8', marginTop:'6px', paddingLeft:'16px', lineHeight:'1.5'}}>
<li>✅ http://localhost:5173 (Vite HMR dev)</li>
<li>✅ http://127.0.0.1:5173</li>
<li>✅ file:///home/elias/KiddOsV3/webapp/dist/index.html (prod)</li>
<li>❌ https://scratch.mit.edu (blocked — now offline Scratch app)</li>
</ul>
<p style={{fontSize:'10px', color:'#8E99A8', marginTop:'6px'}}>Cog launched with --doc-viewer but no external browse. All app content is local React.</p>
</div>
<div style={{background:'#232A36', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>🖥️ Kiosk commands (localhost only)</h3>
<pre style={{fontSize:'10px', whiteSpace:'pre-wrap', color:'#8E99A8', background:'#000', padding:'8px', borderRadius:'6px', overflow:'auto'}}>
{`# dev server (SSH 1) — localhost only
cd ~/KiddOsV3/webapp
npm run dev -- --host 0.0.0.0 --port 5173
# DSI (SSH 2) — only localhost, no external
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \\
cog --platform=wl http://localhost:5173 --bg-color=#2E3440
# prod file:// (no network at all)
cd ~/KiddOsV3/webapp && npm run build
cage -s -- cog --platform=wl file://$PWD/dist/index.html
`}
</pre>
</div>
<div style={{background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', padding:'10px'}}>
<h3 style={{fontSize:'13px', marginBottom:'6px'}}>⚡ Hot reload test</h3>
<p style={{fontSize:'11px', color:'#D8DEE9', lineHeight:'1.4'}}>Edit src/App.jsx on laptop via SSH, save, DSI updates in &lt;1s via Vite WebSocket (same host:port, still localhost). Try changing greeting text or card color.</p>
<pre style={{fontSize:'10px', color:'#88C0D0', background:'#000', padding:'6px', borderRadius:'4px', marginTop:'6px'}}>Test: change "Hey, Elias!" to "Hey, Elias! 🔥 HMR works!" in App.jsx and save → watch DSI</pre>
</div>
</div>
</div>
)
}