feat: cloud native file browser, settings Claude auth, deploy hardening

- Add native Cloud file browser with FileBrowser API integration
- Add cloud store, filebrowser-client, useAudioPlayer, useFileType composables
- Add Cloud components: FileGrid, FileCard, FileCardGrid, CloudToolbar
- Add Claude authentication section to Settings with OAuth status check
- Harden deploy script to preserve /aiui/ and claude-login.html
- Add nginx proxies for btcpay, homeassistant, filebrowser (HTTPS block)
- Add app configs for filebrowser, searxng, penpot in package.rs
- Update goal progress tracking with app aliases
- Improve mobile back button composable with ResizeObserver
- Update various views with cloud integration and UI refinements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-04 23:05:01 +00:00
parent 173bf8fc0f
commit d7ff678e9d
26 changed files with 2053 additions and 265 deletions

View File

@ -1,7 +1,7 @@
use super::RpcHandler;
use crate::port_allocator::PortAllocator;
use anyhow::{Context, Result};
use tracing::debug;
use tracing::{debug, info};
impl RpcHandler {
/// Install a package from a Docker image
@ -200,6 +200,28 @@ impl RpcHandler {
let container_id = String::from_utf8_lossy(&run_output.stdout).trim().to_string();
// Post-install: Nextcloud needs trusted domains configured for iframe embedding
if package_id == "nextcloud" {
let host_ip = self.config.host_ip.clone();
tokio::spawn(async move {
// Wait for Nextcloud to finish first-run initialization
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
for domain_idx in 1..=2u8 {
let value = if domain_idx == 1 { host_ip.as_str() } else { "localhost" };
let _ = tokio::process::Command::new("sudo")
.args([
"podman", "exec", "-u", "33", "nextcloud",
"php", "occ", "config:system:set",
"trusted_domains", &domain_idx.to_string(),
"--value", value,
])
.output()
.await;
}
info!("Nextcloud trusted domains configured for {}", host_ip);
});
}
Ok(serde_json::json!({
"success": true,
"package_id": package_id,

View File

@ -83,6 +83,12 @@ impl DockerPackageScanner {
// Use the container name as-is for manually started containers
container.name.clone()
};
// Normalize multi-container app IDs to their canonical names
let app_id = match app_id.as_str() {
"immich_server" => "immich".to_string(),
_ => app_id,
};
// Skip backend services (databases, APIs, etc.)
if excluded_services.contains(&app_id.as_str()) {

View File

@ -9,18 +9,21 @@ server {
# AIUI SPA (Chat mode iframe)
location /aiui/ {
alias /opt/archipelago/web-ui/aiui/;
try_files $uri $uri/ /aiui/index.html;
index index.html;
try_files $uri $uri/ =404;
}
# AIUI Claude API proxy — forwards API key from AIUI request headers
# AIUI Claude API proxy — routes through claude-proxy service (port 3141)
location /aiui/api/claude/ {
proxy_pass https://api.anthropic.com/;
proxy_pass http://127.0.0.1:3141/;
proxy_http_version 1.1;
proxy_set_header Host api.anthropic.com;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 120s;
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
@ -124,7 +127,19 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/filebrowser/ {
client_max_body_size 10G;
proxy_pass http://127.0.0.1:8083/;
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_request_buffering off;
}
# Proxy WebSocket
location /ws {
proxy_pass http://127.0.0.1:5678;
@ -137,3 +152,148 @@ server {
proxy_read_timeout 86400s;
}
}
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
ssl_certificate_key /etc/archipelago/ssl/archipelago.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
root /opt/archipelago/web-ui;
index index.html;
include snippets/archipelago-pwa.conf;
# AIUI SPA (Chat mode iframe)
location /aiui/ {
alias /opt/archipelago/web-ui/aiui/;
index index.html;
try_files $uri $uri/ =404;
}
location /aiui/api/claude/ {
proxy_pass http://127.0.0.1:3141/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_connect_timeout 120s;
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
location /aiui/api/openrouter/ {
proxy_pass https://openrouter.ai/api/;
proxy_http_version 1.1;
proxy_set_header Host openrouter.ai;
proxy_set_header Connection "";
proxy_ssl_server_name on;
proxy_connect_timeout 120s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
location / {
try_files $uri $uri/ /index.html;
}
location /archipelago/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /rpc/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
location /app/nextcloud/ {
proxy_pass http://127.0.0.1:8085/;
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_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/vaultwarden/ {
proxy_pass http://127.0.0.1:8082/;
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;
}
location /app/immich/ {
proxy_pass http://127.0.0.1:2283/;
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_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/penpot/ {
proxy_pass http://127.0.0.1:9001/;
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_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/btcpay/ {
proxy_pass http://127.0.0.1:23000/;
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;
}
location /app/homeassistant/ {
proxy_pass http://127.0.0.1:8123/;
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_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# All remaining app proxies (mempool, fedimint, lnd, bitcoin-ui, etc.)
include snippets/archipelago-https-app-proxies.conf;
location /ws {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
}

View File

@ -21,7 +21,6 @@
<meta name="application-name" content="Archipelago" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-TileImage" content="/assets/icon/pwa-192x192.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Archipelago OS</title>
</head>
<body>

View File

@ -0,0 +1,122 @@
export interface FileBrowserItem {
name: string
path: string
size: number
modified: string
isDir: boolean
type: string
extension: string
}
interface FileBrowserListResponse {
items: FileBrowserItem[]
numDirs: number
numFiles: number
sorting: { by: string; asc: boolean }
}
class FileBrowserClient {
private token: string | null = null
private baseUrl: string
constructor() {
this.baseUrl = `${window.location.origin}/app/filebrowser`
}
get isAuthenticated(): boolean {
return this.token !== null
}
async login(username = 'admin', password = 'admin'): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) return false
const text = await res.text()
// FileBrowser returns the JWT as a plain string (possibly quoted)
this.token = text.replace(/^"|"$/g, '')
return true
} catch {
return false
}
}
private headers(): Record<string, string> {
const h: Record<string, string> = {}
if (this.token) h['X-Auth'] = this.token
return h
}
async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = path.startsWith('/') ? path : `/${path}`
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to list directory: ${res.status}`)
const data: FileBrowserListResponse = await res.json()
return (data.items || []).map((item) => ({
...item,
extension: item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '',
}))
}
downloadUrl(path: string): string {
const safePath = path.startsWith('/') ? path : `/${path}`
// Token is passed as query param for direct downloads (img src, audio src, etc.)
return `${this.baseUrl}/api/raw${safePath}?auth=${this.token}`
}
async upload(dirPath: string, file: File): Promise<void> {
const safePath = dirPath.endsWith('/') ? dirPath : `${dirPath}/`
const encodedName = encodeURIComponent(file.name)
const res = await fetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
{
method: 'POST',
headers: this.headers(),
body: file,
},
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Upload failed (${res.status}): ${text}`)
}
}
async createFolder(parentPath: string, name: string): Promise<void> {
const safePath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${name}/`, {
method: 'POST',
headers: this.headers(),
})
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
}
async deleteItem(path: string): Promise<void> {
const safePath = path.startsWith('/') ? path : `/${path}`
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'DELETE',
headers: this.headers(),
})
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
}
async rename(oldPath: string, newName: string): Promise<void> {
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
method: 'PATCH',
headers: {
...this.headers(),
'Content-Type': 'application/json',
},
body: JSON.stringify({ destination: `${dir}${newName}` }),
})
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
}
}
export const fileBrowserClient = new FileBrowserClient()

View File

@ -0,0 +1,99 @@
<template>
<div class="cloud-toolbar">
<!-- Breadcrumbs -->
<nav class="cloud-breadcrumbs">
<button
v-for="(crumb, i) in breadcrumbs"
:key="crumb.path"
class="cloud-breadcrumb-item"
:class="{ 'cloud-breadcrumb-active': i === breadcrumbs.length - 1 }"
@click="i < breadcrumbs.length - 1 && $emit('navigate', crumb.path)"
>
<svg v-if="i === 0" 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span v-else>{{ crumb.name }}</span>
<svg v-if="i < breadcrumbs.length - 1" class="w-3 h-3 text-white/30 mx-1" 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>
</button>
</nav>
<!-- Actions -->
<div class="flex items-center gap-2">
<!-- View toggle -->
<div class="cloud-view-toggle">
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'grid' }"
title="Grid view"
@click="$emit('update:viewMode', 'grid')"
>
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
class="cloud-view-toggle-btn"
:class="{ 'cloud-view-toggle-active': viewMode === 'list' }"
title="List view"
@click="$emit('update:viewMode', 'list')"
>
<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="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="hidden md:inline">Upload</span>
</button>
<button class="glass-button cloud-toolbar-btn" title="Refresh" @click="$emit('refresh')">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
@change="handleFileSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid'
}>()
const emit = defineEmits<{
navigate: [path: string]
refresh: []
upload: [files: File[]]
'update:viewMode': [mode: 'list' | 'grid']
}>()
const fileInput = ref<HTMLInputElement | null>(null)
function triggerUpload() {
fileInput.value?.click()
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files.length > 0) {
emit('upload', Array.from(input.files))
input.value = ''
}
}
</script>

View File

@ -0,0 +1,116 @@
<template>
<button
class="cloud-file-item group"
data-controller-container
tabindex="0"
@click="handleClick"
>
<!-- Thumbnail / Icon -->
<div class="cloud-file-item-thumb">
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover rounded-[6px] transition-transform duration-300 group-hover:scale-105"
loading="lazy"
@error="imgFailed = true"
/>
<div v-else class="w-full h-full rounded-[6px] flex items-center justify-center bg-white/8">
<svg class="w-5 h-5" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
</div>
<!-- Content -->
<div class="min-w-0 flex-1 py-0.5">
<p class="text-sm font-semibold truncate text-white/90">{{ item.name }}</p>
<p class="text-xs mt-0.5 text-white/40">
<span v-if="!item.isDir">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir"> · </span>
<span>{{ formatDate(item.modified) }}</span>
</p>
<!-- Type badge -->
<div class="flex items-center gap-1.5 mt-1.5">
<span class="cloud-file-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
<span v-if="item.extension && !item.isDir" class="cloud-file-badge bg-white/8 text-white/50">
.{{ item.extension }}
</span>
</div>
</div>
<!-- Actions -->
<div class="cloud-file-item-actions" @click.stop>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<svg v-if="item.isDir" 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>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { isImage, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (!isImage.value || imgFailed.value) return null
return cloudStore.downloadUrl(props.item.path)
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
}
}
</script>

View File

@ -0,0 +1,166 @@
<template>
<button
class="cloud-grid-card group"
data-controller-container
tabindex="0"
@click="handleClick"
>
<!-- Cover / Thumbnail area -->
<div class="cloud-grid-card-cover" :class="aspectClass">
<!-- Image thumbnail -->
<img
v-if="isImage && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Video thumbnail (try to show, fallback to icon) -->
<img
v-else-if="isVideo && thumbnailUrl && !imgFailed"
:src="thumbnailUrl"
:alt="item.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
loading="lazy"
@error="imgFailed = true"
/>
<!-- Icon fallback -->
<div v-else class="w-full h-full flex items-center justify-center" :class="coverBg">
<svg class="w-10 h-10" :class="iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(d, i) in iconPaths"
:key="i"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
:d="d"
/>
</svg>
</div>
<!-- Gradient overlay -->
<div class="cloud-grid-card-gradient"></div>
<!-- Play button overlay for audio/video -->
<div
v-if="isAudio || isVideo"
class="cloud-grid-card-play"
:class="{ 'cloud-grid-card-play-active': isCurrentlyPlaying }"
@click.stop="emit('play', item.path, item.name)"
>
<span class="cloud-grid-card-play-btn">
<svg v-if="!isCurrentlyPlaying" class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
</span>
</div>
<!-- Info overlay at bottom -->
<div class="cloud-grid-card-info">
<p class="text-xs font-semibold text-white/90 leading-tight truncate">
{{ item.name }}
</p>
<div class="flex items-center gap-1 mt-0.5">
<span v-if="!item.isDir" class="text-xs text-white/40">{{ formatSize(item.size) }}</span>
<span v-if="!item.isDir" class="text-xs text-white/40">·</span>
<span class="text-xs text-white/40">{{ formatDate(item.modified) }}</span>
</div>
</div>
<!-- Badge at top-right -->
<div class="cloud-grid-card-badges">
<span class="cloud-grid-card-badge" :class="badgeClass">
{{ badgeLabel }}
</span>
</div>
<!-- Actions overlay at top-left (visible on hover) -->
<div class="cloud-grid-card-actions" @click.stop>
<a
v-if="!item.isDir"
:href="downloadHref"
download
class="cloud-file-action-btn"
title="Download"
@click.stop
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</a>
<button
v-if="!item.isDir"
class="cloud-file-action-btn cloud-file-action-delete"
title="Delete"
@click.stop="$emit('delete', item.path)"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</button>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCloudStore } from '@/stores/cloud'
import { useFileType, formatSize, formatDate } from '@/composables/useFileType'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
const props = defineProps<{
item: FileBrowserItem
}>()
const emit = defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
}>()
const cloudStore = useCloudStore()
const imgFailed = ref(false)
const ext = computed(() => props.item.extension)
const isDir = computed(() => props.item.isDir)
const { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass } = useFileType(ext, isDir)
const thumbnailUrl = computed(() => {
if (imgFailed.value) return null
if (isImage.value || isVideo.value) return cloudStore.downloadUrl(props.item.path)
return null
})
const downloadHref = computed(() => cloudStore.downloadUrl(props.item.path))
const { playing: audioPlaying, currentSrc } = useAudioPlayer()
const isCurrentlyPlaying = computed(() => audioPlaying.value && currentSrc.value === downloadHref.value)
const aspectClass = computed(() => {
if (isImage.value || isVideo.value) return 'aspect-square'
if (category.value === 'document' || category.value === 'folder') return 'aspect-[4/3]'
return 'aspect-square'
})
const coverBg = computed(() => {
if (props.item.isDir) return 'bg-amber-500/10'
if (isAudio.value) return 'bg-orange-500/10'
if (isVideo.value) return 'bg-purple-500/10'
if (isImage.value) return 'bg-blue-500/10'
if (category.value === 'document') return 'bg-green-500/10'
if (category.value === 'spreadsheet') return 'bg-emerald-500/10'
if (category.value === 'archive') return 'bg-yellow-500/10'
return 'bg-white/5'
})
function handleClick() {
if (props.item.isDir) {
emit('navigate', props.item.path)
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<div class="flex-1 min-h-0 overflow-y-auto">
<!-- Loading skeleton -->
<div v-if="loading" :class="viewMode === 'grid' ? 'cloud-card-grid' : 'cloud-file-list'">
<div
v-for="i in 6"
:key="i"
:class="viewMode === 'grid' ? 'cloud-grid-card-skeleton' : 'cloud-file-item cloud-file-item-skeleton'"
>
<template v-if="viewMode === 'grid'">
<div class="aspect-square rounded-[10px] bg-white/8 animate-pulse"></div>
</template>
<template v-else>
<div class="cloud-file-item-thumb">
<div class="w-full h-full rounded-[6px] bg-white/8 animate-pulse"></div>
</div>
<div class="min-w-0 flex-1 py-0.5">
<div class="h-4 w-32 rounded bg-white/8 animate-pulse mb-1.5"></div>
<div class="h-3 w-20 rounded bg-white/5 animate-pulse"></div>
</div>
</template>
</div>
</div>
<!-- Empty state -->
<div v-else-if="items.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<svg class="w-16 h-16 text-white/10 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p class="text-white/50 text-sm">This folder is empty</p>
<p class="text-white/30 text-xs mt-1">Upload files to get started</p>
</div>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="cloud-card-grid">
<FileCardGrid
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
@play="(path, name) => $emit('play', path, name)"
/>
</div>
<!-- List view -->
<div v-else class="cloud-file-list">
<FileCard
v-for="item in items"
:key="item.path"
:item="item"
@navigate="$emit('navigate', $event)"
@delete="$emit('delete', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { FileBrowserItem } from '@/api/filebrowser-client'
import FileCard from './FileCard.vue'
import FileCardGrid from './FileCardGrid.vue'
withDefaults(defineProps<{
items: FileBrowserItem[]
loading: boolean
viewMode?: 'list' | 'grid'
}>(), {
viewMode: 'grid',
})
defineEmits<{
navigate: [path: string]
delete: [path: string]
play: [path: string, name: string]
}>()
</script>

View File

@ -0,0 +1,82 @@
import { ref, computed } from 'vue'
const audio = ref<HTMLAudioElement | null>(null)
const currentSrc = ref<string | null>(null)
const currentName = ref('')
const playing = ref(false)
const currentTime = ref(0)
const duration = ref(0)
function play(src: string, name: string) {
if (!audio.value) {
audio.value = new Audio()
audio.value.addEventListener('timeupdate', () => {
currentTime.value = audio.value?.currentTime ?? 0
})
audio.value.addEventListener('loadedmetadata', () => {
duration.value = audio.value?.duration ?? 0
})
audio.value.addEventListener('ended', () => {
playing.value = false
})
audio.value.addEventListener('pause', () => {
playing.value = false
})
audio.value.addEventListener('play', () => {
playing.value = true
})
}
if (currentSrc.value === src && playing.value) {
audio.value.pause()
return
}
if (currentSrc.value !== src) {
audio.value.src = src
currentSrc.value = src
currentName.value = name
}
audio.value.play()
}
function pause() {
audio.value?.pause()
}
function seek(time: number) {
if (audio.value) {
audio.value.currentTime = time
}
}
function stop() {
if (audio.value) {
audio.value.pause()
audio.value.currentTime = 0
}
playing.value = false
currentSrc.value = null
currentName.value = ''
}
const progress = computed(() => {
if (duration.value === 0) return 0
return (currentTime.value / duration.value) * 100
})
export function useAudioPlayer() {
return {
play,
pause,
seek,
stop,
playing,
currentName,
currentTime,
duration,
progress,
currentSrc,
}
}

View File

@ -0,0 +1,99 @@
import { computed, type Ref } from 'vue'
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico'])
const AUDIO_EXTS = new Set(['mp3', 'flac', 'wav', 'ogg', 'aac', 'm4a', 'wma'])
const VIDEO_EXTS = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv'])
const DOC_EXTS = new Set(['pdf', 'doc', 'docx', 'txt', 'rtf', 'odt', 'md'])
const SHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods'])
const ARCHIVE_EXTS = new Set(['zip', 'tar', 'gz', 'rar', '7z', 'bz2'])
export type FileCategory = 'folder' | 'image' | 'audio' | 'video' | 'document' | 'spreadsheet' | 'archive' | 'file'
export function getFileCategory(ext: string, isDir: boolean): FileCategory {
if (isDir) return 'folder'
if (IMAGE_EXTS.has(ext)) return 'image'
if (AUDIO_EXTS.has(ext)) return 'audio'
if (VIDEO_EXTS.has(ext)) return 'video'
if (DOC_EXTS.has(ext)) return 'document'
if (SHEET_EXTS.has(ext)) return 'spreadsheet'
if (ARCHIVE_EXTS.has(ext)) return 'archive'
return 'file'
}
const CATEGORY_ICONS: Record<FileCategory, string[]> = {
folder: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
audio: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
video: ['M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z', 'M21 12a9 9 0 11-18 0 9 9 0 0118 0z'],
image: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
document: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
spreadsheet: ['M3 10h18M3 14h18M3 6h18M3 18h18M8 6v12M16 6v12'],
archive: ['M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4'],
file: ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'],
}
const CATEGORY_COLORS: Record<FileCategory, string> = {
folder: 'text-amber-400',
audio: 'text-orange-400',
video: 'text-purple-400',
image: 'text-blue-400',
document: 'text-green-400',
spreadsheet: 'text-emerald-400',
archive: 'text-yellow-400',
file: 'text-white/50',
}
const CATEGORY_LABELS: Record<FileCategory, string> = {
folder: 'Folder',
audio: 'Audio',
video: 'Video',
image: 'Image',
document: 'Document',
spreadsheet: 'Spreadsheet',
archive: 'Archive',
file: 'File',
}
const CATEGORY_BADGE_CLASSES: Record<FileCategory, string> = {
folder: 'bg-amber-500/15 text-amber-400/70',
audio: 'bg-orange-500/15 text-orange-400/70',
video: 'bg-purple-500/15 text-purple-400/70',
image: 'bg-blue-500/15 text-blue-400/70',
document: 'bg-green-500/15 text-green-400/70',
spreadsheet: 'bg-emerald-500/15 text-emerald-400/70',
archive: 'bg-yellow-500/15 text-yellow-400/70',
file: 'bg-white/8 text-white/50',
}
export function useFileType(ext: Ref<string>, isDir: Ref<boolean>) {
const category = computed(() => getFileCategory(ext.value, isDir.value))
const isImage = computed(() => category.value === 'image')
const isAudio = computed(() => category.value === 'audio')
const isVideo = computed(() => category.value === 'video')
const iconPaths = computed(() => CATEGORY_ICONS[category.value])
const iconColor = computed(() => CATEGORY_COLORS[category.value])
const badgeLabel = computed(() => CATEGORY_LABELS[category.value])
const badgeClass = computed(() => CATEGORY_BADGE_CLASSES[category.value])
return { category, isImage, isAudio, isVideo, iconPaths, iconColor, badgeLabel, badgeClass }
}
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
export function formatDate(iso: string): string {
const date = new Date(iso)
const now = Date.now()
const diff = now - date.getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'Just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return date.toLocaleDateString()
}

View File

@ -2,7 +2,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
/**
* Composable for mobile back button positioning
* Ensures back buttons are always 16px above the mobile tab bar
* Ensures back buttons are always 8px above the mobile tab bar
* Uses ResizeObserver to reactively update when tab bar height changes
*/
export function useMobileBackButton() {
@ -10,13 +10,13 @@ export function useMobileBackButton() {
// Computed property for bottom position - always 16px above tab bar
const bottomPosition = computed(() => {
return `${tabBarHeight.value + 16}px`
return `${tabBarHeight.value + 8}px`
})
// Computed property for Tailwind class (for use in class bindings)
const bottomClass = computed(() => {
// Use Tailwind arbitrary value with the computed height
return `bottom-[${tabBarHeight.value + 16}px]`
return `bottom-[${tabBarHeight.value + 8}px]`
})
let resizeObserver: ResizeObserver | null = null

View File

@ -0,0 +1,121 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
export const useCloudStore = defineStore('cloud', () => {
const currentPath = ref('/')
const items = ref<FileBrowserItem[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const authenticated = ref(false)
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
const crumbs = [{ name: 'Home', path: '/' }]
let path = ''
for (const part of parts) {
path += `/${part}`
crumbs.push({ name: part, path })
}
return crumbs
})
const sortedItems = computed(() => {
const dirs = items.value.filter((i) => i.isDir)
const files = items.value.filter((i) => !i.isDir)
dirs.sort((a, b) => a.name.localeCompare(b.name))
files.sort((a, b) => a.name.localeCompare(b.name))
return [...dirs, ...files]
})
async function init(): Promise<boolean> {
if (authenticated.value) return true
const ok = await fileBrowserClient.login()
authenticated.value = ok
return ok
}
async function navigate(path: string): Promise<void> {
loading.value = true
error.value = null
try {
if (!authenticated.value) {
const ok = await init()
if (!ok) {
error.value = 'Failed to authenticate with File Browser'
return
}
}
try {
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Directory may not exist — try to create it, then retry
if (path !== '/') {
try {
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
const dirName = path.substring(path.lastIndexOf('/') + 1)
await fileBrowserClient.createFolder(parentPath, dirName)
const result = await fileBrowserClient.listDirectory(path)
items.value = result
currentPath.value = path
} catch {
// Fall back to root
const result = await fileBrowserClient.listDirectory('/')
items.value = result
currentPath.value = '/'
}
} else {
throw new Error('Failed to list root directory')
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load files'
} finally {
loading.value = false
}
}
async function refresh(): Promise<void> {
await navigate(currentPath.value)
}
async function uploadFile(file: File): Promise<void> {
await fileBrowserClient.upload(currentPath.value, file)
await refresh()
}
async function deleteItem(path: string): Promise<void> {
await fileBrowserClient.deleteItem(path)
await refresh()
}
function downloadUrl(path: string): string {
return fileBrowserClient.downloadUrl(path)
}
function reset(): void {
currentPath.value = '/'
items.value = []
loading.value = false
error.value = null
}
return {
currentPath,
items,
loading,
error,
authenticated,
breadcrumbs,
sortedItems,
init,
navigate,
refresh,
uploadFile,
deleteItem,
downloadUrl,
reset,
}
})

View File

@ -6,6 +6,19 @@ import { useAppStore } from './app'
const STORAGE_KEY = 'archipelago-goal-progress'
/** App ID aliases — goal definitions use canonical IDs but the backend may register under variant names */
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
}
function matchesAppId(pkgId: string, appId: string): boolean {
if (pkgId === appId) return true
const aliases = APP_ALIASES[appId]
return aliases ? aliases.includes(pkgId) : false
}
export const useGoalStore = defineStore('goals', () => {
const progress = ref<Record<string, GoalProgress>>({})
@ -34,15 +47,41 @@ export const useGoalStore = defineStore('goals', () => {
const appStore = useAppStore()
const packages = appStore.packages
// Auto-sync install step completion from actual package state
// This ensures steps tick when apps are installed outside the wizard
let didSync = false
for (const step of goal.steps) {
if (step.appId && step.action === 'install') {
const isInstalled = Object.keys(packages).some((pkgId) => matchesAppId(pkgId, step.appId!))
if (isInstalled) {
if (!progress.value[goalId]) {
progress.value[goalId] = {
goalId,
status: 'in-progress',
currentStepIndex: 0,
completedSteps: [],
startedAt: Date.now(),
}
didSync = true
}
if (!progress.value[goalId].completedSteps.includes(step.id)) {
progress.value[goalId].completedSteps.push(step.id)
didSync = true
}
}
}
}
if (didSync) save()
const allRunning = goal.requiredApps.every((appId) =>
Object.entries(packages).some(
([pkgId, pkg]) => pkgId === appId && pkg.state === 'running',
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
),
)
if (allRunning) return 'completed'
const anyInstalled = goal.requiredApps.some((appId) =>
Object.keys(packages).some((pkgId) => pkgId === appId),
Object.keys(packages).some((pkgId) => matchesAppId(pkgId, appId)),
)
if (anyInstalled || progress.value[goalId]) return 'in-progress'

View File

@ -161,10 +161,16 @@
.chat-mode-pill {
position: absolute;
top: 1rem;
right: 1rem;
top: calc(env(safe-area-inset-top, 0px) + 0.75rem);
right: calc(env(safe-area-inset-right, 0px) + 0.75rem);
z-index: 10;
}
@media (min-width: 768px) {
.chat-mode-pill {
top: 1.25rem;
right: 1.25rem;
}
}
.chat-iframe {
flex: 1;
@ -609,10 +615,12 @@ body {
background: #000;
color: white;
min-height: 100vh;
min-height: 100dvh;
}
#app {
min-height: 100vh;
min-height: 100dvh;
}
/* Custom scrollbar for glass containers */
@ -855,3 +863,360 @@ html:has(body.video-background-active)::before {
95% { transform: translate(-2px,1px); clip-path: inset(50% 0 26% 0); }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
/* ── Cloud File Browser (AIUI-style list cards) ──── */
.cloud-file-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding-bottom: 1rem;
}
.cloud-file-item {
display: flex;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 0.75rem;
transition: all 0.2s ease;
text-align: left;
width: 100%;
cursor: pointer;
background: none;
border: none;
color: inherit;
align-items: center;
}
.cloud-file-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.cloud-file-item:active {
background: rgba(255, 255, 255, 0.1);
}
.cloud-file-item:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
}
.cloud-file-item-thumb {
flex-shrink: 0;
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
overflow: hidden;
}
.cloud-file-item-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s ease;
flex-shrink: 0;
}
.cloud-file-item:hover .cloud-file-item-actions,
.cloud-file-item:focus-within .cloud-file-item-actions {
opacity: 1;
}
.cloud-file-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
border: none;
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.cloud-file-action-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.cloud-file-action-delete:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.cloud-file-badge {
display: inline-flex;
font-size: 0.6875rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.cloud-file-item-skeleton {
pointer-events: none;
}
/* Toolbar */
.cloud-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.cloud-breadcrumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
min-width: 0;
}
.cloud-breadcrumb-item {
display: inline-flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.5);
background: none;
border: none;
cursor: pointer;
border-radius: 0.25rem;
transition: all 0.15s ease;
white-space: nowrap;
}
.cloud-breadcrumb-item:hover:not(.cloud-breadcrumb-active) {
color: white;
background: rgba(255, 255, 255, 0.08);
}
.cloud-breadcrumb-active {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
cursor: default;
}
.cloud-toolbar-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
border-radius: 0.5rem;
}
/* View toggle */
.cloud-view-toggle {
display: flex;
border-radius: 0.5rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.cloud-view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
cursor: pointer;
background: transparent;
color: rgba(255, 255, 255, 0.35);
transition: all 0.15s ease;
}
.cloud-view-toggle-btn:hover {
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.05);
}
.cloud-view-toggle-active {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
/* ── Cloud Card Grid (AIUI-style poster cards) ──── */
.cloud-card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
padding-bottom: 1rem;
}
@media (min-width: 640px) {
.cloud-card-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.cloud-card-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.cloud-grid-card {
display: flex;
flex-direction: column;
text-align: left;
width: 100%;
border: none;
cursor: pointer;
background: rgba(255, 255, 255, 0.04);
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.2s ease;
color: inherit;
padding: 0;
}
.cloud-grid-card:hover {
background: rgba(255, 255, 255, 0.08);
transform: translateY(-2px);
}
.cloud-grid-card:active {
transform: translateY(0);
}
.cloud-grid-card:focus-visible {
box-shadow: 0 0 16px rgba(120, 180, 255, 0.2), 0 0 32px rgba(100, 160, 255, 0.1);
}
.cloud-grid-card-cover {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 0.625rem;
}
.cloud-grid-card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent 60%);
pointer-events: none;
}
.cloud-grid-card-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem;
z-index: 1;
}
.cloud-grid-card-badges {
position: absolute;
top: 0.375rem;
right: 0.375rem;
display: flex;
gap: 0.25rem;
z-index: 1;
}
.cloud-grid-card-badge {
font-size: 0.625rem;
font-weight: 500;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.7);
}
/* Play button overlay (audio/video) */
.cloud-grid-card-play {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
backdrop-filter: blur(2px);
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s ease;
}
.cloud-grid-card:hover .cloud-grid-card-play {
opacity: 1;
}
.cloud-grid-card-play-btn {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
transition: transform 0.15s ease;
}
.cloud-grid-card:hover .cloud-grid-card-play-btn {
transform: scale(1.1);
}
/* Actions overlay (top-left, on hover) */
.cloud-grid-card-actions {
position: absolute;
top: 0.375rem;
left: 0.375rem;
display: flex;
gap: 0.25rem;
z-index: 3;
opacity: 0;
transition: opacity 0.15s ease;
}
.cloud-grid-card:hover .cloud-grid-card-actions {
opacity: 1;
}
/* Grid skeleton */
.cloud-grid-card-skeleton {
border-radius: 0.75rem;
overflow: hidden;
}
/* Playing state — keep play overlay visible */
.cloud-grid-card-play-active {
opacity: 1 !important;
background: rgba(0, 0, 0, 0.4);
}
/* ── Cloud Audio Player (mini bar) ──── */
.cloud-audio-player {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.75rem;
flex-shrink: 0;
}
.cloud-audio-player-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
.cloud-audio-player-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.cloud-audio-progress {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
margin-top: 0.375rem;
overflow: hidden;
}
.cloud-audio-progress-bar {
height: 100%;
background: #fb923c;
border-radius: 2px;
transition: width 0.3s linear;
}

View File

@ -475,6 +475,12 @@ const ROUTE_TO_PACKAGE_KEY: Record<string, string> = {
tailscale: 'tailscale',
}
/** Backend may register under variant container names */
const PACKAGE_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
function resolvePackageKey(routeId: string): string {
return ROUTE_TO_PACKAGE_KEY[routeId] ?? routeId
}
@ -490,6 +496,15 @@ const pkg = computed(() => {
if (store.packages[routeId]) {
return store.packages[routeId]
}
// Check known aliases (backend may use variant container names)
const aliases = PACKAGE_ALIASES[routeId]
if (aliases) {
for (const alias of aliases) {
if (store.packages[alias]) {
return store.packages[alias]
}
}
}
// Fall back to dummy apps
if (dummyApps[routeId]) {
return dummyApps[routeId]

View File

@ -1,6 +1,6 @@
<template>
<div class="pb-6">
<div class="mb-8">
<div class="hidden md:block mb-8">
<h1 class="text-3xl font-bold text-white mb-2">My Apps</h1>
<p class="text-white/70">Manage your installed applications</p>
</div>

View File

@ -1,128 +1,185 @@
<template>
<div>
<div class="mb-8">
<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">Cloud services and storage</p>
<p class="text-white/70">Your files, photos, and media</p>
</div>
<button
@click="openNextcloud"
class="glass-button px-6 py-3 rounded-lg font-medium flex items-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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Open Nextcloud
</button>
</div>
</div>
<!-- Folders Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Content Type Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="folder in folders"
:key="folder.id"
v-for="section in contentSections"
:key="section.id"
data-controller-container
tabindex="0"
class="glass-card p-6 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="openFolder(folder.id)"
@click="openSection(section)"
>
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0">
<svg class="w-12 h-12 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getFolderIcon(folder.type)"
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section.iconBg">
<svg class="w-7 h-7" :class="section.iconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in section.iconPaths"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white mb-1 truncate">
{{ folder.name }}
</h3>
<p class="text-sm text-white/60">
{{ folder.itemCount }} {{ folder.itemCount === 1 ? 'item' : 'items' }}
</p>
<h3 class="text-lg font-semibold text-white mb-0.5 truncate">{{ section.name }}</h3>
<p class="text-xs text-white/50">{{ section.description }}</p>
</div>
<!-- Arrow indicator -->
<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>
<!-- App status -->
<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="isAppRunning(section.appId) ? 'bg-green-500/15 text-green-400' : 'bg-white/5 text-white/40'"
>
<span class="w-1.5 h-1.5 rounded-full" :class="isAppRunning(section.appId) ? 'bg-green-400' : 'bg-white/30'"></span>
{{ section.appLabel }}
</span>
<span v-if="!isAppRunning(section.appId)" class="text-white/30">Not installed</span>
<span v-else-if="sectionCounts[section.id] !== undefined" class="text-white/30">{{ sectionCounts[section.id] }} items</span>
</div>
</div>
</div>
<!-- Not Installed Hint -->
<div v-if="!fileBrowserRunning" class="glass-card p-8 mt-6 text-center">
<p class="text-white/60 mb-3">Install File Browser from the App Store to get started with your cloud storage.</p>
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open App Store
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { computed, ref, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { fileBrowserClient } from '@/api/filebrowser-client'
const router = useRouter()
const store = useAppStore()
const sectionCounts = ref<Record<string, number>>({})
interface Folder {
id: string
name: string
type: 'pictures' | 'videos' | 'music' | 'documents' | 'downloads'
itemCount: number
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
const folders = ref<Folder[]>([
function isAppRunning(appId: string): boolean {
const packages = store.packages
if (packages[appId]?.state === 'running') return true
const aliases = APP_ALIASES[appId]
if (aliases) {
for (const alias of aliases) {
if (packages[alias]?.state === 'running') return true
}
}
return false
}
const fileBrowserRunning = computed(() => isAppRunning('filebrowser'))
interface ContentSection {
id: string
name: string
description: string
appId: string
appLabel: string
iconPaths: string[]
iconBg: string
iconColor: string
}
const contentSections: ContentSection[] = [
{
id: 'pictures',
name: 'Pictures',
type: 'pictures',
itemCount: 0,
},
{
id: 'videos',
name: 'Videos',
type: 'videos',
itemCount: 0,
id: 'photos',
name: 'Photos & Videos',
description: 'Auto-backup & browse your media',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
iconBg: 'bg-blue-500/15',
iconColor: 'text-blue-400',
},
{
id: 'music',
name: 'Music',
type: 'music',
itemCount: 0,
description: 'Your music collection',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
iconBg: 'bg-orange-500/15',
iconColor: 'text-orange-400',
},
{
id: 'documents',
name: 'Documents',
type: 'documents',
itemCount: 0,
description: 'Files, docs & spreadsheets',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
iconBg: 'bg-green-500/15',
iconColor: 'text-green-400',
},
{
id: 'downloads',
name: 'Downloads',
type: 'downloads',
itemCount: 0,
id: 'files',
name: 'All Files',
description: 'Browse your server file system',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
iconBg: 'bg-white/10',
iconColor: 'text-white/70',
},
])
]
function getFolderIcon(type: string): string[] {
const icons: Record<string, string[]> = {
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
const SECTION_PATHS: Record<string, string> = {
photos: '/Photos',
music: '/Music',
documents: '/Documents',
files: '/',
}
async function loadCounts() {
if (!fileBrowserRunning.value) return
try {
const ok = await fileBrowserClient.login()
if (!ok) return
for (const section of contentSections) {
const path = SECTION_PATHS[section.id]
if (!path) continue
try {
const items = await fileBrowserClient.listDirectory(path)
sectionCounts.value[section.id] = items.length
} catch {
sectionCounts.value[section.id] = 0
}
}
} catch {
// Silently fail
}
return icons[type] || ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
}
function openFolder(folderId: string) {
router.push({
name: 'cloud-folder',
params: { folderId }
})
}
onMounted(() => loadCounts())
function openNextcloud() {
const host = window.location.hostname
const url = `http://${host}:8086`
useAppLauncherStore().open({ url, title: 'Nextcloud' })
function openSection(section: ContentSection) {
router.push({ name: 'cloud-folder', params: { folderId: section.id } })
}
</script>

View File

@ -1,172 +1,310 @@
<template>
<div class="cloud-folder-container pb-16 md:pb-16">
<!-- Desktop Back Button -->
<button @click="goBack" class="hidden md:flex mb-6 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 class="cloud-folder-container flex flex-col h-full">
<!-- Desktop Back Button + Header -->
<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>
<!-- Mobile Full-Width Back Button -->
<button
@click="goBack"
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
:style="{
bottom: bottomPosition,
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
}"
>
<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>
<!-- Folder Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<div class="flex-shrink-0">
<svg class="w-16 h-16 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getFolderIcon(folderType)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ folderName }}</h1>
<p class="text-white/70">{{ items.length }} {{ items.length === 1 ? 'item' : 'items' }}</p>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="items.length === 0" class="glass-card p-12 text-center">
<svg class="w-24 h-24 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getFolderIcon(folderType)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<h3 class="text-2xl font-semibold text-white mb-2">No items</h3>
<p class="text-white/70">This folder is empty</p>
</div>
<!-- Items Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div
v-for="item in items"
:key="item.id"
class="glass-card p-4 cursor-pointer transition-all hover:-translate-y-1 hover:bg-white/10"
@click="openItem(item)"
<!-- Mobile Back Button -->
<button
@click="goBack"
class="md:hidden fixed left-4 right-4 z-40 glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
:style="{
bottom: bottomPosition,
filter: 'drop-shadow(0 10px 25px rgba(0, 0, 0, 0.5))'
}"
>
<div class="flex flex-col items-center text-center">
<div class="mb-3">
<svg class="w-12 h-12 text-white/60 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getItemIcon(folderType)"
<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>
<!-- Folder Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center" :class="section?.iconBg || 'bg-white/10'">
<svg class="w-7 h-7" :class="section?.iconColor || 'text-white/70'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in (section?.iconPaths || [])"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</div>
<h3 class="text-sm font-medium text-white mb-1 truncate w-full">
{{ item.name }}
</h3>
<p class="text-xs text-white/60">
{{ formatSize(item.size) }}
</p>
<div>
<h1 class="text-2xl font-bold text-white">{{ section?.name || 'Folder' }}</h1>
<p class="text-sm text-white/50">{{ section?.description }}</p>
</div>
</div>
<div class="flex gap-2">
<button
v-if="appRunning"
@click="openExternal"
class="glass-button px-4 py-2 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in New Tab
</button>
</div>
</div>
</div>
<!-- App Not Installed -->
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-xl font-semibold text-white mb-2">{{ section?.appLabel }} not running</h3>
<p class="text-white/60 mb-4">Install {{ section?.appLabel }} from the App Store to manage your {{ section?.name?.toLowerCase() }}.</p>
<RouterLink to="/dashboard/marketplace" class="glass-button inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium">
Open App Store
</RouterLink>
</div>
<!-- Native File Browser (for FileBrowser-backed sections) -->
<div v-else-if="useNativeUI" class="flex-1 min-h-0 flex flex-col">
<!-- Upload progress -->
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/70">Uploading...</span>
</div>
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
<span class="text-sm text-red-400">{{ uploadError }}</span>
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
</div>
<CloudToolbar
:breadcrumbs="cloudStore.breadcrumbs"
:view-mode="viewMode"
@navigate="cloudStore.navigate($event)"
@refresh="cloudStore.refresh()"
@upload="handleUpload"
@update:view-mode="viewMode = $event"
/>
<FileGrid
:items="cloudStore.sortedItems"
:loading="cloudStore.loading"
:view-mode="viewMode"
@navigate="cloudStore.navigate($event)"
@delete="handleDelete"
@play="handlePlay"
/>
<!-- Mini Audio Player -->
<div v-if="audioPlayer.currentName.value" class="cloud-audio-player">
<button class="cloud-audio-player-btn" @click="audioPlayer.playing.value ? audioPlayer.pause() : audioPlayer.play(audioPlayer.currentSrc.value!, audioPlayer.currentName.value)">
<svg v-if="!audioPlayer.playing.value" class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7L8 5z" /></svg>
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></svg>
</button>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white/90 truncate">{{ audioPlayer.currentName.value }}</p>
<div class="cloud-audio-progress">
<div class="cloud-audio-progress-bar" :style="{ width: audioPlayer.progress.value + '%' }"></div>
</div>
</div>
<button class="cloud-audio-player-btn" @click="audioPlayer.stop()">
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<!-- Fallback iframe (for sections without native UI) -->
<div v-else class="flex-1 min-h-0 rounded-xl overflow-hidden border border-white/10">
<div v-if="!iframeLoaded" class="flex items-center justify-center h-full">
<div class="glass-card p-8 flex flex-col items-center gap-4">
<div class="w-8 h-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<p class="text-sm text-white/60">Loading {{ section?.appLabel }}...</p>
</div>
</div>
<iframe
v-if="appRunning"
:src="iframeUrl"
class="w-full h-full border-0"
:class="{ 'opacity-0': !iframeLoaded }"
style="min-height: 500px"
@load="iframeLoaded = true"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
<!-- Spacer for mobile back button -->
<div class="md:hidden h-[calc(var(--mobile-tab-bar-height,_64px)+96px)]"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import { useMobileBackButton } from '../composables/useMobileBackButton'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import { useAudioPlayer } from '../composables/useAudioPlayer'
const { bottomPosition } = useMobileBackButton()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const cloudStore = useCloudStore()
const viewMode = ref<'list' | 'grid'>('grid')
const audioPlayer = useAudioPlayer()
const iframeLoaded = ref(false)
const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string)
const folderNames: Record<string, string> = {
pictures: 'Pictures',
videos: 'Videos',
music: 'Music',
documents: 'Documents',
downloads: 'Downloads',
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
const folderName = computed(() => folderNames[folderId.value] || 'Folder')
const folderType = computed(() => folderId.value as string)
// Dummy items for each folder type
const items = ref<any[]>([])
function getFolderIcon(type: string): string[] {
const icons: Record<string, string[]> = {
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
function isAppRunning(appId: string): boolean {
const packages = store.packages
if (packages[appId]?.state === 'running') return true
const aliases = APP_ALIASES[appId]
if (aliases) {
for (const alias of aliases) {
if (packages[alias]?.state === 'running') return true
}
}
return icons[type] || ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z']
return false
}
function getItemIcon(type: string): string[] {
const icons: Record<string, string[]> = {
pictures: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
videos: ['M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'],
music: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
documents: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
downloads: ['M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4'],
interface ContentSection {
name: string
description: string
appId: string
appLabel: string
iconPaths: string[]
iconBg: string
iconColor: string
iframeUrl: string
externalUrl: string
nativeUI: boolean
initialPath: string
}
const host = computed(() => window.location.hostname)
const origin = computed(() => window.location.origin)
const sections: Record<string, () => ContentSection> = {
photos: () => ({
name: 'Photos & Videos',
description: 'Auto-backup & browse your media',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'],
iconBg: 'bg-blue-500/15',
iconColor: 'text-blue-400',
iframeUrl: `http://${host.value}:2283/photos`,
externalUrl: `http://${host.value}:8083`,
nativeUI: true,
initialPath: '/Photos',
}),
music: () => ({
name: 'Music',
description: 'Your music collection',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3'],
iconBg: 'bg-orange-500/15',
iconColor: 'text-orange-400',
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Songs`,
externalUrl: `http://${host.value}:8083`,
nativeUI: true,
initialPath: '/Music',
}),
documents: () => ({
name: 'Documents',
description: 'Files, docs & spreadsheets',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'],
iconBg: 'bg-green-500/15',
iconColor: 'text-green-400',
iframeUrl: `${origin.value}/app/nextcloud/apps/files/?dir=/Documents`,
externalUrl: `http://${host.value}:8083`,
nativeUI: true,
initialPath: '/Documents',
}),
files: () => ({
name: 'All Files',
description: 'Browse your server file system',
appId: 'filebrowser',
appLabel: 'File Browser',
iconPaths: ['M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z'],
iconBg: 'bg-white/10',
iconColor: 'text-white/70',
iframeUrl: `http://${host.value}:8083`,
externalUrl: `http://${host.value}:8083`,
nativeUI: true,
initialPath: '/',
}),
}
const section = computed(() => {
const factory = sections[folderId.value]
return factory ? factory() : null
})
const appRunning = computed(() => section.value ? isAppRunning(section.value.appId) : false)
const useNativeUI = computed(() => section.value?.nativeUI === true && appRunning.value)
const iframeUrl = computed(() => section.value?.iframeUrl || '')
// Initialize native file browser when entering a native-UI section
watch(useNativeUI, async (native) => {
if (native && section.value) {
cloudStore.reset()
const ok = await cloudStore.init()
if (ok) {
await cloudStore.navigate(section.value.initialPath)
}
}
}, { immediate: true })
const uploadError = ref<string | null>(null)
async function handleUpload(files: File[]) {
uploading.value = true
uploadError.value = null
try {
for (const file of files) {
await cloudStore.uploadFile(file)
}
} catch (e) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
} finally {
uploading.value = false
}
return icons[type] || ['M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z']
}
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
async function handleDelete(path: string) {
await cloudStore.deleteItem(path)
}
function openItem(item: any) {
console.log('Opening item:', item)
// TODO: Implement item opening logic
function handlePlay(path: string, name: string) {
const url = cloudStore.downloadUrl(path)
audioPlayer.play(url, name)
}
function openExternal() {
if (section.value) {
window.open(section.value.externalUrl, '_blank', 'noopener,noreferrer')
}
}
function goBack() {
router.push('/dashboard/cloud')
}
// Generate dummy items based on folder type
onMounted(() => {
// For now, we'll leave items empty to show the empty state
// In the future, this would fetch real items from the backend
items.value = []
})
</script>

View File

@ -573,6 +573,7 @@ const gamerDesktopNav: NavItem[] = [
const easyDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
@ -598,6 +599,7 @@ const gamerMobileNav: NavItem[] = [
const easyMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]

View File

@ -215,13 +215,26 @@ function isStepCompleted(step: GoalStep): boolean {
return completedSteps.value.has(step.id)
}
/** App ID aliases — backend may register under variant names */
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
}
function matchesAppId(pkgId: string, appId: string): boolean {
if (pkgId === appId) return true
const aliases = APP_ALIASES[appId]
return aliases ? aliases.includes(pkgId) : false
}
function isAppInstalled(appId: string): boolean {
return Object.keys(appStore.packages).some((pkgId) => pkgId === appId)
return Object.keys(appStore.packages).some((pkgId) => matchesAppId(pkgId, appId))
}
function isAppRunning(appId: string): boolean {
return Object.entries(appStore.packages).some(
([pkgId, pkg]) => pkgId === appId && pkg.state === 'running',
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
)
}

View File

@ -102,7 +102,7 @@
<RouterLink to="/dashboard/cloud" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
View Folders
</RouterLink>
<button class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" @click="() => {}">
<button @click="uploadFiles" class="home-card-btn flex-1 px-4 py-2 glass-button rounded-lg text-sm font-medium text-center transition-colors">
Upload Files
</button>
</div>
@ -276,8 +276,9 @@
<script setup lang="ts">
import { computed, ref, watch, onBeforeUnmount } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useLoginTransitionStore } from '../stores/loginTransition'
import { useUIModeStore } from '@/stores/uiMode'
import { PackageState } from '../types/api'
@ -285,6 +286,7 @@ import { playTypingSound } from '@/composables/useLoginSounds'
import { GOALS } from '@/data/goals'
import EasyHome from '@/components/EasyHome.vue'
const router = useRouter()
const uiMode = useUIModeStore()
const topGoals = GOALS.slice(0, 3)
@ -399,6 +401,17 @@ function dismissQuickStart() {
}
loadQuickStartState()
function uploadFiles() {
const pkg = packages.value['filebrowser']
if (pkg && pkg.state === PackageState.Running) {
const host = window.location.hostname
useAppLauncherStore().open({ url: `http://${host}:8083`, title: 'File Browser' })
} else {
router.push('/dashboard/cloud')
}
}
</script>
<style scoped>

View File

@ -510,6 +510,8 @@ const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
function isInstalled(appId: string): boolean {
if (appId in installedPackages.value) return true

View File

@ -1,6 +1,6 @@
<template>
<div>
<div class="mb-8">
<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>

View File

@ -217,6 +217,65 @@
</div>
</div>
<!-- Claude Authentication Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<h2 class="text-xl font-semibold text-white/96 mb-2">Claude Authentication</h2>
<p class="text-sm text-white/60 mb-6">Connect your Claude Max account to enable AI chat features.</p>
<!-- Status -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 mb-4">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 shrink-0" :class="claudeConnected ? 'text-green-400' : 'text-white/40'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="claudeConnected" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 11-12.728 0M12 9v4m0 4h.01" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Connection Status</p>
</div>
<p class="text-base font-medium" :class="claudeConnected ? 'text-green-400' : 'text-white/50'">
{{ claudeConnected ? 'Connected' : 'Not connected' }}
</p>
</div>
<button
@click="showClaudeLoginModal = true"
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border transition-colors"
:class="claudeConnected
? 'border-white/20 text-white/70 hover:bg-white/5'
: 'border-orange-500/50 text-orange-400 font-medium hover:bg-orange-500/10'"
>
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<span>{{ claudeConnected ? 'Re-authenticate' : 'Login with Claude' }}</span>
</button>
</div>
<!-- Claude Login Modal (iframe) -->
<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"
@click.self="showClaudeLoginModal = false"
>
<div class="glass-card p-0 max-w-lg w-full overflow-hidden" style="height: 480px">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<h3 class="text-sm font-semibold text-white/80">Claude Authentication</h3>
<button @click="showClaudeLoginModal = false" class="text-white/50 hover:text-white/80 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<iframe
src="/claude-login"
class="w-full border-0"
style="height: calc(100% - 49px)"
@load="onClaudeIframeLoad"
/>
</div>
</div>
</Teleport>
<!-- AI Data Access Section -->
<div class="path-option-card cursor-default px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-2">
@ -339,6 +398,45 @@ const userDid = computed(() => {
}
})
const claudeConnected = ref(false)
const showClaudeLoginModal = ref(false)
function checkClaudeStatus() {
fetch('/aiui/api/claude/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'haiku', messages: [{ role: 'user', content: 'ping' }] }) })
.then(r => {
if (!r.ok) { claudeConnected.value = false; return }
const reader = r.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let text = ''
function read(): Promise<void> {
return reader!.read().then(({ done, value }) => {
if (done) {
claudeConnected.value = !text.includes('Not logged in') && !text.includes('error')
return
}
text += decoder.decode(value, { stream: true })
return read()
})
}
read()
})
.catch(() => { claudeConnected.value = false })
}
function onClaudeIframeLoad() {
// Listen for success message from login iframe
window.addEventListener('message', handleClaudeLoginMessage)
}
function handleClaudeLoginMessage(e: MessageEvent) {
if (e.data?.type === 'claude-auth-success') {
claudeConnected.value = true
showClaudeLoginModal.value = false
window.removeEventListener('message', handleClaudeLoginMessage)
}
}
const copiedOnion = ref(false)
const showChangePasswordModal = ref(false)
const changePasswordModalRef = ref<HTMLElement | null>(null)
@ -430,6 +528,7 @@ function closeChangePasswordModal() {
}
onMounted(async () => {
checkClaudeStatus()
if (!serverTorAddressFromStore.value) {
try {
const res = await rpcClient.getTorAddress()

View File

@ -61,7 +61,7 @@ if [ "$BOTH" = true ]; then
sudo cp /tmp/archipelago-new /usr/local/bin/archipelago
sudo chmod +x /usr/local/bin/archipelago
rm -f /tmp/archipelago-new
sudo rm -rf /opt/archipelago/web-ui/*
sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name "aiui" ! -name "claude-login.html" -exec rm -rf {} +
sudo cp -r /tmp/web-deploy/web/dist/neode-ui/* /opt/archipelago/web-ui/ 2>/dev/null || true
sudo chown -R 1000:1000 /opt/archipelago/web-ui
sudo systemctl start archipelago
@ -117,9 +117,9 @@ if [ "$LIVE" = true ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
fi
# Deploy frontend
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
echo "$(timestamp) Deploying frontend..."
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/*"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -r $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui"
@ -135,44 +135,20 @@ if [ "$LIVE" = true ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/aiui/*"
cd "$AIUI_DIST" && tar cf - . | sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/"
cd "$PROJECT_DIR"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui"
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui && sudo chmod 755 /opt/archipelago/web-ui/aiui && sudo find /opt/archipelago/web-ui/aiui -type d -exec chmod 755 {} \;"
else
echo "$(timestamp) ⚠️ AIUI not found at $AIUI_DIR, skipping"
fi
# Add /archipelago/ to nginx if missing (for peer messaging over Tor)
if [ -f "$SCRIPT_DIR/nginx-archipelago-patch.conf" ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS "$SCRIPT_DIR/nginx-archipelago-patch.conf" "$TARGET_HOST:/tmp/archipelago-nginx-patch.conf" 2>/dev/null || true
# Sync nginx config from image-recipe (single source of truth)
NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf"
if [ -f "$NGINX_CFG" ]; then
echo "$(timestamp) Syncing nginx config..."
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS "$NGINX_CFG" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null || true
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
CFG=/etc/nginx/sites-available/archipelago
if [ -f "$CFG" ] && [ -f /tmp/archipelago-nginx-patch.conf ] && ! grep -q "location /archipelago/" "$CFG"; then
echo " Adding /archipelago/ to nginx..."
sudo sed -i "/# Proxy API requests to backend/r /tmp/archipelago-nginx-patch.conf" "$CFG"
fi
rm -f /tmp/archipelago-nginx-patch.conf
' 2>/dev/null || true
fi
# Add /app/nextcloud/, /app/vaultwarden/, /app/immich/ proxy for iframe embedding (strip X-Frame-Options)
if [ -f "$SCRIPT_DIR/nginx-app-iframe-patch.conf" ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS "$SCRIPT_DIR/nginx-app-iframe-patch.conf" "$TARGET_HOST:/tmp/nginx-app-iframe-patch.conf" 2>/dev/null || true
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
CFG=/etc/nginx/sites-available/archipelago
if [ -f "$CFG" ] && [ -f /tmp/nginx-app-iframe-patch.conf ] && ! grep -q "location /app/nextcloud/" "$CFG"; then
echo " Adding /app/nextcloud/, /app/vaultwarden/, /app/immich/, /app/penpot/ proxy to nginx..."
sudo sed -i "/# Proxy WebSocket/r /tmp/nginx-app-iframe-patch.conf" "$CFG"
fi
rm -f /tmp/nginx-app-iframe-patch.conf
' 2>/dev/null || true
fi
if [ -f "$SCRIPT_DIR/nginx-penpot-iframe-patch.conf" ]; then
sshpass -p "$ARCHIPELAGO_PASSWORD" scp $SSH_OPTS "$SCRIPT_DIR/nginx-penpot-iframe-patch.conf" "$TARGET_HOST:/tmp/nginx-penpot-patch.conf" 2>/dev/null || true
sshpass -p "$ARCHIPELAGO_PASSWORD" ssh $SSH_OPTS "$TARGET_HOST" '
CFG=/etc/nginx/sites-available/archipelago
if [ -f "$CFG" ] && [ -f /tmp/nginx-penpot-patch.conf ] && ! grep -q "location /app/penpot/" "$CFG"; then
echo " Adding /app/penpot/ proxy to nginx..."
sudo sed -i "/# Proxy WebSocket/r /tmp/nginx-penpot-patch.conf" "$CFG"
fi
rm -f /tmp/nginx-penpot-patch.conf
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
sudo nginx -t 2>&1 && echo " nginx config OK" || echo " ⚠️ nginx config test failed, keeping old config"
rm -f /tmp/nginx-archipelago.conf
' 2>/dev/null || true
fi