Compare commits
16 Commits
main
...
demo-build
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b75310e0b | ||
|
|
7efebb4a8c | ||
|
|
445f08a5c1 | ||
|
|
1b7335f4ac | ||
|
|
c991e61a8f | ||
|
|
b99c4a604f | ||
|
|
cf5f6d021a | ||
|
|
a0f70b3949 | ||
|
|
4cc808c73e | ||
|
|
c9341baa35 | ||
|
|
79c3769542 | ||
|
|
df2ae3d7d8 | ||
|
|
3f411c1d10 | ||
|
|
4d0c2d6717 | ||
|
|
2cffa79d9d | ||
|
|
2715f2d847 |
@ -7,6 +7,14 @@
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!demo/
|
||||
|
||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||
!docker/
|
||||
docker/*
|
||||
!docker/bitcoin-ui/
|
||||
!docker/electrs-ui/
|
||||
!docker/lnd-ui/
|
||||
!docker/fedimint-ui/
|
||||
|
||||
# Allow backend source for ISO source builds
|
||||
!core/
|
||||
!scripts/
|
||||
|
||||
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 }}"
|
||||
@ -195,7 +195,7 @@
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostr.svg",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
|
||||
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
|
||||
22
demo/README-curated-files.md
Normal file
22
demo/README-curated-files.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Curated demo files
|
||||
|
||||
Drop real files into `demo/files/` to make them the cloud's content for **every**
|
||||
demo visitor (read-only — visitors can browse, download, and "buy" them, but only
|
||||
maintainers add them). This is the "private login": the only way to add files is
|
||||
to commit them here, which requires repo access.
|
||||
|
||||
```
|
||||
demo/files/
|
||||
Documents/whitepaper.pdf
|
||||
Photos/rig.jpg
|
||||
Music/track.mp3
|
||||
```
|
||||
|
||||
- Folder structure becomes the cloud's folders.
|
||||
- Text files (`.md .txt .json .csv …`, < 1 MB) are inlined; everything else is
|
||||
streamed from disk on download.
|
||||
- If `demo/files/` is empty, the demo falls back to the built-in seeded set
|
||||
(Documents/Photos/Music/Videos with sample content).
|
||||
|
||||
After adding files, commit and push — CI rebuilds the `:demo` image and Portainer
|
||||
redeploys. Keep the total modest (these load into the demo image).
|
||||
@ -14,6 +14,31 @@
|
||||
<link rel="icon" href="/aiui/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/aiui/apple-touch-icon-180x180.png" />
|
||||
<title>AIUI</title>
|
||||
<!-- Demo (?seed): pre-load the example "Content Showcase" conversation into
|
||||
AIUI's IndexedDB so the chat history isn't empty (live chat is disabled
|
||||
in the demo and points users to these previous chats). Mirrors the app's
|
||||
own /seed exactly by calling its seedPromptsToConversation(). -->
|
||||
<script type="module">
|
||||
(async () => {
|
||||
try {
|
||||
if (!new URLSearchParams(location.search).has('seed')) return;
|
||||
const db = await new Promise((res, rej) => {
|
||||
const r = indexedDB.open('aiui-store', 1);
|
||||
r.onupgradeneeded = (e) => { const d = e.target.result; if (!d.objectStoreNames.contains('conversations')) d.createObjectStore('conversations', { keyPath: 'id' }); };
|
||||
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
|
||||
});
|
||||
const exists = await new Promise((res) => {
|
||||
try { const q = db.transaction('conversations', 'readonly').objectStore('conversations').getKey('seed-all'); q.onsuccess = () => res(!!q.result); q.onerror = () => res(false); }
|
||||
catch { res(false); }
|
||||
});
|
||||
if (exists) return;
|
||||
const { seedPromptsToConversation } = await import('/aiui/assets/seedPrompts-CLWaUv28.js');
|
||||
const conv = seedPromptsToConversation();
|
||||
await new Promise((res, rej) => { const t = db.transaction('conversations', 'readwrite'); t.objectStore('conversations').put(conv); t.oncomplete = () => res(); t.onerror = () => rej(t.error); });
|
||||
try { localStorage.setItem('aiui-active-conversation', conv.id); } catch {}
|
||||
} catch (e) { console.warn('[demo] AIUI seed bootstrap failed', e); }
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/aiui/assets/index-Lh5NfTCq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/aiui/assets/index-CHQ7uqBj.css">
|
||||
<link rel="manifest" href="/aiui/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/aiui/registerSW.js"></script></head>
|
||||
|
||||
0
demo/files/.gitkeep
Normal file
0
demo/files/.gitkeep
Normal file
@ -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
|
||||
|
||||
109
docs/demo-build-info.md
Normal file
109
docs/demo-build-info.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Archipelago Public Demo — build info & status
|
||||
|
||||
**Status:** implemented & deployable (2026-06-22)
|
||||
**Branch:** `demo-build` (worktree `../archy-demo-build`), pushed to
|
||||
`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`.
|
||||
**Main/prod is untouched** — all demo work lives only on `demo-build`.
|
||||
|
||||
A public, click-to-play demo of the Archipelago UI, 100% mock-data driven,
|
||||
multi-visitor, deployed via Portainer. See also `docs/demo-deployment-design.md`
|
||||
(original design) and `demo-deploy/` (thin prebuilt-image stack).
|
||||
|
||||
---
|
||||
|
||||
## Deploy (Portainer)
|
||||
|
||||
Build-from-repo (works today, no registry needed):
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` |
|
||||
| Reference | `refs/heads/demo-build` |
|
||||
| Compose path | `docker-compose.demo.yml` |
|
||||
| Auth | user `lfg2025`, password = Gitea token |
|
||||
| UI port | **2100** · Login password: **`entertoexit`** |
|
||||
|
||||
Redeploy after each push. `docker-compose.demo.yml` builds two images
|
||||
(`neode-ui/Dockerfile.backend` = mock server, `neode-ui/Dockerfile.web` = nginx+UI).
|
||||
The thin `demo-deploy/docker-compose.yml` pulls prebuilt `:demo` images instead
|
||||
(needs the CI image pipeline / registry wired — `.github/workflows/demo-images.yml`).
|
||||
|
||||
### Flags / env
|
||||
- Backend: `DEMO=1` (compose sets it) → multi-session sandbox, no real runtime.
|
||||
- Web build: `VITE_DEMO=1` (Dockerfile.web ARG, default 1) → inlined demo UI behaviour.
|
||||
- Optional: `ANTHROPIC_API_KEY` (NOT needed — AIUI chat is canned in demo),
|
||||
`DEMO_SESSION_TTL_MS` (45m), `DEMO_MAX_SESSIONS` (500), `DEMO_FILE_QUOTA_BYTES` (50MB).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Everything is gated behind `DEMO` (off = classic single-user dev mock, unchanged).
|
||||
|
||||
- **`neode-ui/mock-backend.js`** — the entire fake backend (Node/Express, ~95+ RPCs).
|
||||
- **Per-session isolation:** `AsyncLocalStorage` + Proxy. Globals (`mockData`,
|
||||
`walletState`, `userState`, `mockState`, `bitcoinRelayMockState`) are Proxies
|
||||
that resolve to the current request's store, keyed by a `demo_sid` cookie.
|
||||
Deep-cloned from `SEED_*` on first hit; idle-reaped; per-session WS fan-out.
|
||||
- **Files:** per-session in-memory store + curated disk files (see below).
|
||||
- Forces simulation mode in DEMO (`docker=null`).
|
||||
- **`neode-ui/src/composables/useDemoIntro.ts`** — the frontend demo switch
|
||||
(`IS_DEMO`), per-day intro gate, `DEMO_PASSWORD`, app demoability + launch URLs.
|
||||
- **`neode-ui/docker/nginx-demo.conf`** — routes `/rpc`, `/ws`, `/app/*`,
|
||||
`/electrs-status`, `/proxy/`, `/lnd-connect-info`, the IndeeHub/Mempool
|
||||
reverse-proxies, and the SPA.
|
||||
- **`docker/{bitcoin-ui,electrs-ui,lnd-ui,fedimint-ui}/`** — the REAL registry app
|
||||
UIs, served statically under `/app/<id>/` with mocked data endpoints.
|
||||
- **`demo/aiui/`** — prebuilt AIUI dist (chat is canned; `?mockArchy&seed`).
|
||||
- **`demo/files/`** — curated cloud files drop-in (see below).
|
||||
|
||||
## Demo features (all implemented)
|
||||
Per-session sandbox · per-session file upload (Range streaming) · testnet/signet
|
||||
flavor · per-day intro replay · `entertoexit` login (prefilled + hint) · version
|
||||
`<real>-demo` · onboarding wizard skipped (intro kept) · "No demo" install gating ·
|
||||
real app UIs (Bitcoin Core vs Knots by subversion, ElectrumX, LND, Fedimint;
|
||||
Mempool/IndeeHub iframed) · 12 federation nodes / 5 peers · FIPS active · interactive
|
||||
buy flow (testnet addresses, bolt11, 2s QR) · real testnet tx links (mempool.space) ·
|
||||
networking profits 5,231,978 sats + labelled wallet txs · VPN · Nostr relays ·
|
||||
node-visibility toggle · dummy Cashu mints + Fedimint federations · AIUI canned
|
||||
reply + `?mockArchy` mock data + `?seed` pre-loaded "Content Showcase" chat.
|
||||
|
||||
---
|
||||
|
||||
## Curated cloud files (`demo/files/`)
|
||||
Drop real files into `demo/files/<Folder>/<file>` and commit — they become the
|
||||
cloud content for every visitor (read-only; git access = the "private login").
|
||||
Loader **merges per top-level folder**: adding `Music/` swaps only Music and keeps
|
||||
the sample Documents/Photos/Videos. Empty → built-in seeds. Text inlined; binaries
|
||||
streamed from disk with HTTP Range (seek). Backend reads `/demo/files` —
|
||||
**Dockerfile.backend COPYs it; `.dockerignore` must allow it.**
|
||||
|
||||
---
|
||||
|
||||
## Gotchas (READ before editing)
|
||||
- **Sibling dirs need both the Dockerfile COPY and a `.dockerignore` allow.**
|
||||
`docker/bitcoin-ui`, `docker/electrs-ui`, `docker/lnd-ui`, `docker/fedimint-ui`,
|
||||
`demo/files` are outside `neode-ui/`; they're copied into the backend image and
|
||||
un-ignored in `.dockerignore` (`* ` + `!docker/` + `docker/*` + `!docker/<ui>/`).
|
||||
Forgetting either → Portainer build "not found" or runtime 500/404.
|
||||
- **Real app UIs assume root-serving** — served via `express.static('/app/<id>')`
|
||||
+ `/app/<id>/assets/*` → `/assets/*` redirect + per-path data endpoints
|
||||
(`bitcoin-status`, `rpc/v1`, `bitcoin-rpc/`, `/proxy/lnd/*`, `/electrs-status`).
|
||||
- **Uploaded-via-UI files are ephemeral** (per-session, lost on redeploy/reap).
|
||||
Only `demo/files/` persists.
|
||||
- **Mempool iframe is best-effort** (third-party CSP/websockets). **IndeeHub** is
|
||||
reverse-proxied with header-strip + `sub_filter` asset rewrite; if still black,
|
||||
it's indee's own `X-Frame-Options` (fix on that server).
|
||||
- **AIUI `?seed` bootstrap hardcodes the current AIUI bundle hash**
|
||||
(`/aiui/assets/seedPrompts-CLWaUv28.js`) — re-paste if AIUI is rebuilt. Tiny
|
||||
first-load IndexedDB race (one refresh shows the chat).
|
||||
- **Running mock-backend.js locally in the sandbox is flaky:** start backgrounded,
|
||||
`sleep 5+`, then curl; NEVER `pkill -f mock-backend` (it matches & kills the
|
||||
shell) — use `pkill -x node`.
|
||||
- **Delete-405** seen pre-redeploy was nginx/stale; backend DELETE returns 200.
|
||||
|
||||
---
|
||||
|
||||
## Commit trail (demo-build, newest last)
|
||||
`2715f2d8` sandbox → … → `7efebb4a` media merge + AIUI seed. ~14 commits, all
|
||||
`feat(demo)/fix(demo)`.
|
||||
@ -14,6 +14,14 @@ RUN npm install
|
||||
# Copy application code
|
||||
COPY neode-ui/ ./
|
||||
|
||||
# Sibling assets the mock backend reads relative to /app (../docker, ../demo):
|
||||
# the Bitcoin UI mock shell and any curated cloud files dropped into demo/files.
|
||||
COPY docker/bitcoin-ui /docker/bitcoin-ui
|
||||
COPY docker/electrs-ui /docker/electrs-ui
|
||||
COPY docker/lnd-ui /docker/lnd-ui
|
||||
COPY docker/fedimint-ui /docker/fedimint-ui
|
||||
COPY demo/files /demo/files
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5959
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -62,6 +62,28 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ElectrumX UI status (polled by the electrs-ui shell)
|
||||
location /electrs-status {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# LND UI endpoints (polled by the lnd-ui shell)
|
||||
location /proxy/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy FileBrowser API to mock backend (demo mode)
|
||||
location /app/filebrowser/ {
|
||||
client_max_body_size 10G;
|
||||
@ -72,6 +94,59 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# IndeeHub: reverse-proxy the real site same-origin, strip framing headers,
|
||||
# and rewrite its absolute asset paths (/assets, /, src, href) to the
|
||||
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
|
||||
location /app/indeedhub/ {
|
||||
proxy_pass https://indee.tx1138.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host indee.tx1138.com;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
sub_filter_types text/html text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/indeedhub/';
|
||||
sub_filter 'src="/' 'src="/app/indeedhub/';
|
||||
sub_filter "href='/" "href='/app/indeedhub/";
|
||||
sub_filter "src='/" "src='/app/indeedhub/";
|
||||
sub_filter 'from"/' 'from"/app/indeedhub/';
|
||||
sub_filter 'url(/' 'url(/app/indeedhub/';
|
||||
}
|
||||
|
||||
# Mempool: same approach. NOTE mempool.space is a strict third-party app —
|
||||
# its data/websocket calls may still be blocked; iframe is best-effort.
|
||||
location /app/mempool/ {
|
||||
proxy_pass https://mempool.space/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host mempool.space;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Content-Security-Policy-Report-Only;
|
||||
sub_filter_types text/html text/css application/javascript application/json;
|
||||
sub_filter_once off;
|
||||
sub_filter 'href="/' 'href="/app/mempool/';
|
||||
sub_filter 'src="/' 'src="/app/mempool/';
|
||||
sub_filter "href='/" "href='/app/mempool/";
|
||||
sub_filter "src='/" "src='/app/mempool/";
|
||||
sub_filter 'from"/' 'from"/app/mempool/';
|
||||
sub_filter 'url(/' 'url(/app/mempool/';
|
||||
}
|
||||
|
||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||
# generic "Not available in the demo" notice for the rest.
|
||||
location /app/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Serve AIUI SPA
|
||||
location /aiui/ {
|
||||
alias /usr/share/nginx/html/aiui/;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -195,7 +195,7 @@
|
||||
"title": "Nostr Relay (Rust)",
|
||||
"version": "0.8.0",
|
||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
||||
"icon": "/assets/img/app-icons/nostr.svg",
|
||||
"icon": "/assets/img/app-icons/nostrudel.svg",
|
||||
"author": "Nostr RS Relay",
|
||||
"category": "community",
|
||||
"tier": "recommended",
|
||||
|
||||
96
neode-ui/src/composables/useDemoIntro.ts
Normal file
96
neode-ui/src/composables/useDemoIntro.ts
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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 */
|
||||
}
|
||||
}
|
||||
|
||||
// ── Demoable apps ───────────────────────────────────────────────────────────
|
||||
// Only these apps actually do something in the demo (a mock UI or a real
|
||||
// external site). Everything else shows "No demo" on a disabled install button
|
||||
// and is not launchable.
|
||||
const DEMO_EXTERNAL_URLS: Record<string, string> = {}
|
||||
|
||||
// Apps loaded in the in-app iframe via a same-origin path. IndeeHub and Mempool
|
||||
// are reverse-proxied by nginx (X-Frame-Options/CSP stripped + asset paths
|
||||
// rewritten) so the frame-busting real sites can be embedded.
|
||||
const DEMO_MOCK_UI: Record<string, string> = {
|
||||
indeedhub: '/app/indeedhub/',
|
||||
mempool: '/app/mempool/',
|
||||
'mempool-web': '/app/mempool/',
|
||||
'bitcoin-knots': '/app/bitcoin-knots/',
|
||||
'bitcoin-core': '/app/bitcoin-core/',
|
||||
bitcoin: '/app/bitcoin-core/',
|
||||
'bitcoin-ui': '/app/bitcoin-ui/',
|
||||
electrs: '/app/electrumx/',
|
||||
electrumx: '/app/electrumx/',
|
||||
'archy-electrs-ui': '/app/electrumx/',
|
||||
lnd: '/app/lnd/',
|
||||
'lnd-ui': '/app/lnd/',
|
||||
'archy-lnd-ui': '/app/lnd/',
|
||||
thunderhub: '/app/lnd/',
|
||||
fedimint: '/app/fedimint/',
|
||||
fedimintd: '/app/fedimint/',
|
||||
filebrowser: '/app/filebrowser/',
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a demo app opens in a new tab. Nothing does — IndeeHub and Mempool
|
||||
* both load their real site directly in the in-app iframe.
|
||||
*/
|
||||
export function isDemoExternal(_appId: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/** Can this app be launched/installed in the demo? */
|
||||
export function isDemoApp(appId: string): boolean {
|
||||
return appId in DEMO_EXTERNAL_URLS || appId in DEMO_MOCK_UI
|
||||
}
|
||||
|
||||
/** Resolve the demo launch URL for an app, or null if it isn't demoable. */
|
||||
export function demoAppUrl(appId: string): string | null {
|
||||
return DEMO_EXTERNAL_URLS[appId] ?? DEMO_MOCK_UI[appId] ?? null
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
import router from '@/router'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||
@ -222,6 +223,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
function openSession(appId: string) {
|
||||
recordAppLaunch(appId)
|
||||
const mobile = isMobileViewport()
|
||||
// Demo: apps backed by a real external site that blocks iframing (mempool.space)
|
||||
// open in a new tab; everything else demoable renders in the in-app session.
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) { openExternal(ext); return }
|
||||
}
|
||||
const launchUrl = NEW_TAB_APP_IDS.has(appId) ? directAppUrl(appId) : null
|
||||
if (launchUrl && !mobile) {
|
||||
openExternal(launchUrl)
|
||||
|
||||
@ -109,6 +109,7 @@ import { useAppIdentity } from './appSession/useAppIdentity'
|
||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||
import { openExternalUrl } from '@/utils/openExternal'
|
||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
|
||||
|
||||
const props = defineProps<{
|
||||
appIdProp?: string
|
||||
@ -157,7 +158,11 @@ const packageEntry = computed(() => store.data?.['package-data']?.[appId.value]
|
||||
const blockedReason = computed(() => launchBlockedReason(appId.value, packageEntry.value))
|
||||
const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value === 'fedimintd' ? 'Waiting for Bitcoin sync' : 'App not ready')
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const mustOpenNewTab = computed(() => NEW_TAB_APPS.has(appId.value))
|
||||
// In the demo, apps backed by a real external site that blocks iframing
|
||||
// (mempool.space) open in a new tab rather than the in-app session frame.
|
||||
const mustOpenNewTab = computed(() =>
|
||||
NEW_TAB_APPS.has(appId.value) || (IS_DEMO && isDemoExternal(appId.value))
|
||||
)
|
||||
|
||||
// ElectrumX shows a sync screen before its real UI (the Electrum server only
|
||||
// serves clients once its index is built). Poll /electrs-status while this is
|
||||
|
||||
@ -62,6 +62,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@ -71,9 +72,12 @@ const aiuiConnected = ref(false)
|
||||
let broker: ContextBroker | null = null
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
// Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system,
|
||||
// network, wallet, bitcoin, files) and &seed pre-loads the example chats.
|
||||
const demo = IS_DEMO ? '&mockArchy=1&seed=1' : ''
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
||||
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
|
||||
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@ -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') }}
|
||||
@ -203,14 +208,16 @@
|
||||
>
|
||||
{{ t('login.replayIntro') }}
|
||||
</button>
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
<template v-if="!isDemo">
|
||||
<span class="text-white/30">|</span>
|
||||
<button
|
||||
@click="restartOnboarding"
|
||||
:disabled="isResettingOnboarding"
|
||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -228,6 +235,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 +249,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 +529,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 = '/'
|
||||
}
|
||||
|
||||
@ -63,8 +63,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -74,7 +74,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -129,8 +129,8 @@
|
||||
<button
|
||||
v-else
|
||||
@click="installApp"
|
||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="installBlockedReason || undefined"
|
||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||
>
|
||||
<svg v-if="installing" class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@ -140,7 +140,7 @@
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{{ installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
{{ demoNoInstall ? 'No demo' : installBlockedReason ? 'Bitcoin Pruned' : installing ? t('common.installing') : t('common.install') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -351,6 +351,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '../stores/app'
|
||||
@ -486,6 +487,9 @@ const installBlockedReason = computed(() => {
|
||||
return electrumxArchiveWarning
|
||||
})
|
||||
|
||||
// Demo: only demoable apps can be installed; the rest show "No demo".
|
||||
const demoNoInstall = computed(() => IS_DEMO && !!app.value?.id && !isDemoApp(app.value.id))
|
||||
|
||||
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -22,27 +22,30 @@
|
||||
@click="goToOptions"
|
||||
class="glass-button px-6 py-3 sm:px-8 sm:py-4 rounded-lg text-base sm:text-lg font-medium transition-all hover:bg-black/70 hover:border-white/30 onb-cta"
|
||||
>
|
||||
Unlock your sovereignty →
|
||||
{{ isDemo ? 'Enter the demo →' : 'Unlock your sovereignty →' }}
|
||||
</button>
|
||||
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||
<template v-if="!isDemo">
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||
@click="goToRestore"
|
||||
@keydown.enter="goToRestore"
|
||||
>
|
||||
Restore from seed phrase
|
||||
</a>
|
||||
<a
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||
@click="goToLogin"
|
||||
@keydown.enter="goToLogin"
|
||||
>
|
||||
Already set up? Log in
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -53,11 +56,16 @@ 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)
|
||||
const isDemo = IS_DEMO
|
||||
|
||||
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 })
|
||||
@ -66,6 +74,13 @@ onMounted(() => {
|
||||
|
||||
function goToOptions() {
|
||||
playNavSound('action')
|
||||
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
|
||||
// to login, which is prefilled with the demo password.
|
||||
if (isDemo) {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
router.push('/login').catch(() => {})
|
||||
return
|
||||
}
|
||||
router.push('/onboarding/path').catch(() => {})
|
||||
}
|
||||
|
||||
|
||||
@ -1304,7 +1304,7 @@ async function payWithLightning() {
|
||||
|
||||
function scheduleInvoicePoll() {
|
||||
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
||||
invoicePollTimer = setTimeout(pollInvoice, 3000)
|
||||
invoicePollTimer = setTimeout(pollInvoice, 1000)
|
||||
}
|
||||
|
||||
async function pollInvoice() {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/** Static configuration maps for app session routing and display */
|
||||
|
||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
||||
import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
export type DisplayMode = 'panel' | 'overlay' | 'fullscreen'
|
||||
|
||||
@ -76,6 +77,15 @@ export const IFRAME_BLOCKED_APPS = new Set<string>([])
|
||||
|
||||
/** Resolve app URL using direct port mapping (source of truth) */
|
||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
||||
// Demo: route to the app's mock UI or real external site (mempool.space,
|
||||
// indee.tx1138.com). Carry through a deep-link path (e.g. /tx/<hash> for
|
||||
// mempool). Non-demoable apps fall through to a generic notice page.
|
||||
if (IS_DEMO) {
|
||||
const base = demoAppUrl(id)
|
||||
if (base) return routeQueryPath ? base + routeQueryPath : base
|
||||
return `/app/${id}/`
|
||||
}
|
||||
|
||||
// External HTTPS apps
|
||||
const ext = EXTERNAL_URLS[id]
|
||||
if (ext) return ext
|
||||
|
||||
@ -102,9 +102,9 @@
|
||||
@click.stop="$emit('launch', app)"
|
||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||
>Launch</button>
|
||||
<!-- Scanning -->
|
||||
<!-- Scanning (skipped in demo — there are no real containers to scan) -->
|
||||
<span
|
||||
v-else-if="!containersScanned && (app.source === 'local' || app.dockerImage)"
|
||||
v-else-if="!IS_DEMO && !containersScanned && (app.source === 'local' || app.dockerImage)"
|
||||
class="flex-1 px-4 py-2 rounded-lg text-white/50 text-sm font-medium text-center cursor-default relative overflow-hidden"
|
||||
>
|
||||
<span class="discover-shimmer-bg"></span>
|
||||
@ -116,6 +116,12 @@
|
||||
Checking...
|
||||
</span>
|
||||
</span>
|
||||
<!-- Demo: app not demoable -->
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
|
||||
>No demo</button>
|
||||
<!-- Install button -->
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||
@ -158,6 +164,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
Starting...
|
||||
</span>
|
||||
<button
|
||||
v-else-if="!containersScanned && app.dockerImage"
|
||||
v-else-if="!IS_DEMO && !containersScanned && app.dockerImage"
|
||||
disabled
|
||||
class="text-white/40 text-sm flex items-center gap-2"
|
||||
>
|
||||
@ -74,6 +74,11 @@
|
||||
</svg>
|
||||
Checking...
|
||||
</button>
|
||||
<button
|
||||
v-else-if="IS_DEMO && !isInstalled(app.id) && !isDemoApp(app.id)"
|
||||
disabled
|
||||
class="glass-button glass-button-sm rounded-lg text-sm font-medium opacity-50 cursor-not-allowed"
|
||||
>No demo</button>
|
||||
<button
|
||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||
data-controller-install-btn
|
||||
@ -99,6 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
|
||||
@ -151,6 +151,16 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
// Demo mock app UIs (electrumx, lnd, fedimint) + generic notice page.
|
||||
'/app/electrumx': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/electrs': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/lnd': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/fedimint': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/bitcoin-core': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/app/bitcoin-knots': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/electrs-status': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/proxy': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
'/lnd-connect-info': { target: process.env.BACKEND_URL || 'http://localhost:5959', changeOrigin: true, secure: false },
|
||||
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
|
||||
'/aiui': {
|
||||
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user