bug fixes from sxsw

This commit is contained in:
Dorian 2026-03-14 17:12:41 +00:00
parent dcddc7a5dd
commit b786f68e7a
50 changed files with 1635 additions and 543 deletions

View File

@ -0,0 +1,244 @@
# Manage — Claude Code Configuration Dashboard
## Context
You have 77 skills, 15 hooks, 17 memory files, 19 plans, and settings across 5 projects + global scope. All stored as flat files (markdown with YAML frontmatter, JSON, bash scripts) under `~/.claude/` and `{project}/.claude/`. Currently the only way to manage these is manually editing files. This project creates a visual web dashboard for browsing, creating, editing, and organizing all of it.
**Project location**: `/Users/dorian/Projects/Manage`
**Stack**: Vue 3 + Vite + TypeScript + Tailwind + Pinia (frontend) + Express + tsx (backend)
**Design**: Glassmorphism dark theme (matching Archipelago aesthetic)
---
## Architecture
```
Browser (localhost:5173) Express Server (localhost:3141)
+-----------------------+ +----------------------------+
| Vue 3 SPA | fetch | /api/projects |
| +-- Dashboard | ------> | /api/skills (CRUD) |
| +-- Skills | | /api/hooks (CRUD) |
| +-- Hooks | SSE | /api/memory (CRUD) |
| +-- Memory | <------ | /api/plans (CRUD) |
| +-- Plans | | /api/settings (R/W) |
| +-- Settings | | /api/claude-md (R/W) |
| +-- CLAUDE.md | | /api/search |
+-----------------------+ | /api/events (SSE) |
+-------------+--------------+
| chokidar
+-------------v--------------+
| ~/.claude/ |
| ~/Projects/*/.claude/ |
+----------------------------+
```
Single command start: `npm start` runs both server + Vite via concurrently.
---
## Phase 1: Foundation — Project Setup + Dashboard
### 1.1 Scaffold project
- `npm create vite@latest` with Vue + TypeScript
- Install deps: `express`, `cors`, `gray-matter`, `chokidar`, `concurrently`, `tsx`, `@vueuse/core`, `vue-router`, `pinia`, `fuse.js`
- Configure `vite.config.ts` with `@` alias and `/api` proxy to `:3141`
- Configure Tailwind with glassmorphism tokens from archy
### 1.2 Design system (`src/style.css`)
- Port glassmorphism classes from `neode-ui/src/style.css`: `.glass-card`, `.glass-button`, `.path-option-card`, `.info-card`, `.scope-badge`
- New classes: `.skill-card`, `.hook-node`, `.memory-tree-item`, `.plan-progress-bar`, `.editor-panel`
- Background: `#0a0a0a`, accent: `#fb923c`
### 1.3 Backend: Project discovery
- **`server/index.ts`** — Express on :3141 with CORS + JSON body parser
- **`server/lib/discovery.ts`** — Scan `~/Projects/` for dirs with `.claude/`, decode `~/.claude/projects/` encoded paths, count skills/hooks/memory/plans per project
- **`GET /api/projects`** — Return project list with counts
### 1.4 Frontend: App shell + Dashboard
- **`AppShell.vue`** — Sidebar (project switcher + nav links) + router-view content area
- **`Sidebar.vue`** — "Global" at top, then project list; active project highlighted; click to switch scope
- **`Dashboard.vue`** — Stats row (total skills/hooks/memory/plans) + project cards grid
- **`ProjectCard.vue`** — Glass card showing project name, path, skill/hook/memory counts, click to select
- **`stores/projects.ts`** — Pinia store: `projects[]`, `activeProject`, `fetchProjects()`, `setActiveProject()`
**Verify**: `npm start` opens browser, sidebar shows 5 projects + global, dashboard shows stats.
---
## Phase 2: Skills Manager
### 2.1 Backend
- **`server/lib/skill-parser.ts`** — Parse SKILL.md YAML frontmatter via `gray-matter`, handle both `skills/{name}/SKILL.md` (dir-based) and `skills/{name}.md` (flat) formats
- **`server/lib/fs-utils.ts`** — Safe read/write/delete/mkdir helpers with atomic writes
- **`server/routes/skills.ts`** — Full CRUD + `POST /api/skills/move` for scope transfers
### 2.2 Frontend
- **`Skills.vue`** — Top bar: scope filter, grid/list toggle, category dropdown, search. Grid of SkillCards. FAB for "New Skill"
- **`SkillCard.vue`** — Name, description (truncated), scope badge, category color stripe, allowed-tools pills. Click opens editor.
- **`SkillEditor.vue`** — Slide-in panel: frontmatter form (name, description, category, tags, allowed-tools, disable-model-invocation toggle) + Monaco editor for markdown body + live preview
- **`InheritanceMap.vue`** — Two-column view: global skills left, project skills right, connecting lines for name-matched overrides
- **Drag-and-drop**: Drag SkillCard between global/project columns to move/copy. Uses `vue-draggable-plus`.
**Verify**: Browse all 77 skills, create/edit/delete, drag between scopes, see inheritance.
---
## Phase 3: Hooks Manager
### 3.1 Backend
- **`server/lib/hook-parser.ts`** — Parse `settings.json` hook entries + read referenced `.sh` files. Detect orphaned scripts.
- **`server/routes/hooks.ts`** — CRUD + `PUT /toggle` for enable/disable. Creates .sh + updates settings.json atomically.
### 3.2 Frontend
- **`Hooks.vue`** — Grouped by event type (PreToolUse, PostToolUse, UserPromptSubmit, Stop, SessionEnd)
- **`HookPipeline.vue`** — Visual flow per hook: `[Event Badge] -> [Matcher Pill] -> [Script Name] -> [Action]` with CSS-drawn connecting arrows
- **`HookCard.vue`** — Event type badge (color-coded), matcher, script filename, enabled/disabled toggle switch
- **`HookEditor.vue`** — Monaco editor for `.sh` script + form for event type and matcher pattern
- Orphaned scripts in "Unlinked Scripts" section with "Link" button
**Verify**: See all 15 hooks in pipeline view, toggle enable/disable, edit scripts, create new hook.
---
## Phase 4: Memory Browser
### 4.1 Backend
- **`server/lib/memory-parser.ts`** — Parse from both locations: `{project}/.claude/memory/` (git-tracked) and `~/.claude/projects/{encoded}/memory/` (private). Parse YAML frontmatter.
- **`server/routes/memory.ts`** — CRUD + auto-sync MEMORY.md index on create/delete
### 4.2 Frontend
- **`Memory.vue`** — Split layout: tree panel (left 300px) + content panel (right)
- **`MemoryTree.vue`** — Collapsible tree: Project -> Scope -> Type -> Files. Type badges: user (blue), feedback (orange), project (green), reference (purple)
- **`MemoryEditor.vue`** — Frontmatter form (name, description, type dropdown) + Monaco editor + markdown preview toggle
- Search input at top filters across titles and content
**Verify**: Browse all 17 memory files in tree, types color-coded, edit with preview, create new, MEMORY.md auto-updates.
---
## Phase 5: Plans Tracker
### 5.1 Backend
- **`server/lib/plan-parser.ts`** — Extract title from `#`, phases from `##`, tasks from `- [ ]`/`- [x]` with line numbers. Calculate completion percentages.
- **`server/routes/plans.ts`** — CRUD + `PUT /task` for toggling single checkbox by line number
### 5.2 Frontend
- **`PlanCard.vue`** — Title, overall progress bar, phase count, "12/47 tasks" text
- **`PlanDetail.vue`** — Expanded: title, summary, phases as sections with TaskCheckboxes
- **`PhaseBar.vue`** — Segmented bar: green (done) / amber (in-progress) / gray (pending)
- **`TaskCheckbox.vue`** — Click toggles checkbox, instant API call to update file
- "Edit Raw" switches to Monaco. "New Plan" uses overnight template.
**Verify**: See all 19 plans with progress bars, toggle checkboxes that persist, create new plan.
---
## Phase 6: Settings + CLAUDE.md Editor
### 6.1 Settings
- **`Settings.vue`** — Scope tabs (Global / Project). Sections:
- Permissions: toggle switches for allowed tools
- Hooks: visual tree of event -> matcher -> command with add/remove
- Plugins: installed plugin cards with enable/disable
- Effort Level: dropdown
- Raw JSON: toggle to edit settings.json directly in Monaco
### 6.2 CLAUDE.md
- **`ClaudeMd.vue`** — Scope tabs. Monaco editor with markdown syntax. Live preview panel. Unsaved changes indicator. Save button.
**Verify**: Edit settings, toggle permissions, edit CLAUDE.md with preview, confirm files updated.
---
## Phase 7: Polish — File Watching, Search, Animations
### 7.1 Live file watching
- **`server/lib/file-watcher.ts`** — chokidar watches all `.claude/` dirs. Debounce 300ms. Push SSE events.
- **`useFileWatcher.ts`** composable — EventSource connection, triggers store refresh on changes
### 7.2 Global search
- **`GET /api/search?q=bitcoin`** — Full-text across skills, memory, plans, CLAUDE.md
- **`TopBar.vue`** — Cmd+K search input with dropdown results
### 7.3 Drag-and-drop refinement
- `vue-draggable-plus` for skills between scopes and plan task reordering
### 7.4 Final polish
- Loading skeletons, empty states, confirm dialogs on deletes
- Keyboard shortcuts: Cmd+K (search), Cmd+S (save), Escape (close panels)
- View transitions (fade + slide)
**Verify**: External file edits trigger UI refresh. Cmd+K searches everything. Drag skills between scopes.
---
## Project Structure
```
Manage/
+-- package.json
+-- tsconfig.json
+-- vite.config.ts
+-- tailwind.config.ts
+-- index.html
+-- .gitignore
+-- server/
| +-- index.ts
| +-- tsconfig.json
| +-- routes/
| | +-- projects.ts, skills.ts, hooks.ts, memory.ts
| | +-- plans.ts, settings.ts, claude-md.ts, search.ts
| +-- lib/
| | +-- discovery.ts, skill-parser.ts, hook-parser.ts
| | +-- memory-parser.ts, plan-parser.ts, settings-parser.ts
| | +-- file-watcher.ts, fs-utils.ts
| +-- types/
| +-- index.ts
+-- src/
| +-- main.ts, App.vue, style.css
| +-- api/client.ts
| +-- router/index.ts
| +-- stores/ (projects, skills, hooks, memory, plans, settings, search)
| +-- types/ (skill, hook, memory, plan, project, settings)
| +-- composables/ (useFileWatcher, useMarkdownPreview, useMonaco)
| +-- views/ (Dashboard, Skills, Hooks, Memory, Plans, Settings, ClaudeMd)
| +-- components/
| +-- layout/ (AppShell, Sidebar, TopBar)
| +-- shared/ (GlassCard, GlassButton, ScopeBadge, MonacoEditor, etc.)
| +-- dashboard/ (ProjectCard, QuickStats)
| +-- skills/ (SkillCard, SkillEditor, SkillList, InheritanceMap)
| +-- hooks/ (HookPipeline, HookCard, HookEditor)
| +-- memory/ (MemoryTree, MemoryCard, MemoryEditor)
| +-- plans/ (PlanCard, PlanDetail, PhaseBar, TaskCheckbox)
| +-- settings/ (PermissionToggle, HookConfig, PluginCard)
+-- public/
+-- favicon.svg
```
---
## Key Libraries
| Library | Purpose |
|---------|---------|
| `express` + `cors` | Backend HTTP server |
| `tsx` | Run TypeScript server without build step |
| `concurrently` | Run server + Vite in one command |
| `gray-matter` | Parse YAML frontmatter from markdown |
| `chokidar` | Watch filesystem for live updates |
| `monaco-editor` + `@monaco-editor/loader` | Code editor (md, bash, json, yaml) |
| `marked` + `highlight.js` | Markdown rendering with syntax highlighting |
| `vue-draggable-plus` | Drag-and-drop for skills and plan tasks |
| `fuse.js` | Client-side fuzzy search |
| `@vueuse/core` | Vue utilities (useEventSource, useDebounceFn) |
---
## Key Decisions
- **Express over Bun**: More predictable on macOS, better middleware ecosystem
- **SSE over WebSocket**: File watching is server->client only. SSE auto-reconnects, simpler.
- **Monaco over CodeMirror**: VS Code-like editing for all 4 file types
- **Atomic settings.json writes**: Read-modify-write with temp file + rename
- **MEMORY.md auto-sync**: Create/delete memory files auto-updates the index
- **Both skill formats**: Parser handles dir-based and flat-file skills

56
apps/indeedhub/Dockerfile Normal file
View File

@ -0,0 +1,56 @@
# Multi-stage Dockerfile for Indeehub Frontend (Next.js)
# Build: podman build -t localhost/indeedhub:latest -f apps/indeedhub/Dockerfile /path/to/indeehub-frontend
# Run: podman run -d --name indeedhub -p 8190:3000 localhost/indeedhub:latest
# ── Stage 1: Dependencies ──
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# ── Stage 2: Build ──
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Inject standalone output mode for containerized deployment
RUN sed -i 's/reactStrictMode: true,/reactStrictMode: true, output: "standalone",/' next.config.js
# Build-time environment — connects to Indeehub production services
ENV NEXT_PUBLIC_APP_ENVIRONMENT=production
ENV NEXT_PUBLIC_APP_URL=http://localhost:8190
ENV NEXT_PUBLIC_API_URL=https://api.indeehub.studio
ENV NEXT_PUBLIC_S3_PRIVATE_BUCKET=indeehub-private
ENV NEXT_PUBLIC_S3_PUBLIC_BUCKET=indeehub-public
ENV NEXT_PUBLIC_ENABLE_APPROVAL_FLOW=false
ENV NEXT_TELEMETRY_DISABLED=1
# Remove shaka-player .d.ts files that break the build (per package.json build script)
RUN rm -f ./node_modules/shaka-player/dist/*.d.ts
RUN npm run build
# ── Stage 3: Runner ──
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy standalone build output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]

View File

@ -1,44 +1,53 @@
# IndeedHub (Indeehub Prototype)
# Indeehub — Bitcoin Documentary Streaming
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider.
## Building the Image
The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo).
The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`.
### Option 1: Build from prototype directory
```bash
cd "/path/to/Indeedhub Prototype"
podman build -t localhost/indeedhub:latest .
```
### Option 2: Use the build script
### Option 1: Use the build script
```bash
# From archy repo root
./apps/indeedhub/build-from-prototype.sh
```
### Option 3: Full deploy (build + run on server)
### Option 2: Build from source directory
```bash
cd "/path/to/Indeedhub Prototype"
./deploy-to-archipelago.sh
cd ~/Projects/indeehub-frontend
podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile .
```
## Installing from My Apps
## Installing from App Store
1. **Build the image** using one of the options above (the image must exist before install)
2. Go to **Dashboard → App Store** (Marketplace)
3. Find **Indeehub Prototype** and click **Install**
4. The app will appear in **My Apps** once the container is running
1. **Build the image** using one of the options above (must exist before install)
2. Go to **Dashboard -> App Store** (Marketplace)
3. Find **Indeehub** and click **Install**
4. On first launch, pick a Nostr identity to sign in with
5. The app appears in **My Apps** once the container is running
## Port
- Web UI: 7777
- Web UI: 8190 (maps to container port 3000)
## Container
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
- Port: 7777
- Runtime: Node.js 20 (Next.js standalone)
- Port: 8190 -> 3000
- Read-only root filesystem with tmpfs for /tmp and .next/cache
## Nostr Identity
On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy.
## Services
The app connects to the following external services (configured at build time):
- Indeehub API (content, auth, streaming)
- AWS S3 (media storage via CloudFront CDN)
- Nostr relays (via NIP-07 provider from Archipelago)

View File

@ -1,17 +1,22 @@
#!/bin/bash
# Build Indeehub image from the Indeehub Prototype project
# Usage: ./build-from-prototype.sh [path-to-prototype]
# Build Indeehub container image from the indeehub-frontend project
# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype"
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}"
DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend"
FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}"
IMAGE_TAG="localhost/indeedhub:latest"
if [ ! -d "$PROTOTYPE_DIR" ]; then
echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR"
echo " Set path: $0 /path/to/Indeedhub\ Prototype"
if [ ! -d "$FRONTEND_DIR" ]; then
echo "Indeehub frontend not found at: $FRONTEND_DIR"
echo " Set path: $0 /path/to/indeehub-frontend"
exit 1
fi
if [ ! -f "$FRONTEND_DIR/package.json" ]; then
echo "No package.json found in $FRONTEND_DIR — is this the right directory?"
exit 1
fi
@ -21,10 +26,10 @@ if ! command -v podman >/dev/null 2>&1; then
RUNTIME="docker"
fi
echo "🔨 Building Indeehub from $PROTOTYPE_DIR"
cd "$PROTOTYPE_DIR"
$RUNTIME build -t "$IMAGE_TAG" .
echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile"
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR"
echo "Built $IMAGE_TAG"
echo "Built $IMAGE_TAG"
echo ""
echo "You can now install Indeehub from the App Store in Archipelago."
echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG"

View File

@ -2,51 +2,51 @@ app:
id: indeedhub
name: Indeehub
version: 0.1.0
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: media
container:
image: localhost/indeedhub:1.0.0
pull_policy: never # Built locally
image: git.tx1138.com/lfg2025/indeedhub:latest
pull_policy: always # Pull from registry; falls back to local build
dependencies:
- storage: 500Mi
- storage: 1Gi
resources:
cpu_limit: 1
cpu_limit: 2
memory_limit: 512Mi
disk_limit: 500Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: true # Static nginx content
readonly_root: true
no_new_privileges: true
user: 1000
user: 1001
seccomp_profile: default
network_policy: bridge
apparmor_profile: default
ports:
- host: 7777
container: 7777
protocol: tcp # Web UI
- host: 8190
container: 3000
protocol: tcp # Web UI (Next.js)
volumes:
- type: tmpfs
target: /var/cache/nginx
options: [rw,noexec,nosuid,size=10m]
target: /tmp
options: [rw,noexec,nosuid,size=64m]
- type: tmpfs
target: /var/run
options: [rw,noexec,nosuid,size=10m]
target: /app/.next/cache
options: [rw,noexec,nosuid,size=128m]
environment:
- NGINX_HOST=localhost
- NGINX_PORT=7777
- NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1
health_check:
type: http
endpoint: http://localhost:7777
path: /health
endpoint: http://localhost:3000
path: /
interval: 30s
timeout: 10s
retries: 3
@ -55,9 +55,9 @@ app:
interfaces:
main:
name: Web UI
description: Stream Bitcoin documentaries
description: Stream Bitcoin documentaries with Nostr identity
type: ui
port: 7777
port: 8190
protocol: http
path: /
@ -72,3 +72,4 @@ app:
- streaming
- media
- education
- nostr

View File

@ -0,0 +1,70 @@
#!/bin/bash
# Build and push Indeehub container image to a registry
# Usage: ./push-to-registry.sh [version]
#
# Environment variables:
# REGISTRY - Registry host (default: ghcr.io)
# NAMESPACE - Registry namespace (default: archipelago-os)
# RUNTIME - Container runtime (default: podman)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}"
VERSION="${1:-latest}"
REGISTRY="${REGISTRY:-git.tx1138.com}"
NAMESPACE="${NAMESPACE:-lfg2025}"
IMAGE_NAME="indeedhub"
RUNTIME="${RUNTIME:-podman}"
FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}"
LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest"
if [ ! -d "$FRONTEND_DIR" ]; then
echo "Indeehub frontend not found at: $FRONTEND_DIR"
echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend"
exit 1
fi
echo "=== Indeehub Container Registry Push ==="
echo "Source: $FRONTEND_DIR"
echo "Image: $FULL_TAG"
echo "Runtime: $RUNTIME"
echo ""
# Step 1: Build for linux/amd64 (target architecture)
echo "[1/3] Building image..."
$RUNTIME build --platform linux/amd64 \
-t "$FULL_TAG" \
-t "$LATEST_TAG" \
-t "localhost/${IMAGE_NAME}:latest" \
-t "localhost/${IMAGE_NAME}:${VERSION}" \
-f "$SCRIPT_DIR/Dockerfile" \
"$FRONTEND_DIR"
echo "[2/3] Pushing to registry..."
# Login check
if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then
echo ""
echo "Not logged in to $REGISTRY."
echo "Run: $RUNTIME login $REGISTRY"
exit 1
fi
$RUNTIME push "$FULL_TAG"
if [ "$VERSION" != "latest" ]; then
$RUNTIME push "$LATEST_TAG"
fi
echo ""
echo "[3/3] Done!"
echo ""
echo "Image pushed: $FULL_TAG"
if [ "$VERSION" != "latest" ]; then
echo "Also tagged: $LATEST_TAG"
fi
echo ""
echo "Federated nodes can now install via:"
echo " podman pull $FULL_TAG"
echo ""
echo "Update marketplace dockerImage to: $FULL_TAG"

View File

@ -34,11 +34,6 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Docker image format"));
}
// Virtual app: Indeehub (no container, opens external URL)
if package_id == "indeedhub" {
return Ok(serde_json::json!({ "success": true }));
}
// Multi-container apps: create full stack
if package_id == "immich" {
return self.install_immich_stack().await;
@ -117,8 +112,17 @@ impl RpcHandler {
}
// Pull the image (skip for local images - must be built locally first)
// For registry images, also check if a local build exists first (avoids
// pull failures when the registry image hasn't been pushed yet).
let is_local_image = docker_image.starts_with("localhost/");
if !is_local_image {
let has_local_fallback = if !is_local_image {
let local_tag = format!("localhost/{}:latest", package_id);
let check = tokio::process::Command::new("sudo")
.args(["podman", "images", "-q", &local_tag])
.output().await.ok();
check.map_or(false, |o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
} else { false };
if !is_local_image && !has_local_fallback {
debug!("Pulling image: {}", docker_image);
// Set package state to Installing with progress
@ -156,6 +160,9 @@ impl RpcHandler {
// Mark pull as complete (100%)
self.set_install_progress(package_id, 100, 100).await;
} else if has_local_fallback {
// Registry image exists locally — use the local build
debug!("Using local build for {} (skipping registry pull)", package_id);
} else {
// Verify local image exists
let images_output = tokio::process::Command::new("sudo")
@ -165,7 +172,7 @@ impl RpcHandler {
.context("Failed to check local image")?;
if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() {
return Err(anyhow::anyhow!(
"Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app.",
"Local image {} not found. Build the image first or ensure the registry is reachable.",
docker_image
));
}
@ -342,8 +349,13 @@ printtoconsole=1\n";
run_args.push(arg);
}
// Finally, the image
run_args.push(docker_image);
// Finally, the image — use local build if available, otherwise registry image
let effective_image = if has_local_fallback {
format!("localhost/{}:latest", package_id)
} else {
docker_image.to_string()
};
run_args.push(&effective_image);
debug!("Running container with args: {:?}", run_args);
@ -1723,9 +1735,9 @@ fn get_app_config(
]),
),
"indeedhub" => (
vec!["7777:7777".to_string()],
vec!["8190:3000".to_string()],
vec![],
vec!["NGINX_HOST=0.0.0.0".to_string(), "NGINX_PORT=7777".to_string()],
vec!["NODE_ENV=production".to_string(), "NEXT_TELEMETRY_DISABLED=1".to_string()],
None,
None,
),

View File

@ -136,6 +136,9 @@ impl DockerPackageScanner {
} else if app_id == "dwn" {
debug!("Using DWN server: http://localhost:3100");
Some("http://localhost:3100".to_string())
} else if app_id == "indeedhub" {
debug!("Using Indeehub: http://localhost:8190");
Some("http://localhost:8190".to_string())
} else if app_id == "mempool-electrs" || app_id == "electrs" {
// Electrs UI runs on host at port 50002
debug!("Using electrs-ui for mempool-electrs: http://localhost:50002");
@ -219,66 +222,6 @@ impl DockerPackageScanner {
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
}
// Virtual app: Indeehub (opens external URL, no container required)
if !packages.contains_key("indeedhub") {
let metadata = get_app_metadata("indeedhub");
let lan_address = Some("https://archipelago.indeehub.studio".to_string());
let virtual_pkg = PackageDataEntry {
state: PackageState::Running,
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
icon: metadata.icon.clone(),
},
manifest: Manifest {
id: "indeedhub".to_string(),
title: metadata.title.clone(),
version: "0.1.0".to_string(),
description: Description {
short: metadata.description.clone(),
long: metadata.description.clone(),
},
release_notes: "Virtual app (opens archipelago.indeehub.studio)".to_string(),
license: "MIT".to_string(),
wrapper_repo: metadata.repo.clone(),
upstream_repo: metadata.repo.clone(),
support_site: metadata.repo.clone(),
marketing_site: metadata.repo.clone(),
donation_url: None,
author: Some("Indeehub Team".to_string()),
website: lan_address.clone(),
tier: Some("optional".to_string()),
interfaces: Some(Interfaces {
main: Some(MainInterface {
ui: Some("true".to_string()),
tor_config: None,
lan_config: None,
}),
}),
},
installed: Some(InstalledPackageDataEntry {
current_dependents: HashMap::new(),
current_dependencies: HashMap::new(),
last_backup: None,
interface_addresses: {
let mut addresses = HashMap::new();
addresses.insert(
"main".to_string(),
InterfaceAddress {
tor_address: String::new(),
lan_address: lan_address,
},
);
addresses
},
status: ServiceStatus::Running,
}),
install_progress: None,
};
packages.insert("indeedhub".to_string(), virtual_pkg);
info!("Virtual app: Indeehub (archipelago.indeehub.studio)");
}
Ok(packages)
}
}
@ -505,7 +448,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/tailscale/tailscale".to_string(),
tier: "",
},
"indeedhub" => AppMetadata {
"indeedhub" | "indeehub" => AppMetadata {
title: "Indeehub".to_string(),
description: "Decentralized media streaming platform".to_string(),
icon: "/assets/img/app-icons/indeehub.ico".to_string(),

View File

@ -331,6 +331,20 @@ server {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/indeedhub/ {
proxy_pass http://127.0.0.1:8190/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
add_header X-Content-Type-Options "nosniff" always;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;

View File

@ -231,6 +231,19 @@ location /app/electrs/ {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/indeedhub/ {
proxy_pass http://127.0.0.1:8190/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_http_version 1.1;

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.09ki1c64ohs"
"revision": "0.6f1usind3cc"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1019 KiB

After

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 774 KiB

Binary file not shown.

Binary file not shown.

View File

@ -55,6 +55,11 @@ class RPCClient {
clearTimeout(timeoutId)
if (!response.ok) {
// Session expired — redirect to login
if (response.status === 401 && method !== 'auth.login') {
window.location.href = '/login'
throw new Error('Session expired')
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) {

View File

@ -172,12 +172,21 @@
@approve="store.approveConsent"
@deny="store.denyConsent"
/>
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
<NostrIdentityPicker
:show="showIdentityPicker"
:app-name="store.title || 'App'"
@select="onIdentitySelected"
@cancel="showIdentityPicker = false"
/>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest {
@ -197,6 +206,73 @@ const isRefreshing = ref(false)
const iframeLoading = ref(true)
const iframeBlocked = ref(false)
// Nostr identity picker state
const showIdentityPicker = ref(false)
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
interface SelectedIdentity {
id: string
name: string
did: string
pubkey: string
nostr_pubkey?: string
nostr_npub?: string
}
/** Get the stored identity for an app, or null if first launch */
function getStoredIdentity(appUrl: string): SelectedIdentity | null {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
const stored = localStorage.getItem(key)
return stored ? JSON.parse(stored) as SelectedIdentity : null
} catch {
return null
}
}
/** Store the selected identity for an app */
function storeIdentity(appUrl: string, identity: SelectedIdentity) {
try {
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
localStorage.setItem(key, JSON.stringify(identity))
} catch { /* ignore */ }
}
/** Handle identity selection from the picker */
function onIdentitySelected(identity: SelectedIdentity) {
showIdentityPicker.value = false
if (store.url) {
storeIdentity(store.url, identity)
}
// Send identity to the iframe
sendSelectedIdentity(identity)
}
/** Send a specific identity to the iframe */
async function sendSelectedIdentity(identity: SelectedIdentity) {
try {
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({
method: 'identity.sign',
params: { id: identity.id, message: challenge }
})
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:identity',
did: identity.did,
name: identity.name,
pubkey: identity.pubkey,
nostr_pubkey: identity.nostr_pubkey || null,
nostr_npub: identity.nostr_npub || null,
challenge,
signature: sigRes.signature
}, '*')
} catch {
/* identity signing not available */
}
}
// Timers for iframe load detection
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let contentCheckId: ReturnType<typeof setTimeout> | null = null
@ -274,37 +350,27 @@ function checkIframeContent() {
/** Apps that support the Archipelago identity protocol (postMessage) */
function isIdentityAwareApp(url: string): boolean {
return url.includes('indeehub')
return url.includes('indeehub') || url.includes('indeedhub')
}
/** Send the user's default identity to the iframe via postMessage */
/** Send the user's identity to the iframe via postMessage.
* On first launch, shows the identity picker modal.
* On subsequent launches, uses the previously selected identity. */
async function sendIdentityIfSupported() {
if (!store.url || !isIdentityAwareApp(store.url)) return
try {
const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string; nostr_npub?: string }> }>({ method: 'identity.list' })
const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0]
if (!defaultId) return
// Sign a timestamp challenge to prove ownership
const challenge = `archipelago-identity:${Date.now()}`
const sigRes = await rpcClient.call<{ signature: string }>({
method: 'identity.sign',
params: { id: defaultId.id, message: challenge }
})
const iframe = iframeRef.value
if (!iframe?.contentWindow) return
iframe.contentWindow.postMessage({
type: 'archipelago:identity',
did: defaultId.did,
name: defaultId.name,
pubkey: defaultId.pubkey,
nostr_pubkey: defaultId.nostr_pubkey || null,
nostr_npub: defaultId.nostr_npub || null,
challenge,
signature: sigRes.signature
}, '*')
} catch (e) {
if (import.meta.env.DEV) console.warn('Identity not available — continuing without it', e)
// Check if we have a stored identity for this app
const stored = getStoredIdentity(store.url)
if (stored) {
// Use the previously selected identity
await sendSelectedIdentity(stored)
return
}
// First launch show the identity picker
showIdentityPicker.value = true
return // Identity will be sent after selection via onIdentitySelected
}
function injectScrollbarHideIfSameOrigin() {

View File

@ -8,7 +8,7 @@
</div>
<!-- Full mode switcher -->
<div v-else class="mode-switcher">
<div v-else class="mode-switcher mode-switcher-full">
<button
v-for="m in modes"
:key="m.id"

View File

@ -0,0 +1,444 @@
<template>
<Teleport to="body">
<Transition name="identity-picker">
<div
v-if="show"
class="fixed inset-0 z-[3100] flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<!-- Backdrop with animated scan lines -->
<div class="absolute inset-0 bg-black/80 backdrop-blur-md identity-picker-backdrop"></div>
<!-- Floating binary rain particles (CSS-only) -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div v-for="i in 20" :key="i" class="cyber-particle" :style="particleStyle(i)">
{{ particleChar(i) }}
</div>
</div>
<!-- Main panel -->
<div
ref="modalRef"
@click.stop
class="relative z-10 w-full max-w-lg"
>
<!-- Cypherpunk header graphic -->
<div class="relative mb-6 flex flex-col items-center">
<!-- SVG keyhole / identity node graphic -->
<div class="cyber-glow-ring">
<svg viewBox="0 0 120 120" class="w-24 h-24" xmlns="http://www.w3.org/2000/svg">
<!-- Outer ring with dash animation -->
<circle cx="60" cy="60" r="54" fill="none" stroke="rgba(251,146,60,0.3)" stroke-width="1" />
<circle cx="60" cy="60" r="54" fill="none" stroke="#fb923c" stroke-width="2"
stroke-dasharray="8 4" class="cyber-ring-spin" />
<!-- Inner ring -->
<circle cx="60" cy="60" r="38" fill="none" stroke="rgba(251,146,60,0.15)" stroke-width="1" />
<circle cx="60" cy="60" r="38" fill="none" stroke="#fb923c" stroke-width="1.5"
stroke-dasharray="4 8" class="cyber-ring-spin-reverse" />
<!-- Key icon center -->
<g transform="translate(60,60)">
<!-- Key head (circle) -->
<circle cx="0" cy="-8" r="10" fill="none" stroke="#fb923c" stroke-width="2" />
<circle cx="0" cy="-8" r="4" fill="#fb923c" opacity="0.4" />
<!-- Key shaft -->
<line x1="0" y1="2" x2="0" y2="22" stroke="#fb923c" stroke-width="2" />
<!-- Key teeth -->
<line x1="0" y1="14" x2="6" y2="14" stroke="#fb923c" stroke-width="2" />
<line x1="0" y1="19" x2="4" y2="19" stroke="#fb923c" stroke-width="2" />
</g>
<!-- Network nodes -->
<circle cx="16" cy="28" r="2" fill="#fb923c" opacity="0.6" />
<circle cx="104" cy="32" r="2" fill="#fb923c" opacity="0.6" />
<circle cx="20" cy="92" r="2" fill="#fb923c" opacity="0.6" />
<circle cx="100" cy="88" r="2" fill="#fb923c" opacity="0.6" />
<!-- Connection lines to center -->
<line x1="16" y1="28" x2="40" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.3" />
<line x1="104" y1="32" x2="80" y2="48" stroke="#fb923c" stroke-width="0.5" opacity="0.3" />
<line x1="20" y1="92" x2="40" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.3" />
<line x1="100" y1="88" x2="80" y2="72" stroke="#fb923c" stroke-width="0.5" opacity="0.3" />
</svg>
</div>
<h2 class="mt-4 text-xl font-bold text-white tracking-wide">SELECT IDENTITY</h2>
<p class="mt-1 text-xs text-orange-400/70 font-mono tracking-widest uppercase">Nostr Authentication Protocol</p>
</div>
<!-- Identity list -->
<div class="cyber-panel p-4 space-y-3 max-h-[50vh] overflow-y-auto">
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-8">
<svg class="animate-spin h-6 w-6 text-orange-400" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span class="ml-3 text-white/60 text-sm font-mono">Loading identities...</span>
</div>
<!-- No identities -->
<div v-else-if="identities.length === 0" class="text-center py-8">
<p class="text-white/50 text-sm">No identities found.</p>
<p class="text-white/30 text-xs mt-1">Create one in Settings &rarr; Credentials</p>
</div>
<!-- Identity cards -->
<button
v-for="identity in identities"
:key="identity.id"
type="button"
class="cyber-identity-card w-full text-left"
:class="{ 'cyber-identity-selected': selectedId === identity.id }"
@click="selectedId = identity.id"
>
<div class="flex items-center gap-3">
<!-- Identity avatar -->
<div class="cyber-avatar" :class="purposeColor(identity.purpose)">
<span class="text-sm font-bold">{{ identity.name.charAt(0).toUpperCase() }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-semibold text-sm truncate">{{ identity.name }}</span>
<span v-if="identity.is_default" class="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/20 text-orange-400 font-mono">DEFAULT</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span v-if="identity.nostr_npub" class="text-white/40 text-xs font-mono truncate">{{ truncateNpub(identity.nostr_npub) }}</span>
<span v-else class="text-red-400/60 text-xs font-mono">NO NOSTR KEY</span>
</div>
</div>
<!-- Selection indicator -->
<div class="shrink-0">
<div v-if="selectedId === identity.id" class="w-5 h-5 rounded-full bg-orange-500/30 border border-orange-400 flex items-center justify-center">
<div class="w-2.5 h-2.5 rounded-full bg-orange-400"></div>
</div>
<div v-else class="w-5 h-5 rounded-full border border-white/20"></div>
</div>
</div>
</button>
</div>
<!-- Action buttons -->
<div class="flex gap-3 mt-4">
<button @click="$emit('cancel')" class="cyber-btn flex-1 py-3 text-sm font-medium">
CANCEL
</button>
<button
@click="confirm"
:disabled="!selectedId || !hasNostrKey"
class="cyber-btn-primary flex-1 py-3 text-sm font-bold"
>
<svg class="w-4 h-4 mr-1.5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
AUTHENTICATE
</button>
</div>
<!-- Footer info line -->
<p class="mt-3 text-center text-[10px] text-white/25 font-mono tracking-wider">
NIP-07 &middot; SECP256K1 &middot; SIGNED LOCALLY
</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { rpcClient } from '@/api/rpc-client'
interface Identity {
id: string
name: string
purpose: string
pubkey: string
did: string
is_default: boolean
nostr_pubkey?: string
nostr_npub?: string
}
const props = defineProps<{
show: boolean
appName: string
}>()
const emit = defineEmits<{
select: [identity: Identity]
cancel: []
}>()
const modalRef = ref<HTMLElement | null>(null)
const identities = ref<Identity[]>([])
const selectedId = ref<string | null>(null)
const loading = ref(false)
useModalKeyboard(modalRef, computed(() => props.show), () => emit('cancel'))
const hasNostrKey = computed(() => {
const selected = identities.value.find(i => i.id === selectedId.value)
return selected?.nostr_pubkey != null
})
watch(() => props.show, async (open) => {
if (open) {
await loadIdentities()
}
})
onMounted(() => {
if (props.show) loadIdentities()
})
async function loadIdentities() {
loading.value = true
try {
const res = await rpcClient.call<{ identities: Identity[] }>({ method: 'identity.list' })
identities.value = res.identities || []
// Auto-select the default identity or first one with a Nostr key
const defaultId = identities.value.find(i => i.is_default && i.nostr_pubkey)
|| identities.value.find(i => i.nostr_pubkey)
if (defaultId) {
selectedId.value = defaultId.id
}
} catch {
identities.value = []
} finally {
loading.value = false
}
}
function confirm() {
const selected = identities.value.find(i => i.id === selectedId.value)
if (selected) {
emit('select', selected)
}
}
function truncateNpub(npub: string): string {
if (npub.length <= 20) return npub
return npub.slice(0, 12) + '...' + npub.slice(-6)
}
function purposeColor(purpose: string): string {
switch (purpose) {
case 'business': return 'cyber-avatar-blue'
case 'anonymous': return 'cyber-avatar-purple'
default: return 'cyber-avatar-orange'
}
}
function particleStyle(i: number): Record<string, string> {
const left = ((i * 37 + 13) % 100)
const delay = ((i * 1.3) % 8).toFixed(1)
const duration = (6 + (i % 5) * 2).toFixed(1)
const size = 10 + (i % 3) * 2
return {
left: `${left}%`,
animationDelay: `${delay}s`,
animationDuration: `${duration}s`,
fontSize: `${size}px`,
}
}
function particleChar(i: number): string {
const chars = '01アイウエオカキクケコ暗号鍵身元'
return chars[i % chars.length] ?? '0'
}
</script>
<style scoped>
/* Animated scan line on backdrop */
.identity-picker-backdrop::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(251, 146, 60, 0.015) 2px,
rgba(251, 146, 60, 0.015) 4px
);
pointer-events: none;
animation: scanlines 8s linear infinite;
}
@keyframes scanlines {
0% { transform: translateY(0); }
100% { transform: translateY(4px); }
}
/* Floating binary/katakana particles */
.cyber-particle {
position: absolute;
top: -20px;
color: rgba(251, 146, 60, 0.12);
font-family: monospace;
animation: particle-fall linear infinite;
user-select: none;
}
@keyframes particle-fall {
0% { transform: translateY(-20px); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(calc(100vh + 20px)); opacity: 0; }
}
/* Glowing ring around the SVG icon */
.cyber-glow-ring {
position: relative;
padding: 8px;
}
.cyber-glow-ring::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(251, 146, 60, 0.1) 0%, transparent 70%);
animation: glow-pulse 3s ease-in-out infinite;
}
@keyframes glow-pulse {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 1; transform: scale(1.1); }
}
/* SVG ring animations */
.cyber-ring-spin {
animation: ring-rotate 20s linear infinite;
transform-origin: 60px 60px;
}
.cyber-ring-spin-reverse {
animation: ring-rotate 15s linear infinite reverse;
transform-origin: 60px 60px;
}
@keyframes ring-rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Main panel */
.cyber-panel {
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(251, 146, 60, 0.15);
border-radius: 12px;
backdrop-filter: blur(24px);
box-shadow:
0 0 30px rgba(251, 146, 60, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Identity cards */
.cyber-identity-card {
display: block;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
transition: all 0.2s ease;
cursor: pointer;
}
.cyber-identity-card:hover {
background: rgba(251, 146, 60, 0.05);
border-color: rgba(251, 146, 60, 0.15);
}
.cyber-identity-selected {
background: rgba(251, 146, 60, 0.08) !important;
border-color: rgba(251, 146, 60, 0.3) !important;
box-shadow: 0 0 12px rgba(251, 146, 60, 0.08);
}
/* Avatar badges */
.cyber-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cyber-avatar-orange {
background: rgba(251, 146, 60, 0.15);
color: #fb923c;
border: 1px solid rgba(251, 146, 60, 0.25);
}
.cyber-avatar-blue {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.25);
}
.cyber-avatar-purple {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.25);
}
/* Buttons */
.cyber-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
transition: all 0.2s ease;
font-family: monospace;
letter-spacing: 0.05em;
}
.cyber-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.cyber-btn-primary {
background: rgba(251, 146, 60, 0.15);
border: 1px solid rgba(251, 146, 60, 0.3);
border-radius: 8px;
color: #fb923c;
transition: all 0.2s ease;
font-family: monospace;
letter-spacing: 0.05em;
}
.cyber-btn-primary:hover:not(:disabled) {
background: rgba(251, 146, 60, 0.25);
border-color: rgba(251, 146, 60, 0.5);
box-shadow: 0 0 20px rgba(251, 146, 60, 0.15);
color: #fdba74;
}
.cyber-btn-primary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Transition */
.identity-picker-enter-active,
.identity-picker-leave-active {
transition: opacity 0.3s ease;
}
.identity-picker-enter-active > .relative {
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.3s ease;
}
.identity-picker-leave-active > .relative {
transition: transform 0.25s ease, opacity 0.2s ease;
}
.identity-picker-enter-from {
opacity: 0;
}
.identity-picker-enter-from > .relative {
transform: translateY(20px) scale(0.96);
opacity: 0;
}
.identity-picker-leave-to {
opacity: 0;
}
.identity-picker-leave-to > .relative {
transform: translateY(10px) scale(0.98);
opacity: 0;
}
</style>

View File

@ -370,8 +370,8 @@
"connectedNodes": "Connected Nodes",
"bitcoinDomains": "Bitcoin Domain Names",
"domainsSubtitle": "NIP-05 verified identities",
"copyDid": "Copy DID",
"viewDidDocument": "View DID Document",
"copyDid": "Copy",
"viewDidDocument": "View",
"createDid": "Create DID",
"creatingDid": "Creating...",
"manageDomains": "Manage Domains",

View File

@ -370,8 +370,8 @@
"connectedNodes": "Nodos conectados",
"bitcoinDomains": "Nombres de dominio Bitcoin",
"domainsSubtitle": "Identidades verificadas NIP-05",
"copyDid": "Copiar DID",
"viewDidDocument": "Ver documento DID",
"copyDid": "Copiar",
"viewDidDocument": "Ver",
"createDid": "Crear DID",
"creatingDid": "Creando...",
"manageDomains": "Administrar dominios",

View File

@ -114,9 +114,10 @@ const router = createRouter({
component: () => import('../views/Cloud.vue'),
},
{
path: 'cloud/peers',
path: 'cloud/peers/:peerId?',
name: 'peer-files',
component: () => import('../views/PeerFiles.vue'),
props: true,
},
{
path: 'cloud/:folderId',

View File

@ -67,6 +67,7 @@ const PORT_TO_PROXY: Record<string, string> = {
'8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/',
'8190': '/app/indeedhub/',
}
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
@ -188,12 +189,32 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const origin = url.value || 'unknown'
// Check if app has a per-app identity stored (from identity picker)
const IDENTITY_KEY = 'archipelago_app_identity_'
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
let appIdentityId: string | null = null
try {
const stored = localStorage.getItem(appKey)
if (stored) {
const parsed = JSON.parse(stored) as { id?: string }
appIdentityId = parsed.id || null
}
} catch { /* ignore */ }
try {
let result: unknown
if (method === 'getPublicKey') {
if (appIdentityId) {
// Use the app-specific identity's Nostr key
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
method: 'identity.get', params: { id: appIdentityId }
})
result = res.nostr_pubkey
} else {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
result = res.nostr_pubkey
}
} else if (method === 'signEvent') {
// Check if origin is pre-approved
const approved = getApprovedOrigins()
@ -208,32 +229,41 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return
}
}
if (appIdentityId) {
// Sign with the app-specific identity's Nostr key
const res = await rpcClient.call<unknown>({
method: 'identity.nostr-sign',
params: { id: appIdentityId, event: params.event }
})
result = res
} else {
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
result = res
}
} else if (method === 'getRelays') {
result = {}
} else if (method === 'nip04.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip04',
params: { pubkey: params.pubkey, plaintext: params.plaintext }
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
})
result = res.ciphertext
} else if (method === 'nip04.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip04',
params: { pubkey: params.pubkey, ciphertext: params.ciphertext }
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
})
result = res.plaintext
} else if (method === 'nip44.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip44',
params: { pubkey: params.pubkey, plaintext: params.plaintext }
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
})
result = res.ciphertext
} else if (method === 'nip44.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip44',
params: { pubkey: params.pubkey, ciphertext: params.ciphertext }
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
})
result = res.plaintext
} else {

View File

@ -60,6 +60,22 @@
}
}
/* Scroll container bottom padding — desktop breathing room */
.mobile-scroll-pad,
.mobile-scroll-pad-back {
padding-bottom: 6rem;
}
/* Mobile: override with tab bar clearance */
@media (max-width: 767px) {
.mobile-scroll-pad {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 16px);
}
.mobile-scroll-pad-back {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 64px);
}
}
/* Haptic-like press feedback for all interactive elements */
button:active:not(:disabled),
[role="button"]:active,
@ -131,8 +147,14 @@ input[type="radio"]:active + * {
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Full-width mode switcher variant (sidebar, mobile settings) */
.mode-switcher-full {
display: flex;
width: 100%;
}
.mode-switcher-btn {
flex: none;
flex: 1 1 0%;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
white-space: nowrap;
@ -146,6 +168,14 @@ input[type="radio"]:active + * {
background: transparent;
}
@media (max-width: 767px) {
.mode-switcher-btn {
min-height: 44px;
font-size: 0.875rem;
padding: 0.625rem 1rem;
}
}
.mode-switcher-btn:hover {
color: rgba(255, 255, 255, 0.75);
}
@ -235,10 +265,11 @@ input[type="radio"]:active + * {
background: transparent;
}
/* On mobile, pad iframe so AIUI content ends above the tab bar */
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */
@media (max-width: 767px) {
.chat-iframe-mobile {
padding-bottom: var(--mobile-tab-bar-height, 72px);
height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important;
flex: none;
}
}

View File

@ -540,7 +540,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'interface-addresses': {
main: {
'tor-address': '',
'lan-address': 'https://archipelago.indeehub.studio'
'lan-address': 'http://localhost:8190'
}
},
status: ServiceStatus.Running

View File

@ -417,7 +417,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="absolute inset-0 bg-black/10 backdrop-blur-md"></div>
<div
ref="uninstallModalRef"
@click.stop
@ -486,7 +486,6 @@ const appId = computed(() => route.params.id as string)
// Web-only app detection (no container external websites)
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@ -528,6 +527,7 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
portainer: 'portainer',
'uptime-kuma': 'uptime-kuma',
tailscale: 'tailscale',
indeedhub: 'indeedhub',
}
/** Backend may register under variant container names */
@ -740,8 +740,8 @@ function launchApp() {
prod: 'http://localhost:8103' // Self-hosted splash screen
},
'indeedhub': {
dev: 'https://archipelago.indeehub.studio',
prod: 'https://archipelago.indeehub.studio'
dev: 'http://localhost:8190',
prod: 'http://localhost:8190'
},
// Dummy apps - replace with real URLs when packaged
'bitcoin': {

View File

@ -1,24 +1,28 @@
<template>
<div class="pb-6">
<div class="hidden md:flex items-start justify-between mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
</div>
<!-- Desktop: tabs + search in one row -->
<div class="hidden md:flex items-center gap-4 mb-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn mode-switcher-btn-active">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
</div>
</div>
<!-- Search Bar -->
<div class="mb-4">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
<!-- Mobile: search only (tabs are in Dashboard tab bar) -->
<div class="md:hidden mb-4">
<input
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@ -175,7 +179,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="closeUninstallModal()"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="absolute inset-0 bg-black/10 backdrop-blur-md"></div>
<div
ref="uninstallModalRef"
@click.stop
@ -260,7 +264,6 @@ function showActionError(msg: string) {
// Web-only app IDs and their URLs
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@ -276,11 +279,6 @@ function isWebOnlyApp(id: string): boolean {
// Web-only apps (no container) always show as installed bookmarks
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
'indeedhub': {
state: 'running' as PackageState,
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeehub.ico' },
},
'botfights': {
state: 'running' as PackageState,
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },

View File

@ -69,9 +69,9 @@ const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false =
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
// In production, only return the URL if we've confirmed AIUI files exist
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&v=${Date.now()}`
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
return ''
})

View File

@ -1,13 +1,5 @@
<template>
<div>
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between mb-2">
<div>
<h1 class="text-3xl font-bold text-white mb-2">Cloud</h1>
<p class="text-white/70">Your files, photos, and media</p>
</div>
</div>
</div>
<div class="pb-6">
<!-- Content Type Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@ -56,12 +48,48 @@
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div>
</div>
<!-- Peer Files Card -->
<!-- Individual Peer Cards -->
<div
v-for="peer in peerNodes"
:key="peer.did"
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="router.push({ name: 'peer-files' })"
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">{{ peer.name || truncateDid(peer.did) }}</h3>
<p class="text-xs text-white/50 truncate">{{ peer.onion }}</p>
</div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-2 text-xs">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
{{ peer.trust_level }}
</span>
<span class="text-white/30">Peer Node</span>
</div>
</div>
<!-- No Peers placeholder (only if no peers found) -->
<div
v-if="!peersLoading && peerNodes.length === 0"
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="router.push('/dashboard/server/federation')"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
@ -71,26 +99,17 @@
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
<p class="text-xs text-white/50">Browse files shared by federated nodes</p>
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
</div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
<div class="flex items-center gap-2 text-xs">
<template v-if="hasFederatedPeers">
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-purple-500/15 text-purple-400">
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
{{ peerCount }} peers
</span>
</template>
<template v-else>
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
No peers yet
</span>
<span class="text-white/30">Set up federation to share files</span>
</template>
</div>
</div>
</div>
@ -116,8 +135,17 @@ const router = useRouter()
const store = useAppStore()
const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false)
const peerCount = ref(0)
const hasFederatedPeers = computed(() => peerCount.value > 0)
interface PeerNode {
did: string
pubkey: string
onion: string
name?: string
trust_level: string
}
const peerNodes = ref<PeerNode[]>([])
const peersLoading = ref(true)
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
@ -224,18 +252,26 @@ async function loadCounts() {
onMounted(() => {
loadCounts()
loadPeerCount()
loadPeers()
})
async function loadPeerCount() {
async function loadPeers() {
peersLoading.value = true
try {
const result = await rpcClient.federationListNodes()
peerCount.value = result?.nodes?.length ?? 0
peerNodes.value = result?.nodes ?? []
} catch {
peerCount.value = 0
peerNodes.value = []
} finally {
peersLoading.value = false
}
}
function truncateDid(did: string): string {
if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8)
}
function openSection(section: ContentSection) {
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
}

View File

@ -37,7 +37,7 @@
/>
</svg>
</div>
<div>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ section?.name || 'Folder' }}</h1>
<p class="text-sm text-white/50">{{ section?.description }}</p>
</div>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="pb-6">
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<button @click="$router.push('/dashboard/web5')" class="glass-button glass-button-sm px-3 py-1.5 text-sm">
@ -115,7 +115,7 @@
<!-- Credential Detail Modal -->
<Teleport to="body">
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="fixed inset-0 bg-black/10 backdrop-blur-md"></div>
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Credential Details</h3>

View File

@ -52,10 +52,9 @@
aria-hidden="true"
/>
<!-- Background overlay - 0.3 opacity default, 0.8 opacity for Web5, Network, and Settings -->
<!-- Background overlay - uniform 0.2 opacity -->
<div
class="fixed inset-0 transition-opacity duration-500 pointer-events-none"
:class="showDarkOverlay ? 'bg-black/80' : 'bg-black/30'"
class="fixed inset-0 pointer-events-none bg-black/20"
style="z-index: -5;"
/>
@ -149,7 +148,7 @@
<main
id="main-content"
data-controller-zone="main"
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
:class="{ 'glass-throw-main': showZoomIn }"
>
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
@ -277,14 +276,16 @@
<div
v-else
:class="[
'px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto h-full',
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto',
needsMobileBackButtonSpace
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-24'
: 'pb-[calc(var(--mobile-tab-bar-height,_72px)+48px)] md:pb-24'
? 'mobile-scroll-pad-back'
: 'mobile-scroll-pad'
]"
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
>
<component :is="Component" class="view-container" />
<component :is="Component" class="view-container flex-none" />
<!-- Bottom spacer scroll clearance on all pages -->
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
</div>
</div>
</Transition>
@ -846,6 +847,10 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
} else if (wasAppDetails && isMarketplaceList) {
transitionName = 'depth-back'
}
// Desktop: no transition between Apps Marketplace (same-page tab feel)
else if ((wasAppsList && isMarketplaceList) || (wasMarketplaceList && isAppsList)) {
transitionName = 'fade'
}
// Vertical transition: between main tabs (desktop)
else {
const currentIndex = tabOrder.indexOf(currentPath)
@ -1278,8 +1283,7 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
}
.view-container {
height: 100%;
min-height: 100%;
/* No forced height — content sizes naturally, spacer below provides clearance */
}
/* Forward transition: 2advanced fluid depth */

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="pb-6">
<div class="mb-8">
<button
@click="router.push('/dashboard/web5')"
@ -195,7 +195,7 @@
</template>
<!-- Node Detail Modal -->
<div v-if="selectedNode" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="selectedNode = null; confirmRemove = false">
<div v-if="selectedNode" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="selectedNode = null; confirmRemove = false">
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Node Details</h2>
@ -322,7 +322,7 @@
</div>
<!-- Join Modal -->
<div v-if="showJoinModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showJoinModal = false">
<div v-if="showJoinModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showJoinModal = false">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Join Federation</h2>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="pb-6">
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
<div class="min-h-[4.5rem]">
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">

View File

@ -109,7 +109,7 @@ const launchableApps = computed<KioskApp[]>(() => {
'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/',
'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': 'https://archipelago.indeehub.studio',
'indeedhub': 'http://localhost:8190',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',

View File

@ -74,20 +74,13 @@
</div>
</div>
<div class="hidden md:flex mb-8 items-start justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
</div>
<!-- Desktop: tabs + categories + search in one row -->
<div class="hidden md:flex mb-4 items-center gap-4">
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
</div>
</div>
<!-- Category Tabs + Search (Desktop only) -->
<div class="hidden md:flex mb-6 items-center justify-between gap-4">
<div class="mode-switcher">
<div class="mode-switcher flex-shrink-0">
<button
v-for="category in categoriesWithApps"
:key="category.id"
@ -104,7 +97,7 @@
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/>
</div>
@ -259,7 +252,7 @@
<Transition name="modal">
<div
v-if="showFilterModal"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/60 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
@click.self="closeFilterModal()"
>
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
@ -648,7 +641,6 @@ function isInstalled(appId: string): boolean {
/** Web-only apps — external URLs with no container */
const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
@ -685,6 +677,7 @@ const APP_LAUNCH_URLS: Record<string, string> = {
'fedimint': 'http://localhost:8175',
'nostr-rs-relay': 'http://localhost:18081',
'dwn': 'http://localhost:3100',
'indeedhub': 'http://localhost:8190',
}
function launchInstalledApp(app: MarketplaceApp) {
@ -995,7 +988,7 @@ function getCuratedAppList() {
id: 'indeedhub',
title: 'Indeehub',
version: '0.1.0',
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
icon: '/assets/img/app-icons/indeehub.ico',
author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest',

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="pb-6">
<div class="hidden md:block mb-8">
<div class="flex items-center justify-between">
<div>

View File

@ -1,101 +1,76 @@
<template>
<div>
<div class="flex items-center gap-3 mb-6">
<button class="glass-button p-2 rounded-lg" @click="router.push({ name: 'cloud' })">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="pb-6">
<!-- Header with back button -->
<div class="shrink-0 mb-4">
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back to Cloud
</button>
<div>
<h1 class="text-2xl font-bold text-white">Peer Files</h1>
<p class="text-sm text-white/50">Browse files shared by federated nodes</p>
</div>
</div>
<!-- Peer list -->
<div v-if="!selectedPeer" class="space-y-3">
<div v-if="loading" class="glass-card p-8 text-center">
<p class="text-white/50 animate-pulse">Loading federation peers...</p>
</div>
<div v-else-if="peers.length === 0" class="glass-card p-8 text-center">
<p class="text-white/50">No federated peers found. Join a federation from Settings to share files.</p>
</div>
<div
v-for="peer in peers"
:key="peer.did"
data-controller-container
tabindex="0"
class="glass-card p-5 cursor-pointer transition-all hover:-translate-y-0.5 hover:bg-white/10"
@click="browsePeer(peer)"
<!-- Mobile Back Button -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Back to Cloud</span>
</button>
</Teleport>
<!-- Peer Header -->
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-white truncate">{{ peer.name || truncateDid(peer.did) }}</h3>
<p class="text-xs text-white/40 truncate">{{ peer.onion }}</p>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<p class="text-sm text-white/50">{{ currentPeer?.onion || 'Peer files' }}</p>
</div>
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'"
>
{{ peer.trust_level }}
</span>
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="glass-card p-8 text-center">
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Peer content catalog -->
<div v-else>
<div class="flex items-center gap-3 mb-4">
<button class="glass-button p-2 rounded-lg" @click="selectedPeer = null">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h2 class="text-lg font-semibold text-white">{{ selectedPeer.name || truncateDid(selectedPeer.did) }}</h2>
<p class="text-xs text-white/40">{{ selectedPeer.onion }}</p>
</div>
</div>
<div v-if="catalogLoading" class="glass-card p-8 text-center">
<p class="text-white/50 animate-pulse">Connecting via Tor... This may take a few seconds.</p>
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
</div>
<!-- Error -->
<div v-else-if="catalogError" class="glass-card p-6">
<p class="text-red-400 text-sm">{{ catalogError }}</p>
<button class="glass-button mt-3 px-4 py-2 rounded-lg text-sm" @click="browsePeer(selectedPeer!)">Retry</button>
<p class="text-red-400 text-sm mb-3">{{ catalogError }}</p>
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
</div>
<div v-else-if="catalogItems.length === 0" class="glass-card p-8 text-center">
<!-- Empty -->
<div v-else-if="catalogItems.length === 0 && !loading" class="glass-card p-8 text-center">
<p class="text-white/50">This peer has no shared files.</p>
</div>
<div v-else class="space-y-2">
<!-- File Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in catalogItems"
:key="item.id"
class="glass-card p-4 flex items-center gap-4"
>
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }} &middot; {{ item.mime_type }}</p>
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
</div>
<div class="flex items-center gap-2">
<span
@ -110,20 +85,23 @@
:disabled="downloading === item.id"
@click="downloadFile(item)"
>
{{ downloading === item.id ? 'Downloading...' : 'Download' }}
{{ downloading === item.id ? '...' : 'Download' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, Teleport } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{
peerId?: string
}>()
const router = useRouter()
interface PeerNode {
@ -144,42 +122,65 @@ interface CatalogItem {
}
const loading = ref(true)
const peers = ref<PeerNode[]>([])
const selectedPeer = ref<PeerNode | null>(null)
const catalogLoading = ref(false)
const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const peerDisplayName = computed(() => {
if (currentPeer.value?.name) return currentPeer.value.name
if (currentPeer.value?.did) return truncateDid(currentPeer.value.did)
return props.peerId ? truncateOnion(props.peerId) : 'Peer Files'
})
function goBack() {
router.push({ name: 'cloud' })
}
onMounted(async () => {
if (props.peerId) {
// Find the peer by onion address
try {
const result = await rpcClient.federationListNodes()
peers.value = result?.nodes ?? []
const peers = result?.nodes ?? []
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
} catch {
peers.value = []
} finally {
// Continue with just the onion address
}
await loadCatalog()
} else {
loading.value = false
}
})
async function loadCatalog() {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
loading.value = true
catalogError.value = ''
catalogItems.value = []
try {
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
method: 'content.browse-peer',
params: { onion },
timeout: 30000,
})
catalogItems.value = result?.items ?? []
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
} finally {
loading.value = false
}
}
function truncateDid(did: string): string {
if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8)
}
async function browsePeer(peer: PeerNode) {
selectedPeer.value = peer
catalogLoading.value = true
catalogError.value = ''
catalogItems.value = []
try {
const result = await rpcClient.call<{ items?: CatalogItem[] }>({ method: 'content.browse-peer', params: { onion: peer.onion }, timeout: 30000 })
catalogItems.value = result?.items ?? []
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
} finally {
catalogLoading.value = false
}
function truncateOnion(onion: string): string {
if (onion.length <= 20) return onion
return onion.slice(0, 12) + '...'
}
function formatSize(bytes: number): string {
@ -231,12 +232,13 @@ function canDownload(access: CatalogItem['access']): boolean {
}
async function downloadFile(item: CatalogItem) {
if (!selectedPeer.value) return
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
downloading.value = item.id
try {
const result = await rpcClient.call<{ data?: string }>({
method: 'content.download-peer',
params: { onion: selectedPeer.value.onion, content_id: item.id },
params: { onion, content_id: item.id },
timeout: 120000,
})
if (result?.data) {

View File

@ -1,10 +1,5 @@
<template>
<div>
<div class="hidden md:block mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Network</h1>
<p class="text-white/70">Manage your network infrastructure and Web3 services</p>
<p class="text-sm text-white/60 mt-2">{{ connectedNodes }} connected nodes</p>
</div>
<div class="pb-6">
<!-- Disk Space Warning Banner -->
<div
@ -53,7 +48,7 @@
</div>
<button
@click="restartServices"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
:disabled="restarting"
>
{{ restarting ? 'Restarting...' : 'Restart' }}
@ -74,7 +69,7 @@
</div>
<button
@click="checkConnectivity"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
:disabled="checkingConnectivity"
>
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
@ -83,6 +78,7 @@
<!-- Auto-Sync Toggle -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center justify-between min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
@ -94,15 +90,16 @@
</div>
<button
@click="toggleAutoSync"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors self-start"
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors shrink-0"
:class="autoSyncEnabled ? 'bg-green-500' : 'bg-white/20'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="autoSyncEnabled ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform shadow"
:class="autoSyncEnabled ? 'translate-x-7' : 'translate-x-1'"
/>
</button>
</div>
</div>
<!-- Logs & Diagnostics -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
@ -117,7 +114,7 @@
</div>
<button
@click="viewLogs"
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
View
</button>
@ -128,7 +125,7 @@
<!-- Overview Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col h-full min-h-0">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -220,13 +217,13 @@
</template>
</div>
<button disabled title="Coming Soon" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed shrink-0">
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Local Network
</button>
</div>
<!-- Web3 Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col h-full min-h-0">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -281,7 +278,7 @@
</div>
</div>
<button disabled title="Coming Soon" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed shrink-0">
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
Manage Web3 Services
</button>
</div>
@ -379,7 +376,7 @@
</div>
<!-- WiFi Scan Modal -->
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
<div v-if="showWifiModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
@ -439,7 +436,7 @@
</div>
<!-- DNS Configuration Modal -->
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="showDnsModal = false">
<div v-if="showDnsModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showDnsModal = false">
<div class="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
@ -822,7 +819,7 @@ function copyTorAddress(address: string) {
async function toggleTorApp(appId: string, enabled: boolean) {
try {
await rpcClient.call({ method: 'tor.toggle-service', params: { name: appId, enabled } })
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
@ -832,7 +829,7 @@ async function toggleTorApp(appId: string, enabled: boolean) {
async function rotateNodeAddress() {
torRotating.value = true
try {
await rpcClient.call({ method: 'tor.rotate-address', params: { name: 'archipelago' } })
await rpcClient.call({ method: 'tor.rotate-service', params: { name: 'archipelago' } })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)

View File

@ -1,15 +1,9 @@
<template>
<div>
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ t('settings.title') }}</h1>
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
</div>
<div class="pb-6">
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
<div class="md:hidden">
<div class="md:hidden mb-4">
<ControllerIndicator />
</div>
</div>
<!-- Account Section -->
<div class="glass-card px-6 py-6 mb-6">
@ -102,27 +96,25 @@
<!-- Tor / Onion Address (below DID, with copy button) -->
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
</div>
<p class="text-sm font-mono text-amber-400/90 break-all mb-1" :title="serverTorAddress">{{ serverTorAddress }}</p>
<p class="text-xs text-white/50 mb-3">{{ t('settings.onionHelper') }}</p>
<button
@click="copyOnionAddress"
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
class="w-full min-h-[44px] rounded-lg glass-button text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<svg v-if="!copiedOnion" 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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
<span v-if="!copiedOnion">{{ t('common.copy') }}</span>
<span v-else class="text-green-400">{{ t('common.copied') }}</span>
</button>
</div>
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
<p class="text-xs text-white/50 mt-1">{{ t('settings.onionHelper') }}</p>
</div>
</div>
</div>
@ -143,7 +135,7 @@
<Teleport to="body">
<div
v-if="showChangePasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeChangePasswordModal()"
>
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
@ -255,7 +247,7 @@
<Teleport to="body">
<div
v-if="showTotpSetupModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeTotpSetup"
@keydown.escape="closeTotpSetup"
>
@ -361,7 +353,7 @@
<Teleport to="body">
<div
v-if="showTotpDisableModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="closeTotpDisable"
@keydown.escape="closeTotpDisable"
>
@ -507,7 +499,7 @@
<Teleport to="body">
<div
v-if="showClaudeLoginModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
@click.self="showClaudeLoginModal = false"
>
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
@ -723,12 +715,10 @@
<!-- Backup & Restore Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2>
<p class="text-sm text-white/60 mt-1">{{ t('settings.backupRestoreDesc') }}</p>
</div>
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('settings.backup') }}</h2>
<p class="text-sm text-white/60 mb-3">{{ t('settings.backupRestoreDesc') }}</p>
<button @click="showCreateBackupModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg 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="M12 4v16m8-8H4" />
</svg>
@ -770,7 +760,7 @@
<!-- Create Backup Modal -->
<Teleport to="body">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3">
@ -795,7 +785,7 @@
<!-- Restore Backup Modal -->
<Teleport to="body">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showRestoreModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="pb-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
@ -166,7 +166,7 @@
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="cancelConfirm">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
<div class="glass-card p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}

View File

@ -1,14 +1,9 @@
<template>
<div>
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('web5.title') }}</h1>
<p class="text-white/70">{{ t('web5.subtitle') }}</p>
<p class="text-sm text-white/60 mt-2">{{ t('web5.profitsHelper') }}</p>
</div>
<div class="pb-6">
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 stagger-grid">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
<div class="flex items-center gap-3 min-w-0">
@ -188,8 +183,8 @@
<!-- DID Document Modal -->
<Teleport to="body">
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
<div class="glass-card p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
<div class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
<div class="flex items-center justify-between mb-4">
<h3 id="did-doc-title" class="text-lg font-semibold text-white">{{ t('web5.didDocument') }}</h3>
<div class="flex items-center gap-2">
@ -222,7 +217,7 @@
<!-- Send Message Modal -->
<Teleport to="body">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="closeSendMessageModal()">
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
@ -235,7 +230,7 @@
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
</div>
@ -270,10 +265,10 @@
</div>
</Teleport>
<!-- Core Services Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Core Services Overview Cards Row 1 -->
<div class="flex flex-col md:flex-row gap-6 mb-6">
<!-- Bitcoin Domain Name Portfolio -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 0">
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -320,13 +315,13 @@
</div>
</div>
<button @click="showDomainsModal = true" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
<button @click="showDomainsModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.manageDomains') }}
</button>
</div>
<!-- Wallet -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 1">
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -389,9 +384,12 @@
</button>
</div>
</div>
</div>
<!-- Core Services Overview Cards Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<!-- Nostr Relays -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 2">
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 2">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -438,13 +436,13 @@
</div>
</div>
<button @click="showRelaysModal = true" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
<button @click="showRelaysModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
{{ t('web5.relays') }}
</button>
</div>
<!-- Node Visibility -->
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 3">
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 3">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -508,10 +506,12 @@
{{ t('web5.discoverableWarning') }}
</p>
</div>
</div>
<!-- Connected Nodes (P2P over Tor) -->
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 lg:col-span-4 scroll-mt-24">
<div class="flex items-start gap-4 mb-4">
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24">
<!-- Desktop: side-by-side layout -->
<div class="hidden md:flex items-start gap-4 mb-4">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
@ -536,6 +536,32 @@
</button>
</div>
</div>
<!-- Mobile: stacked layout -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-4 mb-2">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
</div>
<p class="text-white/70 text-sm mb-3">{{ t('web5.peerNodesDescription') }}</p>
<div class="grid grid-cols-2 gap-2">
<button
@click="router.push('/dashboard/server/federation')"
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
{{ t('web5.findNodes') }}
</button>
<button
@click="loadPeers"
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
>
{{ loadingPeers ? '...' : t('common.refresh') }}
</button>
</div>
</div>
<!-- Tabs: Peers | Messages | Requests -->
<div class="flex gap-1 mb-4 border-b border-white/10">
@ -580,7 +606,7 @@
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="min-w-0">
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || p.pubkey.slice(0, 16) + '...' }}</p>
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
</div>
</div>
@ -607,7 +633,7 @@
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
>
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ m.from_pubkey.slice(0, 16) }}...</p>
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ (m.from_pubkey || '').slice(0, 16) }}...</p>
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
</div>
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
@ -678,11 +704,11 @@
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button>
</div>
</div>
<!-- Shared Content -->
<div class="glass-card p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -694,11 +720,34 @@
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button v-if="contentTab === 'mine'" @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button v-if="contentTab === 'mine'" @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<svg 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="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg 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="M12 4v16m8-8H4" />
</svg>
@ -822,7 +871,7 @@
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@ -912,7 +961,7 @@
<!-- Content Streaming Player -->
<Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click.self="closePlayer" @keydown.escape="closePlayer">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
<div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
<!-- Player Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
@ -984,7 +1033,7 @@
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4">
@ -1035,7 +1084,8 @@
<!-- Identity Management -->
<div class="glass-card p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1054,6 +1104,24 @@
Create
</button>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
<button @click="showCreateIdentityModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg 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="M12 4v16m8-8H4" />
</svg>
Create
</button>
</div>
<!-- Loading -->
<div v-if="identitiesLoading" class="py-6 text-center">
@ -1112,7 +1180,7 @@
<!-- Actions -->
<div class="flex items-center gap-1 shrink-0">
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy DID">
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<svg 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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
@ -1133,7 +1201,7 @@
</div>
<!-- Create Identity Modal -->
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
<div class="space-y-4">
@ -1167,7 +1235,7 @@
</div>
<!-- Delete Confirmation Modal -->
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
@ -1180,7 +1248,7 @@
</div>
</div>
<!-- Unified Send Modal -->
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
@ -1280,7 +1348,7 @@
</div>
<!-- Unified Receive Modal -->
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
@ -1470,7 +1538,7 @@
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ msg.record_id.slice(0, 8) }}...</span>
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
</div>
<div class="flex flex-wrap gap-2 text-xs">
@ -1501,7 +1569,8 @@
<!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1517,6 +1586,21 @@
Manage
</router-link>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
Manage
</router-link>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4">
@ -1539,7 +1623,7 @@
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="min-w-0 flex-1">
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
<div class="text-xs text-white/50 truncate">To: {{ vc.subject.slice(0, 30) }}...</div>
<div class="text-xs text-white/50 truncate">To: {{ (vc.subject || '').slice(0, 30) }}...</div>
</div>
<span :class="{
'text-green-400': vc.status === 'active',
@ -1557,7 +1641,7 @@
</div>
<!-- Domains Management Modal -->
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
<div class="glass-card p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
<div class="flex items-center justify-between mb-4">
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2>
@ -1604,7 +1688,7 @@
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
<select v-model="newDomainIdentityId" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30">
<option value="" disabled>Select identity...</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ id.did.slice(0, 24) }}...)</option>
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
</select>
</div>
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
@ -1634,7 +1718,7 @@
</div>
<!-- Relay Management Modal -->
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
<div class="glass-card p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
<div class="flex items-center justify-between mb-4">
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
@ -1908,6 +1992,15 @@ async function createDID() {
localStorage.setItem('neode_did', res.did)
} catch {
// Fallback: generate a did:key locally using Web Crypto
if (!crypto.subtle) {
// crypto.subtle requires HTTPS generate random fallback
const randomBytes = new Uint8Array(32)
crypto.getRandomValues(randomBytes)
const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
const did = `did:key:z${hex}`
storedDid.value = did
localStorage.setItem('neode_did', did)
} else {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
@ -1915,11 +2008,11 @@ async function createDID() {
)
const exported = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const bytes = new Uint8Array(exported)
// Multicodec prefix for P-256 public key (0x1200) + base58btc
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
const did = `did:key:z${hex}`
storedDid.value = did
localStorage.setItem('neode_did', did)
}
} finally {
creatingDid.value = false
}
@ -1965,7 +2058,7 @@ async function refreshDhtDid() {
async function copyDhtDid() {
if (!dhtDid.value) return
await navigator.clipboard.writeText(dhtDid.value)
await safeClipboardWrite(dhtDid.value)
dhtDidCopied.value = true
setTimeout(() => { dhtDidCopied.value = false }, 2000)
}
@ -1977,7 +2070,7 @@ try {
async function copyDid() {
if (!userDid.value) return
await navigator.clipboard.writeText(userDid.value)
await safeClipboardWrite(userDid.value)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
@ -2016,7 +2109,7 @@ async function showDidDocument() {
async function copyDidDocument() {
if (!didDocumentFormatted.value) return
await navigator.clipboard.writeText(didDocumentFormatted.value)
await safeClipboardWrite(didDocumentFormatted.value)
didDocCopied.value = true
setTimeout(() => { didDocCopied.value = false }, 2000)
}
@ -2411,7 +2504,7 @@ async function finalizePsbt() {
function copyPsbt() {
if (!psbtData.value) return
window.navigator.clipboard.writeText(psbtData.value)
window.safeClipboardWrite(psbtData.value)
unifiedSendError.value = t('web5.psbtCopied')
}
@ -2478,12 +2571,27 @@ async function unifiedReceive() {
}
function copyEcashToken(token: string) {
navigator.clipboard.writeText(token)
safeClipboardWrite(token)
showIdentityToast(t('web5.ecashTokenCopied'))
}
async function safeClipboardWrite(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await safeClipboardWrite(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
}
function copyToClipboard(text: string, msg: string) {
navigator.clipboard.writeText(text)
safeClipboardWrite(text)
showIdentityToast(msg)
}
@ -2711,7 +2819,7 @@ function downloadPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
const url = `http://${browsePeerOnion.value}/content/${item.id}`
showIdentityToast(t('web5.downloadUrlCopied'))
navigator.clipboard.writeText(url)
safeClipboardWrite(url)
}
function closePlayer() {
@ -2743,7 +2851,7 @@ function onPlayerError() {
function copyStreamUrl() {
if (streamUrl.value) {
navigator.clipboard.writeText(streamUrl.value)
safeClipboardWrite(streamUrl.value)
showIdentityToast(t('web5.streamUrlCopied'))
}
}
@ -2832,8 +2940,8 @@ const settingVisibility = ref(false)
const visibilityOptions = [
{ value: 'hidden' as VisibilityLevel, label: 'Hidden', description: 'Your node is not discoverable by others' },
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Other Archipelago nodes can find you via Nostr' },
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Visible to everyone with your onion address published' },
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Federated peers can find and connect to your node' },
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Accepting connections from any Archipelago node' },
]
async function loadVisibility() {
@ -2869,7 +2977,7 @@ async function setVisibility(level: VisibilityLevel) {
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
navigator.clipboard.writeText(nodeOnionAddress.value)
safeClipboardWrite(nodeOnionAddress.value)
showIdentityToast(t('web5.onionAddressCopied'))
}
@ -2928,7 +3036,7 @@ async function createIdentity() {
}
function copyIdentityDid(did: string) {
navigator.clipboard.writeText(did)
safeClipboardWrite(did)
showIdentityToast(t('web5.didCopied'))
}
@ -3098,8 +3206,7 @@ async function connectWallet() {
}
function manageRelays() {
// TODO: Navigate to relay management or open modal
if (import.meta.env.DEV) console.log('Managing Nostr relays...')
showRelaysModal.value = true
}
</script>

View File

@ -119,7 +119,7 @@
</Transition>
<!-- Open Channel Modal -->
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showOpenModal = false">
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
<div class="glass-card p-6 w-full max-w-md mx-4">
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@ -165,7 +165,7 @@
</div>
<!-- Close Confirmation Modal -->
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeTarget = null">
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
<div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>

View File

@ -43,10 +43,10 @@ export default defineConfig({
'**/*-backup-*.mp4',
'**/*-1.47mb.mp4',
'**/bg-*.mp4', // Exclude large background videos from precache
'**/video-intro.mp4', // Exclude video-intro.mp4 from precache (7MB, cached at runtime)
'**/video-intro*.mp4', // Exclude all intro video variants from precache
'**/assets/icon/**', // Icons are in includeAssets — don't duplicate in glob precache
],
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB limit (increased from 2MB)
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB limit
skipWaiting: false, // Wait for user to accept update
clientsClaim: false, // Don't claim clients immediately
runtimeCaching: [

View File

@ -556,6 +556,27 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
fi
fi
# 8b. Indeehub (pull from registry, or use local build)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
INDEEDHUB_IMAGE=""
# Try local image first (pre-built or loaded from ISO)
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then
INDEEDHUB_IMAGE="localhost/indeedhub:latest"
# Try registry image
elif $DOCKER pull git.tx1138.com/lfg2025/indeedhub:latest 2>>"$LOG"; then
INDEEDHUB_IMAGE="git.tx1138.com/lfg2025/indeedhub:latest"
fi
if [ -n "$INDEEDHUB_IMAGE" ]; then
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
$DOCKER run -d --name indeedhub --restart unless-stopped \
--cap-drop ALL --security-opt no-new-privileges:true \
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
-p 8190:3000 \
-e NODE_ENV=production -e NEXT_TELEMETRY_DISABLED=1 \
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
fi
fi
# 9. Custom UI containers (bitcoin-ui, lnd-ui)
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.
for ui in bitcoin-ui lnd-ui; do