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:
parent
173bf8fc0f
commit
d7ff678e9d
@ -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,
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
122
neode-ui/src/api/filebrowser-client.ts
Normal file
122
neode-ui/src/api/filebrowser-client.ts
Normal 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()
|
||||
99
neode-ui/src/components/cloud/CloudToolbar.vue
Normal file
99
neode-ui/src/components/cloud/CloudToolbar.vue
Normal 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>
|
||||
116
neode-ui/src/components/cloud/FileCard.vue
Normal file
116
neode-ui/src/components/cloud/FileCard.vue
Normal 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>
|
||||
166
neode-ui/src/components/cloud/FileCardGrid.vue
Normal file
166
neode-ui/src/components/cloud/FileCardGrid.vue
Normal 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>
|
||||
77
neode-ui/src/components/cloud/FileGrid.vue
Normal file
77
neode-ui/src/components/cloud/FileGrid.vue
Normal 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>
|
||||
82
neode-ui/src/composables/useAudioPlayer.ts
Normal file
82
neode-ui/src/composables/useAudioPlayer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
99
neode-ui/src/composables/useFileType.ts
Normal file
99
neode-ui/src/composables/useFileType.ts
Normal 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()
|
||||
}
|
||||
@ -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
|
||||
|
||||
121
neode-ui/src/stores/cloud.ts
Normal file
121
neode-ui/src/stores/cloud.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user