feat(demo): public multi-visitor demo sandbox for Portainer
Turn the mock backend + UI into a public, click-to-play demo deployable as a
Portainer stack, gated behind DEMO=1 (classic single-user mock unchanged when off).
Backend (neode-ui/mock-backend.js):
- Per-session state isolation via AsyncLocalStorage + Proxy: every visitor gets
an isolated, deep-cloned copy of mockData/walletState/userState/etc., keyed by
a demo_sid cookie. Per-session WebSocket fan-out, idle reaper, session cap.
- Real per-session file storage (upload/folder/rename/delete) with a 50MB quota,
replacing the no-op filebrowser handlers; adds the missing app.filebrowser-token RPC.
- Force simulation mode (never touch a host Docker/Podman socket).
- Testnet (signet) flavor; shared login password "entertoexit".
- Report the real app version suffixed with -demo.
Frontend:
- VITE_DEMO build flag (useDemoIntro.ts): replay the intro once per calendar day
per browser; prefill + show the "entertoexit" login hint.
Deploy:
- docker-compose.demo.yml wired for DEMO, UI on :2100 (build-from-repo).
- demo-deploy/ thin stack (prebuilt :demo image refs + .env.example + README).
- .github/workflows/demo-images.yml builds/pushes archy-demo-{web,backend} images.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
29cd167894
commit
2715f2d847
67
.github/workflows/demo-images.yml
vendored
Normal file
67
.github/workflows/demo-images.yml
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
name: Demo images
|
||||
|
||||
# Builds and pushes the public-demo images on every change to the UI / mock
|
||||
# backend, so the separated `archy-demo` Portainer stack auto-tracks the real
|
||||
# code (see demo-deploy/ and docs/demo-deployment-design.md).
|
||||
#
|
||||
# Required repo configuration:
|
||||
# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025
|
||||
# secrets.DEMO_REGISTRY_USER
|
||||
# secrets.DEMO_REGISTRY_TOKEN
|
||||
# Optional:
|
||||
# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push demo images
|
||||
runs-on: ubuntu-latest
|
||||
# Skip cleanly on forks / before registry config is set.
|
||||
if: ${{ vars.DEMO_REGISTRY != '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}
|
||||
username: ${{ secrets.DEMO_REGISTRY_USER }}
|
||||
password: ${{ secrets.DEMO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build & push backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.backend
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }}
|
||||
|
||||
- name: Build & push web
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: neode-ui/Dockerfile.web
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
18
demo-deploy/.env.example
Normal file
18
demo-deploy/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
# Copy to .env and adjust. Used by demo-deploy/docker-compose.yml.
|
||||
|
||||
# Registry host + namespace that holds the prebuilt demo images.
|
||||
REGISTRY=146.59.87.168:3000/lfg2025
|
||||
|
||||
# Image tag to deploy (CI publishes :demo and :<git-sha>).
|
||||
IMAGE_TAG=demo
|
||||
|
||||
# Host port for the demo UI.
|
||||
DEMO_WEB_PORT=2100
|
||||
|
||||
# Optional — enables the in-app AI chat panel. Leave blank to disable.
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# Optional sandbox tuning (defaults shown).
|
||||
DEMO_SESSION_TTL_MS=2700000 # 45 min idle before a visitor session is reaped
|
||||
DEMO_MAX_SESSIONS=500 # concurrent visitor cap
|
||||
DEMO_FILE_QUOTA_BYTES=52428800 # 50 MB uploads per visitor
|
||||
33
demo-deploy/README.md
Normal file
33
demo-deploy/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# Archipelago — Public Demo deploy
|
||||
|
||||
A click-to-play demo of the Archipelago UI, backed entirely by a mock backend.
|
||||
Every visitor gets an **isolated, ephemeral sandbox** (own apps, wallet, files),
|
||||
real container runtimes are never touched, and Bitcoin runs on **signet** test
|
||||
coins. **Login password: `entertoexit`** (shown on the login screen).
|
||||
|
||||
This directory is the full contents of the public `archy-demo` repo. It holds no
|
||||
source — only this compose file that pulls prebuilt `:demo` images.
|
||||
|
||||
## Deploy in Portainer
|
||||
|
||||
1. **Stacks → Add stack → Repository** (or paste `docker-compose.yml` into the web editor).
|
||||
2. Set environment variables (see `.env.example`) — at minimum `REGISTRY`, and
|
||||
`ANTHROPIC_API_KEY` if you want the AI chat panel.
|
||||
3. Deploy. The UI is served on `:2100` (override with `DEMO_WEB_PORT`).
|
||||
|
||||
To pick up a new build, redeploy the stack (or wire the CI Portainer webhook).
|
||||
|
||||
## How it stays current
|
||||
|
||||
The images are built from the Archipelago monorepo by
|
||||
`.github/workflows/demo-images.yml` on every change to `neode-ui/`, tagged `:demo`
|
||||
and `:<git-sha>`, and pushed to `REGISTRY`. Editing the real UI → CI rebuilds →
|
||||
redeploy here. No source lives in this repo.
|
||||
|
||||
## What's mocked
|
||||
|
||||
- **Per-visitor isolation** — state keyed by a `demo_sid` cookie, idle-reaped.
|
||||
- **Apps** — install/uninstall/start/stop are simulated (no real Docker).
|
||||
- **Wallet/Bitcoin** — signet-flavored; use the in-UI faucet for test sats.
|
||||
- **Files** — real per-session upload/rename/delete, 50 MB quota, wiped on reap.
|
||||
- **Intro** — replays once per calendar day per browser.
|
||||
49
demo-deploy/docker-compose.yml
Normal file
49
demo-deploy/docker-compose.yml
Normal file
@ -0,0 +1,49 @@
|
||||
# Archipelago Public Demo — thin deploy stack
|
||||
#
|
||||
# This is the ENTIRE contents intended for the public `archy-demo` repo. It holds
|
||||
# NO source — it pulls prebuilt `:demo` images that CI builds from the monorepo on
|
||||
# every neode-ui change (see .github/workflows/demo-images.yml). Deploy this in
|
||||
# Portainer ("deploy from repository" or paste into the web editor).
|
||||
#
|
||||
# Demo login password: entertoexit
|
||||
# Access on http://<host>:2100
|
||||
#
|
||||
# Configure via a .env file (see .env.example):
|
||||
# REGISTRY registry host/namespace holding the demo images
|
||||
# IMAGE_TAG image tag to pull (default: demo)
|
||||
# ANTHROPIC_API_KEY optional — enables the AI chat panel
|
||||
# DEMO_WEB_PORT host port for the UI (default 2100)
|
||||
|
||||
services:
|
||||
neode-backend:
|
||||
image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-backend:${IMAGE_TAG:-demo}
|
||||
container_name: archy-demo-backend
|
||||
environment:
|
||||
DEMO: "1"
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
NODE_OPTIONS: "--dns-result-order=ipv4first"
|
||||
DEMO_SESSION_TTL_MS: ${DEMO_SESSION_TTL_MS:-2700000}
|
||||
DEMO_MAX_SESSIONS: ${DEMO_MAX_SESSIONS:-500}
|
||||
DEMO_FILE_QUOTA_BYTES: ${DEMO_FILE_QUOTA_BYTES:-52428800}
|
||||
expose:
|
||||
- "5959"
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:5959/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
neode-web:
|
||||
image: ${REGISTRY:-146.59.87.168:3000/lfg2025}/archy-demo-web:${IMAGE_TAG:-demo}
|
||||
container_name: archy-demo-web
|
||||
ports:
|
||||
- "${DEMO_WEB_PORT:-2100}:80"
|
||||
environment:
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
depends_on:
|
||||
- neode-backend
|
||||
restart: unless-stopped
|
||||
@ -1,6 +1,13 @@
|
||||
# Archipelago Demo Stack - Mock backend + Vue UI + AIUI Chat
|
||||
# Deploy via Portainer: Web editor -> paste this, or deploy from repo
|
||||
# Access at http://localhost:4848
|
||||
# Archipelago Public Demo Stack - Mock backend + Vue UI + AIUI Chat
|
||||
# Deploy via Portainer: Web editor -> paste this, or deploy from repo (build).
|
||||
# Access at http://localhost:2100
|
||||
#
|
||||
# This builds the demo images from source. For the separated, auto-updating
|
||||
# deploy that pulls prebuilt :demo images, see demo-deploy/docker-compose.yml.
|
||||
#
|
||||
# DEMO=1 turns on the public multi-visitor sandbox: each visitor gets an
|
||||
# isolated, ephemeral copy of all state; real container runtimes are never
|
||||
# touched; the shared login password is "entertoexit".
|
||||
#
|
||||
# Required: Set ANTHROPIC_API_KEY in environment or .env file for chat to work
|
||||
# IndeedHub is deployed as a separate Portainer stack (indee-demo repo)
|
||||
@ -12,9 +19,13 @@ services:
|
||||
dockerfile: neode-ui/Dockerfile.backend
|
||||
container_name: archy-demo-backend
|
||||
environment:
|
||||
VITE_DEV_MODE: "existing"
|
||||
DEMO: "1"
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
NODE_OPTIONS: "--dns-result-order=ipv4first"
|
||||
# Optional tuning (defaults shown):
|
||||
# DEMO_SESSION_TTL_MS: "2700000" # 45 min idle before a session is reaped
|
||||
# DEMO_MAX_SESSIONS: "500" # concurrent visitor cap
|
||||
# DEMO_FILE_QUOTA_BYTES: "52428800" # 50 MB uploads per visitor
|
||||
expose:
|
||||
- "5959"
|
||||
dns:
|
||||
@ -31,9 +42,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: neode-ui/Dockerfile.web
|
||||
args:
|
||||
VITE_DEMO: "1"
|
||||
container_name: archy-demo-web
|
||||
ports:
|
||||
- "4848:80"
|
||||
- "2100:80"
|
||||
depends_on:
|
||||
- neode-backend
|
||||
restart: unless-stopped
|
||||
|
||||
@ -20,6 +20,12 @@ RUN find public/assets -name "*backup*" -type f -delete || true && \
|
||||
ENV DOCKER_BUILD=true
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Public-demo build flag — inlined into the bundle (import.meta.env.VITE_DEMO).
|
||||
# Enables the per-day intro replay, the "entertoexit" login hint, and other
|
||||
# demo-only UI affordances. Override with --build-arg VITE_DEMO=0 for a plain build.
|
||||
ARG VITE_DEMO=1
|
||||
ENV VITE_DEMO=$VITE_DEMO
|
||||
|
||||
# Use npm script which handles build better
|
||||
RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1)
|
||||
|
||||
|
||||
@ -17,14 +17,34 @@ import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import Docker from 'dockerode'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const execPromise = promisify(exec)
|
||||
|
||||
// DEMO mode: public, multi-visitor sandbox. Each visitor gets an isolated,
|
||||
// ephemeral copy of all mutable state (see per-session store below), real
|
||||
// container runtimes are never touched, and idle sessions are reaped.
|
||||
// When DEMO is off, behaviour is identical to the classic single-user dev mock.
|
||||
const DEMO =
|
||||
process.env.DEMO === '1' ||
|
||||
process.env.VITE_DEMO === '1' ||
|
||||
process.env.VITE_DEV_MODE === 'demo'
|
||||
|
||||
// Find container socket: Podman (macOS/Linux) or Docker
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
|
||||
// Report the real app version, suffixed with -demo in the public sandbox so it's
|
||||
// obviously the demo while still tracking whatever version the UI ships.
|
||||
let APP_VERSION = '0.1.0'
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
||||
if (pkg.version) APP_VERSION = pkg.version
|
||||
} catch { /* fall back to default */ }
|
||||
if (DEMO) APP_VERSION += '-demo'
|
||||
|
||||
function findContainerSocket() {
|
||||
// DOCKER_HOST env var (set by podman machine start)
|
||||
@ -47,9 +67,13 @@ function findContainerSocket() {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerSocket = findContainerSocket()
|
||||
// In DEMO mode we never bind to a real container runtime — the public demo must
|
||||
// be host-independent and unable to touch the host's Docker/Podman.
|
||||
const containerSocket = DEMO ? null : findContainerSocket()
|
||||
const docker = containerSocket ? new Docker({ socketPath: containerSocket }) : null
|
||||
if (containerSocket) {
|
||||
if (DEMO) {
|
||||
console.log('[Container] DEMO mode — simulation only (real runtime disabled)')
|
||||
} else if (containerSocket) {
|
||||
console.log(`[Container] Socket: ${containerSocket}`)
|
||||
} else {
|
||||
console.log('[Container] No socket found — simulation mode (no Docker/Podman)')
|
||||
@ -84,12 +108,25 @@ app.use((req, res, next) => {
|
||||
})
|
||||
app.use(cookieParser())
|
||||
|
||||
// DEMO: bind every request to an isolated per-visitor state store (keyed by the
|
||||
// `demo_sid` cookie) for the remainder of the request. Outside DEMO this is a
|
||||
// no-op and all handlers share the single default store (classic mock behaviour).
|
||||
app.use((req, res, next) => {
|
||||
if (!DEMO) return next()
|
||||
const store = resolveSessionStore(req, res)
|
||||
stateContext.run(store, () => next())
|
||||
})
|
||||
|
||||
// Mock session storage
|
||||
const sessions = new Map()
|
||||
const MOCK_PASSWORD = 'password123'
|
||||
// Public demo uses a memorable shared password (shown on the login screen);
|
||||
// the classic dev mock keeps password123.
|
||||
const MOCK_PASSWORD = DEMO ? 'entertoexit' : 'password123'
|
||||
|
||||
// Mutable wallet state — faucet/send/receive modify these values
|
||||
const walletState = {
|
||||
// Mutable wallet state — faucet/send/receive modify these values.
|
||||
// SEED_* objects are pristine templates; each demo session gets a deep clone
|
||||
// (see makeSessionStore). `walletState` itself becomes a session-aware proxy below.
|
||||
const SEED_WALLET = {
|
||||
onchain_sats: 2_350_000,
|
||||
channel_sats: 8_250_000,
|
||||
ecash_sats: 250_000,
|
||||
@ -103,7 +140,7 @@ const walletState = {
|
||||
}
|
||||
function randomHex(bytes) { return Array.from({length: bytes}, () => Math.floor(Math.random()*256).toString(16).padStart(2,'0')).join('') }
|
||||
|
||||
const bitcoinRelayMockState = {
|
||||
const SEED_BTCRELAY = {
|
||||
settings: {
|
||||
enabled_for_peers: true,
|
||||
allow_peer_requests: true,
|
||||
@ -144,77 +181,45 @@ const bitcoinRelayMockState = {
|
||||
],
|
||||
}
|
||||
|
||||
// User state (simulated file-based storage)
|
||||
let userState = {
|
||||
setupComplete: false,
|
||||
onboardingComplete: false,
|
||||
passwordHash: null, // In real app, this would be bcrypt hash
|
||||
}
|
||||
|
||||
let mockState = { analyticsEnabled: false }
|
||||
|
||||
// Initialize user state based on dev mode
|
||||
function initializeUserState() {
|
||||
switch (DEV_MODE) {
|
||||
// User state (simulated file-based storage). Returns a fresh object per session.
|
||||
// In DEMO mode the effective dev mode is "onboarding" so the intro/onboarding can
|
||||
// play for each new visitor (the per-day replay gate lives in the frontend).
|
||||
function seedUserState() {
|
||||
const mode = DEMO ? 'onboarding' : DEV_MODE
|
||||
switch (mode) {
|
||||
case 'setup':
|
||||
// Setup mode: Original StartOS node setup - user needs to set password
|
||||
// This is the simple password setup, NOT the experimental onboarding
|
||||
userState = {
|
||||
setupComplete: false, // User hasn't set password yet
|
||||
onboardingComplete: false, // Onboarding not relevant for setup mode
|
||||
passwordHash: null,
|
||||
}
|
||||
break
|
||||
// Setup mode: user needs to set a password (simple setup, not onboarding).
|
||||
return { setupComplete: false, onboardingComplete: false, passwordHash: null }
|
||||
case 'onboarding':
|
||||
// Onboarding mode: Experimental onboarding flow
|
||||
// User has set password (via setup) but needs to go through experimental onboarding
|
||||
userState = {
|
||||
setupComplete: true, // Password already set
|
||||
onboardingComplete: false, // Needs experimental onboarding
|
||||
passwordHash: MOCK_PASSWORD,
|
||||
}
|
||||
break
|
||||
// Password already set; visitor still needs to go through onboarding/intro.
|
||||
return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD }
|
||||
case 'existing':
|
||||
// Existing user: Fully set up, just needs to login
|
||||
userState = {
|
||||
setupComplete: true,
|
||||
onboardingComplete: true,
|
||||
passwordHash: MOCK_PASSWORD,
|
||||
}
|
||||
break
|
||||
// Fully set up, just needs to log in.
|
||||
return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD }
|
||||
case 'boot':
|
||||
// Boot mode: Simulate server startup delay (shows boot screen)
|
||||
// Server responds with 502 for the first 10 seconds, then works like onboarding mode
|
||||
userState = {
|
||||
setupComplete: true,
|
||||
onboardingComplete: false,
|
||||
passwordHash: MOCK_PASSWORD,
|
||||
}
|
||||
break
|
||||
// Simulate server startup delay (boot screen), then behave like onboarding.
|
||||
return { setupComplete: true, onboardingComplete: false, passwordHash: MOCK_PASSWORD }
|
||||
default:
|
||||
// Default: Fully set up (for UI development)
|
||||
userState = {
|
||||
setupComplete: true,
|
||||
onboardingComplete: true,
|
||||
passwordHash: MOCK_PASSWORD,
|
||||
}
|
||||
// Default: fully set up (for UI development).
|
||||
return { setupComplete: true, onboardingComplete: true, passwordHash: MOCK_PASSWORD }
|
||||
}
|
||||
console.log(`[Auth] Dev mode: ${DEV_MODE}`)
|
||||
console.log(`[Auth] Setup: ${userState.setupComplete}, Onboarding: ${userState.onboardingComplete}`)
|
||||
}
|
||||
|
||||
initializeUserState()
|
||||
function seedMockState() {
|
||||
return { analyticsEnabled: false }
|
||||
}
|
||||
|
||||
// WebSocket clients for broadcasting updates
|
||||
const wsClients = new Set()
|
||||
console.log(`[Auth] Dev mode: ${DEV_MODE}${DEMO ? ' (DEMO multi-session)' : ''}`)
|
||||
|
||||
// Helper: Broadcast data update to all WebSocket clients
|
||||
// Broadcast a data-update patch to the WebSocket clients of the CURRENT session
|
||||
// only (so demo visitors never see each other's state). Outside a request context
|
||||
// (e.g. startup) this resolves to the default store, matching single-user mode.
|
||||
function broadcastUpdate(patch) {
|
||||
const message = JSON.stringify({
|
||||
rev: Date.now(),
|
||||
patch: patch
|
||||
})
|
||||
wsClients.forEach(client => {
|
||||
currentStore().sockets.forEach(client => {
|
||||
if (client.readyState === 1) { // OPEN
|
||||
client.send(message)
|
||||
}
|
||||
@ -676,10 +681,10 @@ async function uninstallPackage(id) {
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockData = {
|
||||
const SEED_MOCKDATA = {
|
||||
'server-info': {
|
||||
id: 'archipelago-demo',
|
||||
version: '0.1.0',
|
||||
version: APP_VERSION,
|
||||
name: 'Archipelago',
|
||||
pubkey: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
'status-info': {
|
||||
@ -907,9 +912,12 @@ app.get('/rpc/v1', (_req, res) => {
|
||||
.send(`JSON-RPC is available at /rpc/v1 for POST requests only.\nOpen the dashboard at http://localhost:${uiPort}/.\n`)
|
||||
})
|
||||
|
||||
// DEMO runs on a testnet (signet) so visitors can play with worthless coins.
|
||||
const DEMO_CHAIN = DEMO ? 'signet' : 'main'
|
||||
|
||||
function mockBitcoinBlockchainInfo() {
|
||||
return {
|
||||
chain: 'main',
|
||||
chain: DEMO_CHAIN,
|
||||
blocks: 902418,
|
||||
headers: 902418,
|
||||
bestblockhash: randomHex(32),
|
||||
@ -948,7 +956,7 @@ function bitcoinRelayStatusPayload() {
|
||||
synced: true,
|
||||
blocks: 902418,
|
||||
headers: 902418,
|
||||
chain: 'main',
|
||||
chain: DEMO_CHAIN,
|
||||
status_ok: true,
|
||||
status_stale: false,
|
||||
error: null,
|
||||
@ -1142,6 +1150,11 @@ app.post('/rpc/v1', (req, res) => {
|
||||
return res.json({ result: true })
|
||||
}
|
||||
|
||||
case 'app.filebrowser-token': {
|
||||
// The Cloud/Files UI exchanges this for a filebrowser auth cookie.
|
||||
// The mock filebrowser endpoints don't validate it, so any token works.
|
||||
return res.json({ result: { token: `demo-fb-${Date.now().toString(36)}` } })
|
||||
}
|
||||
case 'node.did': {
|
||||
const mockDid = 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH'
|
||||
const mockPubkey = 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456'
|
||||
@ -3012,10 +3025,10 @@ app.post('/rpc/v1', (req, res) => {
|
||||
case 'update.status': {
|
||||
return res.json({
|
||||
result: {
|
||||
current_version: '0.1.0',
|
||||
latest_version: '0.1.1',
|
||||
update_available: true,
|
||||
release_notes: 'Bug fixes and performance improvements.',
|
||||
current_version: APP_VERSION,
|
||||
latest_version: APP_VERSION,
|
||||
update_available: false,
|
||||
release_notes: 'You are running the latest demo build.',
|
||||
channel: 'stable',
|
||||
},
|
||||
})
|
||||
@ -3251,7 +3264,7 @@ app.post('/rpc/v1', (req, res) => {
|
||||
// =============================================================================
|
||||
// Mock FileBrowser API (for Cloud page in demo/Docker deployments)
|
||||
// =============================================================================
|
||||
const MOCK_FILES = {
|
||||
const SEED_FILES = {
|
||||
'/': [
|
||||
{ name: 'Music', path: '/Music', size: 0, modified: '2025-03-01T10:00:00Z', isDir: true, type: '' },
|
||||
{ name: 'Documents', path: '/Documents', size: 0, modified: '2025-02-28T14:30:00Z', isDir: true, type: '' },
|
||||
@ -3298,7 +3311,7 @@ const MOCK_FILES = {
|
||||
],
|
||||
}
|
||||
|
||||
const MOCK_FILE_CONTENTS = {
|
||||
const SEED_FILE_CONTENTS = {
|
||||
'/Documents/bitcoin-whitepaper-notes.md': `# Bitcoin Whitepaper Notes\n\n## Key Concepts\n\n### Peer-to-Peer Electronic Cash\n- No trusted third party needed\n- Double-spending solved via proof-of-work\n- Longest chain = truth\n\n### Proof of Work\n- SHA-256 based hashing\n- Difficulty adjusts every 2016 blocks (~2 weeks)\n- Incentive: block reward + transaction fees\n\n## My Thoughts\n- The 21M supply cap is genius - digital scarcity\n- Lightning Network solves the scaling concern\n- Self-custody is the whole point`,
|
||||
'/Documents/node-setup-checklist.md': `# Archipelago Node Setup Checklist\n\n## Hardware\n- [x] Intel NUC / Mini PC (16GB RAM minimum)\n- [x] 2TB NVMe SSD\n- [x] USB drive for installer\n- [x] Ethernet cable\n\n## Core Apps\n- [x] Bitcoin Knots\n- [x] LND\n- [x] Mempool Explorer\n- [ ] BTCPay Server\n- [ ] Fedimint`,
|
||||
'/Documents/lightning-channels.csv': `channel_id,peer_alias,capacity_sats,local_balance,remote_balance,status\nch_001,ACINQ,5000000,2450000,2550000,active\nch_002,WalletOfSatoshi,2000000,1200000,800000,active\nch_003,Voltage,10000000,4500000,5500000,active\nch_004,Kraken,3000000,1800000,1200000,active`,
|
||||
@ -3320,53 +3333,153 @@ app.post('/app/filebrowser/api/login', (req, res) => {
|
||||
res.send('"mock-filebrowser-token-demo"')
|
||||
})
|
||||
|
||||
// FileBrowser list resources
|
||||
app.get('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
const reqPath = decodeURIComponent(req.params[0] || '/').replace(/\/+$/, '') || '/'
|
||||
const items = MOCK_FILES[reqPath] || []
|
||||
// ── Per-session file store helpers ──────────────────────────────────────────
|
||||
// store().files = { tree: { '<dir>': [entries] }, contents: { '<path>': string|Buffer }, bytes }
|
||||
const FB_QUOTA_BYTES = Number(process.env.DEMO_FILE_QUOTA_BYTES) || 50 * 1024 * 1024
|
||||
|
||||
function fbNormalize(raw) {
|
||||
// → leading slash, no trailing slash (root stays '/')
|
||||
const p = '/' + decodeURIComponent(raw || '').split('/').filter(Boolean).join('/')
|
||||
return p === '/' ? '/' : p.replace(/\/+$/, '')
|
||||
}
|
||||
function fbParent(p) {
|
||||
const i = p.lastIndexOf('/')
|
||||
return i <= 0 ? '/' : p.slice(0, i)
|
||||
}
|
||||
function fbBase(p) { return p.slice(p.lastIndexOf('/') + 1) }
|
||||
function fbType(name) {
|
||||
const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase()
|
||||
if (['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'].includes(ext)) return 'audio'
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image'
|
||||
if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext)) return 'video'
|
||||
if (['txt', 'md', 'json', 'csv', 'log', 'yaml', 'yml', 'xml', 'conf', 'ini'].includes(ext)) return 'text'
|
||||
return ''
|
||||
}
|
||||
function fbContentType(name) {
|
||||
const t = fbType(name)
|
||||
const ext = (name.includes('.') ? name.split('.').pop() : '').toLowerCase()
|
||||
if (t === 'audio') return ext === 'wav' ? 'audio/wav' : 'audio/mpeg'
|
||||
if (t === 'image') return ext === 'png' ? 'image/png' : ext === 'svg' ? 'image/svg+xml' : 'image/jpeg'
|
||||
if (t === 'video') return 'video/mp4'
|
||||
return 'text/plain; charset=utf-8'
|
||||
}
|
||||
function fbListResponse(res, items) {
|
||||
res.json({
|
||||
items,
|
||||
numDirs: items.filter(i => i.isDir).length,
|
||||
numFiles: items.filter(i => !i.isDir).length,
|
||||
sorting: { by: 'name', asc: true },
|
||||
})
|
||||
}
|
||||
|
||||
// FileBrowser list resources (root: /api/resources or /api/resources/)
|
||||
app.get(['/app/filebrowser/api/resources', '/app/filebrowser/api/resources/*'], (req, res) => {
|
||||
const dir = fbNormalize(req.params[0] || '')
|
||||
const items = currentStore().files.tree[dir] || []
|
||||
fbListResponse(res, items)
|
||||
})
|
||||
|
||||
app.get('/app/filebrowser/api/resources', (req, res) => {
|
||||
const items = MOCK_FILES['/'] || []
|
||||
res.json({
|
||||
items,
|
||||
numDirs: items.filter(i => i.isDir).length,
|
||||
numFiles: items.filter(i => !i.isDir).length,
|
||||
sorting: { by: 'name', asc: true },
|
||||
})
|
||||
})
|
||||
|
||||
// FileBrowser upload (POST to resources path) — mock accepts and discards the body
|
||||
// FileBrowser POST = upload a file OR create a folder (trailing slash ⇒ folder)
|
||||
app.post('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
req.resume()
|
||||
req.on('end', () => res.sendStatus(200))
|
||||
})
|
||||
const store = currentStore()
|
||||
const { tree, contents } = store.files
|
||||
const isFolder = (req.params[0] || '').endsWith('/')
|
||||
const full = fbNormalize(req.params[0] || '')
|
||||
const parent = fbParent(full)
|
||||
const name = fbBase(full)
|
||||
if (!name) return res.sendStatus(400)
|
||||
if (!tree[parent]) tree[parent] = []
|
||||
|
||||
// FileBrowser delete
|
||||
app.delete('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
// FileBrowser rename
|
||||
app.patch('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
// FileBrowser raw file content (for text file reading)
|
||||
app.get('/app/filebrowser/api/raw/*', (req, res) => {
|
||||
const reqPath = '/' + decodeURIComponent(req.params[0] || '')
|
||||
const content = MOCK_FILE_CONTENTS[reqPath]
|
||||
if (content) {
|
||||
res.type('text/plain').send(content)
|
||||
} else {
|
||||
res.status(404).send('File not found')
|
||||
if (isFolder) {
|
||||
if (!tree[parent].some(e => e.name === name && e.isDir)) {
|
||||
tree[parent].push({ name, path: full, size: 0, modified: new Date().toISOString(), isDir: true, type: '' })
|
||||
if (!tree[full]) tree[full] = []
|
||||
}
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
// File upload — collect body with a quota guard.
|
||||
const chunks = []
|
||||
let size = 0
|
||||
let aborted = false
|
||||
req.on('data', (c) => {
|
||||
size += c.length
|
||||
if (store.files.bytes + size > FB_QUOTA_BYTES) {
|
||||
aborted = true
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(c)
|
||||
})
|
||||
req.on('end', () => {
|
||||
if (aborted) return res.status(507).send('Demo storage quota exceeded (50 MB)')
|
||||
const buf = Buffer.concat(chunks)
|
||||
// Replace existing entry of the same name (override=true).
|
||||
const existing = tree[parent].find(e => e.name === name && !e.isDir)
|
||||
if (existing) store.files.bytes -= existing.size
|
||||
tree[parent] = tree[parent].filter(e => !(e.name === name && !e.isDir))
|
||||
const type = fbType(name)
|
||||
tree[parent].push({ name, path: full, size: buf.length, modified: new Date().toISOString(), isDir: false, type })
|
||||
contents[full] = type === 'text' ? buf.toString('utf-8') : buf
|
||||
store.files.bytes += buf.length
|
||||
res.sendStatus(200)
|
||||
})
|
||||
req.on('error', () => { if (!res.headersSent) res.sendStatus(400) })
|
||||
})
|
||||
|
||||
// FileBrowser delete (file or folder + its subtree)
|
||||
app.delete('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
const store = currentStore()
|
||||
const { tree, contents } = store.files
|
||||
const full = fbNormalize(req.params[0] || '')
|
||||
const parent = fbParent(full)
|
||||
if (tree[parent]) {
|
||||
const entry = tree[parent].find(e => e.path === full)
|
||||
if (entry && !entry.isDir) store.files.bytes -= entry.size || 0
|
||||
tree[parent] = tree[parent].filter(e => e.path !== full)
|
||||
}
|
||||
// Recursively drop a directory's children.
|
||||
if (tree[full]) {
|
||||
const stack = [full]
|
||||
while (stack.length) {
|
||||
const d = stack.pop()
|
||||
for (const e of tree[d] || []) {
|
||||
if (e.isDir) stack.push(e.path)
|
||||
else { store.files.bytes -= e.size || 0; delete contents[e.path] }
|
||||
}
|
||||
delete tree[d]
|
||||
}
|
||||
}
|
||||
delete contents[full]
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
// FileBrowser rename/move (PATCH with { destination })
|
||||
app.patch('/app/filebrowser/api/resources/*', (req, res) => {
|
||||
const store = currentStore()
|
||||
const { tree, contents } = store.files
|
||||
const full = fbNormalize(req.params[0] || '')
|
||||
const dest = fbNormalize((req.body && req.body.destination) || '')
|
||||
if (!dest || dest === '/') return res.sendStatus(400)
|
||||
const parent = fbParent(full)
|
||||
const entry = (tree[parent] || []).find(e => e.path === full)
|
||||
if (!entry) return res.sendStatus(404)
|
||||
const newName = fbBase(dest)
|
||||
entry.name = newName
|
||||
entry.path = dest
|
||||
entry.modified = new Date().toISOString()
|
||||
entry.type = entry.isDir ? '' : fbType(newName)
|
||||
if (contents[full] !== undefined) { contents[dest] = contents[full]; delete contents[full] }
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
// FileBrowser raw file content (text reads, blob/stream fetches)
|
||||
app.get('/app/filebrowser/api/raw/*', (req, res) => {
|
||||
const full = fbNormalize(req.params[0] || '')
|
||||
const content = currentStore().files.contents[full]
|
||||
if (content === undefined) return res.status(404).send('File not found')
|
||||
res.type(fbContentType(fbBase(full)))
|
||||
res.send(Buffer.isBuffer(content) ? content : String(content))
|
||||
})
|
||||
|
||||
// Claude API Proxy (reads ANTHROPIC_API_KEY from environment)
|
||||
@ -3718,13 +3831,137 @@ app.get('/health', (req, res) => {
|
||||
res.status(200).send('healthy')
|
||||
})
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Per-session state isolation (DEMO multi-visitor sandbox)
|
||||
//
|
||||
// Every mutable global (mockData, walletState, userState, mockState,
|
||||
// bitcoinRelayMockState) and the filebrowser file store is partitioned per
|
||||
// visitor. Handlers keep referring to those names unchanged — the names are
|
||||
// Proxies that forward to the current request's store, resolved via
|
||||
// AsyncLocalStorage. Outside DEMO (or outside a request, e.g. at startup) they
|
||||
// resolve to a single shared `defaultStore`, so classic single-user behaviour
|
||||
// is byte-for-byte preserved.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
const stateContext = new AsyncLocalStorage()
|
||||
|
||||
// Build a fresh, fully-isolated state bundle from the pristine seeds.
|
||||
function makeSessionStore() {
|
||||
const md = structuredClone(SEED_MOCKDATA)
|
||||
// No real runtime in DEMO → package list is the curated static app set.
|
||||
md['package-data'] = structuredClone(staticDevApps)
|
||||
return {
|
||||
mockData: md,
|
||||
walletState: structuredClone(SEED_WALLET),
|
||||
userState: seedUserState(),
|
||||
mockState: seedMockState(),
|
||||
bitcoinRelayMockState: structuredClone(SEED_BTCRELAY),
|
||||
files: { tree: structuredClone(SEED_FILES), contents: structuredClone(SEED_FILE_CONTENTS), bytes: 0 },
|
||||
sockets: new Set(),
|
||||
lastSeen: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// The shared store used in single-user mode and for any work outside a request.
|
||||
const defaultStore = makeSessionStore()
|
||||
|
||||
function currentStore() {
|
||||
return stateContext.getStore() || defaultStore
|
||||
}
|
||||
|
||||
// A Proxy whose every operation is delegated to currentStore()[bucket], so the
|
||||
// existing handler code (`mockData['package-data']`, `walletState.x += n`, …)
|
||||
// transparently reads/writes the right visitor's state.
|
||||
function sessionBucketProxy(bucket) {
|
||||
const target = () => currentStore()[bucket]
|
||||
return new Proxy(Object.create(null), {
|
||||
get: (_t, k) => target()[k],
|
||||
set: (_t, k, v) => { target()[k] = v; return true },
|
||||
has: (_t, k) => k in target(),
|
||||
deleteProperty: (_t, k) => { delete target()[k]; return true },
|
||||
ownKeys: () => Reflect.ownKeys(target()),
|
||||
getOwnPropertyDescriptor: (_t, k) => {
|
||||
const d = Object.getOwnPropertyDescriptor(target(), k)
|
||||
if (d) d.configurable = true
|
||||
return d
|
||||
},
|
||||
defineProperty: (_t, k, d) => { Object.defineProperty(target(), k, d); return true },
|
||||
getPrototypeOf: () => Object.prototype,
|
||||
})
|
||||
}
|
||||
|
||||
const mockData = sessionBucketProxy('mockData')
|
||||
const walletState = sessionBucketProxy('walletState')
|
||||
const userState = sessionBucketProxy('userState')
|
||||
const mockState = sessionBucketProxy('mockState')
|
||||
const bitcoinRelayMockState = sessionBucketProxy('bitcoinRelayMockState')
|
||||
|
||||
// Demo session lifecycle: keyed by the `demo_sid` cookie, capped, idle-reaped.
|
||||
const demoSessions = new Map() // sid -> store
|
||||
const DEMO_SESSION_TTL_MS = Number(process.env.DEMO_SESSION_TTL_MS) || 45 * 60 * 1000
|
||||
const DEMO_MAX_SESSIONS = Number(process.env.DEMO_MAX_SESSIONS) || 500
|
||||
|
||||
function resolveSessionStore(req, res) {
|
||||
let sid = req.cookies?.demo_sid
|
||||
if (!sid || !demoSessions.has(sid)) {
|
||||
// Cap concurrent sessions: evict the oldest if we're at the limit.
|
||||
if (demoSessions.size >= DEMO_MAX_SESSIONS) {
|
||||
let oldestSid = null, oldest = Infinity
|
||||
for (const [k, s] of demoSessions) if (s.lastSeen < oldest) { oldest = s.lastSeen; oldestSid = k }
|
||||
if (oldestSid) { reapSession(oldestSid) }
|
||||
}
|
||||
sid = crypto.randomUUID()
|
||||
demoSessions.set(sid, makeSessionStore())
|
||||
res.cookie('demo_sid', sid, { httpOnly: true, sameSite: 'lax', maxAge: DEMO_SESSION_TTL_MS })
|
||||
}
|
||||
const store = demoSessions.get(sid)
|
||||
store.lastSeen = Date.now()
|
||||
store.sid = sid
|
||||
return store
|
||||
}
|
||||
|
||||
// Resolve the session store for a WebSocket upgrade request. The HTTP layer has
|
||||
// already issued the `demo_sid` cookie by the time the socket connects; if it is
|
||||
// somehow absent we fall back to a fresh ephemeral store (no cookie to set here).
|
||||
function wsStoreForRequest(req) {
|
||||
const raw = req.headers?.cookie || ''
|
||||
const m = raw.match(/(?:^|;\s*)demo_sid=([^;]+)/)
|
||||
const sid = m && m[1]
|
||||
if (sid && demoSessions.has(sid)) {
|
||||
const store = demoSessions.get(sid)
|
||||
store.lastSeen = Date.now()
|
||||
return store
|
||||
}
|
||||
const store = makeSessionStore()
|
||||
if (sid) demoSessions.set(sid, store)
|
||||
return store
|
||||
}
|
||||
|
||||
function reapSession(sid) {
|
||||
const store = demoSessions.get(sid)
|
||||
if (!store) return
|
||||
for (const ws of store.sockets) { try { ws.close(4000, 'session expired') } catch { /* ignore */ } }
|
||||
demoSessions.delete(sid)
|
||||
}
|
||||
|
||||
if (DEMO) {
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [sid, store] of demoSessions) {
|
||||
if (now - store.lastSeen > DEMO_SESSION_TTL_MS) reapSession(sid)
|
||||
}
|
||||
}, 60 * 1000).unref?.()
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
const server = http.createServer(app)
|
||||
const wss = new WebSocketServer({ server, path: '/ws/db' })
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('[WebSocket] Client connected from', req.socket.remoteAddress)
|
||||
wsClients.add(ws)
|
||||
// Attach this socket to the visitor's session store so broadcasts only reach
|
||||
// that visitor. In non-DEMO mode every socket joins the single default store.
|
||||
const wsStore = DEMO ? wsStoreForRequest(req) : defaultStore
|
||||
wsStore.sockets.add(ws)
|
||||
|
||||
// Set up ping/pong to keep connection alive
|
||||
const pingInterval = setInterval(() => {
|
||||
@ -3751,11 +3988,12 @@ wss.on('connection', (ws, req) => {
|
||||
}
|
||||
}, 45000) // Every 45s (client expects data within 60s)
|
||||
|
||||
// Send initial data immediately
|
||||
// Send initial data immediately (this visitor's store, not the global proxy —
|
||||
// there is no request context inside the WS connection handler).
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'initial',
|
||||
data: mockData,
|
||||
data: wsStore.mockData,
|
||||
}))
|
||||
console.log('[WebSocket] Initial data sent')
|
||||
} catch (err) {
|
||||
@ -3780,14 +4018,14 @@ wss.on('connection', (ws, req) => {
|
||||
console.log('[WebSocket] Client disconnected', { code, reason: reason.toString() })
|
||||
clearInterval(pingInterval)
|
||||
clearInterval(heartbeatInterval)
|
||||
wsClients.delete(ws)
|
||||
wsStore.sockets.delete(ws)
|
||||
})
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('[WebSocket Error]', error)
|
||||
clearInterval(pingInterval)
|
||||
clearInterval(heartbeatInterval)
|
||||
wsClients.delete(ws)
|
||||
wsStore.sockets.delete(ws)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
49
neode-ui/src/composables/useDemoIntro.ts
Normal file
49
neode-ui/src/composables/useDemoIntro.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Public-demo helpers.
|
||||
*
|
||||
* The demo build (VITE_DEMO=1) replays the intro/onboarding on each visit, but
|
||||
* only once per calendar day per browser — tracked in localStorage so it
|
||||
* survives the short-lived backend session. Also exposes the shared demo
|
||||
* credentials shown on the login screen.
|
||||
*/
|
||||
|
||||
export const IS_DEMO =
|
||||
import.meta.env.VITE_DEMO === '1' || import.meta.env.VITE_DEMO === 'true'
|
||||
|
||||
/** Memorable shared password for the public demo (must match the mock backend). */
|
||||
export const DEMO_PASSWORD = 'entertoexit'
|
||||
|
||||
const INTRO_DATE_KEY = 'demo_intro_date'
|
||||
|
||||
function todayKey(): string {
|
||||
// Local calendar day, e.g. "2026-06-22".
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** True if this browser already watched the intro earlier today. */
|
||||
export function demoIntroSeenToday(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(INTRO_DATE_KEY) === todayKey()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Record that the intro has been seen today, so it won't replay until tomorrow. */
|
||||
export function markDemoIntroSeen(): void {
|
||||
try {
|
||||
localStorage.setItem(INTRO_DATE_KEY, todayKey())
|
||||
} catch {
|
||||
/* ignore (private mode / storage disabled) */
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget today's "seen" marker so the intro plays again (e.g. "Replay Intro"). */
|
||||
export function clearDemoIntroSeen(): void {
|
||||
try {
|
||||
localStorage.removeItem(INTRO_DATE_KEY)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@ -156,6 +156,11 @@
|
||||
|
||||
<!-- Normal Login Mode -->
|
||||
<template v-else>
|
||||
<!-- Demo credential hint -->
|
||||
<div v-if="isDemo" class="mb-4 p-3 bg-orange-500/15 border border-orange-400/30 rounded-lg text-orange-100 text-sm text-center">
|
||||
🎮 Demo mode — Password: <span class="font-mono font-semibold">{{ DEMO_PASSWORD }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||
{{ t('login.password') }}
|
||||
@ -228,6 +233,7 @@ const { t } = useI18n()
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { rpcClient } from '../api/rpc-client'
|
||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||
import { IS_DEMO, DEMO_PASSWORD, clearDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const currentRoute = useRoute()
|
||||
@ -241,7 +247,8 @@ const loginRedirectTo = computed(() => {
|
||||
const store = useAppStore()
|
||||
const loginTransition = useLoginTransitionStore()
|
||||
|
||||
const password = ref('')
|
||||
const isDemo = IS_DEMO
|
||||
const password = ref(IS_DEMO ? DEMO_PASSWORD : '')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@ -520,6 +527,8 @@ async function handleTotpVerify() {
|
||||
function replayIntro() {
|
||||
// Clear the intro seen flag
|
||||
localStorage.removeItem('neode_intro_seen')
|
||||
// Demo: also clear the per-day gate so the intro plays again now.
|
||||
if (IS_DEMO) clearDemoIntroSeen()
|
||||
// Navigate to root to trigger splash screen
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
@ -53,11 +53,15 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||
|
||||
const router = useRouter()
|
||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
// Demo: once the visitor has seen the intro today, don't auto-replay it again
|
||||
// until tomorrow (they can still use "Replay Intro" on the login screen).
|
||||
if (IS_DEMO) markDemoIntroSeen()
|
||||
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||
setTimeout(() => {
|
||||
ctaButton.value?.focus({ preventScroll: true })
|
||||
|
||||
@ -16,11 +16,22 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
|
||||
import BootScreen from '@/components/BootScreen.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const showBootScreen = ref(false)
|
||||
|
||||
/**
|
||||
* Public demo: replay the intro on every visit, but at most once per calendar
|
||||
* day per browser. If already seen today → straight to login; otherwise → intro.
|
||||
*/
|
||||
function demoRoute() {
|
||||
const dest = demoIntroSeenToday() ? '/login' : '/onboarding/intro'
|
||||
log('demoRoute', { dest })
|
||||
router.replace(dest).catch(() => {})
|
||||
}
|
||||
|
||||
function log(msg: string, data?: unknown) {
|
||||
const ts = new Date().toISOString()
|
||||
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
|
||||
@ -68,6 +79,10 @@ async function checkOnboarded(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function proceedToApp() {
|
||||
if (IS_DEMO) {
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const devMode = import.meta.env.VITE_DEV_MODE
|
||||
if (devMode === 'setup' || devMode === 'existing') {
|
||||
log('proceedToApp devMode', { devMode })
|
||||
@ -121,6 +136,11 @@ onMounted(async () => {
|
||||
log('production flow', { isUp })
|
||||
|
||||
if (isUp) {
|
||||
// Demo: per-day intro gate instead of server-side onboarding state.
|
||||
if (IS_DEMO) {
|
||||
demoRoute()
|
||||
return
|
||||
}
|
||||
const onboarded = await checkOnboarded()
|
||||
if (onboarded) {
|
||||
log('server up + onboarded → proceedToApp')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user