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
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
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 <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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user