bug fixes from sxsw
244
.claude/plans/sequential-jingling-moth.md
Normal 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
@ -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"]
|
||||
@ -1,44 +1,53 @@
|
||||
# IndeedHub (Indeehub Prototype)
|
||||
# Indeehub — Bitcoin Documentary Streaming
|
||||
|
||||
Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
|
||||
Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider.
|
||||
|
||||
## Building the Image
|
||||
|
||||
The app image is built from the **Indeehub Prototype** project. The prototype lives at `../../Indeedhub Prototype` (relative to the archy repo).
|
||||
The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`.
|
||||
|
||||
### Option 1: Build from prototype directory
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
podman build -t localhost/indeedhub:latest .
|
||||
```
|
||||
|
||||
### Option 2: Use the build script
|
||||
### Option 1: Use the build script
|
||||
|
||||
```bash
|
||||
# From archy repo root
|
||||
./apps/indeedhub/build-from-prototype.sh
|
||||
```
|
||||
|
||||
### Option 3: Full deploy (build + run on server)
|
||||
### Option 2: Build from source directory
|
||||
|
||||
```bash
|
||||
cd "/path/to/Indeedhub Prototype"
|
||||
./deploy-to-archipelago.sh
|
||||
cd ~/Projects/indeehub-frontend
|
||||
podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile .
|
||||
```
|
||||
|
||||
## Installing from My Apps
|
||||
## Installing from App Store
|
||||
|
||||
1. **Build the image** using one of the options above (the image must exist before install)
|
||||
2. Go to **Dashboard → App Store** (Marketplace)
|
||||
3. Find **Indeehub Prototype** and click **Install**
|
||||
4. The app will appear in **My Apps** once the container is running
|
||||
1. **Build the image** using one of the options above (must exist before install)
|
||||
2. Go to **Dashboard -> App Store** (Marketplace)
|
||||
3. Find **Indeehub** and click **Install**
|
||||
4. On first launch, pick a Nostr identity to sign in with
|
||||
5. The app appears in **My Apps** once the container is running
|
||||
|
||||
## Port
|
||||
|
||||
- Web UI: 7777
|
||||
- Web UI: 8190 (maps to container port 3000)
|
||||
|
||||
## Container
|
||||
|
||||
- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry)
|
||||
- Port: 7777
|
||||
- Runtime: Node.js 20 (Next.js standalone)
|
||||
- Port: 8190 -> 3000
|
||||
- Read-only root filesystem with tmpfs for /tmp and .next/cache
|
||||
|
||||
## Nostr Identity
|
||||
|
||||
On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy.
|
||||
|
||||
## Services
|
||||
|
||||
The app connects to the following external services (configured at build time):
|
||||
- Indeehub API (content, auth, streaming)
|
||||
- AWS S3 (media storage via CloudFront CDN)
|
||||
- Nostr relays (via NIP-07 provider from Archipelago)
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Build Indeehub image from the Indeehub Prototype project
|
||||
# Usage: ./build-from-prototype.sh [path-to-prototype]
|
||||
# Build Indeehub container image from the indeehub-frontend project
|
||||
# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_PROTOTYPE="$SCRIPT_DIR/../../Indeedhub Prototype"
|
||||
PROTOTYPE_DIR="${1:-$DEFAULT_PROTOTYPE}"
|
||||
DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend"
|
||||
FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}"
|
||||
IMAGE_TAG="localhost/indeedhub:latest"
|
||||
|
||||
if [ ! -d "$PROTOTYPE_DIR" ]; then
|
||||
echo "❌ Indeehub Prototype not found at: $PROTOTYPE_DIR"
|
||||
echo " Set path: $0 /path/to/Indeedhub\ Prototype"
|
||||
if [ ! -d "$FRONTEND_DIR" ]; then
|
||||
echo "Indeehub frontend not found at: $FRONTEND_DIR"
|
||||
echo " Set path: $0 /path/to/indeehub-frontend"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$FRONTEND_DIR/package.json" ]; then
|
||||
echo "No package.json found in $FRONTEND_DIR — is this the right directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@ -21,10 +26,10 @@ if ! command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME="docker"
|
||||
fi
|
||||
|
||||
echo "🔨 Building Indeehub from $PROTOTYPE_DIR"
|
||||
cd "$PROTOTYPE_DIR"
|
||||
$RUNTIME build -t "$IMAGE_TAG" .
|
||||
echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile"
|
||||
$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR"
|
||||
|
||||
echo "✅ Built $IMAGE_TAG"
|
||||
echo "Built $IMAGE_TAG"
|
||||
echo ""
|
||||
echo "You can now install Indeehub from the App Store in Archipelago."
|
||||
echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG"
|
||||
|
||||
@ -2,62 +2,62 @@ app:
|
||||
id: indeedhub
|
||||
name: Indeehub
|
||||
version: 0.1.0
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.
|
||||
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
|
||||
category: media
|
||||
|
||||
|
||||
container:
|
||||
image: localhost/indeedhub:1.0.0
|
||||
pull_policy: never # Built locally
|
||||
|
||||
image: git.tx1138.com/lfg2025/indeedhub:latest
|
||||
pull_policy: always # Pull from registry; falls back to local build
|
||||
|
||||
dependencies:
|
||||
- storage: 500Mi
|
||||
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
cpu_limit: 2
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 500Mi
|
||||
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true # Static nginx content
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
user: 1001
|
||||
seccomp_profile: default
|
||||
network_policy: bridge
|
||||
apparmor_profile: default
|
||||
|
||||
|
||||
ports:
|
||||
- host: 7777
|
||||
container: 7777
|
||||
protocol: tcp # Web UI
|
||||
|
||||
- host: 8190
|
||||
container: 3000
|
||||
protocol: tcp # Web UI (Next.js)
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /var/cache/nginx
|
||||
options: [rw,noexec,nosuid,size=10m]
|
||||
target: /tmp
|
||||
options: [rw,noexec,nosuid,size=64m]
|
||||
- type: tmpfs
|
||||
target: /var/run
|
||||
options: [rw,noexec,nosuid,size=10m]
|
||||
|
||||
target: /app/.next/cache
|
||||
options: [rw,noexec,nosuid,size=128m]
|
||||
|
||||
environment:
|
||||
- NGINX_HOST=localhost
|
||||
- NGINX_PORT=7777
|
||||
|
||||
- NODE_ENV=production
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:7777
|
||||
path: /health
|
||||
endpoint: http://localhost:3000
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Web UI
|
||||
description: Stream Bitcoin documentaries
|
||||
description: Stream Bitcoin documentaries with Nostr identity
|
||||
type: ui
|
||||
port: 7777
|
||||
port: 8190
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
@ -72,3 +72,4 @@ app:
|
||||
- streaming
|
||||
- media
|
||||
- education
|
||||
- nostr
|
||||
|
||||
70
apps/indeedhub/push-to-registry.sh
Executable 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"
|
||||
@ -34,11 +34,6 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Docker image format"));
|
||||
}
|
||||
|
||||
// Virtual app: Indeehub (no container, opens external URL)
|
||||
if package_id == "indeedhub" {
|
||||
return Ok(serde_json::json!({ "success": true }));
|
||||
}
|
||||
|
||||
// Multi-container apps: create full stack
|
||||
if package_id == "immich" {
|
||||
return self.install_immich_stack().await;
|
||||
@ -117,8 +112,17 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
// Pull the image (skip for local images - must be built locally first)
|
||||
// For registry images, also check if a local build exists first (avoids
|
||||
// pull failures when the registry image hasn't been pushed yet).
|
||||
let is_local_image = docker_image.starts_with("localhost/");
|
||||
if !is_local_image {
|
||||
let has_local_fallback = if !is_local_image {
|
||||
let local_tag = format!("localhost/{}:latest", package_id);
|
||||
let check = tokio::process::Command::new("sudo")
|
||||
.args(["podman", "images", "-q", &local_tag])
|
||||
.output().await.ok();
|
||||
check.map_or(false, |o| !String::from_utf8_lossy(&o.stdout).trim().is_empty())
|
||||
} else { false };
|
||||
if !is_local_image && !has_local_fallback {
|
||||
debug!("Pulling image: {}", docker_image);
|
||||
|
||||
// Set package state to Installing with progress
|
||||
@ -156,6 +160,9 @@ impl RpcHandler {
|
||||
|
||||
// Mark pull as complete (100%)
|
||||
self.set_install_progress(package_id, 100, 100).await;
|
||||
} else if has_local_fallback {
|
||||
// Registry image exists locally — use the local build
|
||||
debug!("Using local build for {} (skipping registry pull)", package_id);
|
||||
} else {
|
||||
// Verify local image exists
|
||||
let images_output = tokio::process::Command::new("sudo")
|
||||
@ -165,7 +172,7 @@ impl RpcHandler {
|
||||
.context("Failed to check local image")?;
|
||||
if String::from_utf8_lossy(&images_output.stdout).trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Local image {} not found. Run ./deploy-to-archipelago.sh from the Indeehub Prototype project on your Mac—it builds the image on this server and starts the app.",
|
||||
"Local image {} not found. Build the image first or ensure the registry is reachable.",
|
||||
docker_image
|
||||
));
|
||||
}
|
||||
@ -342,8 +349,13 @@ printtoconsole=1\n";
|
||||
run_args.push(arg);
|
||||
}
|
||||
|
||||
// Finally, the image
|
||||
run_args.push(docker_image);
|
||||
// Finally, the image — use local build if available, otherwise registry image
|
||||
let effective_image = if has_local_fallback {
|
||||
format!("localhost/{}:latest", package_id)
|
||||
} else {
|
||||
docker_image.to_string()
|
||||
};
|
||||
run_args.push(&effective_image);
|
||||
|
||||
debug!("Running container with args: {:?}", run_args);
|
||||
|
||||
@ -1723,9 +1735,9 @@ fn get_app_config(
|
||||
]),
|
||||
),
|
||||
"indeedhub" => (
|
||||
vec!["7777:7777".to_string()],
|
||||
vec!["8190:3000".to_string()],
|
||||
vec![],
|
||||
vec!["NGINX_HOST=0.0.0.0".to_string(), "NGINX_PORT=7777".to_string()],
|
||||
vec!["NODE_ENV=production".to_string(), "NEXT_TELEMETRY_DISABLED=1".to_string()],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
|
||||
@ -136,6 +136,9 @@ impl DockerPackageScanner {
|
||||
} else if app_id == "dwn" {
|
||||
debug!("Using DWN server: http://localhost:3100");
|
||||
Some("http://localhost:3100".to_string())
|
||||
} else if app_id == "indeedhub" {
|
||||
debug!("Using Indeehub: http://localhost:8190");
|
||||
Some("http://localhost:8190".to_string())
|
||||
} else if app_id == "mempool-electrs" || app_id == "electrs" {
|
||||
// Electrs UI runs on host at port 50002
|
||||
debug!("Using electrs-ui for mempool-electrs: http://localhost:50002");
|
||||
@ -219,66 +222,6 @@ impl DockerPackageScanner {
|
||||
info!("Detected container: {} ({})", metadata.title, package_state_str(&package_state));
|
||||
}
|
||||
|
||||
// Virtual app: Indeehub (opens external URL, no container required)
|
||||
if !packages.contains_key("indeedhub") {
|
||||
let metadata = get_app_metadata("indeedhub");
|
||||
let lan_address = Some("https://archipelago.indeehub.studio".to_string());
|
||||
let virtual_pkg = PackageDataEntry {
|
||||
state: PackageState::Running,
|
||||
static_files: StaticFiles {
|
||||
license: "MIT".to_string(),
|
||||
instructions: metadata.description.clone(),
|
||||
icon: metadata.icon.clone(),
|
||||
},
|
||||
manifest: Manifest {
|
||||
id: "indeedhub".to_string(),
|
||||
title: metadata.title.clone(),
|
||||
version: "0.1.0".to_string(),
|
||||
description: Description {
|
||||
short: metadata.description.clone(),
|
||||
long: metadata.description.clone(),
|
||||
},
|
||||
release_notes: "Virtual app (opens archipelago.indeehub.studio)".to_string(),
|
||||
license: "MIT".to_string(),
|
||||
wrapper_repo: metadata.repo.clone(),
|
||||
upstream_repo: metadata.repo.clone(),
|
||||
support_site: metadata.repo.clone(),
|
||||
marketing_site: metadata.repo.clone(),
|
||||
donation_url: None,
|
||||
author: Some("Indeehub Team".to_string()),
|
||||
website: lan_address.clone(),
|
||||
tier: Some("optional".to_string()),
|
||||
interfaces: Some(Interfaces {
|
||||
main: Some(MainInterface {
|
||||
ui: Some("true".to_string()),
|
||||
tor_config: None,
|
||||
lan_config: None,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
installed: Some(InstalledPackageDataEntry {
|
||||
current_dependents: HashMap::new(),
|
||||
current_dependencies: HashMap::new(),
|
||||
last_backup: None,
|
||||
interface_addresses: {
|
||||
let mut addresses = HashMap::new();
|
||||
addresses.insert(
|
||||
"main".to_string(),
|
||||
InterfaceAddress {
|
||||
tor_address: String::new(),
|
||||
lan_address: lan_address,
|
||||
},
|
||||
);
|
||||
addresses
|
||||
},
|
||||
status: ServiceStatus::Running,
|
||||
}),
|
||||
install_progress: None,
|
||||
};
|
||||
packages.insert("indeedhub".to_string(), virtual_pkg);
|
||||
info!("Virtual app: Indeehub (archipelago.indeehub.studio)");
|
||||
}
|
||||
|
||||
Ok(packages)
|
||||
}
|
||||
}
|
||||
@ -505,7 +448,7 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
|
||||
repo: "https://github.com/tailscale/tailscale".to_string(),
|
||||
tier: "",
|
||||
},
|
||||
"indeedhub" => AppMetadata {
|
||||
"indeedhub" | "indeehub" => AppMetadata {
|
||||
title: "Indeehub".to_string(),
|
||||
description: "Decentralized media streaming platform".to_string(),
|
||||
icon: "/assets/img/app-icons/indeehub.ico".to_string(),
|
||||
|
||||
@ -331,6 +331,20 @@ server {
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/indeedhub/ {
|
||||
proxy_pass http://127.0.0.1:8190/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/lnd/ {
|
||||
proxy_pass http://127.0.0.1:8081/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@ -231,6 +231,19 @@ location /app/electrs/ {
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/indeedhub/ {
|
||||
proxy_pass http://127.0.0.1:8190/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
|
||||
}
|
||||
location /app/nginx-proxy-manager/ {
|
||||
proxy_pass http://127.0.0.1:81/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@ -82,7 +82,7 @@ define(['./workbox-21a80088'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.09ki1c64ohs"
|
||||
"revision": "0.6f1usind3cc"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
|
||||
BIN
neode-ui/public/assets/img/app-icons/bg-appstore.jpg
Normal file
|
After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 1019 KiB After Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 1014 KiB |
|
Before Width: | Height: | Size: 901 KiB After Width: | Height: | Size: 996 KiB |
|
Before Width: | Height: | Size: 999 KiB After Width: | Height: | Size: 774 KiB |
BIN
neode-ui/public/assets/video/video-intro-new.mp4
Normal file
BIN
neode-ui/public/assets/video/video-intro-old.mp4
Normal file
@ -55,6 +55,11 @@ class RPCClient {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
// Session expired — redirect to login
|
||||
if (response.status === 401 && method !== 'auth.login') {
|
||||
window.location.href = '/login'
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
const isRetryable = response.status === 502 || response.status === 503
|
||||
if (isRetryable && attempt < maxRetries - 1) {
|
||||
|
||||
@ -172,12 +172,21 @@
|
||||
@approve="store.approveConsent"
|
||||
@deny="store.denyConsent"
|
||||
/>
|
||||
|
||||
<!-- Nostr identity picker (first-launch for identity-aware apps) -->
|
||||
<NostrIdentityPicker
|
||||
:show="showIdentityPicker"
|
||||
:app-name="store.title || 'App'"
|
||||
@select="onIdentitySelected"
|
||||
@cancel="showIdentityPicker = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
interface PaymentRequest {
|
||||
@ -197,6 +206,73 @@ const isRefreshing = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
const iframeBlocked = ref(false)
|
||||
|
||||
// Nostr identity picker state
|
||||
const showIdentityPicker = ref(false)
|
||||
const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_'
|
||||
|
||||
interface SelectedIdentity {
|
||||
id: string
|
||||
name: string
|
||||
did: string
|
||||
pubkey: string
|
||||
nostr_pubkey?: string
|
||||
nostr_npub?: string
|
||||
}
|
||||
|
||||
/** Get the stored identity for an app, or null if first launch */
|
||||
function getStoredIdentity(appUrl: string): SelectedIdentity | null {
|
||||
try {
|
||||
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
|
||||
const stored = localStorage.getItem(key)
|
||||
return stored ? JSON.parse(stored) as SelectedIdentity : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Store the selected identity for an app */
|
||||
function storeIdentity(appUrl: string, identity: SelectedIdentity) {
|
||||
try {
|
||||
const key = IDENTITY_STORAGE_KEY + appUrl.replace(/[^a-z0-9]/gi, '_')
|
||||
localStorage.setItem(key, JSON.stringify(identity))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Handle identity selection from the picker */
|
||||
function onIdentitySelected(identity: SelectedIdentity) {
|
||||
showIdentityPicker.value = false
|
||||
if (store.url) {
|
||||
storeIdentity(store.url, identity)
|
||||
}
|
||||
// Send identity to the iframe
|
||||
sendSelectedIdentity(identity)
|
||||
}
|
||||
|
||||
/** Send a specific identity to the iframe */
|
||||
async function sendSelectedIdentity(identity: SelectedIdentity) {
|
||||
try {
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({
|
||||
method: 'identity.sign',
|
||||
params: { id: identity.id, message: challenge }
|
||||
})
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:identity',
|
||||
did: identity.did,
|
||||
name: identity.name,
|
||||
pubkey: identity.pubkey,
|
||||
nostr_pubkey: identity.nostr_pubkey || null,
|
||||
nostr_npub: identity.nostr_npub || null,
|
||||
challenge,
|
||||
signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch {
|
||||
/* identity signing not available */
|
||||
}
|
||||
}
|
||||
|
||||
// Timers for iframe load detection
|
||||
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let contentCheckId: ReturnType<typeof setTimeout> | null = null
|
||||
@ -274,37 +350,27 @@ function checkIframeContent() {
|
||||
|
||||
/** Apps that support the Archipelago identity protocol (postMessage) */
|
||||
function isIdentityAwareApp(url: string): boolean {
|
||||
return url.includes('indeehub')
|
||||
return url.includes('indeehub') || url.includes('indeedhub')
|
||||
}
|
||||
|
||||
/** Send the user's default identity to the iframe via postMessage */
|
||||
/** Send the user's identity to the iframe via postMessage.
|
||||
* On first launch, shows the identity picker modal.
|
||||
* On subsequent launches, uses the previously selected identity. */
|
||||
async function sendIdentityIfSupported() {
|
||||
if (!store.url || !isIdentityAwareApp(store.url)) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ identities: Array<{ id: string; name: string; did: string; pubkey: string; is_default: boolean; nostr_pubkey?: string; nostr_npub?: string }> }>({ method: 'identity.list' })
|
||||
const defaultId = res.identities?.find(i => i.is_default) || res.identities?.[0]
|
||||
if (!defaultId) return
|
||||
// Sign a timestamp challenge to prove ownership
|
||||
const challenge = `archipelago-identity:${Date.now()}`
|
||||
const sigRes = await rpcClient.call<{ signature: string }>({
|
||||
method: 'identity.sign',
|
||||
params: { id: defaultId.id, message: challenge }
|
||||
})
|
||||
const iframe = iframeRef.value
|
||||
if (!iframe?.contentWindow) return
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'archipelago:identity',
|
||||
did: defaultId.did,
|
||||
name: defaultId.name,
|
||||
pubkey: defaultId.pubkey,
|
||||
nostr_pubkey: defaultId.nostr_pubkey || null,
|
||||
nostr_npub: defaultId.nostr_npub || null,
|
||||
challenge,
|
||||
signature: sigRes.signature
|
||||
}, '*')
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Identity not available — continuing without it', e)
|
||||
|
||||
// Check if we have a stored identity for this app
|
||||
const stored = getStoredIdentity(store.url)
|
||||
if (stored) {
|
||||
// Use the previously selected identity
|
||||
await sendSelectedIdentity(stored)
|
||||
return
|
||||
}
|
||||
|
||||
// First launch — show the identity picker
|
||||
showIdentityPicker.value = true
|
||||
return // Identity will be sent after selection via onIdentitySelected
|
||||
|
||||
}
|
||||
|
||||
function injectScrollbarHideIfSameOrigin() {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Full mode switcher -->
|
||||
<div v-else class="mode-switcher">
|
||||
<div v-else class="mode-switcher mode-switcher-full">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
|
||||
444
neode-ui/src/components/NostrIdentityPicker.vue
Normal 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 → 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 · SECP256K1 · 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>
|
||||
@ -370,8 +370,8 @@
|
||||
"connectedNodes": "Connected Nodes",
|
||||
"bitcoinDomains": "Bitcoin Domain Names",
|
||||
"domainsSubtitle": "NIP-05 verified identities",
|
||||
"copyDid": "Copy DID",
|
||||
"viewDidDocument": "View DID Document",
|
||||
"copyDid": "Copy",
|
||||
"viewDidDocument": "View",
|
||||
"createDid": "Create DID",
|
||||
"creatingDid": "Creating...",
|
||||
"manageDomains": "Manage Domains",
|
||||
|
||||
@ -370,8 +370,8 @@
|
||||
"connectedNodes": "Nodos conectados",
|
||||
"bitcoinDomains": "Nombres de dominio Bitcoin",
|
||||
"domainsSubtitle": "Identidades verificadas NIP-05",
|
||||
"copyDid": "Copiar DID",
|
||||
"viewDidDocument": "Ver documento DID",
|
||||
"copyDid": "Copiar",
|
||||
"viewDidDocument": "Ver",
|
||||
"createDid": "Crear DID",
|
||||
"creatingDid": "Creando...",
|
||||
"manageDomains": "Administrar dominios",
|
||||
|
||||
@ -114,9 +114,10 @@ const router = createRouter({
|
||||
component: () => import('../views/Cloud.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloud/peers',
|
||||
path: 'cloud/peers/:peerId?',
|
||||
name: 'peer-files',
|
||||
component: () => import('../views/PeerFiles.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: 'cloud/:folderId',
|
||||
|
||||
@ -67,6 +67,7 @@ const PORT_TO_PROXY: Record<string, string> = {
|
||||
'8176': '/app/fedimint-gateway/',
|
||||
'3100': '/app/dwn/',
|
||||
'18081': '/app/nostr-rs-relay/',
|
||||
'8190': '/app/indeedhub/',
|
||||
}
|
||||
|
||||
/** Rewrite to same-origin proxy ONLY when needed for HTTPS mixed-content.
|
||||
@ -188,12 +189,32 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
|
||||
const origin = url.value || 'unknown'
|
||||
|
||||
// Check if app has a per-app identity stored (from identity picker)
|
||||
const IDENTITY_KEY = 'archipelago_app_identity_'
|
||||
const appKey = IDENTITY_KEY + (url.value || '').replace(/[^a-z0-9]/gi, '_')
|
||||
let appIdentityId: string | null = null
|
||||
try {
|
||||
const stored = localStorage.getItem(appKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as { id?: string }
|
||||
appIdentityId = parsed.id || null
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
try {
|
||||
let result: unknown
|
||||
|
||||
if (method === 'getPublicKey') {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
if (appIdentityId) {
|
||||
// Use the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string; nostr_npub: string; id: string; name: string; pubkey: string; did: string; is_default: boolean }>({
|
||||
method: 'identity.get', params: { id: appIdentityId }
|
||||
})
|
||||
result = res.nostr_pubkey
|
||||
} else {
|
||||
const res = await rpcClient.call<{ nostr_pubkey: string }>({ method: 'node.nostr-pubkey' })
|
||||
result = res.nostr_pubkey
|
||||
}
|
||||
} else if (method === 'signEvent') {
|
||||
// Check if origin is pre-approved
|
||||
const approved = getApprovedOrigins()
|
||||
@ -208,32 +229,41 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
result = res
|
||||
if (appIdentityId) {
|
||||
// Sign with the app-specific identity's Nostr key
|
||||
const res = await rpcClient.call<unknown>({
|
||||
method: 'identity.nostr-sign',
|
||||
params: { id: appIdentityId, event: params.event }
|
||||
})
|
||||
result = res
|
||||
} else {
|
||||
const res = await rpcClient.call<unknown>({ method: 'node.nostr-sign', params: { event: params.event } })
|
||||
result = res
|
||||
}
|
||||
} else if (method === 'getRelays') {
|
||||
result = {}
|
||||
} else if (method === 'nip04.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip04',
|
||||
params: { pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip04.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip04',
|
||||
params: { pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else if (method === 'nip44.encrypt') {
|
||||
const res = await rpcClient.call<{ ciphertext: string }>({
|
||||
method: 'identity.nostr-encrypt-nip44',
|
||||
params: { pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, plaintext: params.plaintext }
|
||||
})
|
||||
result = res.ciphertext
|
||||
} else if (method === 'nip44.decrypt') {
|
||||
const res = await rpcClient.call<{ plaintext: string }>({
|
||||
method: 'identity.nostr-decrypt-nip44',
|
||||
params: { pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
params: { id: appIdentityId || undefined, pubkey: params.pubkey, ciphertext: params.ciphertext }
|
||||
})
|
||||
result = res.plaintext
|
||||
} else {
|
||||
|
||||
@ -60,6 +60,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll container bottom padding — desktop breathing room */
|
||||
.mobile-scroll-pad,
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
/* Mobile: override with tab bar clearance */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-pad {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 16px);
|
||||
}
|
||||
.mobile-scroll-pad-back {
|
||||
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + env(safe-area-inset-bottom, 0px) + 64px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Haptic-like press feedback for all interactive elements */
|
||||
button:active:not(:disabled),
|
||||
[role="button"]:active,
|
||||
@ -131,8 +147,14 @@ input[type="radio"]:active + * {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Full-width mode switcher variant (sidebar, mobile settings) */
|
||||
.mode-switcher-full {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-switcher-btn {
|
||||
flex: none;
|
||||
flex: 1 1 0%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
@ -146,6 +168,14 @@ input[type="radio"]:active + * {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mode-switcher-btn {
|
||||
min-height: 44px;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-switcher-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
@ -235,10 +265,11 @@ input[type="radio"]:active + * {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* On mobile, pad iframe so AIUI content ends above the tab bar */
|
||||
/* On mobile, shrink iframe height so AIUI ends above the Archipelago tab bar */
|
||||
@media (max-width: 767px) {
|
||||
.chat-iframe-mobile {
|
||||
padding-bottom: var(--mobile-tab-bar-height, 72px);
|
||||
height: calc(100% - var(--mobile-tab-bar-height, 72px)) !important;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -540,7 +540,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
|
||||
'interface-addresses': {
|
||||
main: {
|
||||
'tor-address': '',
|
||||
'lan-address': 'https://archipelago.indeehub.studio'
|
||||
'lan-address': 'http://localhost:8190'
|
||||
}
|
||||
},
|
||||
status: ServiceStatus.Running
|
||||
|
||||
@ -417,7 +417,7 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click="closeUninstallModal()"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="absolute inset-0 bg-black/10 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="uninstallModalRef"
|
||||
@click.stop
|
||||
@ -486,7 +486,6 @@ const appId = computed(() => route.params.id as string)
|
||||
|
||||
// Web-only app detection (no container — external websites)
|
||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
@ -528,6 +527,7 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
|
||||
portainer: 'portainer',
|
||||
'uptime-kuma': 'uptime-kuma',
|
||||
tailscale: 'tailscale',
|
||||
indeedhub: 'indeedhub',
|
||||
}
|
||||
|
||||
/** Backend may register under variant container names */
|
||||
@ -740,8 +740,8 @@ function launchApp() {
|
||||
prod: 'http://localhost:8103' // Self-hosted splash screen
|
||||
},
|
||||
'indeedhub': {
|
||||
dev: 'https://archipelago.indeehub.studio',
|
||||
prod: 'https://archipelago.indeehub.studio'
|
||||
dev: 'http://localhost:8190',
|
||||
prod: 'http://localhost:8190'
|
||||
},
|
||||
// Dummy apps - replace with real URLs when packaged
|
||||
'bitcoin': {
|
||||
|
||||
@ -1,24 +1,28 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<div class="hidden md:flex items-start justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('apps.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('apps.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Desktop: tabs + search in one row -->
|
||||
<div class="hidden md:flex items-center gap-4 mb-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn mode-switcher-btn-active">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn">App Store</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: search only (tabs are in Dashboard tab bar) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('apps.searchPlaceholder')"
|
||||
:aria-label="t('apps.searchLabel')"
|
||||
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -175,7 +179,7 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click="closeUninstallModal()"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="absolute inset-0 bg-black/10 backdrop-blur-md"></div>
|
||||
<div
|
||||
ref="uninstallModalRef"
|
||||
@click.stop
|
||||
@ -260,7 +264,6 @@ function showActionError(msg: string) {
|
||||
|
||||
// Web-only app IDs and their URLs
|
||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
@ -276,11 +279,6 @@ function isWebOnlyApp(id: string): boolean {
|
||||
|
||||
// Web-only apps (no container) — always show as installed bookmarks
|
||||
const WEB_ONLY_APPS: Record<string, PackageDataEntry> = {
|
||||
'indeedhub': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'indeedhub', title: 'Indeehub', version: '0.1.0', description: { short: 'Bitcoin documentary streaming platform', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
'static-files': { license: '', instructions: '', icon: '/assets/img/app-icons/indeehub.ico' },
|
||||
},
|
||||
'botfights': {
|
||||
state: 'running' as PackageState,
|
||||
manifest: { id: 'botfights', title: 'BotFights', version: '1.0.0', description: { short: 'AI bot arena — build, train, and battle autonomous agents', long: '' }, 'release-notes': '', license: '', 'wrapper-repo': '', 'upstream-repo': '', 'support-site': '', 'marketing-site': '', 'donation-url': null },
|
||||
|
||||
@ -69,9 +69,9 @@ const aiuiAvailable = ref<boolean | null>(null) // null = checking, true/false =
|
||||
|
||||
const aiuiUrl = computed(() => {
|
||||
const envUrl = import.meta.env.VITE_AIUI_URL
|
||||
if (envUrl) return `${envUrl}?embedded=true`
|
||||
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
|
||||
// In production, only return the URL if we've confirmed AIUI files exist
|
||||
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&v=${Date.now()}`
|
||||
if (import.meta.env.PROD && aiuiAvailable.value === true) return `/aiui/?embedded=true&hideClose=true&v=${Date.now()}`
|
||||
return ''
|
||||
})
|
||||
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Cloud</h1>
|
||||
<p class="text-white/70">Your files, photos, and media</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-6">
|
||||
|
||||
<!-- Content Type Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@ -56,12 +48,48 @@
|
||||
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Peer Files Card -->
|
||||
<!-- Individual Peer Cards -->
|
||||
<div
|
||||
v-for="peer in peerNodes"
|
||||
:key="peer.did"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push({ name: 'peer-files' })"
|
||||
@click="router.push({ name: 'peer-files', params: { peerId: peer.onion } })"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
<svg class="w-7 h-7 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">{{ peer.name || truncateDid(peer.did) }}</h3>
|
||||
<p class="text-xs text-white/50 truncate">{{ peer.onion }}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peer.trust_level === 'trusted' ? 'bg-green-500/15 text-green-400' : 'bg-purple-500/15 text-purple-400'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Peers placeholder (only if no peers found) -->
|
||||
<div
|
||||
v-if="!peersLoading && peerNodes.length === 0"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center bg-purple-500/15">
|
||||
@ -71,26 +99,17 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">Peer Files</h3>
|
||||
<p class="text-xs text-white/50">Browse files shared by federated nodes</p>
|
||||
<p class="text-xs text-white/50">Set up federation to share files with peers</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<template v-if="hasFederatedPeers">
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-purple-500/15 text-purple-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
{{ peerCount }} peers
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/5 text-white/40">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white/30"></span>
|
||||
No peers yet
|
||||
</span>
|
||||
<span class="text-white/30">Set up federation to share files</span>
|
||||
</template>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -116,8 +135,17 @@ const router = useRouter()
|
||||
const store = useAppStore()
|
||||
const sectionCounts = ref<Record<string, number>>({})
|
||||
const countsLoading = ref(false)
|
||||
const peerCount = ref(0)
|
||||
const hasFederatedPeers = computed(() => peerCount.value > 0)
|
||||
|
||||
interface PeerNode {
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
name?: string
|
||||
trust_level: string
|
||||
}
|
||||
|
||||
const peerNodes = ref<PeerNode[]>([])
|
||||
const peersLoading = ref(true)
|
||||
|
||||
const APP_ALIASES: Record<string, string[]> = {
|
||||
immich: ['immich_server', 'immich-server'],
|
||||
@ -224,18 +252,26 @@ async function loadCounts() {
|
||||
|
||||
onMounted(() => {
|
||||
loadCounts()
|
||||
loadPeerCount()
|
||||
loadPeers()
|
||||
})
|
||||
|
||||
async function loadPeerCount() {
|
||||
async function loadPeers() {
|
||||
peersLoading.value = true
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peerCount.value = result?.nodes?.length ?? 0
|
||||
peerNodes.value = result?.nodes ?? []
|
||||
} catch {
|
||||
peerCount.value = 0
|
||||
peerNodes.value = []
|
||||
} finally {
|
||||
peersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (did.length <= 24) return did
|
||||
return did.slice(0, 16) + '...' + did.slice(-8)
|
||||
}
|
||||
|
||||
function openSection(section: ContentSection) {
|
||||
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hidden md:block">
|
||||
<h1 class="text-2xl font-bold text-white">{{ section?.name || 'Folder' }}</h1>
|
||||
<p class="text-sm text-white/50">{{ section?.description }}</p>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-6">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<button @click="$router.push('/dashboard/web5')" class="glass-button glass-button-sm px-3 py-1.5 text-sm">
|
||||
@ -115,7 +115,7 @@
|
||||
<!-- Credential Detail Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedCredential" class="fixed inset-0 z-50 flex items-center justify-center p-4" @click.self="selectedCredential = null">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="fixed inset-0 bg-black/10 backdrop-blur-md"></div>
|
||||
<div class="relative glass-card p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Credential Details</h3>
|
||||
|
||||
@ -52,10 +52,9 @@
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Background overlay - 0.3 opacity default, 0.8 opacity for Web5, Network, and Settings -->
|
||||
<div
|
||||
class="fixed inset-0 transition-opacity duration-500 pointer-events-none"
|
||||
:class="showDarkOverlay ? 'bg-black/80' : 'bg-black/30'"
|
||||
<!-- Background overlay - uniform 0.2 opacity -->
|
||||
<div
|
||||
class="fixed inset-0 pointer-events-none bg-black/20"
|
||||
style="z-index: -5;"
|
||||
/>
|
||||
|
||||
@ -149,7 +148,7 @@
|
||||
<main
|
||||
id="main-content"
|
||||
data-controller-zone="main"
|
||||
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
||||
class="flex-1 overflow-hidden relative pb-0 glass-piece z-10"
|
||||
:class="{ 'glass-throw-main': showZoomIn }"
|
||||
>
|
||||
<div data-controller-main-entry class="absolute top-4 right-4 md:top-6 md:right-8 z-20">
|
||||
@ -277,14 +276,16 @@
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto h-full',
|
||||
'absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto',
|
||||
needsMobileBackButtonSpace
|
||||
? 'pb-[calc(var(--mobile-tab-bar-height,_72px)+96px)] md:pb-24'
|
||||
: 'pb-[calc(var(--mobile-tab-bar-height,_72px)+48px)] md:pb-24'
|
||||
? 'mobile-scroll-pad-back'
|
||||
: 'mobile-scroll-pad'
|
||||
]"
|
||||
:style="mobileTabPaddingTop ? { paddingTop: (mobileTabPaddingTop + 16) + 'px' } : undefined"
|
||||
>
|
||||
<component :is="Component" class="view-container" />
|
||||
<component :is="Component" class="view-container flex-none" />
|
||||
<!-- Bottom spacer — scroll clearance on all pages -->
|
||||
<div class="shrink-0 h-6 md:h-12" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -846,6 +847,10 @@ function getTransitionName(currentRoute: RouteLocationNormalizedLoaded) {
|
||||
} else if (wasAppDetails && isMarketplaceList) {
|
||||
transitionName = 'depth-back'
|
||||
}
|
||||
// Desktop: no transition between Apps ↔ Marketplace (same-page tab feel)
|
||||
else if ((wasAppsList && isMarketplaceList) || (wasMarketplaceList && isAppsList)) {
|
||||
transitionName = 'fade'
|
||||
}
|
||||
// Vertical transition: between main tabs (desktop)
|
||||
else {
|
||||
const currentIndex = tabOrder.indexOf(currentPath)
|
||||
@ -1278,8 +1283,7 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
}
|
||||
|
||||
.view-container {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
/* No forced height — content sizes naturally, spacer below provides clearance */
|
||||
}
|
||||
|
||||
/* Forward transition: 2advanced fluid depth */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-6">
|
||||
<div class="mb-8">
|
||||
<button
|
||||
@click="router.push('/dashboard/web5')"
|
||||
@ -195,7 +195,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Node Detail Modal -->
|
||||
<div v-if="selectedNode" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="selectedNode = null; confirmRemove = false">
|
||||
<div v-if="selectedNode" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="selectedNode = null; confirmRemove = false">
|
||||
<div class="glass-card p-6 w-full max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-white">Node Details</h2>
|
||||
@ -322,7 +322,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Join Modal -->
|
||||
<div v-if="showJoinModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showJoinModal = false">
|
||||
<div v-if="showJoinModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showJoinModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-white">Join Federation</h2>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-6">
|
||||
<div class="mb-4 md:mb-8 flex items-start justify-between gap-4">
|
||||
<div class="min-h-[4.5rem]">
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">
|
||||
|
||||
@ -109,7 +109,7 @@ const launchableApps = computed<KioskApp[]>(() => {
|
||||
'fedimint-gateway': '/app/fedimint-gateway/',
|
||||
'dwn': '/app/dwn/',
|
||||
'nostr-rs-relay': '/app/nostr-rs-relay/',
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'indeedhub': 'http://localhost:8190',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
|
||||
@ -74,20 +74,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex mb-8 items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('marketplace.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('marketplace.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Desktop: tabs + categories + search in one row -->
|
||||
<div class="hidden md:flex mb-4 items-center gap-4">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
|
||||
<RouterLink to="/dashboard/marketplace" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Tabs + Search (Desktop only) -->
|
||||
<div class="hidden md:flex mb-6 items-center justify-between gap-4">
|
||||
<div class="mode-switcher">
|
||||
<div class="mode-switcher flex-shrink-0">
|
||||
<button
|
||||
v-for="category in categoriesWithApps"
|
||||
:key="category.id"
|
||||
@ -104,7 +97,7 @@
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-label="t('marketplace.searchApps')"
|
||||
class="flex-shrink-0 w-64 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
class="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -259,7 +252,7 @@
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showFilterModal"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center md:hidden bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeFilterModal()"
|
||||
>
|
||||
<div ref="filterModalRef" class="glass-card p-6 w-full rounded-t-3xl max-h-[80vh] overflow-y-auto">
|
||||
@ -648,7 +641,6 @@ function isInstalled(appId: string): boolean {
|
||||
|
||||
/** Web-only apps — external URLs with no container */
|
||||
const WEB_ONLY_APP_URLS: Record<string, string> = {
|
||||
'indeedhub': 'https://archipelago.indeehub.studio',
|
||||
'botfights': 'https://botfights.net',
|
||||
'nwnn': 'https://nwnn.l484.com',
|
||||
'484-kitchen': 'https://484.kitchen',
|
||||
@ -685,6 +677,7 @@ const APP_LAUNCH_URLS: Record<string, string> = {
|
||||
'fedimint': 'http://localhost:8175',
|
||||
'nostr-rs-relay': 'http://localhost:18081',
|
||||
'dwn': 'http://localhost:3100',
|
||||
'indeedhub': 'http://localhost:8190',
|
||||
}
|
||||
|
||||
function launchInstalledApp(app: MarketplaceApp) {
|
||||
@ -995,7 +988,7 @@ function getCuratedAppList() {
|
||||
id: 'indeedhub',
|
||||
title: 'Indeehub',
|
||||
version: '0.1.0',
|
||||
description: 'Bitcoin documentary streaming platform. Stream God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology.',
|
||||
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
|
||||
icon: '/assets/img/app-icons/indeehub.ico',
|
||||
author: 'Indeehub Team',
|
||||
dockerImage: 'localhost/indeedhub:latest',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-6">
|
||||
<div class="hidden md:block mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@ -1,118 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button class="glass-button p-2 rounded-lg" @click="router.push({ name: 'cloud' })">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div class="pb-6">
|
||||
<!-- Header with back button -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cloud
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Peer Files</h1>
|
||||
<p class="text-sm text-white/50">Browse files shared by federated nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peer list -->
|
||||
<div v-if="!selectedPeer" class="space-y-3">
|
||||
<div v-if="loading" class="glass-card p-8 text-center">
|
||||
<p class="text-white/50 animate-pulse">Loading federation peers...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="peers.length === 0" class="glass-card p-8 text-center">
|
||||
<p class="text-white/50">No federated peers found. Join a federation from Settings to share files.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="peer in peers"
|
||||
:key="peer.did"
|
||||
data-controller-container
|
||||
tabindex="0"
|
||||
class="glass-card p-5 cursor-pointer transition-all hover:-translate-y-0.5 hover:bg-white/10"
|
||||
@click="browsePeer(peer)"
|
||||
>
|
||||
<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">
|
||||
<!-- Mobile Back Button -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Cloud</span>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ selectedPeer.name || truncateDid(selectedPeer.did) }}</h2>
|
||||
<p class="text-xs text-white/40">{{ selectedPeer.onion }}</p>
|
||||
</Teleport>
|
||||
|
||||
<!-- 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 v-if="catalogLoading" class="glass-card p-8 text-center">
|
||||
<p class="text-white/50 animate-pulse">Connecting via Tor... This may take a few seconds.</p>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="glass-card p-8 text-center">
|
||||
<svg class="animate-spin h-6 w-6 text-purple-400 mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<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">
|
||||
<p class="text-red-400 text-sm">{{ catalogError }}</p>
|
||||
<button class="glass-button mt-3 px-4 py-2 rounded-lg text-sm" @click="browsePeer(selectedPeer!)">Retry</button>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div v-else-if="catalogError" class="glass-card p-6">
|
||||
<p class="text-red-400 text-sm mb-3">{{ catalogError }}</p>
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="loadCatalog">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="catalogItems.length === 0" class="glass-card p-8 text-center">
|
||||
<p class="text-white/50">This peer has no shared files.</p>
|
||||
</div>
|
||||
<!-- Empty -->
|
||||
<div v-else-if="catalogItems.length === 0 && !loading" class="glass-card p-8 text-center">
|
||||
<p class="text-white/50">This peer has no shared files.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card p-4 flex items-center gap-4"
|
||||
>
|
||||
<div class="flex-shrink-0 w-9 h-9 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
|
||||
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }} · {{ item.mime_type }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="accessBadgeClass(item.access)"
|
||||
>
|
||||
{{ accessLabel(item.access) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canDownload(item.access)"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
:disabled="downloading === item.id"
|
||||
@click="downloadFile(item)"
|
||||
>
|
||||
{{ downloading === item.id ? 'Downloading...' : 'Download' }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- File Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="item in catalogItems"
|
||||
:key="item.id"
|
||||
class="glass-card p-4 flex items-center gap-4"
|
||||
>
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
|
||||
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
|
||||
<p class="text-xs text-white/40">{{ formatSize(item.size_bytes) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="accessBadgeClass(item.access)"
|
||||
>
|
||||
{{ accessLabel(item.access) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canDownload(item.access)"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium"
|
||||
:disabled="downloading === item.id"
|
||||
@click="downloadFile(item)"
|
||||
>
|
||||
{{ downloading === item.id ? '...' : 'Download' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -120,10 +94,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, Teleport } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const props = defineProps<{
|
||||
peerId?: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
interface PeerNode {
|
||||
@ -144,42 +122,65 @@ interface CatalogItem {
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const peers = ref<PeerNode[]>([])
|
||||
const selectedPeer = ref<PeerNode | null>(null)
|
||||
const catalogLoading = ref(false)
|
||||
const currentPeer = ref<PeerNode | null>(null)
|
||||
const catalogError = ref('')
|
||||
const catalogItems = ref<CatalogItem[]>([])
|
||||
const downloading = ref<string | null>(null)
|
||||
|
||||
const peerDisplayName = computed(() => {
|
||||
if (currentPeer.value?.name) return currentPeer.value.name
|
||||
if (currentPeer.value?.did) return truncateDid(currentPeer.value.did)
|
||||
return props.peerId ? truncateOnion(props.peerId) : 'Peer Files'
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'cloud' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
peers.value = result?.nodes ?? []
|
||||
} catch {
|
||||
peers.value = []
|
||||
} finally {
|
||||
if (props.peerId) {
|
||||
// Find the peer by onion address
|
||||
try {
|
||||
const result = await rpcClient.federationListNodes()
|
||||
const peers = result?.nodes ?? []
|
||||
currentPeer.value = peers.find((p: PeerNode) => p.onion === props.peerId) || null
|
||||
} catch {
|
||||
// Continue with just the onion address
|
||||
}
|
||||
await loadCatalog()
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCatalog() {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
loading.value = true
|
||||
catalogError.value = ''
|
||||
catalogItems.value = []
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
|
||||
method: 'content.browse-peer',
|
||||
params: { onion },
|
||||
timeout: 30000,
|
||||
})
|
||||
catalogItems.value = result?.items ?? []
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDid(did: string): string {
|
||||
if (did.length <= 24) return did
|
||||
return did.slice(0, 16) + '...' + did.slice(-8)
|
||||
}
|
||||
|
||||
async function browsePeer(peer: PeerNode) {
|
||||
selectedPeer.value = peer
|
||||
catalogLoading.value = true
|
||||
catalogError.value = ''
|
||||
catalogItems.value = []
|
||||
try {
|
||||
const result = await rpcClient.call<{ items?: CatalogItem[] }>({ method: 'content.browse-peer', params: { onion: peer.onion }, timeout: 30000 })
|
||||
catalogItems.value = result?.items ?? []
|
||||
} catch (e: unknown) {
|
||||
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
function truncateOnion(onion: string): string {
|
||||
if (onion.length <= 20) return onion
|
||||
return onion.slice(0, 12) + '...'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@ -231,12 +232,13 @@ function canDownload(access: CatalogItem['access']): boolean {
|
||||
}
|
||||
|
||||
async function downloadFile(item: CatalogItem) {
|
||||
if (!selectedPeer.value) return
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
downloading.value = item.id
|
||||
try {
|
||||
const result = await rpcClient.call<{ data?: string }>({
|
||||
method: 'content.download-peer',
|
||||
params: { onion: selectedPeer.value.onion, content_id: item.id },
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (result?.data) {
|
||||
|
||||
@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="hidden md:block mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Network</h1>
|
||||
<p class="text-white/70">Manage your network infrastructure and Web3 services</p>
|
||||
<p class="text-sm text-white/60 mt-2">{{ connectedNodes }} connected nodes</p>
|
||||
</div>
|
||||
<div class="pb-6">
|
||||
|
||||
<!-- Disk Space Warning Banner -->
|
||||
<div
|
||||
@ -53,7 +48,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="restartServices"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
:disabled="restarting"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
@ -74,7 +69,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="checkConnectivity"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
:disabled="checkingConnectivity"
|
||||
>
|
||||
{{ checkingConnectivity ? 'Checking...' : 'Check' }}
|
||||
@ -83,25 +78,27 @@
|
||||
|
||||
<!-- Auto-Sync Toggle -->
|
||||
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
<div class="flex items-center justify-between min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
@ -117,7 +114,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="viewLogs"
|
||||
class="w-fit px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
@ -128,7 +125,7 @@
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Local Network Card -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -220,13 +217,13 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button disabled title="Coming Soon" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed shrink-0">
|
||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
||||
Manage Local Network
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Web3 Card -->
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col h-full min-h-0">
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -281,7 +278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button disabled title="Coming Soon" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed shrink-0">
|
||||
<button disabled title="Coming Soon" class="mt-4 w-full min-h-[44px] glass-button rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center">
|
||||
Manage Web3 Services
|
||||
</button>
|
||||
</div>
|
||||
@ -379,7 +376,7 @@
|
||||
</div>
|
||||
|
||||
<!-- WiFi Scan Modal -->
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showWifiModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
||||
@ -439,7 +436,7 @@
|
||||
</div>
|
||||
|
||||
<!-- DNS Configuration Modal -->
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4" @click.self="showDnsModal = false">
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/10 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="showDnsModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
|
||||
@ -822,7 +819,7 @@ function copyTorAddress(address: string) {
|
||||
|
||||
async function toggleTorApp(appId: string, enabled: boolean) {
|
||||
try {
|
||||
await rpcClient.call({ method: 'tor.toggle-service', params: { name: appId, enabled } })
|
||||
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
|
||||
await loadTorServices()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
|
||||
@ -832,7 +829,7 @@ async function toggleTorApp(appId: string, enabled: boolean) {
|
||||
async function rotateNodeAddress() {
|
||||
torRotating.value = true
|
||||
try {
|
||||
await rpcClient.call({ method: 'tor.rotate-address', params: { name: 'archipelago' } })
|
||||
await rpcClient.call({ method: 'tor.rotate-service', params: { name: 'archipelago' } })
|
||||
await loadTorServices()
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ t('settings.title') }}</h1>
|
||||
<p class="text-white/80">{{ t('settings.subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
<div class="pb-6">
|
||||
<!-- Controller indicator - Mobile only (desktop shows in sidebar) -->
|
||||
<div class="md:hidden mb-4">
|
||||
<ControllerIndicator />
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
@ -102,26 +96,24 @@
|
||||
|
||||
<!-- Tor / Onion Address (below DID, with copy button) -->
|
||||
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
|
||||
<div class="flex items-center justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
|
||||
</div>
|
||||
<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 class="flex items-center gap-3 mb-2">
|
||||
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">{{ t('settings.onionAddress') }}</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-amber-400/90 break-all" :title="serverTorAddress">{{ serverTorAddress }}</p>
|
||||
<p class="text-xs text-white/50 mt-1">{{ t('settings.onionHelper') }}</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 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>
|
||||
@ -143,7 +135,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showChangePasswordModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeChangePasswordModal()"
|
||||
>
|
||||
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
|
||||
@ -255,7 +247,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpSetupModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeTotpSetup"
|
||||
@keydown.escape="closeTotpSetup"
|
||||
>
|
||||
@ -361,7 +353,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showTotpDisableModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
@click.self="closeTotpDisable"
|
||||
@keydown.escape="closeTotpDisable"
|
||||
>
|
||||
@ -507,7 +499,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showClaudeLoginModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
|
||||
@click.self="showClaudeLoginModal = false"
|
||||
>
|
||||
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
|
||||
@ -723,12 +715,10 @@
|
||||
|
||||
<!-- Backup & Restore Section -->
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">{{ t('settings.backup') }}</h2>
|
||||
<p class="text-sm text-white/60 mt-1">{{ t('settings.backupRestoreDesc') }}</p>
|
||||
</div>
|
||||
<button @click="showCreateBackupModal = true" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ t('settings.backup') }}</h2>
|
||||
<p class="text-sm text-white/60 mb-3">{{ t('settings.backupRestoreDesc') }}</p>
|
||||
<button @click="showCreateBackupModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
@ -770,7 +760,7 @@
|
||||
|
||||
<!-- Create Backup Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showCreateBackupModal = false">
|
||||
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
|
||||
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
|
||||
<div class="space-y-3">
|
||||
@ -795,7 +785,7 @@
|
||||
|
||||
<!-- Restore Backup Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showRestoreModal = false">
|
||||
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showRestoreModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
|
||||
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
|
||||
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="pb-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('systemUpdate.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('systemUpdate.subtitle') }}</p>
|
||||
@ -166,7 +166,7 @@
|
||||
|
||||
<!-- Confirmation modal -->
|
||||
<Transition name="fade">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="cancelConfirm">
|
||||
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
|
||||
<div class="glass-card p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">
|
||||
{{ confirmAction === 'apply' ? t('systemUpdate.applyTitle') : t('systemUpdate.rollbackTitle') }}
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('web5.title') }}</h1>
|
||||
<p class="text-white/70">{{ t('web5.subtitle') }}</p>
|
||||
<p class="text-sm text-white/60 mt-2">{{ t('web5.profitsHelper') }}</p>
|
||||
</div>
|
||||
<div class="pb-6">
|
||||
|
||||
<!-- Quick Actions Container -->
|
||||
<div class="glass-card p-6 mb-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 stagger-grid">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6 gap-4 stagger-grid">
|
||||
<!-- Networking Profits -->
|
||||
<div data-controller-container tabindex="0" class="card-stagger flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
@ -188,8 +183,8 @@
|
||||
|
||||
<!-- DID Document Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
|
||||
<div class="glass-card p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
|
||||
<div v-if="showDidDocModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showDidDocModal = false" @keydown.escape="showDidDocModal = false">
|
||||
<div class="glass-card p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="did-doc-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 id="did-doc-title" class="text-lg font-semibold text-white">{{ t('web5.didDocument') }}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
@ -222,7 +217,7 @@
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="closeSendMessageModal()">
|
||||
<div v-if="showSendMessageModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closeSendMessageModal()">
|
||||
<div ref="sendMessageModalRef" class="glass-card p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">{{ t('web5.sendMessageTitle') }}</h3>
|
||||
<p class="text-white/70 text-sm mb-4">Messages are sent over the Tor network to the selected peer.</p>
|
||||
@ -235,7 +230,7 @@
|
||||
>
|
||||
<option value="">{{ t('web5.selectPeer') }}</option>
|
||||
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
|
||||
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -270,10 +265,10 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Core Services Overview Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Core Services Overview Cards — Row 1 -->
|
||||
<div class="flex flex-col md:flex-row gap-6 mb-6">
|
||||
<!-- Bitcoin Domain Name Portfolio -->
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 0">
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 0">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -320,13 +315,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="showDomainsModal = true" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
<button @click="showDomainsModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
{{ t('web5.manageDomains') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Wallet -->
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 1">
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 1">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -389,9 +384,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Core Services Overview Cards — Row 2 -->
|
||||
<div class="flex flex-col md:flex-row gap-6 mb-8">
|
||||
<!-- Nostr Relays -->
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 2">
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 2">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -438,13 +436,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="showRelaysModal = true" class="mt-auto pt-4 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
<button @click="showRelaysModal = true" class="mt-6 w-full px-4 py-2 glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors shrink-0">
|
||||
{{ t('web5.relays') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Node Visibility -->
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col h-full min-h-0" style="--stagger-index: 3">
|
||||
<div data-controller-container tabindex="0" class="glass-card card-stagger p-6 flex flex-col md:w-1/2" style="--stagger-index: 3">
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -508,10 +506,12 @@
|
||||
{{ t('web5.discoverableWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected Nodes (P2P over Tor) -->
|
||||
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 lg:col-span-4 scroll-mt-24">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<!-- Connected Nodes (P2P over Tor) -->
|
||||
<div ref="nodesContainerRef" data-controller-container tabindex="0" class="glass-card p-6 mb-8 scroll-mt-24">
|
||||
<!-- Desktop: side-by-side layout -->
|
||||
<div class="hidden md:flex items-start gap-4 mb-4">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
@ -536,6 +536,32 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: stacked layout -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white">{{ t('web5.connectedNodes') }}</h2>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-3">{{ t('web5.peerNodesDescription') }}</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@click="router.push('/dashboard/server/federation')"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ t('web5.findNodes') }}
|
||||
</button>
|
||||
<button
|
||||
@click="loadPeers"
|
||||
class="min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
{{ loadingPeers ? '...' : t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: Peers | Messages | Requests -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
@ -580,7 +606,7 @@
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || p.pubkey.slice(0, 16) + '...' }}</p>
|
||||
<p class="text-sm font-mono text-white/90 truncate">{{ p.name || p.onion || (p.pubkey || '').slice(0, 16) + '...' }}</p>
|
||||
<p class="text-xs text-white/50 truncate">{{ p.onion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -607,7 +633,7 @@
|
||||
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ m.from_pubkey.slice(0, 16) }}...</p>
|
||||
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ (m.from_pubkey || '').slice(0, 16) }}...</p>
|
||||
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
|
||||
@ -678,11 +704,11 @@
|
||||
{{ loadingRequests ? t('common.loading') : t('web5.refreshRequests') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Content -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Desktop: side-by-side -->
|
||||
<div class="hidden md:flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -694,11 +720,34 @@
|
||||
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="contentTab === 'mine'" @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
|
||||
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
|
||||
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
|
||||
{{ contentLoading ? '...' : 'Refresh' }}
|
||||
</button>
|
||||
<button v-if="contentTab === 'mine'" @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile: stacked -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
|
||||
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
|
||||
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
|
||||
{{ contentLoading ? '...' : 'Refresh' }}
|
||||
</button>
|
||||
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
@ -822,7 +871,7 @@
|
||||
>
|
||||
<option value="">{{ t('web5.selectPeer') }}</option>
|
||||
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
|
||||
{{ p.name || p.onion || p.pubkey.slice(0, 12) + '...' }}
|
||||
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@ -912,7 +961,7 @@
|
||||
|
||||
<!-- Content Streaming Player -->
|
||||
<Teleport to="body">
|
||||
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" @click.self="closePlayer" @keydown.escape="closePlayer">
|
||||
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
|
||||
<div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
|
||||
<!-- Player Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
||||
@ -984,7 +1033,7 @@
|
||||
|
||||
<!-- Add Content Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
|
||||
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
|
||||
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
|
||||
<div class="space-y-4">
|
||||
@ -1035,7 +1084,8 @@
|
||||
|
||||
<!-- Identity Management -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Desktop: side-by-side -->
|
||||
<div class="hidden md:flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -1054,6 +1104,24 @@
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
<!-- Mobile: stacked -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.identities') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.identitiesDesc') }}</p>
|
||||
<button @click="showCreateIdentityModal = true" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="identitiesLoading" class="py-6 text-center">
|
||||
@ -1112,7 +1180,7 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy DID">
|
||||
<button @click="copyIdentityDid(identity.did)" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
@ -1133,7 +1201,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Create Identity Modal -->
|
||||
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
|
||||
<div v-if="showCreateIdentityModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showCreateIdentityModal = false" @keydown.escape="showCreateIdentityModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="create-identity-title">
|
||||
<h2 id="create-identity-title" class="text-lg font-bold text-white mb-4">{{ t('web5.createIdentityTitle') }}</h2>
|
||||
<div class="space-y-4">
|
||||
@ -1167,7 +1235,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
|
||||
<div v-if="deleteIdentityTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="deleteIdentityTarget = null" @keydown.escape="deleteIdentityTarget = null">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true" aria-labelledby="delete-identity-title">
|
||||
<h2 id="delete-identity-title" class="text-lg font-bold text-white mb-2">{{ t('web5.deleteIdentityTitle') }}</h2>
|
||||
<p class="text-white/60 text-sm mb-4">{{ t('web5.deleteIdentityConfirm') }}</p>
|
||||
@ -1180,7 +1248,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Unified Send Modal -->
|
||||
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
|
||||
<div v-if="showUnifiedSendModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedSendModal" @keydown.escape="closeUnifiedSendModal">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="send-bitcoin-title">
|
||||
<h2 id="send-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.sendBitcoinTitle') }}</h2>
|
||||
|
||||
@ -1280,7 +1348,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Unified Receive Modal -->
|
||||
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
|
||||
<div v-if="showUnifiedReceiveModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeUnifiedReceiveModal" @keydown.escape="closeUnifiedReceiveModal">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true" aria-labelledby="receive-bitcoin-title">
|
||||
<h2 id="receive-bitcoin-title" class="text-lg font-bold text-white mb-4">{{ t('web5.receiveBitcoinTitle') }}</h2>
|
||||
|
||||
@ -1470,7 +1538,7 @@
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ msg.record_id.slice(0, 8) }}...</span>
|
||||
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
|
||||
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs">
|
||||
@ -1501,7 +1569,8 @@
|
||||
|
||||
<!-- Verifiable Credentials -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Desktop: side-by-side -->
|
||||
<div class="hidden md:flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -1517,6 +1586,21 @@
|
||||
Manage →
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Mobile: stacked -->
|
||||
<div class="md:hidden mb-4">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-white">{{ t('web5.verifiableCredentials') }}</h2>
|
||||
</div>
|
||||
<p class="text-xs text-white/60 mb-3">{{ t('web5.verifiableCredentialsDesc') }}</p>
|
||||
<router-link to="/dashboard/web5/credentials" class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
Manage →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-4">
|
||||
@ -1539,7 +1623,7 @@
|
||||
<div v-for="vc in vcCredentials.slice(0, 3)" :key="vc.id" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-white font-medium">{{ vc.type }}</div>
|
||||
<div class="text-xs text-white/50 truncate">To: {{ vc.subject.slice(0, 30) }}...</div>
|
||||
<div class="text-xs text-white/50 truncate">To: {{ (vc.subject || '').slice(0, 30) }}...</div>
|
||||
</div>
|
||||
<span :class="{
|
||||
'text-green-400': vc.status === 'active',
|
||||
@ -1557,7 +1641,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Domains Management Modal -->
|
||||
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
|
||||
<div v-if="showDomainsModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showDomainsModal = false" @keydown.escape="showDomainsModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="domains-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="domains-title" class="text-lg font-bold text-white">{{ t('web5.domainsTitle') }}</h2>
|
||||
@ -1604,7 +1688,7 @@
|
||||
<label class="text-white/60 text-xs block mb-1">Link to Identity</label>
|
||||
<select v-model="newDomainIdentityId" class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-white/30">
|
||||
<option value="" disabled>Select identity...</option>
|
||||
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ id.did.slice(0, 24) }}...)</option>
|
||||
<option v-for="id in managedIdentities" :key="id.id" :value="id.id">{{ id.name }} ({{ (id.did || '').slice(0, 24) }}...)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="domainError" class="text-xs text-red-400 mb-2">{{ domainError }}</div>
|
||||
@ -1634,7 +1718,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Relay Management Modal -->
|
||||
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
|
||||
<div v-if="showRelaysModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showRelaysModal = false" @keydown.escape="showRelaysModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="relays-title">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="relays-title" class="text-lg font-bold text-white">{{ t('web5.nostrRelays') }}</h2>
|
||||
@ -1908,18 +1992,27 @@ async function createDID() {
|
||||
localStorage.setItem('neode_did', res.did)
|
||||
} catch {
|
||||
// Fallback: generate a did:key locally using Web Crypto
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['sign', 'verify']
|
||||
)
|
||||
const exported = await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
||||
const bytes = new Uint8Array(exported)
|
||||
// Multicodec prefix for P-256 public key (0x1200) + base58btc
|
||||
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
const did = `did:key:z${hex}`
|
||||
storedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
if (!crypto.subtle) {
|
||||
// crypto.subtle requires HTTPS — generate random fallback
|
||||
const randomBytes = new Uint8Array(32)
|
||||
crypto.getRandomValues(randomBytes)
|
||||
const hex = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
const did = `did:key:z${hex}`
|
||||
storedDid.value = did
|
||||
localStorage.setItem('neode_did', did)
|
||||
} else {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||
true,
|
||||
['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 {
|
||||
creatingDid.value = false
|
||||
}
|
||||
@ -1965,7 +2058,7 @@ async function refreshDhtDid() {
|
||||
|
||||
async function copyDhtDid() {
|
||||
if (!dhtDid.value) return
|
||||
await navigator.clipboard.writeText(dhtDid.value)
|
||||
await safeClipboardWrite(dhtDid.value)
|
||||
dhtDidCopied.value = true
|
||||
setTimeout(() => { dhtDidCopied.value = false }, 2000)
|
||||
}
|
||||
@ -1977,7 +2070,7 @@ try {
|
||||
|
||||
async function copyDid() {
|
||||
if (!userDid.value) return
|
||||
await navigator.clipboard.writeText(userDid.value)
|
||||
await safeClipboardWrite(userDid.value)
|
||||
didCopied.value = true
|
||||
setTimeout(() => { didCopied.value = false }, 2000)
|
||||
}
|
||||
@ -2016,7 +2109,7 @@ async function showDidDocument() {
|
||||
|
||||
async function copyDidDocument() {
|
||||
if (!didDocumentFormatted.value) return
|
||||
await navigator.clipboard.writeText(didDocumentFormatted.value)
|
||||
await safeClipboardWrite(didDocumentFormatted.value)
|
||||
didDocCopied.value = true
|
||||
setTimeout(() => { didDocCopied.value = false }, 2000)
|
||||
}
|
||||
@ -2411,7 +2504,7 @@ async function finalizePsbt() {
|
||||
|
||||
function copyPsbt() {
|
||||
if (!psbtData.value) return
|
||||
window.navigator.clipboard.writeText(psbtData.value)
|
||||
window.safeClipboardWrite(psbtData.value)
|
||||
unifiedSendError.value = t('web5.psbtCopied')
|
||||
}
|
||||
|
||||
@ -2478,12 +2571,27 @@ async function unifiedReceive() {
|
||||
}
|
||||
|
||||
function copyEcashToken(token: string) {
|
||||
navigator.clipboard.writeText(token)
|
||||
safeClipboardWrite(token)
|
||||
showIdentityToast(t('web5.ecashTokenCopied'))
|
||||
}
|
||||
|
||||
async function safeClipboardWrite(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await safeClipboardWrite(text)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string, msg: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
safeClipboardWrite(text)
|
||||
showIdentityToast(msg)
|
||||
}
|
||||
|
||||
@ -2711,7 +2819,7 @@ function downloadPeerContent(item: PeerContentItem) {
|
||||
if (!browsePeerOnion.value) return
|
||||
const url = `http://${browsePeerOnion.value}/content/${item.id}`
|
||||
showIdentityToast(t('web5.downloadUrlCopied'))
|
||||
navigator.clipboard.writeText(url)
|
||||
safeClipboardWrite(url)
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
@ -2743,7 +2851,7 @@ function onPlayerError() {
|
||||
|
||||
function copyStreamUrl() {
|
||||
if (streamUrl.value) {
|
||||
navigator.clipboard.writeText(streamUrl.value)
|
||||
safeClipboardWrite(streamUrl.value)
|
||||
showIdentityToast(t('web5.streamUrlCopied'))
|
||||
}
|
||||
}
|
||||
@ -2832,8 +2940,8 @@ const settingVisibility = ref(false)
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: 'hidden' as VisibilityLevel, label: 'Hidden', description: 'Your node is not discoverable by others' },
|
||||
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Other Archipelago nodes can find you via Nostr' },
|
||||
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Visible to everyone with your onion address published' },
|
||||
{ value: 'discoverable' as VisibilityLevel, label: 'Discoverable', description: 'Federated peers can find and connect to your node' },
|
||||
{ value: 'public' as VisibilityLevel, label: 'Public', description: 'Accepting connections from any Archipelago node' },
|
||||
]
|
||||
|
||||
async function loadVisibility() {
|
||||
@ -2869,7 +2977,7 @@ async function setVisibility(level: VisibilityLevel) {
|
||||
|
||||
function copyOnionAddress() {
|
||||
if (!nodeOnionAddress.value) return
|
||||
navigator.clipboard.writeText(nodeOnionAddress.value)
|
||||
safeClipboardWrite(nodeOnionAddress.value)
|
||||
showIdentityToast(t('web5.onionAddressCopied'))
|
||||
}
|
||||
|
||||
@ -2928,7 +3036,7 @@ async function createIdentity() {
|
||||
}
|
||||
|
||||
function copyIdentityDid(did: string) {
|
||||
navigator.clipboard.writeText(did)
|
||||
safeClipboardWrite(did)
|
||||
showIdentityToast(t('web5.didCopied'))
|
||||
}
|
||||
|
||||
@ -3098,8 +3206,7 @@ async function connectWallet() {
|
||||
}
|
||||
|
||||
function manageRelays() {
|
||||
// TODO: Navigate to relay management or open modal
|
||||
if (import.meta.env.DEV) console.log('Managing Nostr relays...')
|
||||
showRelaysModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -119,7 +119,7 @@
|
||||
</Transition>
|
||||
|
||||
<!-- Open Channel Modal -->
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="showOpenModal = false">
|
||||
<div v-if="showOpenModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="showOpenModal = false">
|
||||
<div class="glass-card p-6 w-full max-w-md mx-4">
|
||||
<h2 class="text-lg font-bold text-white mb-4">Open Channel</h2>
|
||||
|
||||
@ -165,7 +165,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Close Confirmation Modal -->
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="closeTarget = null">
|
||||
<div v-if="closeTarget" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="closeTarget = null">
|
||||
<div class="glass-card p-6 w-full max-w-sm mx-4">
|
||||
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
|
||||
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
|
||||
|
||||
@ -43,10 +43,10 @@ export default defineConfig({
|
||||
'**/*-backup-*.mp4',
|
||||
'**/*-1.47mb.mp4',
|
||||
'**/bg-*.mp4', // Exclude large background videos from precache
|
||||
'**/video-intro.mp4', // Exclude video-intro.mp4 from precache (7MB, cached at runtime)
|
||||
'**/video-intro*.mp4', // Exclude all intro video variants from precache
|
||||
'**/assets/icon/**', // Icons are in includeAssets — don't duplicate in glob precache
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB limit (increased from 2MB)
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB limit
|
||||
skipWaiting: false, // Wait for user to accept update
|
||||
clientsClaim: false, // Don't claim clients immediately
|
||||
runtimeCaching: [
|
||||
|
||||
@ -556,6 +556,27 @@ if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'str
|
||||
fi
|
||||
fi
|
||||
|
||||
# 8b. Indeehub (pull from registry, or use local build)
|
||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q indeedhub; then
|
||||
INDEEDHUB_IMAGE=""
|
||||
# Try local image first (pre-built or loaded from ISO)
|
||||
if $DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q 'localhost/indeedhub'; then
|
||||
INDEEDHUB_IMAGE="localhost/indeedhub:latest"
|
||||
# Try registry image
|
||||
elif $DOCKER pull git.tx1138.com/lfg2025/indeedhub:latest 2>>"$LOG"; then
|
||||
INDEEDHUB_IMAGE="git.tx1138.com/lfg2025/indeedhub:latest"
|
||||
fi
|
||||
if [ -n "$INDEEDHUB_IMAGE" ]; then
|
||||
log "Creating Indeehub from $INDEEDHUB_IMAGE..."
|
||||
$DOCKER run -d --name indeedhub --restart unless-stopped \
|
||||
--cap-drop ALL --security-opt no-new-privileges:true \
|
||||
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=64m --tmpfs /app/.next/cache:rw,noexec,nosuid,size=128m \
|
||||
-p 8190:3000 \
|
||||
-e NODE_ENV=production -e NEXT_TELEMETRY_DISABLED=1 \
|
||||
"$INDEEDHUB_IMAGE" 2>>"$LOG" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 9. Custom UI containers (bitcoin-ui, lnd-ui)
|
||||
# These are built from Dockerfiles in /opt/archipelago/docker/ or loaded from pre-built images.
|
||||
for ui in bitcoin-ui lnd-ui; do
|
||||
|
||||