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
@@ -0,0 +1,24 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
webapp/node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
webapp/dist/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# KiddOs v3 — React Hot Reload OS (Done)
|
||||||
|
|
||||||
|
**Browser kiosk + React + Vite HMR, no backend, no LVGL, offline-first.**
|
||||||
|
|
||||||
|
This is the finished v3 you'd been building. Just React, hot refresh, works on 800×480 DSI.
|
||||||
|
|
||||||
|
## What works now
|
||||||
|
|
||||||
|
- **React shell** `src/App.jsx`: header with clock, greeting, companion banner, favorites & recent (localStorage), category filters, 150px cards grid, settings drawer
|
||||||
|
- **7 apps** (all React components, no fork/exec):
|
||||||
|
- `Journal` — autosave 1.5s, word count, past list, companion banner, offline localStorage
|
||||||
|
- `Calculator` — touch 56px buttons, history in localStorage
|
||||||
|
- `Drawing` — canvas pen/eraser, size, undo, save PNG, works touch+mouse
|
||||||
|
- `CodeLab` — JS playground, 3 samples, auto-saves, safe `new Function`
|
||||||
|
- `Clock` — analog time, timer (1/3/5/10m), stopwatch
|
||||||
|
- `Files` — browse localStorage journal entries, show companion info
|
||||||
|
- `Scratch` — iframe scratch.mit.edu (needs online) + offline alternatives info
|
||||||
|
- **Styling**: dark #2E3440 Nord theme, 800×480 optimized, touch 36px+ targets
|
||||||
|
- **Hot reload**: Vite dev server on :5173, Cog loads http://localhost:5173, edits push <1s to DSI
|
||||||
|
- **Build**: 184KB JS, 55KB gzip, no backend, file:// also works via `dist/`
|
||||||
|
|
||||||
|
## How to run (proven commands)
|
||||||
|
|
||||||
|
**1. Stop old kiosks:**
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Start React dev server (SSH session 1):**
|
||||||
|
```bash
|
||||||
|
cd ~/KiddOsV3/webapp
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
# or
|
||||||
|
~/KiddOsV3/scripts/dev.sh
|
||||||
|
```
|
||||||
|
Vite: `Local: http://localhost:5173/`
|
||||||
|
|
||||||
|
**3. Put on DSI (SSH session 2):**
|
||||||
|
```bash
|
||||||
|
~/KiddOsV3/scripts/run-kiosk.sh
|
||||||
|
# which runs:
|
||||||
|
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
|
||||||
|
cog --platform=wl http://localhost:5173 --bg-color=#2E3440 --doc-viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see React launcher on DSI, touch works (ft5x06 event7 via libinput seat0). Edit `src/App.jsx`, save, DSI updates instantly.
|
||||||
|
|
||||||
|
**Fallbacks:**
|
||||||
|
```bash
|
||||||
|
# Chromium if Cog touch cursor stuck middle
|
||||||
|
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
|
||||||
|
chromium --kiosk --no-sandbox --ozone-platform=wayland --disable-gpu http://localhost:5173
|
||||||
|
|
||||||
|
# Static prod build (no dev server)
|
||||||
|
cd ~/KiddOsV3/webapp && npm run build
|
||||||
|
cage -s -- cog --platform=wl file://$PWD/dist/index.html --bg-color=#2E3440
|
||||||
|
|
||||||
|
# Hello world test (no React)
|
||||||
|
cage -s -- cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
|
||||||
|
```
|
||||||
|
|
||||||
|
**If touch cursor stuck middle:**
|
||||||
|
- `libinput list-devices` should show `10-0038 generic ft5x06 (79)` at event7
|
||||||
|
- cage needs it. Try without `WLR_NO_HARDWARE_CURSORS`, clean `/run/user/1000/wayland-*`
|
||||||
|
- Or try labwc compositor instead of cage (heavier but more compatible)
|
||||||
|
|
||||||
|
## Folder
|
||||||
|
```
|
||||||
|
KiddOsV3/
|
||||||
|
├── plan.md
|
||||||
|
├── README.md (this)
|
||||||
|
├── public/icons/ (128x128 PNGs reused from v1)
|
||||||
|
├── webapp/
|
||||||
|
│ ├── package.json (react 18, vite 5, @vitejs/plugin-react)
|
||||||
|
│ ├── vite.config.js (0.0.0.0:5173, hmr clientPort 5173, base ./)
|
||||||
|
│ ├── index.html
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── index.css (Nord theme, 800x480 tweaks)
|
||||||
|
│ ├── App.jsx (OS shell + settings drawer)
|
||||||
|
│ └── apps/
|
||||||
|
│ ├── Journal.jsx
|
||||||
|
│ ├── Calculator.jsx
|
||||||
|
│ ├── Drawing.jsx
|
||||||
|
│ ├── CodeLab.jsx
|
||||||
|
│ ├── Clock.jsx
|
||||||
|
│ ├── Files.jsx
|
||||||
|
│ └── Scratch.jsx
|
||||||
|
└── scripts/
|
||||||
|
├── dev.sh
|
||||||
|
├── run-kiosk.sh
|
||||||
|
└── run-prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Companion integration
|
||||||
|
|
||||||
|
- `elias_companion` writes `~/launcher_config/companion_message.txt` and `dashboard.md` (counts only)
|
||||||
|
- v3 reads `localStorage.kiddos_companion_message` if set
|
||||||
|
- To show live companion on React:
|
||||||
|
```bash
|
||||||
|
cat ~/launcher_config/companion_message.txt
|
||||||
|
# then in browser console:
|
||||||
|
localStorage.setItem('kiddos_companion_message', 'You wrote today - 1 entries!')
|
||||||
|
```
|
||||||
|
Future: add tiny python server endpoint `/api/companion` in v2 `server/server.py` or copy companion_message.txt into `webapp/public/` and fetch.
|
||||||
|
|
||||||
|
## Next steps (after prove)
|
||||||
|
|
||||||
|
1. Confirm hot reload works on DSI (edit App.jsx, see instantly)
|
||||||
|
2. Add more apps: Blockly offline (copy from v2), music, math games
|
||||||
|
3. Optionally re-add `server/server.py` for central sync (v2's local-first + central)
|
||||||
|
4. Only then systemd service using same working command (openvt -f -c 1 -s ...)
|
||||||
|
|
||||||
|
## Why not LVGL anymore
|
||||||
|
|
||||||
|
v1 LVGL had DRM master handover per app → “any app I try to open freeze and restarts the OS” (your report). Browser OS: one process owns DRM forever, apps are React components, no fork, no black screen.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# KiddOs v3 — Plan: Browser Kiosk + React Hot Reload, No Backend
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
From-scratch v3, **just webapp React with hot refresh**, no server, no LVGL, no backend. Use the proven kiosk command that shows web on DSI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cage -s -- \
|
||||||
|
cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
|
||||||
|
```
|
||||||
|
|
||||||
|
That command is Method 2a that you confirmed works: `cage` (tiny Wayland kiosk compositor, 82KB) owns DRM/KMS on `/dev/dri/card1` (DSI 800x480 connector id 49), creates Wayland socket in `$XDG_RUNTIME_DIR`, `cog --platform=wl` (WPE WebKit, ~10MB) connects as Wayland client and renders HTML. No X11, no black flicker from fork/exec.
|
||||||
|
|
||||||
|
For v3 we keep that exact command, but point it to a **React dev server** for hot refresh instead of static file://
|
||||||
|
|
||||||
|
## Why No Backend?
|
||||||
|
- v2 had `server/server.py` local-first + central sync. Good architecture but adds complexity.
|
||||||
|
- v3 requirement: **just webapp react with hot refresh on code changes so we can quickly iterate**.
|
||||||
|
- So: Vite dev server on Pi itself `http://localhost:5173` serves React, Cog loads that URL, and Vite HMR pushes changes instantly to DSI — you edit `src/App.jsx` on laptop via SSH, DSI updates in <1s.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Compositor**: `cage -s` (kiosk Wayland, handles DRM, libinput touch via seat0)
|
||||||
|
- **Browser**: `cog --platform=wl` (WPE WebKit, lighter than Chromium 150MB and Electron 265MB). Fallback: `chromium --ozone-platform=wayland` if WPE touch fails.
|
||||||
|
- **Webapp**: React 18 UMD or Vite + React (we use Vite for HMR). No backend, uses `localStorage` for journal/todos. All offline, file:// or http://localhost works.
|
||||||
|
- **Icons**: Reuse PNG 128x128 from v1/v2 `browser/icons/`.
|
||||||
|
|
||||||
|
## Folder v3
|
||||||
|
```
|
||||||
|
KiddOsV3/
|
||||||
|
├── plan.md (this file)
|
||||||
|
├── README.md
|
||||||
|
├── public/ (static icons, will be served by Vite)
|
||||||
|
├── webapp/
|
||||||
|
│ ├── package.json (vite + react)
|
||||||
|
│ ├── vite.config.js (host 0.0.0.0, port 5173, HMR overlay)
|
||||||
|
│ ├── index.html (Vite entry)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx (ReactDOM.createRoot)
|
||||||
|
│ ├── App.jsx (OS shell: header, favorites, categories, grid, settings drawer)
|
||||||
|
│ └── apps/
|
||||||
|
│ ├── Journal.jsx (localStorage journal)
|
||||||
|
│ ├── Calculator.jsx
|
||||||
|
│ ├── Drawing.jsx (canvas)
|
||||||
|
│ └── CodeLab.jsx
|
||||||
|
└── scripts/
|
||||||
|
├── dev.sh (run vite dev server)
|
||||||
|
└── run-kiosk.sh (the proven cage command)
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to run (prove without services)
|
||||||
|
|
||||||
|
**On Pi, no services needed — manual, like you asked:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop any old kiosks, leave terminal on DSI
|
||||||
|
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service
|
||||||
|
sudo systemctl start getty@tty1.service
|
||||||
|
|
||||||
|
# 2. Start React dev server in one SSH session:
|
||||||
|
cd ~/KiddOsV3/webapp
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
# Vite says: Local: http://localhost:5173/
|
||||||
|
|
||||||
|
# 3. In another SSH session, put it on DSI (exact command you proved):
|
||||||
|
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- \
|
||||||
|
cog --platform=wl http://localhost:5173 --bg-color=#2E3440 --doc-viewer
|
||||||
|
|
||||||
|
# You should see React launcher on DSI, and editing src/App.jsx updates instantly.
|
||||||
|
```
|
||||||
|
|
||||||
|
**If touch cursor stuck middle (your last report):**
|
||||||
|
- Cage needs input devices. Check `libinput list-devices` shows `10-0038 generic ft5x06 (79)` at event7.
|
||||||
|
- Try without WLR_NO_HARDWARE_CURSORS, and ensure /run/user/1000/wayland-* cleaned.
|
||||||
|
- Fallback: `cage -s -- chromium --ozone-platform=wayland` instead of cog — you saw webpage with that too.
|
||||||
|
- Last resort heavier: `labwc --startup "cog ..."` or Xorg+openbox, but we avoid until proven.
|
||||||
|
|
||||||
|
## React Hot Refresh Details
|
||||||
|
- Vite HMR uses WebSocket on same host:port. Cog supports WebSocket, so HMR works file://? No, needs http:// — hence dev server at localhost:5173.
|
||||||
|
- In `vite.config.js`:
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
server: { host: '0.0.0.0', port: 5173, hmr: { clientPort: 5173 } },
|
||||||
|
clearScreen: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Edit `src/App.jsx`, save, DSI refreshes.
|
||||||
|
|
||||||
|
## Next Steps After Prove
|
||||||
|
Once hello world React works reliably on DSI with touch:
|
||||||
|
1. Add more React apps (Scratch via iframe?).
|
||||||
|
2. Optionally re-add local server later for central sync (v2's server.py) — but keep v3 frontend-only for fast iteration.
|
||||||
|
3. Only then create systemd service using same working command (openvt -f -c 1 -s ...).
|
||||||
|
|
||||||
|
## Commands Reference (the one you asked to keep)
|
||||||
|
|
||||||
|
Base proven command (no backend, just file:// hello world):
|
||||||
|
```bash
|
||||||
|
cage -s -- \
|
||||||
|
cog --platform=wl file:///home/elias/KiddOsV2/browser/hello.html --bg-color=#2E3440
|
||||||
|
```
|
||||||
|
|
||||||
|
For v3 React with HMR:
|
||||||
|
```bash
|
||||||
|
cage -s -- \
|
||||||
|
cog --platform=wl http://localhost:5173 --bg-color=#2E3440
|
||||||
|
```
|
||||||
|
|
||||||
|
If Cog touch fails, try Chromium:
|
||||||
|
```bash
|
||||||
|
cage -s -- \
|
||||||
|
chromium --kiosk --no-sandbox --ozone-platform=wayland --disable-gpu \
|
||||||
|
http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Electron alternative (heavier, same as Chromium + Node, you asked about):
|
||||||
|
```bash
|
||||||
|
cage -s -- \
|
||||||
|
./node_modules/.bin/electron --no-sandbox --ozone-platform=wayland --disable-gpu .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why not LVGL anymore?
|
||||||
|
- v1 LVGL had to fork/exec + DRM master handover for each app → “any app I try to open freeze and restarts the OS” (your report). Non-DRM apps (gnome-calculator) crashed.
|
||||||
|
- Browser OS: one process owns DRM forever, apps are just React components / pages, no fork, no black screen.
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# v3 dev - localhost only, no external IPs per safety boundary
|
||||||
|
# This script enforces localhost + file:// only.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../webapp"
|
||||||
|
|
||||||
|
echo "🔒 KiddOs v3 — LOCALHOST ONLY MODE"
|
||||||
|
echo " Allowed: http://localhost:5173, http://127.0.0.1:5173, file://dist/index.html"
|
||||||
|
echo " Blocked: any https:// external (scratch.mit.edu etc removed)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check no external URLs in actual code (ignore comments that say blocked)
|
||||||
|
if grep -R "https://scratch.mit.edu\|https://turbowarp\|https://machinelearningforkids" src/ --include="*.jsx" | grep -v "Blocked\|blocked\|❌" ; then
|
||||||
|
echo "❌ Found external URL in code - remove per safety boundary!"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ No external URLs in App code (localhost only lock OK)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kill old cage/cog if still from v2 test
|
||||||
|
sudo pkill -9 cage cog 2>/dev/null || true
|
||||||
|
sudo rm -f /run/user/1000/wayland-* 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Starting Vite dev server on localhost:5173..."
|
||||||
|
echo " Edit src/App.jsx -> DSI hot reloads in <1s (still localhost WebSocket)"
|
||||||
|
echo " DSI command (in another SSH):"
|
||||||
|
echo " sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- cog --platform=wl http://localhost:5173 --bg-color=#2E3440"
|
||||||
|
echo ""
|
||||||
|
./node_modules/.bin/vite --host 0.0.0.0 --port 5173
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Proven kiosk - LOCALHOST ONLY per safety boundary
|
||||||
|
set -e
|
||||||
|
MODE="${1:-dev}" # dev | prod | hello (hello only for local file test)
|
||||||
|
URL="http://localhost:5173"
|
||||||
|
BG="#2E3440"
|
||||||
|
|
||||||
|
if [ "$MODE" = "prod" ]; then
|
||||||
|
URL="file:///home/elias/KiddOsV3/webapp/dist/index.html"
|
||||||
|
elif [ "$MODE" = "hello" ]; then
|
||||||
|
URL="file:///home/elias/KiddOsV2/browser/hello.html"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Safety check: only allow localhost or file://
|
||||||
|
if [[ "$URL" != http://localhost* && "$URL" != http://127.0.0.1* && "$URL" != file://* ]]; then
|
||||||
|
echo "❌ Blocked: Only localhost:5173 or file:// allowed per parent boundary! Tried: $URL"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🔒 KiddOs v3 kiosk — localhost/file only"
|
||||||
|
echo " URL: $URL (allowed)"
|
||||||
|
echo " BG: $BG"
|
||||||
|
echo " Stopping old v2/v1 kiosks..."
|
||||||
|
sudo systemctl stop kiddos.service kiddos-browser.service journal-kiosk.service kiddos-v3.service 2>/dev/null || true
|
||||||
|
sudo pkill -9 cage cog 2>/dev/null || true
|
||||||
|
sudo rm -f /run/user/1000/wayland-* 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if [ "$MODE" = "dev" ]; then
|
||||||
|
if ! curl -s --connect-timeout 2 http://localhost:5173/ | grep -q "KiddOs" ; then
|
||||||
|
echo "⚠️ Vite dev server not running on :5173!"
|
||||||
|
echo " Start in another SSH: cd ~/KiddOsV3/webapp && npm run dev"
|
||||||
|
echo " Falling back to prod file://"
|
||||||
|
URL="file:///home/elias/KiddOsV3/webapp/dist/index.html"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Starting: cage -s -- cog --platform=wl $URL"
|
||||||
|
sudo openvt -f -c 1 -s -- env XDG_RUNTIME_DIR=/run/user/1000 cage -s -- cog --platform=wl "$URL" --bg-color="$BG"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Dev helper: run both vite + quick test
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/../webapp"
|
||||||
|
echo "Checking vite build..."
|
||||||
|
npm run build
|
||||||
|
echo "Build ok -> dist/"
|
||||||
|
echo ""
|
||||||
|
echo "Preview static build on http://localhost:4173 to test without HMR"
|
||||||
|
npm run preview -- --host 0.0.0.0 --port 4173 &
|
||||||
|
PREVIEW_PID=$!
|
||||||
|
echo "Preview PID $PREVIEW_PID"
|
||||||
|
sleep 2
|
||||||
|
echo ""
|
||||||
|
echo "Try kiosk with prod file:"
|
||||||
|
echo "cage -s -- cog --platform=wl file://$PWD/dist/index.html --bg-color=#2E3440"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop preview"
|
||||||
|
wait $PREVIEW_PID
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# KiddOs v3 React - Hot Reload
|
||||||
|
Run dev server:
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host 0.0.0.0 --port 5173
|
||||||
|
Then on Pi DSI:
|
||||||
|
cage -s -- cog --platform=wl http://localhost:5173 --bg-color=#2E3440
|
||||||
|
Or:
|
||||||
|
cage -s -- chromium --kiosk --ozone-platform=wayland http://localhost:5173
|
||||||
|
Edit src/App.jsx and see hot reload instantly on DSI.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
|
<title>KiddOs v3 React</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{background:#2E3440; color:#ECEFF4; font-family: -apple-system, sans-serif; overflow:hidden}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "kiddos-v3-react",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 402 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 402 B |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function Calculator(){
|
||||||
|
const [expr, setExpr] = useState('')
|
||||||
|
const [history, setHistory] = useState(()=> JSON.parse(localStorage.getItem('calc_history')||'[]'))
|
||||||
|
|
||||||
|
const press = (c)=> setExpr(e=>e+c)
|
||||||
|
|
||||||
|
const eq = ()=>{
|
||||||
|
try{
|
||||||
|
// safe-ish: only allow math chars
|
||||||
|
if(!/^[0-9+\-*/().% ]+$/.test(expr)) throw new Error('Invalid')
|
||||||
|
const res = String(Function('"use strict";return ('+expr+')')())
|
||||||
|
const entry = expr+' = '+res
|
||||||
|
const h = [entry, ...history].slice(0,20)
|
||||||
|
setHistory(h)
|
||||||
|
localStorage.setItem('calc_history', JSON.stringify(h))
|
||||||
|
setExpr(res)
|
||||||
|
}catch{
|
||||||
|
setExpr('Error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
[{l:'C', a:()=>setExpr(''), s:{background:'#D08770', color:'#000'}}, {l:'÷', a:()=>press('/'), s:{background:'#434C5E'}}, {l:'×', a:()=>press('*'), s:{background:'#434C5E'}}, {l:'⌫', a:()=>setExpr(e=>e.slice(0,-1)), s:{}}],
|
||||||
|
[{l:'7', a:()=>press('7')}, {l:'8', a:()=>press('8')}, {l:'9', a:()=>press('9')}, {l:'-', a:()=>press('-'), s:{background:'#434C5E'}}],
|
||||||
|
[{l:'4', a:()=>press('4')}, {l:'5', a:()=>press('5')}, {l:'6', a:()=>press('6')}, {l:'+', a:()=>press('+'), s:{background:'#434C5E'}}],
|
||||||
|
[{l:'1', a:()=>press('1')}, {l:'2', a:()=>press('2')}, {l:'3', a:()=>press('3')}, {l:'=', a:eq, s:{background:'#A3BE8C', color:'#000', fontWeight:'700'}}],
|
||||||
|
[{l:'0', a:()=>press('0'), span:2}, {l:'.', a:()=>press('.')}, {l:'(', a:()=>press('(')}],
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
|
||||||
|
<div style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'14px', padding:'16px'}}>
|
||||||
|
<div style={{width:'300px', background:'#232A36', border:'2px solid #4C566A', borderRadius:'16px', padding:'4px', display:'flex', flexDirection:'column', gap:'8px'}}>
|
||||||
|
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'14px 12px', fontSize:'26px', textAlign:'right', minHeight:'60px', fontVariantNumeric:'tabular-nums', wordBreak:'break-all', color:'#ECEFF4'}}>{expr||'0'}</div>
|
||||||
|
<div style={{display:'flex', flexDirection:'column', gap:'10px', padding:'8px'}}>
|
||||||
|
{buttons.map((row,i)=>(
|
||||||
|
<div key={i} style={{display:'grid', gridTemplateColumns:'repeat(4,1fr)', gap:'10px'}}>
|
||||||
|
{row.map((b,j)=>{
|
||||||
|
const style = {height:'56px', borderRadius:'12px', border:'1px solid #4C566A', background:'#3B4252', color:'#ECEFF4', fontSize:'20px', cursor:'pointer', gridColumn: b.span ? `span ${b.span}` : 'auto', ...(b.s||{})}
|
||||||
|
return <button key={j} onClick={b.a} style={style}>{b.l}</button>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8'}}>Touch friendly • Offline • localStorage history • React v3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{width:'30%', borderLeft:'1px solid #4C566A', background:'#232A36', display:'flex', flexDirection:'column'}}>
|
||||||
|
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<strong style={{fontSize:'13px'}}>History</strong>
|
||||||
|
<button onClick={()=>{setHistory([]); localStorage.removeItem('calc_history')}} style={{fontSize:'11px', padding:'4px 8px', borderRadius:'6px'}}>Clear</button>
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1, overflowY:'auto', padding:'8px', display:'flex', flexDirection:'column', gap:'6px'}}>
|
||||||
|
{history.length===0 && <div style={{fontSize:'11px', color:'#8E99A8'}}>No calculations yet</div>}
|
||||||
|
{history.map((h,i)=>(
|
||||||
|
<div key={i} onClick={()=>setExpr(h.split(' = ')[0])} style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
|
||||||
|
<div style={{fontSize:'12px', color:'#ECEFF4', wordBreak:'break-all'}}>{h}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function Clock(){
|
||||||
|
const [time, setTime] = useState(new Date())
|
||||||
|
const [timerSec, setTimerSec] = useState(0)
|
||||||
|
const [timerRunning, setTimerRunning] = useState(false)
|
||||||
|
const [mode, setMode] = useState('clock') // clock | timer | stopwatch
|
||||||
|
const [stopwatchMs, setStopwatchMs] = useState(0)
|
||||||
|
const [swRunning, setSwRunning] = useState(false)
|
||||||
|
const intervalRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
const t=setInterval(()=>setTime(new Date()),1000)
|
||||||
|
return ()=>clearInterval(t)
|
||||||
|
},[])
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
if(timerRunning && timerSec>0){
|
||||||
|
intervalRef.current=setInterval(()=>setTimerSec(s=>{ if(s<=1){ setTimerRunning(false); return 0 } return s-1 }),1000)
|
||||||
|
} else {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
return ()=>clearInterval(intervalRef.current)
|
||||||
|
},[timerRunning, timerSec])
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
if(!swRunning) return
|
||||||
|
const start = Date.now() - stopwatchMs
|
||||||
|
const id=setInterval(()=>setStopwatchMs(Date.now()-start), 50)
|
||||||
|
return ()=>clearInterval(id)
|
||||||
|
},[swRunning])
|
||||||
|
|
||||||
|
const fmt = (ms)=>{
|
||||||
|
const totalSec=Math.floor(ms/1000)
|
||||||
|
const m=Math.floor(totalSec/60)
|
||||||
|
const s=totalSec%60
|
||||||
|
const cs=Math.floor((ms%1000)/10)
|
||||||
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(cs).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
const fmtSec = (sec)=>{
|
||||||
|
const m=Math.floor(sec/60)
|
||||||
|
const s=sec%60
|
||||||
|
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', flexDirection:'column', height:'100%', background:'#2E3440', color:'#ECEFF4'}}>
|
||||||
|
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A'}}>
|
||||||
|
{['clock','timer','stopwatch'].map(m=>(
|
||||||
|
<button key={m} onClick={()=>setMode(m)} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: mode===m ? '#88C0D0' : '#434C5E', color: mode===m ? '#000':'#ECEFF4', fontWeight: mode===m?'600':'400'}}>{m}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:'20px'}}>
|
||||||
|
{mode==='clock' && (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize:'64px', fontWeight:'700'}}>{time.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</div>
|
||||||
|
<div style={{fontSize:'18px', color:'#8E99A8'}}>{time.toLocaleDateString(undefined,{weekday:'long', month:'long', day:'numeric'})}</div>
|
||||||
|
<div style={{fontSize:'14px', color:'#8E99A8', marginTop:'20px', textAlign:'center', maxWidth:'300px'}}>DSI 800x480 • Offline • cage + cog • React v3 hot reload works</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode==='timer' && (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize:'72px', fontWeight:'700', color: timerSec===0 && !timerRunning ? '#A3BE8C' : '#ECEFF4'}}>{fmtSec(timerSec)}</div>
|
||||||
|
<div style={{display:'flex', gap:'10px', flexWrap:'wrap', justifyContent:'center'}}>
|
||||||
|
{[1,3,5,10].map(m=>(
|
||||||
|
<button key={m} onClick={()=>{setTimerSec(m*60); setTimerRunning(false)}} style={{padding:'10px 16px', borderRadius:'10px', background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4'}}>{m}m</button>
|
||||||
|
))}
|
||||||
|
<button onClick={()=>{setTimerSec(s=>s+30)}} style={{padding:'10px 16px', borderRadius:'10px'}}>+30s</button>
|
||||||
|
</div>
|
||||||
|
<div style={{display:'flex', gap:'12px'}}>
|
||||||
|
<button onClick={()=>setTimerRunning(r=>!r)} style={{padding:'14px 32px', borderRadius:'12px', background: timerRunning?'#D08770':'#A3BE8C', color:'#000', border:'none', fontSize:'18px', fontWeight:'700'}}>{timerRunning?'Pause':'Start'}</button>
|
||||||
|
<button onClick={()=>{setTimerRunning(false); setTimerSec(0)}} style={{padding:'14px 20px', borderRadius:'12px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4'}}>Reset</button>
|
||||||
|
</div>
|
||||||
|
{timerSec===0 && !timerRunning && <div style={{fontSize:'20px'}}>🎉 Time's up!</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{mode==='stopwatch' && (
|
||||||
|
<>
|
||||||
|
<div style={{fontSize:'64px', fontWeight:'700', fontVariantNumeric:'tabular-nums'}}>{fmt(stopwatchMs)}</div>
|
||||||
|
<div style={{display:'flex', gap:'12px'}}>
|
||||||
|
<button onClick={()=>setSwRunning(r=>!r)} style={{padding:'14px 32px', borderRadius:'12px', background: swRunning?'#D08770':'#A3BE8C', color:'#000', border:'none', fontSize:'18px', fontWeight:'700'}}>{swRunning?'Stop':'Start'}</button>
|
||||||
|
<button onClick={()=>{setSwRunning(false); setStopwatchMs(0)}} style={{padding:'14px 20px', borderRadius:'12px', background:'#3B4252', border:'1px solid #4C566A'}}>Reset</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const SAMPLES = {
|
||||||
|
hello: `let name = "Elias";
|
||||||
|
console.log("Hey " + name + "!");
|
||||||
|
for(let i=1; i<=5; i++){
|
||||||
|
console.log(i + " x 7 = " + (i*7));
|
||||||
|
}
|
||||||
|
function rocket(n){
|
||||||
|
return "🚀".repeat(n) + " To the stars!";
|
||||||
|
}
|
||||||
|
console.log(rocket(5));`,
|
||||||
|
|
||||||
|
game: `// Tiny guessing game
|
||||||
|
let secret = Math.floor(Math.random()*10)+1;
|
||||||
|
let guesses = [];
|
||||||
|
for(let i=0;i<5;i++){
|
||||||
|
let g = Math.floor(Math.random()*10)+1;
|
||||||
|
guesses.push(g);
|
||||||
|
console.log("Guess " + g + (g===secret ? " 🎉 HIT!" : " ..nope"));
|
||||||
|
if(g===secret) break;
|
||||||
|
}
|
||||||
|
console.log("Secret was " + secret);`,
|
||||||
|
|
||||||
|
art: `// ASCII art loops
|
||||||
|
let w = 20;
|
||||||
|
for(let y=0;y<10;y++){
|
||||||
|
let line="";
|
||||||
|
for(let x=0;x<w;x++){
|
||||||
|
line += (x+y)%3===0 ? "█" : (x*y)%5===0 ? "▒" : " ";
|
||||||
|
}
|
||||||
|
console.log(line);
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodeLab(){
|
||||||
|
const [code, setCode] = useState(()=> localStorage.getItem('codelab_last') || SAMPLES.hello)
|
||||||
|
const [out, setOut] = useState('Click Run • Offline • React v3 hot reload \n\nEdit src/apps/CodeLab.jsx and DSI updates instantly!')
|
||||||
|
const [selectedSample, setSelectedSample] = useState('hello')
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
localStorage.setItem('codelab_last', code)
|
||||||
|
},[code])
|
||||||
|
|
||||||
|
const run=()=>{
|
||||||
|
let logs=[]
|
||||||
|
const customLog=(...a)=>logs.push(a.map(v=>{
|
||||||
|
if(typeof v==='object') return JSON.stringify(v, null, 2)
|
||||||
|
return String(v)
|
||||||
|
}).join(' '))
|
||||||
|
try{
|
||||||
|
const fn=new Function('console', '"use strict";\n'+code)
|
||||||
|
fn({log:customLog, warn:customLog, error:customLog})
|
||||||
|
setOut(logs.length ? logs.join('\\n') : '(no output — try console.log something)')
|
||||||
|
}catch(e){ setOut('Error: '+e.message+'\\n'+(e.stack||'')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
|
||||||
|
<div style={{flex:1, display:'flex', flexDirection:'column', padding:'8px', gap:'8px', borderRight:'1px solid #4C566A'}}>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<div style={{fontSize:'12px', color:'#8E99A8'}}>JS playground • offline • Vite HMR • Edit saves to localStorage</div>
|
||||||
|
<div style={{display:'flex', gap:'6px'}}>
|
||||||
|
{Object.keys(SAMPLES).map(k=>(
|
||||||
|
<button key={k} onClick={()=>{setSelectedSample(k); setCode(SAMPLES[k])}} style={{padding:'4px 8px', borderRadius:'6px', fontSize:'11px', border:'1px solid #4C566A', background: selectedSample===k ? '#88C0D0':'#3B4252', color: selectedSample===k ? '#000':'#ECEFF4'}}>{k}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={code}
|
||||||
|
onChange={e=>setCode(e.target.value)}
|
||||||
|
style={{flex:1, background:'#232A36', border:'1px solid #4C566A', borderRadius:'8px', color:'#ECEFF4', padding:'12px', fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize:'13px', lineHeight:'1.5', resize:'none', outline:'none'}}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{display:'flex', gap:'8px'}}>
|
||||||
|
<button onClick={run} style={{flex:1, background:'#A3BE8C', color:'#000', padding:'10px', borderRadius:'8px', border:'none', fontWeight:'700', fontSize:'14px', cursor:'pointer'}}>▶ Run (Ctrl+Enter)</button>
|
||||||
|
<button onClick={()=>setCode('')} style={{padding:'10px 16px', borderRadius:'8px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4'}}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{flex:1, borderLeft:'1px solid #4C566A', display:'flex', flexDirection:'column', background:'#0a0e14'}}>
|
||||||
|
<div style={{padding:'8px 10px', borderBottom:'1px solid #1E222E', display:'flex', justifyContent:'space-between', alignItems:'center', background:'#1E222E'}}>
|
||||||
|
<div style={{fontSize:'12px', color:'#8E99A8'}}>Output</div>
|
||||||
|
<button onClick={()=>setOut('')} style={{fontSize:'11px', padding:'3px 8px', borderRadius:'6px'}}>Clear</button>
|
||||||
|
</div>
|
||||||
|
<pre style={{flex:1, margin:0, padding:'12px', color:'#A3BE8C', fontSize:'13px', overflow:'auto', whiteSpace:'pre-wrap', wordBreak:'break-word', fontFamily:'ui-monospace, monospace'}}>{out}</pre>
|
||||||
|
<div style={{padding:'6px 10px', background:'#1E222E', borderTop:'1px solid #232A36', fontSize:'10px', color:'#4C566A'}}>Tip: This uses new Function(). Safe locally — no network. Try loops, functions, console.log. In v3, editing this file on laptop SSH hot-reloads on DSI in <1s via Vite.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function Drawing(){
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const [color, setColor] = useState('#88C0D0')
|
||||||
|
const [size, setSize] = useState(8)
|
||||||
|
const [tool, setTool] = useState('pen')
|
||||||
|
const drawing = useRef(false)
|
||||||
|
const history = useRef([])
|
||||||
|
const [showSaveOk, setShowSaveOk] = useState(false)
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
const c = canvasRef.current
|
||||||
|
const resize = ()=>{
|
||||||
|
const parent = c.parentElement
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
c.width = parent.clientWidth * dpr
|
||||||
|
c.height = parent.clientHeight * dpr
|
||||||
|
c.style.width = parent.clientWidth+'px'
|
||||||
|
c.style.height = parent.clientHeight+'px'
|
||||||
|
const ctx=c.getContext('2d')
|
||||||
|
ctx.scale(dpr,dpr)
|
||||||
|
ctx.fillStyle='#fff'
|
||||||
|
ctx.fillRect(0,0,parent.clientWidth,parent.clientHeight)
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
return ()=>window.removeEventListener('resize', resize)
|
||||||
|
},[])
|
||||||
|
|
||||||
|
const getCtx = ()=>{
|
||||||
|
const c = canvasRef.current
|
||||||
|
const ctx = c.getContext('2d')
|
||||||
|
// reset transform that was scaled
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = (e)=>{
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect()
|
||||||
|
const t = e.touches ? e.touches[0] : e
|
||||||
|
return {x:t.clientX-rect.left, y:t.clientY-rect.top}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveState = ()=>{
|
||||||
|
try{
|
||||||
|
history.current.push(canvasRef.current.toDataURL('image/png'))
|
||||||
|
if(history.current.length>25) history.current.shift()
|
||||||
|
// save thumbnail for files app preview?
|
||||||
|
localStorage.setItem('drawing_last', canvasRef.current.toDataURL('image/png'))
|
||||||
|
}catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (e)=>{
|
||||||
|
drawing.current=true
|
||||||
|
const {x,y}=pos(e)
|
||||||
|
const ctx=canvasRef.current.getContext('2d')
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x,y)
|
||||||
|
saveState()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (e)=>{
|
||||||
|
if(!drawing.current) return
|
||||||
|
const {x,y}=pos(e)
|
||||||
|
const ctx=canvasRef.current.getContext('2d')
|
||||||
|
ctx.lineTo(x,y)
|
||||||
|
ctx.strokeStyle= tool==='eraser' ? '#ffffff' : color
|
||||||
|
ctx.lineWidth=size
|
||||||
|
ctx.lineCap='round'
|
||||||
|
ctx.lineJoin='round'
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x,y)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = ()=>{
|
||||||
|
if(!drawing.current) return
|
||||||
|
drawing.current=false
|
||||||
|
const ctx=canvasRef.current.getContext('2d')
|
||||||
|
ctx.beginPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCanvas = ()=>{
|
||||||
|
saveState()
|
||||||
|
const c=canvasRef.current
|
||||||
|
const ctx=c.getContext('2d')
|
||||||
|
const rect=c.getBoundingClientRect()
|
||||||
|
ctx.fillStyle='#fff'
|
||||||
|
ctx.fillRect(0,0,rect.width,rect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = ()=>{
|
||||||
|
if(history.current.length===0) return
|
||||||
|
const data = history.current.pop()
|
||||||
|
const img=new Image()
|
||||||
|
img.src=data
|
||||||
|
img.onload=()=>{
|
||||||
|
const c=canvasRef.current
|
||||||
|
const ctx=c.getContext('2d')
|
||||||
|
const r=c.getBoundingClientRect()
|
||||||
|
ctx.clearRect(0,0,r.width,r.height)
|
||||||
|
ctx.drawImage(img,0,0,r.width,r.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveImg = ()=>{
|
||||||
|
const a=document.createElement('a')
|
||||||
|
a.download='kiddos-drawing-'+Date.now()+'.png'
|
||||||
|
a.href=canvasRef.current.toDataURL('image/png')
|
||||||
|
a.click()
|
||||||
|
setShowSaveOk(true)
|
||||||
|
setTimeout(()=>setShowSaveOk(false),2000)
|
||||||
|
// also save to localStorage for Files app
|
||||||
|
try{
|
||||||
|
localStorage.setItem('drawing_'+Date.now(), canvasRef.current.toDataURL())
|
||||||
|
}catch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', flexDirection:'column', height:'100%', overflow:'hidden'}}>
|
||||||
|
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A', alignItems:'center', flexWrap:'wrap'}}>
|
||||||
|
<input type="color" value={color} onChange={e=>setColor(e.target.value)} style={{height:'36px', width:'36px', borderRadius:'8px', padding:'0', border:'1px solid #4C566A'}}/>
|
||||||
|
<input type="range" min="2" max="40" value={size} onChange={e=>setSize(e.target.value)} style={{width:'80px'}}/>
|
||||||
|
<span style={{fontSize:'11px', color:'#8E99A8', minWidth:'26px'}}>{size}px</span>
|
||||||
|
<div style={{width:'1px', height:'24px', background:'#4C566A', margin:'0 4px'}}/>
|
||||||
|
<button onClick={()=>setTool('pen')} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: tool==='pen' ? '#88C0D0' : '#434C5E', color: tool==='pen' ? '#000' : '#ECEFF4'}}>✏ Pen</button>
|
||||||
|
<button onClick={()=>setTool('eraser')} style={{padding:'6px 12px', borderRadius:'8px', border:'1px solid #4C566A', background: tool==='eraser' ? '#D8DEE9' : '#434C5E', color: tool==='eraser' ? '#000' : '#ECEFF4'}}>🧽 Eraser</button>
|
||||||
|
<div style={{width:'1px', height:'24px', background:'#4C566A', margin:'0 4px'}}/>
|
||||||
|
<button onClick={undo} style={{padding:'6px 12px', borderRadius:'8px', background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4'}}>↩ Undo</button>
|
||||||
|
<button onClick={clearCanvas} style={{padding:'6px 12px', borderRadius:'8px', background:'#434C5E', border:'1px solid #4C566A', color:'#D08770'}}>Clear</button>
|
||||||
|
<button onClick={saveImg} style={{padding:'6px 14px', borderRadius:'8px', background:'#A3BE8C', border:'none', color:'#000', fontWeight:'700'}}>💾 Save</button>
|
||||||
|
{showSaveOk && <span style={{fontSize:'11px', color:'#A3BE8C'}}>Saved!</span>}
|
||||||
|
<div style={{marginLeft:'auto', fontSize:'11px', color:'#8E99A8'}}>Touch + mouse • 800×480 • localStorage</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{flex:1, background:'#777', position:'relative', overflow:'hidden'}}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={start} onMouseMove={move} onMouseUp={end} onMouseLeave={end}
|
||||||
|
onTouchStart={start} onTouchMove={move} onTouchEnd={end}
|
||||||
|
style={{display:'block', background:'#fff', touchAction:'none', cursor:'crosshair', width:'100%', height:'100%'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function Files(){
|
||||||
|
const [entries, setEntries] = useState([])
|
||||||
|
const [selected, setSelected] = useState(null)
|
||||||
|
const [preview, setPreview] = useState('')
|
||||||
|
const [dash, setDash] = useState('')
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_')).sort().reverse()
|
||||||
|
setEntries(keys.map(k=>({id:k, text: localStorage.getItem(k)})))
|
||||||
|
const d = localStorage.getItem('kiddos_dashboard_fallback')
|
||||||
|
if(d) setDash(d)
|
||||||
|
// Try companion files via fetch if served from same origin? On dev we can't read file://, so show localStorage only.
|
||||||
|
// If user wants real files, they can cat ~/journal_entries via terminal.
|
||||||
|
},[])
|
||||||
|
|
||||||
|
const open = (e)=>{
|
||||||
|
setSelected(e)
|
||||||
|
setPreview(e.text.slice(0,2000))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
|
||||||
|
<div style={{width:'38%', borderRight:'1px solid #4C566A', display:'flex', flexDirection:'column'}}>
|
||||||
|
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', background:'#3B4252'}}>
|
||||||
|
<strong style={{fontSize:'14px'}}>📁 Files • {entries.length} journal entries</strong>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8', marginTop:'2px'}}>localStorage • Offline • Private</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1, overflowY:'auto', padding:'6px', display:'flex', flexDirection:'column', gap:'6px'}}>
|
||||||
|
<div style={{background:'#232A36', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px'}}>
|
||||||
|
<div style={{fontSize:'12px', fontWeight:'600'}}>journal_entries/ (local)</div>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8'}}>{entries.length} files • plain text</div>
|
||||||
|
</div>
|
||||||
|
{entries.map(e=>{
|
||||||
|
const wordCount = e.text.trim().split(/\s+/).filter(Boolean).length
|
||||||
|
return (
|
||||||
|
<div key={e.id} onClick={()=>open(e)} style={{background: selected?.id===e.id ? '#434C5E' : '#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
|
||||||
|
<div style={{fontSize:'12px', fontWeight:'600', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}}>{e.id}</div>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8'}}>{wordCount} words • {e.text.slice(0,40)}...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{entries.length===0 && <div style={{fontSize:'12px', color:'#8E99A8', padding:'12px'}}>No entries yet. Go write in Journal!</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{flex:1, padding:'12px', display:'flex', flexDirection:'column', gap:'8px', overflow:'hidden'}}>
|
||||||
|
{!selected ? (
|
||||||
|
<div style={{display:'flex', flexDirection:'column', gap:'12px'}}>
|
||||||
|
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'12px'}}>
|
||||||
|
<h3 style={{fontSize:'14px', marginBottom:'6px'}}>💾 Your creations are local</h3>
|
||||||
|
<p style={{fontSize:'12px', color:'#D8DEE9', lineHeight:'1.4'}}>All journal entries live in browser localStorage in v3. In v2 they were in ~/KiddOsV2/data/journal/*.json plain files. Try TTY2: <code style={{background:'#000', padding:'2px 4px', borderRadius:'4px'}}>ls ~/journal_entries/</code></p>
|
||||||
|
</div>
|
||||||
|
<div style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'12px', padding:'12px'}}>
|
||||||
|
<h3 style={{fontSize:'14px', marginBottom:'6px'}}>Companion dashboard (if available)</h3>
|
||||||
|
<p style={{fontSize:'11px', color:'#8E99A8'}}>The companion writes ~/elias_companion/dashboard.md counts only, no text leaks.</p>
|
||||||
|
<pre style={{background:'#000', color:'#0f0', padding:'8px', borderRadius:'6px', fontSize:'10px', maxHeight:'200px', overflow:'auto', marginTop:'6px'}}>{dash || 'Open dashboard.md in TTY2: cat ~/elias_companion/dashboard.md'}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<strong style={{fontSize:'13px'}}>{selected.id}</strong>
|
||||||
|
<button onClick={()=>setSelected(null)} style={{padding:'4px 10px', borderRadius:'6px'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
<pre style={{flex:1, background:'#434C5E', border:'1px solid #4C566A', borderRadius:'8px', padding:'10px', overflow:'auto', whiteSpace:'pre-wrap', fontSize:'13px', color:'#ECEFF4'}}>{preview}</pre>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8'}}>{selected.text.length} chars</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function Journal(){
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [list, setList] = useState([])
|
||||||
|
const [status, setStatus] = useState('Auto-save on')
|
||||||
|
const [currentId, setCurrentId] = useState(null)
|
||||||
|
const [companionMsg, setCompanionMsg] = useState('')
|
||||||
|
const saveTimer = useRef(null)
|
||||||
|
|
||||||
|
const loadList = ()=>{
|
||||||
|
const keys = Object.keys(localStorage).filter(k=>k.startsWith('journal_')).sort().reverse()
|
||||||
|
setList(keys.map(k=>{
|
||||||
|
const t = localStorage.getItem(k) || ''
|
||||||
|
return {id:k, date: k.replace('journal_','').slice(0,16), preview: t.slice(0,80), full:t, words: t.trim().split(/\s+/).filter(Boolean).length}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
loadList()
|
||||||
|
const today = 'journal_'+new Date().toISOString().slice(0,10)
|
||||||
|
const legacy = Object.keys(localStorage).filter(k=>k.startsWith('journal_') && k.includes(new Date().toISOString().slice(0,10))).sort().reverse()[0]
|
||||||
|
const key = legacy || today
|
||||||
|
if(localStorage.getItem(key)) {
|
||||||
|
setText(localStorage.getItem(key))
|
||||||
|
setCurrentId(key)
|
||||||
|
setStatus('Restored today • '+localStorage.getItem(key).split(/\s+/).filter(Boolean).length+' words')
|
||||||
|
}
|
||||||
|
// Try load companion message (from dashboard if fetched? local fallback)
|
||||||
|
const msg = localStorage.getItem('kiddos_companion_message') || ''
|
||||||
|
if(msg) setCompanionMsg(msg)
|
||||||
|
},[])
|
||||||
|
|
||||||
|
const save = (forceText)=>{
|
||||||
|
const content = typeof forceText === 'string' ? forceText : text
|
||||||
|
if(!content.trim()) return
|
||||||
|
const id = currentId || 'journal_'+new Date().toISOString()
|
||||||
|
localStorage.setItem(id, content)
|
||||||
|
localStorage.setItem('journal_'+new Date().toISOString().slice(0,10), content)
|
||||||
|
localStorage.setItem('journal_'+new Date().toISOString().slice(0,10)+'_latest', content)
|
||||||
|
setCurrentId(id)
|
||||||
|
setStatus('Saved '+new Date().toLocaleTimeString()+' • '+content.trim().split(/\s+/).filter(Boolean).length+' words')
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = (v)=>{
|
||||||
|
setText(v)
|
||||||
|
setStatus('Typing... '+v.trim().split(/\s+/).filter(Boolean).length+' words')
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
saveTimer.current = setTimeout(()=>save(v), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry = ()=>{
|
||||||
|
if(text.trim()) save()
|
||||||
|
setText('')
|
||||||
|
setCurrentId(null)
|
||||||
|
setStatus('New adventure!')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', height:'100%', background:'#2E3440', overflow:'hidden'}}>
|
||||||
|
<div style={{width:'34%', borderRight:'1px solid #4C566A', display:'flex', flexDirection:'column', background:'#232A36'}}>
|
||||||
|
<div style={{padding:'10px', borderBottom:'1px solid #4C566A', display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<strong style={{fontSize:'13px'}}>📓 Past • {list.length}</strong>
|
||||||
|
<button onClick={loadList} style={{padding:'4px 10px', borderRadius:'6px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', cursor:'pointer'}}>↻</button>
|
||||||
|
</div>
|
||||||
|
{companionMsg && (
|
||||||
|
<div style={{margin:'8px', background:'#3B4252', border:'1px solid #EBCB8B', borderRadius:'8px', padding:'8px', fontSize:'11px'}}>
|
||||||
|
<div style={{fontSize:'10px', color:'#EBCB8B', fontWeight:'700', marginBottom:'2px'}}>COMPANION</div>
|
||||||
|
<div style={{color:'#D8DEE9'}}>{companionMsg}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{flex:1, overflowY:'auto', padding:'6px', display:'flex', flexDirection:'column', gap:'6px'}}>
|
||||||
|
<button onClick={newEntry} style={{padding:'10px', borderRadius:'8px', background:'#88C0D0', border:'none', color:'#000', fontWeight:'700', cursor:'pointer', fontSize:'13px'}}>+ New Entry</button>
|
||||||
|
{list.map(item=>(
|
||||||
|
<div key={item.id} onClick={()=>{ setText(item.full); setCurrentId(item.id); setStatus('Reading • '+item.words+' words'); }} style={{background: currentId===item.id ? '#434C5E' : '#3B4252', border:`1px solid ${currentId===item.id ? '#88C0D0' : '#4C566A'}`, borderRadius:'8px', padding:'8px', cursor:'pointer'}}>
|
||||||
|
<div style={{fontSize:'11px', fontWeight:'600', display:'flex', justifyContent:'space-between'}}><span>{item.date}</span><span style={{color:'#88C0D0'}}>{item.words}w</span></div>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8', marginTop:'3px', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis'}}>{item.preview}...</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{list.length===0 && <div style={{fontSize:'11px', color:'#8E99A8', padding:'12px', textAlign:'center'}}>No stories yet. Your first adventure starts here! ✨<br/><br/>Tip: Auto-saves while you type. Offline, private.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{flex:1, display:'flex', flexDirection:'column', padding:'12px', gap:'8px', background:'#2E3440'}}>
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<div style={{fontSize:'13px', color:'#D8DEE9'}}>What's on your mind today, explorer? <span style={{color:'#8E99A8', fontSize:'11px'}}>• React + localStorage + hot reload</span></div>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8'}}>{status}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e=>onChange(e.target.value)}
|
||||||
|
placeholder={"Hey superstar! What adventure did you have today?\n\n- What made you smile?\n- What did you learn?\n- What do you want to build tomorrow?\n\nYour journal is private, offline, and stays on this Pi. No internet needed.\n\nStart typing... you got this! 💪"}
|
||||||
|
style={{flex:1, background:'#434C5E', border:'1px solid #4C566A', borderRadius:'12px', color:'#ECEFF4', padding:'14px', fontSize:'15px', lineHeight:'1.5', resize:'none', outline:'none', fontFamily:'system-ui, sans-serif'}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||||
|
<span style={{fontSize:'11px', color:'#8E99A8'}}>{text.trim().split(/\s+/).filter(Boolean).length} words • {text.length} chars • auto-save 1.5s</span>
|
||||||
|
<div style={{display:'flex', gap:'8px'}}>
|
||||||
|
<button onClick={()=>{setText(''); setCurrentId(null)}} style={{padding:'8px 14px', borderRadius:'8px', background:'#3B4252', border:'1px solid #4C566A', color:'#ECEFF4', cursor:'pointer', fontSize:'12px'}}>Clear</button>
|
||||||
|
<button onClick={()=>save()} style={{padding:'8px 16px', borderRadius:'8px', background:'#88C0D0', border:'none', color:'#000', fontWeight:'700', cursor:'pointer', fontSize:'13px'}}>💾 Save now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const BLOCKS = [
|
||||||
|
{emoji:'🚀', name:'Move 10', code:'x += 10;'},
|
||||||
|
{emoji:'🔁', name:'Repeat', code:'for(let i=0;i<4;i++){\n console.log("step "+i);\n}'},
|
||||||
|
{emoji:'🎨', name:'Draw', code:'ctx.fillStyle="#88C0D0";\nctx.fillRect(x,y,40,40);'},
|
||||||
|
{emoji:'🔊', name:'Say', code:'console.log("Hello!");'},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Scratch(){
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [log, setLog] = useState(['Scratch offline • No internet • Local only','Drag blocks is todo — for now edit JS below'])
|
||||||
|
const [code, setCode] = useState(`// Offline Scratch-like playground\n// No external URLs, localhost only\nlet x=20, y=20;\nfunction drawBlock(){\n console.log("Drawing at "+x+","+y);\n x+=30;\n}\nfor(let i=0;i<5;i++) drawBlock();\nconsole.log("Done! Try editing.\n");`)
|
||||||
|
|
||||||
|
const run = ()=>{
|
||||||
|
setRunning(true)
|
||||||
|
const out=[]
|
||||||
|
const c = {log: (...a)=> out.push(a.join(' '))}
|
||||||
|
try{
|
||||||
|
const fn = new Function('console','x','y','ctx','out', code+'\nreturn out;')
|
||||||
|
// dummy ctx for offline
|
||||||
|
const dummyCtx = {fillStyle:'', fillRect:()=>{}}
|
||||||
|
fn(c, 20,20, dummyCtx, out)
|
||||||
|
setLog([`▶ Ran at ${new Date().toLocaleTimeString()}`, ...out])
|
||||||
|
}catch(e){
|
||||||
|
setLog(['Error: '+e.message])
|
||||||
|
}
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex', height:'100%', background:'#2E3440'}}>
|
||||||
|
<div style={{width:'32%', background:'#232A36', borderRight:'1px solid #4C566A', padding:'8px', display:'flex', flexDirection:'column', gap:'8px'}}>
|
||||||
|
<div style={{fontSize:'12px', fontWeight:'700', color:'#88C0D0'}}>🧱 Blocks • Offline only</div>
|
||||||
|
<div style={{fontSize:'10px', color:'#8E99A8'}}>No internet • localhost only • file:// only • Parent boundary respected</div>
|
||||||
|
{BLOCKS.map(b=>(
|
||||||
|
<div key={b.name} style={{background:'#3B4252', border:'1px solid #4C566A', borderRadius:'8px', padding:'8px', display:'flex', gap:'8px', alignItems:'center'}}>
|
||||||
|
<span style={{fontSize:'20px'}}>{b.emoji}</span>
|
||||||
|
<div style={{flex:1}}>
|
||||||
|
<div style={{fontSize:'12px', fontWeight:'600'}}>{b.name}</div>
|
||||||
|
<div style={{fontSize:'10px', color:'#8E99A8', fontFamily:'monospace'}}>{b.code.slice(0,20)}...</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={()=>setCode(c=>c+'\n'+b.code)} style={{padding:'4px 8px', borderRadius:'6px', fontSize:'11px'}}>Add</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{marginTop:'auto', background:'#3B4252', border:'1px dashed #4C566A', borderRadius:'8px', padding:'8px', fontSize:'11px', color:'#8E99A8'}}>
|
||||||
|
Future: real block drag-and-drop using Blockly local files (no CDN). Current: JS playground, offline.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flex:1, display:'flex', flexDirection:'column'}}>
|
||||||
|
<div style={{display:'flex', gap:'8px', padding:'8px', background:'#3B4252', borderBottom:'1px solid #4C566A'}}>
|
||||||
|
<button onClick={run} disabled={running} style={{background:'#A3BE8C', color:'#000', border:'none', padding:'8px 16px', borderRadius:'8px', fontWeight:'700'}}>{running?'Running...':'▶ Run'}</button>
|
||||||
|
<button onClick={()=>setCode('')} style={{background:'#434C5E', border:'1px solid #4C566A', color:'#ECEFF4', padding:'8px 12px', borderRadius:'8px'}}>Clear</button>
|
||||||
|
<span style={{fontSize:'11px', color:'#8E99A8', alignSelf:'center', marginLeft:'8px'}}>Offline • No external IP • Vite HMR localhost:5173 only</span>
|
||||||
|
</div>
|
||||||
|
<textarea value={code} onChange={e=>setCode(e.target.value)} style={{flex:1, background:'#1E222E', color:'#ECEFF4', border:'none', padding:'12px', fontFamily:'monospace', fontSize:'13px', resize:'none', outline:'none'}} spellCheck={false}/>
|
||||||
|
<div style={{height:'32%', background:'#000', borderTop:'1px solid #4C566A', padding:'8px', overflow:'auto'}}>
|
||||||
|
<div style={{fontSize:'11px', color:'#8E99A8', marginBottom:'4px'}}>Output:</div>
|
||||||
|
{log.map((l,i)=><div key={i} style={{fontFamily:'monospace', fontSize:'12px', color:'#A3BE8C', whiteSpace:'pre-wrap'}}>{l}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #2E3440;
|
||||||
|
--bg-deep: #232A36;
|
||||||
|
--header: #1E222E;
|
||||||
|
--surface: #3B4252;
|
||||||
|
--surface-light: #434C5E;
|
||||||
|
--border: #4C566A;
|
||||||
|
--text: #ECEFF4;
|
||||||
|
--text-sec: #D8DEE9;
|
||||||
|
--text-muted: #8E99A8;
|
||||||
|
--accent-blue: #5E81AC;
|
||||||
|
--accent-cyan: #88C0D0;
|
||||||
|
--accent-green: #A3BE8C;
|
||||||
|
--accent-yellow: #EBCB8B;
|
||||||
|
--accent-purple: #9D7BD8;
|
||||||
|
--accent-orange: #D08770;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin:0; padding:0; height:100%; overflow:hidden; }
|
||||||
|
body { background:var(--bg); color:var(--text); font-family: system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; -webkit-tap-highlight-color: transparent; }
|
||||||
|
button { font-family: inherit; cursor:pointer; user-select:none; }
|
||||||
|
button:active { transform: scale(0.97); }
|
||||||
|
::-webkit-scrollbar { width:6px; height:6px; }
|
||||||
|
::-webkit-scrollbar-thumb { background:#4C566A; border-radius:3px; }
|
||||||
|
::-webkit-scrollbar-track { background:transparent; }
|
||||||
|
|
||||||
|
/* 800x480 specific */
|
||||||
|
@media (max-width: 800px), (max-height: 480px) {
|
||||||
|
header { height:48px !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
hmr: {
|
||||||
|
clientPort: 5173,
|
||||||
|
// overlay shows errors on DSI too
|
||||||
|
overlay: true
|
||||||
|
},
|
||||||
|
cors: true
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 4173
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
// For file:// fallback if needed, built files use relative paths
|
||||||
|
base: './'
|
||||||
|
})
|
||||||