merge: demo build updates
# Conflicts: # neode-ui/src/stores/appLauncher.ts # neode-ui/src/views/AppSession.vue
This commit is contained in:
commit
c2c4b5af7d
@ -7,6 +7,14 @@
|
|||||||
# Allow demo assets (AIUI pre-built dist)
|
# Allow demo assets (AIUI pre-built dist)
|
||||||
!demo/
|
!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
|
# Allow backend source for ISO source builds
|
||||||
!core/
|
!core/
|
||||||
!scripts/
|
!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)",
|
"title": "Nostr Relay (Rust)",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
"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",
|
"author": "Nostr RS Relay",
|
||||||
"category": "community",
|
"category": "community",
|
||||||
"tier": "recommended",
|
"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="icon" href="/aiui/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="/aiui/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" href="/aiui/apple-touch-icon-180x180.png" />
|
||||||
<title>AIUI</title>
|
<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>
|
<script type="module" crossorigin src="/aiui/assets/index-Lh5NfTCq.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/aiui/assets/index-CHQ7uqBj.css">
|
<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>
|
<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
|
# Archipelago Public Demo Stack - Mock backend + Vue UI + AIUI Chat
|
||||||
# Deploy via Portainer: Web editor -> paste this, or deploy from repo
|
# Deploy via Portainer: Web editor -> paste this, or deploy from repo (build).
|
||||||
# Access at http://localhost:4848
|
# 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
|
# 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)
|
# IndeedHub is deployed as a separate Portainer stack (indee-demo repo)
|
||||||
@ -12,9 +19,13 @@ services:
|
|||||||
dockerfile: neode-ui/Dockerfile.backend
|
dockerfile: neode-ui/Dockerfile.backend
|
||||||
container_name: archy-demo-backend
|
container_name: archy-demo-backend
|
||||||
environment:
|
environment:
|
||||||
VITE_DEV_MODE: "existing"
|
DEMO: "1"
|
||||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
NODE_OPTIONS: "--dns-result-order=ipv4first"
|
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:
|
expose:
|
||||||
- "5959"
|
- "5959"
|
||||||
dns:
|
dns:
|
||||||
@ -31,9 +42,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: neode-ui/Dockerfile.web
|
dockerfile: neode-ui/Dockerfile.web
|
||||||
|
args:
|
||||||
|
VITE_DEMO: "1"
|
||||||
container_name: archy-demo-web
|
container_name: archy-demo-web
|
||||||
ports:
|
ports:
|
||||||
- "4848:80"
|
- "2100:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- neode-backend
|
- neode-backend
|
||||||
restart: unless-stopped
|
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 application code
|
||||||
COPY neode-ui/ ./
|
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 port
|
||||||
EXPOSE 5959
|
EXPOSE 5959
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,12 @@ RUN find public/assets -name "*backup*" -type f -delete || true && \
|
|||||||
ENV DOCKER_BUILD=true
|
ENV DOCKER_BUILD=true
|
||||||
ENV NODE_ENV=production
|
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
|
# 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)
|
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;
|
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)
|
# Proxy FileBrowser API to mock backend (demo mode)
|
||||||
location /app/filebrowser/ {
|
location /app/filebrowser/ {
|
||||||
client_max_body_size 10G;
|
client_max_body_size 10G;
|
||||||
@ -72,6 +94,59 @@ http {
|
|||||||
proxy_request_buffering off;
|
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
|
# Serve AIUI SPA
|
||||||
location /aiui/ {
|
location /aiui/ {
|
||||||
alias /usr/share/nginx/html/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)",
|
"title": "Nostr Relay (Rust)",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
|
"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",
|
"author": "Nostr RS Relay",
|
||||||
"category": "community",
|
"category": "community",
|
||||||
"tier": "recommended",
|
"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
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import router from '@/router'
|
|||||||
import { recordAppLaunch } from '@/utils/appUsage'
|
import { recordAppLaunch } from '@/utils/appUsage'
|
||||||
import { requestExternalOpen } from '@/api/remote-relay'
|
import { requestExternalOpen } from '@/api/remote-relay'
|
||||||
import { openInAppOrNewTab } from '@/utils/openExternal'
|
import { openInAppOrNewTab } from '@/utils/openExternal'
|
||||||
|
import { IS_DEMO, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
* Open a URL in a new browser tab — but if a companion (phone) is currently
|
||||||
@ -224,6 +225,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
recordAppLaunch(appId)
|
recordAppLaunch(appId)
|
||||||
const mobile = isMobileViewport()
|
const mobile = isMobileViewport()
|
||||||
|
|
||||||
|
// Demo: apps backed by a real external site that blocks iframing
|
||||||
|
// (mempool.space) open externally; everything else demoable renders in the
|
||||||
|
// in-app session.
|
||||||
|
if (IS_DEMO && isDemoExternal(appId)) {
|
||||||
|
const ext = demoAppUrl(appId)
|
||||||
|
if (ext) {
|
||||||
|
if (mobile) openInAppOrNewTab(ext)
|
||||||
|
else openExternal(ext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
|
// Tab-only apps (set X-Frame-Options, can't be iframed). No interstitial:
|
||||||
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
|
// desktop opens a new browser tab; mobile opens the in-app WebView (Android
|
||||||
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
|
// companion) or a new browser tab (PWA) — see openInAppOrNewTab.
|
||||||
|
|||||||
@ -110,6 +110,7 @@ import { useAppIdentity } from './appSession/useAppIdentity'
|
|||||||
import { useNostrBridge } from './appSession/useNostrBridge'
|
import { useNostrBridge } from './appSession/useNostrBridge'
|
||||||
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
import { openExternalUrl, openInAppOrNewTab } from '@/utils/openExternal'
|
||||||
import { useElectrsSync } from '@/composables/useElectrsSync'
|
import { useElectrsSync } from '@/composables/useElectrsSync'
|
||||||
|
import { IS_DEMO, isDemoExternal } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
appIdProp?: string
|
appIdProp?: string
|
||||||
@ -166,7 +167,11 @@ const blockedTitle = computed(() => appId.value === 'fedimint' || appId.value ==
|
|||||||
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
// viewport (and match the CSS `md` breakpoint) instead of a stale one-shot read.
|
||||||
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||||||
function updateIsMobile() { isMobile.value = window.innerWidth < 768 }
|
function updateIsMobile() { isMobile.value = 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
|
// 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
|
// 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 { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ContextBroker } from '@/services/contextBroker'
|
import { ContextBroker } from '@/services/contextBroker'
|
||||||
|
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@ -71,9 +72,12 @@ const aiuiConnected = ref(false)
|
|||||||
let broker: ContextBroker | null = null
|
let broker: ContextBroker | null = null
|
||||||
|
|
||||||
const aiuiUrl = computed(() => {
|
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
|
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}`
|
||||||
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
|
if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}`
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -156,6 +156,11 @@
|
|||||||
|
|
||||||
<!-- Normal Login Mode -->
|
<!-- Normal Login Mode -->
|
||||||
<template v-else>
|
<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">
|
<div class="mb-6">
|
||||||
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
<label for="login-password" class="block text-sm font-medium text-white/80 mb-2">
|
||||||
{{ t('login.password') }}
|
{{ t('login.password') }}
|
||||||
@ -203,14 +208,16 @@
|
|||||||
>
|
>
|
||||||
{{ t('login.replayIntro') }}
|
{{ t('login.replayIntro') }}
|
||||||
</button>
|
</button>
|
||||||
<span class="text-white/30">|</span>
|
<template v-if="!isDemo">
|
||||||
<button
|
<span class="text-white/30">|</span>
|
||||||
@click="restartOnboarding"
|
<button
|
||||||
:disabled="isResettingOnboarding"
|
@click="restartOnboarding"
|
||||||
class="text-xs text-white/50 hover:text-white/70 transition-colors underline-offset-2 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
: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>
|
{{ isResettingOnboarding ? t('login.resetting') : t('login.onboarding') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -228,6 +235,7 @@ const { t } = useI18n()
|
|||||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||||
import { rpcClient } from '../api/rpc-client'
|
import { rpcClient } from '../api/rpc-client'
|
||||||
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
import { resumeAudioContext, startSynthwave, stopSynthwave, playLoginSuccessWhoosh, playPop } from '@/composables/useLoginSounds'
|
||||||
|
import { IS_DEMO, DEMO_PASSWORD, clearDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentRoute = useRoute()
|
const currentRoute = useRoute()
|
||||||
@ -241,7 +249,8 @@ const loginRedirectTo = computed(() => {
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const loginTransition = useLoginTransitionStore()
|
const loginTransition = useLoginTransitionStore()
|
||||||
|
|
||||||
const password = ref('')
|
const isDemo = IS_DEMO
|
||||||
|
const password = ref(IS_DEMO ? DEMO_PASSWORD : '')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@ -520,6 +529,8 @@ async function handleTotpVerify() {
|
|||||||
function replayIntro() {
|
function replayIntro() {
|
||||||
// Clear the intro seen flag
|
// Clear the intro seen flag
|
||||||
localStorage.removeItem('neode_intro_seen')
|
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
|
// Navigate to root to trigger splash screen
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,8 +74,8 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!isInstalled"
|
v-if="!isInstalled"
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="installBlockedReason || undefined"
|
: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"
|
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">
|
<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">
|
||||||
@ -85,7 +85,7 @@
|
|||||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -153,8 +153,8 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="installBlockedReason || undefined"
|
: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"
|
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">
|
<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">
|
||||||
@ -164,7 +164,7 @@
|
|||||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -375,6 +375,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '../stores/app'
|
import { useAppStore } from '../stores/app'
|
||||||
@ -516,6 +517,9 @@ const installBlockedReason = computed(() => {
|
|||||||
return electrumxArchiveWarning
|
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
|
let pendingRedirect: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -22,27 +22,30 @@
|
|||||||
@click="goToOptions"
|
@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"
|
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>
|
</button>
|
||||||
|
|
||||||
<a
|
<!-- Onboarding wizard entry points are hidden in the demo (no seed/identity setup) -->
|
||||||
tabindex="0"
|
<template v-if="!isDemo">
|
||||||
role="button"
|
<a
|
||||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
tabindex="0"
|
||||||
@click="goToRestore"
|
role="button"
|
||||||
@keydown.enter="goToRestore"
|
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-4 block text-center onb-cta"
|
||||||
>
|
@click="goToRestore"
|
||||||
Restore from seed phrase
|
@keydown.enter="goToRestore"
|
||||||
</a>
|
>
|
||||||
<a
|
Restore from seed phrase
|
||||||
tabindex="0"
|
</a>
|
||||||
role="button"
|
<a
|
||||||
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
tabindex="0"
|
||||||
@click="goToLogin"
|
role="button"
|
||||||
@keydown.enter="goToLogin"
|
class="text-white/50 hover:text-white/80 underline text-sm cursor-pointer mt-2 block text-center onb-cta"
|
||||||
>
|
@click="goToLogin"
|
||||||
Already set up? Log in
|
@keydown.enter="goToLogin"
|
||||||
</a>
|
>
|
||||||
|
Already set up? Log in
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -53,11 +56,16 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
import AnimatedLogo from '@/components/AnimatedLogo.vue'
|
||||||
import { playNavSound } from '@/composables/useNavSounds'
|
import { playNavSound } from '@/composables/useNavSounds'
|
||||||
|
import { IS_DEMO, markDemoIntroSeen } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const ctaButton = ref<HTMLButtonElement | null>(null)
|
const ctaButton = ref<HTMLButtonElement | null>(null)
|
||||||
|
const isDemo = IS_DEMO
|
||||||
|
|
||||||
onMounted(() => {
|
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)
|
// Auto-focus after entry animation completes (1.4s animation delay + 0.6s duration)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ctaButton.value?.focus({ preventScroll: true })
|
ctaButton.value?.focus({ preventScroll: true })
|
||||||
@ -66,6 +74,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
function goToOptions() {
|
function goToOptions() {
|
||||||
playNavSound('action')
|
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(() => {})
|
router.push('/onboarding/path').catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1304,7 +1304,7 @@ async function payWithLightning() {
|
|||||||
|
|
||||||
function scheduleInvoicePoll() {
|
function scheduleInvoicePoll() {
|
||||||
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
||||||
invoicePollTimer = setTimeout(pollInvoice, 3000)
|
invoicePollTimer = setTimeout(pollInvoice, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollInvoice() {
|
async function pollInvoice() {
|
||||||
|
|||||||
@ -16,11 +16,22 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
import { isOnboardingComplete } from '@/composables/useOnboarding'
|
||||||
|
import { IS_DEMO, demoIntroSeenToday } from '@/composables/useDemoIntro'
|
||||||
import BootScreen from '@/components/BootScreen.vue'
|
import BootScreen from '@/components/BootScreen.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const showBootScreen = ref(false)
|
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) {
|
function log(msg: string, data?: unknown) {
|
||||||
const ts = new Date().toISOString()
|
const ts = new Date().toISOString()
|
||||||
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
|
const entry = `[RootRedirect ${ts}] ${msg}` + (data !== undefined ? ` ${JSON.stringify(data)}` : '')
|
||||||
@ -68,6 +79,10 @@ async function checkOnboarded(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function proceedToApp() {
|
async function proceedToApp() {
|
||||||
|
if (IS_DEMO) {
|
||||||
|
demoRoute()
|
||||||
|
return
|
||||||
|
}
|
||||||
const devMode = import.meta.env.VITE_DEV_MODE
|
const devMode = import.meta.env.VITE_DEV_MODE
|
||||||
if (devMode === 'setup' || devMode === 'existing') {
|
if (devMode === 'setup' || devMode === 'existing') {
|
||||||
log('proceedToApp devMode', { devMode })
|
log('proceedToApp devMode', { devMode })
|
||||||
@ -121,6 +136,11 @@ onMounted(async () => {
|
|||||||
log('production flow', { isUp })
|
log('production flow', { isUp })
|
||||||
|
|
||||||
if (isUp) {
|
if (isUp) {
|
||||||
|
// Demo: per-day intro gate instead of server-side onboarding state.
|
||||||
|
if (IS_DEMO) {
|
||||||
|
demoRoute()
|
||||||
|
return
|
||||||
|
}
|
||||||
const onboarded = await checkOnboarded()
|
const onboarded = await checkOnboarded()
|
||||||
if (onboarded) {
|
if (onboarded) {
|
||||||
log('server up + onboarded → proceedToApp')
|
log('server up + onboarded → proceedToApp')
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/** Static configuration maps for app session routing and display */
|
/** Static configuration maps for app session routing and display */
|
||||||
|
|
||||||
import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig'
|
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'
|
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) */
|
/** Resolve app URL using direct port mapping (source of truth) */
|
||||||
export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: string): string {
|
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
|
// External HTTPS apps
|
||||||
const ext = EXTERNAL_URLS[id]
|
const ext = EXTERNAL_URLS[id]
|
||||||
if (ext) return ext
|
if (ext) return ext
|
||||||
|
|||||||
@ -102,9 +102,9 @@
|
|||||||
@click.stop="$emit('launch', app)"
|
@click.stop="$emit('launch', app)"
|
||||||
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
|
||||||
>Launch</button>
|
>Launch</button>
|
||||||
<!-- Scanning -->
|
<!-- Scanning (skipped in demo — there are no real containers to scan) -->
|
||||||
<span
|
<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"
|
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>
|
<span class="discover-shimmer-bg"></span>
|
||||||
@ -116,6 +116,12 @@
|
|||||||
Checking...
|
Checking...
|
||||||
</span>
|
</span>
|
||||||
</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 -->
|
<!-- Install button -->
|
||||||
<button
|
<button
|
||||||
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
|
||||||
@ -158,6 +164,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MarketplaceApp } from './types'
|
import type { MarketplaceApp } from './types'
|
||||||
import { handleImageError } from '@/views/apps/appsConfig'
|
import { handleImageError } from '@/views/apps/appsConfig'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
filteredApps: MarketplaceApp[]
|
filteredApps: MarketplaceApp[]
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
Starting...
|
Starting...
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-else-if="!containersScanned && app.dockerImage"
|
v-else-if="!IS_DEMO && !containersScanned && app.dockerImage"
|
||||||
disabled
|
disabled
|
||||||
class="text-white/40 text-sm flex items-center gap-2"
|
class="text-white/40 text-sm flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@ -74,6 +74,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Checking...
|
Checking...
|
||||||
</button>
|
</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
|
<button
|
||||||
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
v-else-if="!isInstalled(app.id) && app.dockerImage"
|
||||||
data-controller-install-btn
|
data-controller-install-btn
|
||||||
@ -99,6 +104,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||||
import { handleImageError } from '@/views/apps/appsConfig'
|
import { handleImageError } from '@/views/apps/appsConfig'
|
||||||
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
featuredApps: FeaturedApp[]
|
featuredApps: FeaturedApp[]
|
||||||
|
|||||||
@ -151,6 +151,16 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
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/)
|
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
|
||||||
'/aiui': {
|
'/aiui': {
|
||||||
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user