bug fixes from sxsw

This commit is contained in:
Dorian 2026-03-14 17:12:41 +00:00
parent dfffa8606d
commit ee15fbc457
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. 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 ## 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 ### Option 1: Use the build script
```bash
cd "/path/to/Indeedhub Prototype"
podman build -t localhost/indeedhub:latest .
```
### Option 2: Use the build script
```bash ```bash
# From archy repo root # From archy repo root
./apps/indeedhub/build-from-prototype.sh ./apps/indeedhub/build-from-prototype.sh
``` ```
### Option 3: Full deploy (build + run on server) ### Option 2: Build from source directory
```bash ```bash
cd "/path/to/Indeedhub Prototype" cd ~/Projects/indeehub-frontend
./deploy-to-archipelago.sh 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) 1. **Build the image** using one of the options above (must exist before install)
2. Go to **Dashboard → App Store** (Marketplace) 2. Go to **Dashboard -> App Store** (Marketplace)
3. Find **Indeehub Prototype** and click **Install** 3. Find **Indeehub** and click **Install**
4. The app will appear in **My Apps** once the container is running 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 ## Port
- Web UI: 7777 - Web UI: 8190 (maps to container port 3000)
## Container ## Container
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry) - 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 #!/bin/bash
# Build Indeehub image from the Indeehub Prototype project # Build Indeehub container image from the indeehub-frontend project
# Usage: ./build-from-prototype.sh [path-to-prototype] # Usage: ./build-from-prototype.sh [path-to-indeehub-frontend]
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype" DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend"
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}" FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}"
IMAGE_TAG="localhost/indeedhub:latest" IMAGE_TAG="localhost/indeedhub:latest"
if [ ! -d "$PROTOTYPE_DIR" ]; then if [ ! -d "$FRONTEND_DIR" ]; then
echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR" echo "Indeehub frontend not found at: $FRONTEND_DIR"
echo " Set path: $0 /path/to/Indeedhub\ Prototype" 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 exit 1
fi fi
@ -21,10 +26,10 @@ if ! command -v podman >/dev/null 2>&1; then
RUNTIME="docker" RUNTIME="docker"
fi fi
echo "🔨 Building Indeehub from $PROTOTYPE_DIR" echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile"
cd "$PROTOTYPE_DIR" $RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR"
$RUNTIME build -t "$IMAGE_TAG" .
echo "Built $IMAGE_TAG" echo "Built $IMAGE_TAG"
echo "" echo ""
echo "You can now install Indeehub from the App Store in Archipelago." 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,62 +2,62 @@ app:
id: indeedhub id: indeedhub
name: Indeehub name: Indeehub
version: 0.1.0 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 category: media
container: container:
image: localhost/indeedhub:1.0.0 image: git.tx1138.com/lfg2025/indeedhub:latest
pull_policy: never # Built locally pull_policy: always # Pull from registry; falls back to local build
dependencies: dependencies:
- storage: 500Mi - storage: 1Gi
resources: resources:
cpu_limit: 1 cpu_limit: 2
memory_limit: 512Mi memory_limit: 512Mi
disk_limit: 500Mi disk_limit: 1Gi
security: security:
capabilities: [] capabilities: []
readonly_root: true # Static nginx content readonly_root: true
no_new_privileges: true no_new_privileges: true
user: 1000 user: 1001
seccomp_profile: default seccomp_profile: default
network_policy: bridge network_policy: bridge
apparmor_profile: default apparmor_profile: default
ports: ports:
- host: 7777 - host: 8190
container: 7777 container: 3000
protocol: tcp # Web UI protocol: tcp # Web UI (Next.js)
volumes: volumes:
- type: tmpfs - type: tmpfs
target: /var/cache/nginx target: /tmp
options: [rw,noexec,nosuid,size=10m] options: [rw,noexec,nosuid,size=64m]
- type: tmpfs - type: tmpfs
target: /var/run target: /app/.next/cache
options: [rw,noexec,nosuid,size=10m] options: [rw,noexec,nosuid,size=128m]
environment: environment:
- NGINX_HOST=localhost - NODE_ENV=production
- NGINX_PORT=7777 - NEXT_TELEMETRY_DISABLED=1
health_check: health_check:
type: http type: http
endpoint: http://localhost:7777 endpoint: http://localhost:3000
path: /health path: /
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
interfaces: interfaces:
main: main:
name: Web UI name: Web UI
description: Stream Bitcoin documentaries description: Stream Bitcoin documentaries with Nostr identity
type: ui type: ui
port: 7777 port: 8190
protocol: http protocol: http
path: / path: /
@ -72,3 +72,4 @@ app:
- streaming - streaming
- media - media
- education - 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")); 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 // Multi-container apps: create full stack
if package_id == "immich" { if package_id == "immich" {
return self.install_immich_stack().await; return self.install_immich_stack().await;
@ -117,8 +112,17 @@ impl RpcHandler {
} }
// Pull the image (skip for local images - must be built locally first) // 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/"); 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); debug!("Pulling image: {}", docker_image);
// Set package state to Installing with progress // Set package state to Installing with progress
@ -156,6 +160,9 @@ impl RpcHandler {
// Mark pull as complete (100%) // Mark pull as complete (100%)
self.set_install_progress(package_id, 100, 100).await; 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 { } else {
// Verify local image exists // Verify local image exists
let images_output = tokio::process::Command::new("sudo") let images_output = tokio::process::Command::new("sudo")
@ -165,7 +172,7 @@ impl RpcHandler {
.context("Failed to check local image")?; .context("Failed to check local image")?;
if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() { if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() {
return Err(anyhow::anyhow!( 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 docker_image
)); ));
} }
@ -342,8 +349,13 @@ printtoconsole=1\n";
run_args.push(arg); run_args.push(arg);
} }
// Finally, the image // Finally, the image — use local build if available, otherwise registry image
run_args.push(docker_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); debug!("Running container with args: {:?}", run_args);
@ -1723,9 +1735,9 @@ fn get_app_config(
]), ]),
), ),
"indeedhub" => ( "indeedhub" => (
vec!["7777:7777".to_string()], vec!["8190:3000".to_string()],
vec![], 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,
None, None,
), ),

View File

@ -136,6 +136,9 @@ impl DockerPackageScanner {
} else if app_id == "dwn" { } else if app_id == "dwn" {
debug!("Using DWN server: http://localhost:3100"); debug!("Using DWN server: http://localhost:3100");
Some("http://localhost:3100".to_string()) 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" { } else if app_id == "mempool-electrs" || app_id == "electrs" {
// Electrs UI runs on host at port 50002 // Electrs UI runs on host at port 50002
debug!("Using electrs-ui for mempool-electrs: http://localhost: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)); 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) Ok(packages)
} }
} }
@ -505,7 +448,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/tailscale/tailscale".to_string(), repo: "https://github.com/tailscale/tailscale".to_string(),
tier: "", tier: "",
}, },
"indeedhub" => AppMetadata { "indeedhub" | "indeehub" => AppMetadata {
title: "Indeehub".to_string(), title: "Indeehub".to_string(),
description: "Decentralized media streaming platform".to_string(), description: "Decentralized media streaming platform".to_string(),
icon: "/assets/img/app-icons/indeehub.ico".to_string(), icon: "/assets/img/app-icons/indeehub.ico".to_string(),

View File

@ -331,6 +331,20 @@ server {
sub_filter_once on; sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>'; 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/ { location /app/lnd/ {
proxy_pass http://127.0.0.1:8081/; proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -231,6 +231,19 @@ location /app/electrs/ {
sub_filter_once on; sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>'; 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/ { location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/; proxy_pass http://127.0.0.1:81/;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.09ki1c64ohs" "revision": "0.6f1usind3cc"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { 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) clearTimeout(timeoutId)
if (!response.ok) { 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 err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503 const isRetryable = response.status === 502 || response.status === 503
if (isRetryable && attempt < maxRetries - 1) { if (isRetryable && attempt < maxRetries - 1) {

View File

@ -172,12 +172,21 @@
@approve="store.approveConsent" @approve="store.approveConsent"
@deny="store.denyConsent" @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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher' import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue' import NostrSignConsent from '@/components/NostrSignConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
interface PaymentRequest { interface PaymentRequest {
@ -197,6 +206,73 @@ const isRefreshing = ref(false)
const iframeLoading = ref(true) const iframeLoading = ref(true)
const iframeBlocked = ref(false) 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 // Timers for iframe load detection
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
let contentCheckId: 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) */ /** Apps that support the Archipelago identity protocol (postMessage) */
function isIdentityAwareApp(url: string): boolean { 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() { async function sendIdentityIfSupported() {
if (!store.url || !isIdentityAwareApp(store.url)) return 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' }) // Check if we have a stored identity for this app
const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0] const stored = getStoredIdentity(store.url)
if (!defaultId) return if (stored) {
// Sign a timestamp challenge to prove ownership // Use the previously selected identity
const challenge = `archipelago-identity:${Date.now()}` await sendSelectedIdentity(stored)
const sigRes = await rpcClient.call<{ signature: string }>({ return
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)
} }
// First launch show the identity picker
showIdentityPicker.value = true
return // Identity will be sent after selection via onIdentitySelected
} }
function injectScrollbarHideIfSameOrigin() { function injectScrollbarHideIfSameOrigin() {

View File

@ -8,7 +8,7 @@
</div> </div>
<!-- Full mode switcher --> <!-- Full mode switcher -->
<div v-else class="mode-switcher"> <div v-else class="mode-switcher mode-switcher-full">
<button <button
v-for="m in modes" v-for="m in modes"
:key="m.id" :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", "connectedNodes": "Connected Nodes",
"bitcoinDomains": "Bitcoin Domain Names", "bitcoinDomains": "Bitcoin Domain Names",
"domainsSubtitle": "NIP-05 verified identities", "domainsSubtitle": "NIP-05 verified identities",
"copyDid": "Copy DID", "copyDid": "Copy",
"viewDidDocument": "View DID Document", "viewDidDocument": "View",
"createDid": "Create DID", "createDid": "Create DID",
"creatingDid": "Creating...", "creatingDid": "Creating...",
"manageDomains": "Manage Domains", "manageDomains": "Manage Domains",

View File

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

View File

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

View File

@ -67,6 +67,7 @@ const PORT_TO_PROXY: Record<string, string> = {
'8176': '/app/fedimint-gateway/', '8176': '/app/fedimint-gateway/',
'3100': '/app/dwn/', '3100': '/app/dwn/',
'18081': '/app/nostr-rs-relay/', '18081': '/app/nostr-rs-relay/',
'8190': '/app/indeedhub/',
} }
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content. /** 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' 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 { try {
let result: unknown let result: unknown
if (method === 'getPublicKey') { if (method === 'getPublicKey') {
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' }) if (appIdentityId) {
result = res.nostr_pubkey // 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') { } else if (method === 'signEvent') {
// Check if origin is pre-approved // Check if origin is pre-approved
const approved = getApprovedOrigins() const approved = getApprovedOrigins()
@ -208,32 +229,41 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
return return
} }
} }
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } }) if (appIdentityId) {
result = res // 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') { } else if (method === 'getRelays') {
result = {} result = {}
} else if (method === 'nip04.encrypt') { } else if (method === 'nip04.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({ const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip04', 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 result = res.ciphertext
} else if (method === 'nip04.decrypt') { } else if (method === 'nip04.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({ const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip04', 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 result = res.plaintext
} else if (method === 'nip44.encrypt') { } else if (method === 'nip44.encrypt') {
const res = await rpcClient.call<{ ciphertext: string }>({ const res = await rpcClient.call<{ ciphertext: string }>({
method: 'identity.nostr-encrypt-nip44', 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 result = res.ciphertext
} else if (method === 'nip44.decrypt') { } else if (method === 'nip44.decrypt') {
const res = await rpcClient.call<{ plaintext: string }>({ const res = await rpcClient.call<{ plaintext: string }>({
method: 'identity.nostr-decrypt-nip44', 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 result = res.plaintext
} else { } 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 */ /* Haptic-like press feedback for all interactive elements */
button:active:not(:disabled), button:active:not(:disabled),
[role="button"]:active, [role="button"]:active,
@ -131,8 +147,14 @@ input[type="radio"]:active + * {
border: 1px solid rgba(255, 255, 255, 0.08); 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 { .mode-switcher-btn {
flex: none; flex: 1 1 0%;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
border-radius: 0.375rem; border-radius: 0.375rem;
white-space: nowrap; white-space: nowrap;
@ -146,6 +168,14 @@ input[type="radio"]:active + * {
background: transparent; background: transparent;
} }
@media (max-width: 767px) {
.mode-switcher-btn {
min-height: 44px;
font-size: 0.875rem;
padding: 0.625rem 1rem;
}
}
.mode-switcher-btn:hover { .mode-switcher-btn:hover {
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
} }
@ -235,10 +265,11 @@ input[type="radio"]:active + * {
background: transparent; 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) { @media (max-width: 767px) {
.chat-iframe-mobile { .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': { 'interface-addresses': {
main: { main: {
'tor-address': '', 'tor-address': '',
'lan-address': 'https://archipelago.indeehub.studio' 'lan-address': 'http://localhost:8190'
} }
}, },
status: ServiceStatus.Running status: ServiceStatus.Running

View File

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

View File

@ -1,24 +1,28 @@
<template> <template>
<div class="pb-6"> <div class="pb-6">
<div class="hidden md:flex items-start justify-between mb-8 gap-4"> <!-- Desktop: tabs + search in one row -->
<div> <div class="hidden md:flex items-center gap-4 mb-4">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
</div>
<div class="mode-switcher flex-shrink-0"> <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/apps" class="mode-switcher-btn mode-switcher-btn-active">My Apps</RouterLink>
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink> <RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
</div> </div>
</div>
<!-- Search Bar -->
<div class="mb-4">
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
:placeholder="t('apps.searchPlaceholder')" :placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')" :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> </div>
@ -175,7 +179,7 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4" class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click="closeUninstallModal()" @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 <div
ref="uninstallModalRef" ref="uninstallModalRef"
@click.stop @click.stop
@ -260,7 +264,6 @@ function showActionError(msg: string) {
// Web-only app IDs and their URLs // Web-only app IDs and their URLs
const WEB_ONLY_APP_URLS: Record<string, string> = { const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net', 'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com', 'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen', '484-kitchen': 'https://484.kitchen',
@ -276,11 +279,6 @@ function isWebOnlyApp(id: string): boolean {
// Web-only apps (no container) always show as installed bookmarks // Web-only apps (no container) always show as installed bookmarks
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = { 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': { 'botfights': {
state: 'running' as PackageState, 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 }, 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 aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL 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 // 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 '' return ''
}) })

View File

@ -1,13 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<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>
<!-- Content Type Cards --> <!-- Content Type Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <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> <span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div> </div>
</div> </div>
<!-- Peer Files Card --> <!-- Individual Peer Cards -->
<div <div
v-for="peer in peerNodes"
:key="peer.did"
data-controller-container data-controller-container
tabindex="0" tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10" 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 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"> <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>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3> <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> </div>
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</div> </div>
<div class="flex items-center gap-2 text-xs"> <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-white/5 text-white/40">
<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-white/30"></span>
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span> No peers yet
{{ peerCount }} peers </span>
</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> </div>
</div> </div>
@ -116,8 +135,17 @@ const router = useRouter()
const store = useAppStore() const store = useAppStore()
const sectionCounts = ref<Record<string, number>>({}) const sectionCounts = ref<Record<string, number>>({})
const countsLoading = ref(false) 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[]> = { const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'], immich: ['immich_server', 'immich-server'],
@ -224,18 +252,26 @@ async function loadCounts() {
onMounted(() => { onMounted(() => {
loadCounts() loadCounts()
loadPeerCount() loadPeers()
}) })
async function loadPeerCount() { async function loadPeers() {
peersLoading.value = true
try { try {
const result = await rpcClient.federationListNodes() const result = await rpcClient.federationListNodes()
peerCount.value = result?.nodes?.length ?? 0 peerNodes.value = result?.nodes ?? []
} catch { } 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) { function openSection(section: ContentSection) {
router.push({ name: 'cloud-folder', params: { folderId: section.id } }) router.push({ name: 'cloud-folder', params: { folderId: section.id } })
} }

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center gap-3 mb-2"> <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"> <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 --> <!-- Credential Detail Modal -->
<Teleport to="body"> <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 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="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"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">Credential Details</h3> <h3 class="text-lg font-semibold text-white">Credential Details</h3>

View File

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

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<div class="mb-8"> <div class="mb-8">
<button <button
@click="router.push('/dashboard/web5')" @click="router.push('/dashboard/web5')"
@ -195,7 +195,7 @@
</template> </template>
<!-- Node Detail Modal --> <!-- 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="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Node Details</h2> <h2 class="text-xl font-semibold text-white">Node Details</h2>
@ -322,7 +322,7 @@
</div> </div>
<!-- Join Modal --> <!-- 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="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-white">Join Federation</h2> <h2 class="text-xl font-semibold text-white">Join Federation</h2>

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4"> <div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
<div class="min-h-[4.5rem]"> <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)]"> <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/', 'fedimint-gateway': '/app/fedimint-gateway/',
'dwn': '/app/dwn/', 'dwn': '/app/dwn/',
'nostr-rs-relay': '/app/nostr-rs-relay/', 'nostr-rs-relay': '/app/nostr-rs-relay/',
'indeedhub': 'https://archipelago.indeehub.studio', 'indeedhub': 'http://localhost:8190',
'botfights': 'https://botfights.net', 'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com', 'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen', '484-kitchen': 'https://484.kitchen',

View File

@ -74,20 +74,13 @@
</div> </div>
</div> </div>
<div class="hidden md:flex mb-8 items-start justify-between gap-4"> <!-- Desktop: tabs + categories + search in one row -->
<div> <div class="hidden md:flex mb-4 items-center gap-4">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
</div>
<div class="mode-switcher flex-shrink-0"> <div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink> <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> <RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
</div> </div>
</div> <div class="mode-switcher flex-shrink-0">
<!-- Category Tabs + Search (Desktop only) -->
<div class="hidden md:flex mb-6 items-center justify-between gap-4">
<div class="mode-switcher">
<button <button
v-for="category in categoriesWithApps" v-for="category in categoriesWithApps"
:key="category.id" :key="category.id"
@ -104,7 +97,7 @@
type="text" type="text"
:placeholder="t('marketplace.searchPlaceholder')" :placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')" :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> </div>
@ -259,7 +252,7 @@
<Transition name="modal"> <Transition name="modal">
<div <div
v-if="showFilterModal" 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()" @click.self="closeFilterModal()"
> >
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto"> <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 */ /** Web-only apps — external URLs with no container */
const WEB_ONLY_APP_URLS: Record<string, string> = { const WEB_ONLY_APP_URLS: Record<string, string> = {
'indeedhub': 'https://archipelago.indeehub.studio',
'botfights': 'https://botfights.net', 'botfights': 'https://botfights.net',
'nwnn': 'https://nwnn.l484.com', 'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen', '484-kitchen': 'https://484.kitchen',
@ -685,6 +677,7 @@ const APP_LAUNCH_URLS: Record<string, string> = {
'fedimint': 'http://localhost:8175', 'fedimint': 'http://localhost:8175',
'nostr-rs-relay': 'http://localhost:18081', 'nostr-rs-relay': 'http://localhost:18081',
'dwn': 'http://localhost:3100', 'dwn': 'http://localhost:3100',
'indeedhub': 'http://localhost:8190',
} }
function launchInstalledApp(app: MarketplaceApp) { function launchInstalledApp(app: MarketplaceApp) {
@ -995,7 +988,7 @@ function getCuratedAppList() {
id: 'indeedhub', id: 'indeedhub',
title: 'Indeehub', title: 'Indeehub',
version: '0.1.0', 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', icon: '/assets/img/app-icons/indeehub.ico',
author: 'Indeehub Team', author: 'Indeehub Team',
dockerImage: 'localhost/indeedhub:latest', dockerImage: 'localhost/indeedhub:latest',

View File

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

View File

@ -1,118 +1,92 @@
<template> <template>
<div> <div class="pb-6">
<div class="flex items-center gap-3 mb-6"> <!-- Header with back button -->
<button class="glass-button p-2 rounded-lg" @click="router.push({ name: 'cloud' })"> <div class="shrink-0 mb-4">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
Back to Cloud
</button> </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 --> <!-- Mobile Back Button -->
<div v-if="!selectedPeer" class="space-y-3"> <Teleport to="body">
<div v-if="loading" class="glass-card p-8 text-center"> <button
<p class="text-white/50 animate-pulse">Loading federation peers...</p> @click="goBack"
</div> 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"
>
<div v-else-if="peers.length === 0" class="glass-card p-8 text-center"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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)"
>
<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">
<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>
<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" />
</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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg> </svg>
<span>Back to Cloud</span>
</button> </button>
<div> </Teleport>
<h2 class="text-lg font-semibold text-white">{{ selectedPeer.name || truncateDid(selectedPeer.did) }}</h2>
<p class="text-xs text-white/40">{{ selectedPeer.onion }}</p> <!-- Peer Header -->
<div class="flex items-center gap-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="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>
</div> </div>
</div>
<div v-if="catalogLoading" class="glass-card p-8 text-center"> <!-- Loading -->
<p class="text-white/50 animate-pulse">Connecting via Tor... This may take a few seconds.</p> <div v-if="loading" class="glass-card p-8 text-center">
</div> <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>
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
</div>
<div v-else-if="catalogError" class="glass-card p-6"> <!-- Error -->
<p class="text-red-400 text-sm">{{ catalogError }}</p> <div v-else-if="catalogError" class="glass-card p-6">
<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>
</div> <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 -->
<p class="text-white/50">This peer has no shared files.</p> <div v-else-if="catalogItems.length === 0 && !loading" class="glass-card p-8 text-center">
</div> <p class="text-white/50">This peer has no shared files.</p>
</div>
<div v-else class="space-y-2"> <!-- File Grid -->
<div <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
v-for="item in catalogItems" <div
:key="item.id" v-for="item in catalogItems"
class="glass-card p-4 flex items-center gap-4" :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)"> >
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" /> <svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
</div> </svg>
<div class="flex-1 min-w-0"> </div>
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p> <div class="flex-1 min-w-0">
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }} &middot; {{ item.mime_type }}</p> <p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
</div> <p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
<div class="flex items-center gap-2"> </div>
<span <div class="flex items-center gap-2">
class="text-xs px-2 py-0.5 rounded-full" <span
:class="accessBadgeClass(item.access)" class="text-xs px-2 py-0.5 rounded-full"
> :class="accessBadgeClass(item.access)"
{{ accessLabel(item.access) }} >
</span> {{ accessLabel(item.access) }}
<button </span>
v-if="canDownload(item.access)" <button
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium" v-if="canDownload(item.access)"
:disabled="downloading === item.id" class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
@click="downloadFile(item)" :disabled="downloading === item.id"
> @click="downloadFile(item)"
{{ downloading === item.id ? 'Downloading...' : 'Download' }} >
</button> {{ downloading === item.id ? '...' : 'Download' }}
</div> </button>
</div> </div>
</div> </div>
</div> </div>
@ -120,10 +94,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted, Teleport } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
const props = defineProps<{
peerId?: string
}>()
const router = useRouter() const router = useRouter()
interface PeerNode { interface PeerNode {
@ -144,42 +122,65 @@ interface CatalogItem {
} }
const loading = ref(true) const loading = ref(true)
const peers = ref<PeerNode[]>([]) const currentPeer = ref<PeerNode | null>(null)
const selectedPeer = ref<PeerNode | null>(null)
const catalogLoading = ref(false)
const catalogError = ref('') const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([]) const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null) 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 () => { onMounted(async () => {
try { if (props.peerId) {
const result = await rpcClient.federationListNodes() // Find the peer by onion address
peers.value = result?.nodes ?? [] try {
} catch { const result = await rpcClient.federationListNodes()
peers.value = [] const peers = result?.nodes ?? []
} finally { currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
} catch {
// Continue with just the onion address
}
await loadCatalog()
} else {
loading.value = false 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 { function truncateDid(did: string): string {
if (did.length <= 24) return did if (did.length <= 24) return did
return did.slice(0, 16) + '...' + did.slice(-8) return did.slice(0, 16) + '...' + did.slice(-8)
} }
async function browsePeer(peer: PeerNode) { function truncateOnion(onion: string): string {
selectedPeer.value = peer if (onion.length <= 20) return onion
catalogLoading.value = true return onion.slice(0, 12) + '...'
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 formatSize(bytes: number): string { function formatSize(bytes: number): string {
@ -231,12 +232,13 @@ function canDownload(access: CatalogItem['access']): boolean {
} }
async function downloadFile(item: CatalogItem) { async function downloadFile(item: CatalogItem) {
if (!selectedPeer.value) return const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
downloading.value = item.id downloading.value = item.id
try { try {
const result = await rpcClient.call<{ data?: string }>({ const result = await rpcClient.call<{ data?: string }>({
method: 'content.download-peer', method: 'content.download-peer',
params: { onion: selectedPeer.value.onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 120000, timeout: 120000,
}) })
if (result?.data) { if (result?.data) {

View File

@ -1,10 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<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>
<!-- Disk Space Warning Banner --> <!-- Disk Space Warning Banner -->
<div <div
@ -53,7 +48,7 @@
</div> </div>
<button <button
@click="restartServices" @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" :disabled="restarting"
> >
{{ restarting ? 'Restarting...' : 'Restart' }} {{ restarting ? 'Restarting...' : 'Restart' }}
@ -74,7 +69,7 @@
</div> </div>
<button <button
@click="checkConnectivity" @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" :disabled="checkingConnectivity"
> >
{{ checkingConnectivity ? 'Checking...' : 'Check' }} {{ checkingConnectivity ? 'Checking...' : 'Check' }}
@ -83,25 +78,27 @@
<!-- Auto-Sync Toggle --> <!-- 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 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 gap-3 min-w-0"> <div class="flex items-center justify-between min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex items-center gap-3 min-w-0">
<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" /> <svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <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" />
<div class="min-w-0"> </svg>
<p class="text-sm font-medium text-white">Auto-Sync</p> <div class="min-w-0">
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p> <p class="text-sm font-medium text-white">Auto-Sync</p>
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
</div>
</div> </div>
<button
@click="toggleAutoSync"
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-6 w-6 transform rounded-full bg-white transition-transform shadow"
:class="autoSyncEnabled ? 'translate-x-7' : 'translate-x-1'"
/>
</button>
</div> </div>
<button
@click="toggleAutoSync"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors self-start"
: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'"
/>
</button>
</div> </div>
<!-- Logs & Diagnostics --> <!-- Logs & Diagnostics -->
@ -117,7 +114,7 @@
</div> </div>
<button <button
@click="viewLogs" @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 View
</button> </button>
@ -128,7 +125,7 @@
<!-- Overview Cards --> <!-- Overview Cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card --> <!-- 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 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"> <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"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -220,13 +217,13 @@
</template> </template>
</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 Local Network Manage Local Network
</button> </button>
</div> </div>
<!-- Web3 Card --> <!-- 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 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"> <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"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -281,7 +278,7 @@
</div> </div>
</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 Manage Web3 Services
</button> </button>
</div> </div>
@ -379,7 +376,7 @@
</div> </div>
<!-- WiFi Scan Modal --> <!-- 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="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3> <h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
@ -439,7 +436,7 @@
</div> </div>
<!-- DNS Configuration Modal --> <!-- 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="glass-card p-6 w-full max-w-md">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3> <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) { async function toggleTorApp(appId: string, enabled: boolean) {
try { 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() await loadTorServices()
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', 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() { async function rotateNodeAddress() {
torRotating.value = true torRotating.value = true
try { try {
await rpcClient.call({ method: 'tor.rotate-address', params: { name: 'archipelago' } }) await rpcClient.call({ method: 'tor.rotate-service', params: { name: 'archipelago' } })
await loadTorServices() await loadTorServices()
} catch (e) { } catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e) if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)

View File

@ -1,14 +1,8 @@
<template> <template>
<div> <div class="pb-6">
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
<div> <div class="md:hidden mb-4">
<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> <ControllerIndicator />
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
</div>
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
<div class="md:hidden">
<ControllerIndicator />
</div>
</div> </div>
<!-- Account Section --> <!-- Account Section -->
@ -102,26 +96,24 @@
<!-- Tor / Onion Address (below DID, with copy button) --> <!-- Tor / Onion Address (below DID, with copy button) -->
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''"> <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 mb-2">
<div class="flex items-center gap-3 min-w-0"> <svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<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>
</svg> <p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
</div>
<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"
>
<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>
</button>
</div> </div>
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p> <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 mt-1">{{ t('settings.onionHelper') }}</p> <p class="text-xs text-white/50 mb-3">{{ t('settings.onionHelper') }}</p>
<button
@click="copyOnionAddress"
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-if="!copiedOnion">{{ t('common.copy') }}</span>
<span v-else class="text-green-400">{{ t('common.copied') }}</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -143,7 +135,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="showChangePasswordModal" 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()" @click.self="closeChangePasswordModal()"
> >
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full"> <div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
@ -255,7 +247,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="showTotpSetupModal" 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" @click.self="closeTotpSetup"
@keydown.escape="closeTotpSetup" @keydown.escape="closeTotpSetup"
> >
@ -361,7 +353,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="showTotpDisableModal" 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" @click.self="closeTotpDisable"
@keydown.escape="closeTotpDisable" @keydown.escape="closeTotpDisable"
> >
@ -507,7 +499,7 @@
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="showClaudeLoginModal" 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" @click.self="showClaudeLoginModal = false"
> >
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px"> <div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
@ -723,12 +715,10 @@
<!-- Backup & Restore Section --> <!-- Backup & Restore Section -->
<div class="glass-card px-6 py-6 mb-6"> <div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4"> <div class="mb-4">
<div> <h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('settings.backup') }}</h2>
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2> <p class="text-sm text-white/60 mb-3">{{ t('settings.backupRestoreDesc') }}</p>
<p class="text-sm text-white/60 mt-1">{{ 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">
</div>
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
@ -770,7 +760,7 @@
<!-- Create Backup Modal --> <!-- Create Backup Modal -->
<Teleport to="body"> <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"> <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> <h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3"> <div class="space-y-3">
@ -795,7 +785,7 @@
<!-- Restore Backup Modal --> <!-- Restore Backup Modal -->
<Teleport to="body"> <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"> <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> <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> <p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>

View File

@ -1,5 +1,5 @@
<template> <template>
<div> <div class="pb-6">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1> <h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p> <p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
@ -166,7 +166,7 @@
<!-- Confirmation modal --> <!-- Confirmation modal -->
<Transition name="fade"> <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"> <div class="glass-card p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-3"> <h3 class="text-lg font-semibold text-white mb-3">
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }} {{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}

View File

@ -1,14 +1,9 @@
<template> <template>
<div> <div class="pb-6">
<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>
<!-- Quick Actions Container --> <!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6"> <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 --> <!-- 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 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"> <div class="flex items-center gap-3 min-w-0">
@ -188,8 +183,8 @@
<!-- DID Document Modal --> <!-- DID Document Modal -->
<Teleport to="body"> <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 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-lg w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title"> <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"> <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> <h3 id="did-doc-title" class="text-lg font-semibold text-white">{{ t('web5.didDocument') }}</h3>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -222,7 +217,7 @@
<!-- Send Message Modal --> <!-- Send Message Modal -->
<Teleport to="body"> <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"> <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> <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> <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 value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion"> <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> </option>
</select> </select>
</div> </div>
@ -270,10 +265,10 @@
</div> </div>
</Teleport> </Teleport>
<!-- Core Services Overview Cards --> <!-- Core Services Overview Cards Row 1 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div class="flex flex-col md:flex-row gap-6 mb-6">
<!-- Bitcoin Domain Name Portfolio --> <!-- 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 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"> <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"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -320,13 +315,13 @@
</div> </div>
</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') }} {{ t('web5.manageDomains') }}
</button> </button>
</div> </div>
<!-- Wallet --> <!-- 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 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"> <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"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -389,9 +384,12 @@
</button> </button>
</div> </div>
</div> </div>
</div>
<!-- Core Services Overview Cards Row 2 -->
<div class="flex flex-col md:flex-row gap-6 mb-8">
<!-- Nostr Relays --> <!-- 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 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"> <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"> <svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -438,13 +436,13 @@
</div> </div>
</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') }} {{ t('web5.relays') }}
</button> </button>
</div> </div>
<!-- Node Visibility --> <!-- 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 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"> <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"> <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') }} {{ t('web5.discoverableWarning') }}
</p> </p>
</div> </div>
</div>
<!-- Connected Nodes (P2P over Tor) --> <!-- 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 ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24">
<div class="flex items-start gap-4 mb-4"> <!-- 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"> <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"> <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" /> <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> </button>
</div> </div>
</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 --> <!-- Tabs: Peers | Messages | Requests -->
<div class="flex gap-1 mb-4 border-b border-white/10"> <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="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="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
<div class="min-w-0"> <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> <p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
</div> </div>
</div> </div>
@ -607,7 +633,7 @@
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50" 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"> <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> <span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
</div> </div>
<p class="text-sm text-white/90 break-words">{{ m.message }}</p> <p class="text-sm text-white/90 break-words">{{ m.message }}</p>
@ -678,11 +704,11 @@
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }} {{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
</button> </button>
</div> </div>
</div>
<!-- Shared Content --> <!-- Shared Content -->
<div class="glass-card p-6 mb-8"> <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 items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center"> <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"> <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> <p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div v-if="contentTab === 'mine'" 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"> <button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }} {{ contentLoading ? '...' : 'Refresh' }}
</button> </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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
@ -822,7 +871,7 @@
> >
<option value="">{{ t('web5.selectPeer') }}</option> <option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion"> <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> </option>
</select> </select>
<button <button
@ -912,7 +961,7 @@
<!-- Content Streaming Player --> <!-- Content Streaming Player -->
<Teleport to="body"> <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"> <div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
<!-- Player Header --> <!-- Player Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10"> <div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
@ -984,7 +1033,7 @@
<!-- Add Content Modal --> <!-- Add Content Modal -->
<Teleport to="body"> <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"> <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> <h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4"> <div class="space-y-4">
@ -1035,7 +1084,8 @@
<!-- Identity Management --> <!-- Identity Management -->
<div class="glass-card p-6 mb-8"> <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 items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center"> <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"> <svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1054,6 +1104,24 @@
Create Create
</button> </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="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 --> <!-- Loading -->
<div v-if="identitiesLoading" class="py-6 text-center"> <div v-if="identitiesLoading" class="py-6 text-center">
@ -1112,7 +1180,7 @@
<!-- Actions --> <!-- Actions -->
<div class="flex items-center gap-1 shrink-0"> <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"> <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" /> <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> </svg>
@ -1133,7 +1201,7 @@
</div> </div>
<!-- Create Identity Modal --> <!-- 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"> <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> <h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
<div class="space-y-4"> <div class="space-y-4">
@ -1167,7 +1235,7 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- 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"> <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> <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> <p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
@ -1180,7 +1248,7 @@
</div> </div>
</div> </div>
<!-- Unified Send Modal --> <!-- 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"> <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> <h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
@ -1280,7 +1348,7 @@
</div> </div>
<!-- Unified Receive Modal --> <!-- 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"> <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> <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-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 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"> <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> <span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
</div> </div>
<div class="flex flex-wrap gap-2 text-xs"> <div class="flex flex-wrap gap-2 text-xs">
@ -1501,7 +1569,8 @@
<!-- Verifiable Credentials --> <!-- Verifiable Credentials -->
<div class="glass-card p-6 mb-8"> <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 items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center"> <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"> <svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -1517,6 +1586,21 @@
Manage Manage
</router-link> </router-link>
</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="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 --> <!-- Stats -->
<div class="grid grid-cols-3 gap-3 mb-4"> <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 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="min-w-0 flex-1">
<div class="text-sm text-white font-medium">{{ vc.type }}</div> <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> </div>
<span :class="{ <span :class="{
'text-green-400': vc.status === 'active', 'text-green-400': vc.status === 'active',
@ -1557,7 +1641,7 @@
</div> </div>
<!-- Domains Management Modal --> <!-- 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="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"> <div class="flex items-center justify-between mb-4">
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2> <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> <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"> <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 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> </select>
</div> </div>
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div> <div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
@ -1634,7 +1718,7 @@
</div> </div>
<!-- Relay Management Modal --> <!-- 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="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"> <div class="flex items-center justify-between mb-4">
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2> <h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
@ -1908,18 +1992,27 @@ async function createDID() {
localStorage.setItem('neode_did', res.did) localStorage.setItem('neode_did', res.did)
} catch { } catch {
// Fallback: generate a did:key locally using Web Crypto // Fallback: generate a did:key locally using Web Crypto
const keyPair = await crypto.subtle.generateKey( if (!crypto.subtle) {
{ name: 'ECDSA', namedCurve: 'P-256' }, // crypto.subtle requires HTTPS generate random fallback
true, const randomBytes = new Uint8Array(32)
['sign', 'verify'] crypto.getRandomValues(randomBytes)
) const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
const exported = await crypto.subtle.exportKey('raw', keyPair.publicKey) const did = `did:key:z${hex}`
const bytes = new Uint8Array(exported) storedDid.value = did
// Multicodec prefix for P-256 public key (0x1200) + base58btc localStorage.setItem('neode_did', did)
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('') } else {
const did = `did:key:z${hex}` const keyPair = await crypto.subtle.generateKey(
storedDid.value = did { name: 'ECDSA', namedCurve: 'P-256' },
localStorage.setItem('neode_did', did) true,
['sign', 'verify']
)
const exported = await crypto.subtle.exportKey('raw', keyPair.publicKey)
const bytes = new Uint8Array(exported)
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 { } finally {
creatingDid.value = false creatingDid.value = false
} }
@ -1965,7 +2058,7 @@ async function refreshDhtDid() {
async function copyDhtDid() { async function copyDhtDid() {
if (!dhtDid.value) return if (!dhtDid.value) return
await navigator.clipboard.writeText(dhtDid.value) await safeClipboardWrite(dhtDid.value)
dhtDidCopied.value = true dhtDidCopied.value = true
setTimeout(() => { dhtDidCopied.value = false }, 2000) setTimeout(() => { dhtDidCopied.value = false }, 2000)
} }
@ -1977,7 +2070,7 @@ try {
async function copyDid() { async function copyDid() {
if (!userDid.value) return if (!userDid.value) return
await navigator.clipboard.writeText(userDid.value) await safeClipboardWrite(userDid.value)
didCopied.value = true didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000) setTimeout(() => { didCopied.value = false }, 2000)
} }
@ -2016,7 +2109,7 @@ async function showDidDocument() {
async function copyDidDocument() { async function copyDidDocument() {
if (!didDocumentFormatted.value) return if (!didDocumentFormatted.value) return
await navigator.clipboard.writeText(didDocumentFormatted.value) await safeClipboardWrite(didDocumentFormatted.value)
didDocCopied.value = true didDocCopied.value = true
setTimeout(() => { didDocCopied.value = false }, 2000) setTimeout(() => { didDocCopied.value = false }, 2000)
} }
@ -2411,7 +2504,7 @@ async function finalizePsbt() {
function copyPsbt() { function copyPsbt() {
if (!psbtData.value) return if (!psbtData.value) return
window.navigator.clipboard.writeText(psbtData.value) window.safeClipboardWrite(psbtData.value)
unifiedSendError.value = t('web5.psbtCopied') unifiedSendError.value = t('web5.psbtCopied')
} }
@ -2478,12 +2571,27 @@ async function unifiedReceive() {
} }
function copyEcashToken(token: string) { function copyEcashToken(token: string) {
navigator.clipboard.writeText(token) safeClipboardWrite(token)
showIdentityToast(t('web5.ecashTokenCopied')) 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) { function copyToClipboard(text: string, msg: string) {
navigator.clipboard.writeText(text) safeClipboardWrite(text)
showIdentityToast(msg) showIdentityToast(msg)
} }
@ -2711,7 +2819,7 @@ function downloadPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return if (!browsePeerOnion.value) return
const url = `http://${browsePeerOnion.value}/content/${item.id}` const url = `http://${browsePeerOnion.value}/content/${item.id}`
showIdentityToast(t('web5.downloadUrlCopied')) showIdentityToast(t('web5.downloadUrlCopied'))
navigator.clipboard.writeText(url) safeClipboardWrite(url)
} }
function closePlayer() { function closePlayer() {
@ -2743,7 +2851,7 @@ function onPlayerError() {
function copyStreamUrl() { function copyStreamUrl() {
if (streamUrl.value) { if (streamUrl.value) {
navigator.clipboard.writeText(streamUrl.value) safeClipboardWrite(streamUrl.value)
showIdentityToast(t('web5.streamUrlCopied')) showIdentityToast(t('web5.streamUrlCopied'))
} }
} }
@ -2832,8 +2940,8 @@ const settingVisibility = ref(false)
const visibilityOptions = [ const visibilityOptions = [
{ value: 'hidden' as VisibilityLevel, label: 'Hidden', description: 'Your node is not discoverable by others' }, { 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: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Federated peers can find and connect to your node' },
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Visible to everyone with your onion address published' }, { value: 'public' as VisibilityLevel, label: 'Public', description: 'Accepting connections from any Archipelago node' },
] ]
async function loadVisibility() { async function loadVisibility() {
@ -2869,7 +2977,7 @@ async function setVisibility(level: VisibilityLevel) {
function copyOnionAddress() { function copyOnionAddress() {
if (!nodeOnionAddress.value) return if (!nodeOnionAddress.value) return
navigator.clipboard.writeText(nodeOnionAddress.value) safeClipboardWrite(nodeOnionAddress.value)
showIdentityToast(t('web5.onionAddressCopied')) showIdentityToast(t('web5.onionAddressCopied'))
} }
@ -2928,7 +3036,7 @@ async function createIdentity() {
} }
function copyIdentityDid(did: string) { function copyIdentityDid(did: string) {
navigator.clipboard.writeText(did) safeClipboardWrite(did)
showIdentityToast(t('web5.didCopied')) showIdentityToast(t('web5.didCopied'))
} }
@ -3098,8 +3206,7 @@ async function connectWallet() {
} }
function manageRelays() { function manageRelays() {
// TODO: Navigate to relay management or open modal showRelaysModal.value = true
if (import.meta.env.DEV) console.log('Managing Nostr relays...')
} }
</script> </script>

View File

@ -119,7 +119,7 @@
</Transition> </Transition>
<!-- Open Channel Modal --> <!-- 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"> <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> <h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
@ -165,7 +165,7 @@
</div> </div>
<!-- Close Confirmation Modal --> <!-- 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"> <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> <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> <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', '**/*-backup-*.mp4',
'**/*-1.47mb.mp4', '**/*-1.47mb.mp4',
'**/bg-*.mp4', // Exclude large background videos from precache '**/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 '**/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 skipWaiting: false, // Wait for user to accept update
clientsClaim: false, // Don't claim clients immediately clientsClaim: false, // Don't claim clients immediately
runtimeCaching: [ runtimeCaching: [

View File

@ -556,6 +556,27 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
fi fi
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) # 9. Custom UI containers (bitcoin-ui, lnd-ui)
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images. # These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.
for ui in bitcoin-ui lnd-ui; do for ui in bitcoin-ui lnd-ui; do