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:
co-authored by
Claude Opus 4.6
parent
173bf8fc0f
commit
d7ff678e9d
@@ -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>
|
||||
|
||||
+133
-76
@@ -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>
|
||||
|
||||
+269
-131
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user