diff --git a/core/archipelago/src/api/rpc/package.rs b/core/archipelago/src/api/rpc/package.rs
index bbd138cd..42994fc9 100644
--- a/core/archipelago/src/api/rpc/package.rs
+++ b/core/archipelago/src/api/rpc/package.rs
@@ -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,
diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs
index b365a1c9..bea7f0f5 100644
--- a/core/archipelago/src/container/docker_packages.rs
+++ b/core/archipelago/src/container/docker_packages.rs
@@ -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()) {
diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf
index 6cbf041e..f64d4208 100644
--- a/image-recipe/configs/nginx-archipelago.conf
+++ b/image-recipe/configs/nginx-archipelago.conf
@@ -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;
+ }
+}
+
diff --git a/neode-ui/index.html b/neode-ui/index.html
index b49a276d..f3979c2e 100644
--- a/neode-ui/index.html
+++ b/neode-ui/index.html
@@ -21,7 +21,6 @@
-
Archipelago OS
diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts
new file mode 100644
index 00000000..507bde75
--- /dev/null
+++ b/neode-ui/src/api/filebrowser-client.ts
@@ -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 {
+ 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 {
+ const h: Record = {}
+ if (this.token) h['X-Auth'] = this.token
+ return h
+ }
+
+ async listDirectory(path: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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()
diff --git a/neode-ui/src/components/cloud/CloudToolbar.vue b/neode-ui/src/components/cloud/CloudToolbar.vue
new file mode 100644
index 00000000..1aaed4f0
--- /dev/null
+++ b/neode-ui/src/components/cloud/CloudToolbar.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
diff --git a/neode-ui/src/components/cloud/FileCard.vue b/neode-ui/src/components/cloud/FileCard.vue
new file mode 100644
index 00000000..9a57f017
--- /dev/null
+++ b/neode-ui/src/components/cloud/FileCard.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
diff --git a/neode-ui/src/components/cloud/FileCardGrid.vue b/neode-ui/src/components/cloud/FileCardGrid.vue
new file mode 100644
index 00000000..af3fe53f
--- /dev/null
+++ b/neode-ui/src/components/cloud/FileCardGrid.vue
@@ -0,0 +1,166 @@
+
+
+
+
+
diff --git a/neode-ui/src/components/cloud/FileGrid.vue b/neode-ui/src/components/cloud/FileGrid.vue
new file mode 100644
index 00000000..778b21fa
--- /dev/null
+++ b/neode-ui/src/components/cloud/FileGrid.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
This folder is empty
+
Upload files to get started
+
+
+
+
+ $emit('play', path, name)"
+ />
+
+
+
+
+
+
+
+
+
+
diff --git a/neode-ui/src/composables/useAudioPlayer.ts b/neode-ui/src/composables/useAudioPlayer.ts
new file mode 100644
index 00000000..82dbeba4
--- /dev/null
+++ b/neode-ui/src/composables/useAudioPlayer.ts
@@ -0,0 +1,82 @@
+import { ref, computed } from 'vue'
+
+const audio = ref(null)
+const currentSrc = ref(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,
+ }
+}
diff --git a/neode-ui/src/composables/useFileType.ts b/neode-ui/src/composables/useFileType.ts
new file mode 100644
index 00000000..6241f484
--- /dev/null
+++ b/neode-ui/src/composables/useFileType.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ folder: 'Folder',
+ audio: 'Audio',
+ video: 'Video',
+ image: 'Image',
+ document: 'Document',
+ spreadsheet: 'Spreadsheet',
+ archive: 'Archive',
+ file: 'File',
+}
+
+const CATEGORY_BADGE_CLASSES: Record = {
+ 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, isDir: Ref) {
+ 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()
+}
diff --git a/neode-ui/src/composables/useMobileBackButton.ts b/neode-ui/src/composables/useMobileBackButton.ts
index 46a9910a..8ca95363 100644
--- a/neode-ui/src/composables/useMobileBackButton.ts
+++ b/neode-ui/src/composables/useMobileBackButton.ts
@@ -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
diff --git a/neode-ui/src/stores/cloud.ts b/neode-ui/src/stores/cloud.ts
new file mode 100644
index 00000000..17ece69b
--- /dev/null
+++ b/neode-ui/src/stores/cloud.ts
@@ -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([])
+ const loading = ref(false)
+ const error = ref(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 {
+ if (authenticated.value) return true
+ const ok = await fileBrowserClient.login()
+ authenticated.value = ok
+ return ok
+ }
+
+ async function navigate(path: string): Promise {
+ 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 {
+ await navigate(currentPath.value)
+ }
+
+ async function uploadFile(file: File): Promise {
+ await fileBrowserClient.upload(currentPath.value, file)
+ await refresh()
+ }
+
+ async function deleteItem(path: string): Promise {
+ 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,
+ }
+})
diff --git a/neode-ui/src/stores/goals.ts b/neode-ui/src/stores/goals.ts
index d7aec789..52a2f574 100644
--- a/neode-ui/src/stores/goals.ts
+++ b/neode-ui/src/stores/goals.ts
@@ -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 = {
+ 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>({})
@@ -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'
diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css
index 6ae9f86d..eccae966 100644
--- a/neode-ui/src/style.css
+++ b/neode-ui/src/style.css
@@ -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;
+}
diff --git a/neode-ui/src/views/AppDetails.vue b/neode-ui/src/views/AppDetails.vue
index 53cde0e3..ec20a434 100644
--- a/neode-ui/src/views/AppDetails.vue
+++ b/neode-ui/src/views/AppDetails.vue
@@ -475,6 +475,12 @@ const ROUTE_TO_PACKAGE_KEY: Record = {
tailscale: 'tailscale',
}
+/** Backend may register under variant container names */
+const PACKAGE_ALIASES: Record = {
+ 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]
diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue
index d3498e6d..3032e236 100644
--- a/neode-ui/src/views/Apps.vue
+++ b/neode-ui/src/views/Apps.vue
@@ -1,6 +1,6 @@
-
+
My Apps
Manage your installed applications
diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue
index 80994cd7..60c7d498 100644
--- a/neode-ui/src/views/Cloud.vue
+++ b/neode-ui/src/views/Cloud.vue
@@ -1,128 +1,185 @@
-
+
Cloud
-
Cloud services and storage
+
Your files, photos, and media
-
-
-
+
+
-
-
- {{ folder.name }}
-
-
- {{ folder.itemCount }} {{ folder.itemCount === 1 ? 'item' : 'items' }}
-
+
{{ section.name }}
+
{{ section.description }}
+
+
+
+
+
+
+
+
+ {{ section.appLabel }}
+
+ Not installed
+ {{ sectionCounts[section.id] }} items
+
+
+
+
Install File Browser from the App Store to get started with your cloud storage.
+
+ Open App Store
+
+
diff --git a/neode-ui/src/views/CloudFolder.vue b/neode-ui/src/views/CloudFolder.vue
index d76d122d..034e135c 100644
--- a/neode-ui/src/views/CloudFolder.vue
+++ b/neode-ui/src/views/CloudFolder.vue
@@ -1,172 +1,310 @@
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
{{ folderName }}
-
{{ items.length }} {{ items.length === 1 ? 'item' : 'items' }}
-
-
-
-
-
-
-
-
No items
-
This folder is empty
-
-
-
-
-
+
@@ -276,8 +276,9 @@