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 @@ + + + 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 @@