Files
playable-showcase/DEVLOG.md
HPL-JesusCastro 5d6f228224 Initial Commit
2026-05-26 16:04:14 +08:00

266 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Playable Showcase — Dev Log
## Project Overview
Internal tool for uploading, previewing, and organizing HTML playable ads.
Single-file HTML playables are uploaded, stored on disk, and served via Express.
React SPA (Vite) for the frontend. JSON file-based DB (no SQL).
**Stack:** Node.js + Express · React + Vite · React Router DOM · Multer · Puppeteer
---
## Architecture
```
PlayableShowcase/
├── server/
│ ├── index.js — Express server (port 3000), serves /public and /uploads
│ ├── db.js — JSON file-based database (ads + apps)
│ ├── routes/
│ │ ├── ads.js — GET/PATCH /api/ads/:id, POST /api/ads/:id/screenshot
│ │ ├── upload.js — POST /api/upload (multer, creates ad record)
│ │ └── apps.js — CRUD /api/apps, GET /api/apps/lookup
│ └── utils/
│ └── screenshot.js — Puppeteer headless screenshot utility
├── client/
│ ├── vite.config.js — outDir: '../public' (matches Express static path)
│ └── src/
│ ├── App.jsx — Routes: / (Catalog), /apps, /viewer/:id
│ ├── api.js — All fetch calls to Express API
│ ├── components/
│ │ ├── AppLayout.jsx/css — Shared sidebar shell + mobile hamburger
│ │ ├── AdCard.jsx/css — Catalog grid card (thumbnail + app icon)
│ │ ├── AppModal.jsx — Create/edit application modal (shared)
│ │ ├── MetadataModal.jsx — Edit playable metadata in Viewer
│ │ └── UploadPreviewModal.jsx/css — Upload flow modal
│ └── pages/
│ ├── Catalog.jsx/css — Main grid + sidebar with search/filter
│ ├── Viewer.jsx/css — Playable preview with device frame
│ └── Apps.jsx/css — Application management page
├── uploads/ — Uploaded HTML files + thumbnail PNGs
└── public/ — Vite build output (served by Express)
```
---
## Features Implemented
### Catalog (`/`)
- Grid of AdCards with thumbnails, app icon, title, app name
- Sidebar: brand, upload button (hidden file input → UploadPreviewModal), app filter, search
- Search filters by: title, developer, app name, tags
- Apps sorted alphabetically in filter list
### Viewer (`/viewer/:id`)
- Device frame (phone/tablet/desktop) with scale-to-fit
- Rotate frame button
- Manual screenshot button (server-side Puppeteer)
- Auto-screenshot on iframe load if no thumbnail exists
- Sidebar: playable info, app icon + store links, repo@branch, tags, frame controls, edit button
- MetadataModal for editing: title, app, tags, developer, repo, branch, notes, replace HTML file
### Apps (`/apps`)
- List all applications with icon, name, store links
- Create/edit/delete apps
- App Store / Play Store URL auto-fill (lookupApp API)
### Upload Flow
- Upload HTML → UploadPreviewModal opens
- Left: live iframe preview of the playable
- Right: metadata form (title, app, tags, developer, repo, branch, notes)
- "Take Screenshot" button (required before Save) — calls Puppeteer server-side
- "New Application" inline button opens AppModal without leaving the flow
- Screenshot taken via server-side Puppeteer (not client-side canvas capture)
### Mobile
- Sidebar collapses to hamburger menu at ≤680px
- Overlay closes sidebar when tapped
- Upload preview frame uses aspect-ratio: 9/19.5
---
## Key Technical Decisions
### Screenshot: Server-side Puppeteer (not client-side html2canvas)
Client-side approaches (html2canvas, canvas.toDataURL) were tried and failed for various reasons:
- html2canvas can't access iframe CSS from parent window context → HTML overlays invisible
- WebGL `preserveDrawingBuffer: false` → canvas.toDataURL() always returns blank
- Composite-canvas fallback only captures WebGL, misses HTML UI layers
**Decision:** All screenshots go through `POST /api/ads/:id/screenshot` which runs Puppeteer.
Puppeteer captures the GPU-composited output, getting WebGL + HTML overlays correctly.
### Vite Build Output
`vite.config.js` sets `build: { outDir: '../public', emptyOutDir: true }`.
Express serves static files from `./public`. Without this, Vite outputs to `client/dist`
and Express throws `ENOENT: no such file or directory, stat '.../public/index.html'`.
### AdCard Thumbnail Display
`.ad-card__thumb` uses `aspect-ratio: 9 / 19.5` to match the 390×844 Puppeteer output.
`object-fit: cover` fills the container cleanly since both are the same ratio.
### AppModal Shared Component
AppModal was extracted from Apps.jsx into `components/AppModal.jsx` so it can be used
by both Apps.jsx (edit apps) and UploadPreviewModal.jsx (create app inline during upload).
Imports styles from `'../pages/Apps.css'` (note the relative path from components/).
### Sidebar Layout Pattern
All pages use `AppLayout` + consistent CSS class names:
- `.sb-brand` — top brand name
- `.sb-nav` — nav button area
- `.sb-btn` — sidebar button
- `.sb-divider` — horizontal rule
- `.sb-section-label` — uppercase section label
- `.viewer__sb-info` — padded content block inside a section
---
## Outstanding Issues
### 1. White Screenshots — Unity WebGL (UNRESOLVED)
**Symptom:** Clicking "Take Screenshot" on Unity WebGL playables returns a white/blank image.
**Root cause analysis:**
Unity WebGL uses `preserveDrawingBuffer: false` by default.
This means after the GPU compositor reads the WebGL framebuffer to display the frame,
the buffer is cleared. `canvas.toDataURL()` called between frames returns blank.
**Fixes attempted:**
1. `html2canvas` with frozen canvas data — failed (doesn't capture HTML overlays)
2. Canvas composite fallback — failed (same issue)
3. Puppeteer with `networkidle2` + 1500ms wait — failed (Unity initializes after network idle)
4. Puppeteer with `waitForFunction` polling `canvas.toDataURL()` — failed (still blank due to preserveDrawingBuffer)
5. **`evaluateOnNewDocument` monkey-patch to force `preserveDrawingBuffer: true`** — partial
- Monkey-patches `HTMLCanvasElement.prototype.getContext` before any page scripts run
- Forces `{ preserveDrawingBuffer: true }` on all webgl/webgl2 contexts
- Changed `waitUntil: 'networkidle2'``'load'` because Unity's fetch requests
keep the network permanently busy, causing networkidle2 timeout on large files
- Test script confirmed this works in isolation (saw "PL Island House" 3D screenshot)
- **But user still reports white screenshots in the actual app**
**Suspected remaining cause:**
The server process was NOT restarted after screenshot.js was changed.
Node.js caches `require()`d modules — the old code runs until the server restarts.
Or: there are additional Unity playables with different initialization patterns.
**What to try next:**
- Restart the server (`Ctrl+C` then `node index.js` or `npm start` in `/server`)
- If still white: add console logging to screenshot.js to see what `waitForFunction` detects
- Consider: `page.waitForFunction` might not see `window.unityInstance` if Unity sets it
as a local variable (not on window) — check the actual Unity HTML template used
- Nuclear option: after canvas polling resolves, take 3 screenshots 500ms apart and
keep the largest (most content = most bytes)
**Current screenshot.js logic (server/utils/screenshot.js):**
```js
// 1. Inject monkey-patch BEFORE page scripts run
await page.evaluateOnNewDocument(() => {
const orig = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true });
}
return orig.call(this, type, attrs);
};
});
// 2. Navigate (load, not networkidle2)
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
// 3. Poll canvas for non-blank pixels (up to 20s)
await page.waitForFunction(() => {
const canvases = Array.from(document.querySelectorAll('canvas'))
.filter(c => c.width > 0 && c.height > 0);
return canvases.some(c => {
try {
const d = c.toDataURL('image/png');
return (d.split(',')[1] || '').length > 2000;
} catch { return false; }
});
}, { timeout: 20000, polling: 500 });
// 4. Settle 800ms, then screenshot
await new Promise(r => setTimeout(r, 800));
const buffer = await page.screenshot({ type: 'png' });
```
---
### 2. Simulated Device Too Small on Mobile (UNRESOLVED)
**Symptom:** On mobile viewports, the device frame in Viewer is very small — doesn't fill the available space.
**Root cause:**
`.viewer__stage` is a flex child of `.app-layout__main` which is `display: flex; flex-direction: column`.
The stage needs `flex: 1; min-height: 0` to grow and fill the column.
`min-height: 0` is required because flex children default to `min-height: auto`,
preventing them from shrinking below their content size.
**Fix was applied** (Viewer.css sets `flex: 1; min-height: 0` on `.viewer__stage`)
but mobile may still show the device too small because:
- On mobile, the hamburger bar takes 56px at the top (`padding-top: 56px` on `.app-layout__main`)
- The scale calculation in Viewer.jsx uses `stageDims` from a ResizeObserver
- If ResizeObserver fires before layout settles, `stageDims` could be stale
- The scale formula: `Math.min(1, (sw - 32) / fw, (sh - 32) / fh)` where fw/fh are frame dimensions
**What to try next:**
- Verify `.viewer__stage` actually has `flex: 1; min-height: 0` in the built CSS
(run `npm run build` in `/client` and check `/public/assets/index-*.css`)
- On mobile, check if `stageDims.height` reflects the full available height minus hamburger
- The `DeviceFrame` component's `getFrameSize(frame, rotated)` returns fixed pixel dimensions —
if the stage is e.g. 400px tall on mobile but the frame is 844px, scale = 400/844 ≈ 0.47
which might feel too small. Consider increasing margin or adjusting min-scale.
---
## Playable Fields (DB Schema)
Each ad record:
```json
{
"id": "uuid",
"filename": "uuid.html",
"title": "string",
"appId": "uuid (ref to apps)",
"tags": ["string"],
"developer": "string",
"repo": "string",
"branch": "string",
"notes": "string",
"thumbnail": "uuid-thumb.png",
"uploaded_at": 1234567890,
"updated_at": 1234567890
}
```
Each app record:
```json
{
"id": "uuid",
"name": "string",
"ios_url": "string",
"android_url": "string",
"icon_url": "string"
}
```
---
## Dev Commands
```bash
# Server (from /server)
node index.js
# or if package.json has start script:
npm start
# Client dev server (from /client)
npm run dev
# Client production build (from /client)
npm run build
# Output goes to /public (Express static root)
```