archy/neode-ui/src/views/CloudFolder.vue
Dorian 11cee9dc70 fix: resolve content clipping on mobile by moving tab padding to scroll container
Moves dynamic pt-20/pt-40 padding from perspective-container-wrapper (which
shrank the content area) to the inner scroll container via computed style.
Removes spacer divs in CloudFolder, AppDetails, MarketplaceAppDetails.
Reduces excessive bottom padding in Marketplace. Hides Cloud/Network tabs
in CloudFolder detail view. Teleports mobile back buttons to body to escape
CSS transform containing block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:14:10 +00:00

343 lines
13 KiB
Vue

<template>
<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 Back Button (teleported to escape CSS transform containing block) -->
<Teleport to="body">
<button
@click="goBack"
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-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="M15 19l-7-7 7-7" />
</svg>
<span>Back to Cloud</span>
</button>
</Teleport>
<!-- 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"
/>
</svg>
</div>
<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 relative"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
>
<!-- Drag-and-drop overlay -->
<div v-if="draggingOver" class="cloud-drop-overlay">
<div class="cloud-drop-overlay-inner">
<svg class="w-12 h-12 text-white/80 mb-3" 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>
<p class="text-lg font-medium text-white/90">Drop files to upload</p>
<p class="text-sm text-white/50">Files will be added to the current folder</p>
</div>
</div>
<!-- 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-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7L8 5z" /></svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" /></svg>
</button>
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
<p v-else 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-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>
</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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app'
import { useCloudStore } from '../stores/cloud'
import CloudToolbar from '../components/cloud/CloudToolbar.vue'
import FileGrid from '../components/cloud/FileGrid.vue'
import { useAudioPlayer } from '../composables/useAudioPlayer'
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 APP_ALIASES: Record<string, string[]> = {
immich: ['immich_server', 'immich-server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
}
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
}
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)
const draggingOver = ref(false)
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
function onDragOver() {
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
draggingOver.value = true
}
function onDragLeave() {
// Debounce to avoid flicker when dragging between child elements
if (dragLeaveTimer) clearTimeout(dragLeaveTimer)
dragLeaveTimer = setTimeout(() => { draggingOver.value = false }, 100)
}
function onDrop(e: DragEvent) {
draggingOver.value = false
if (dragLeaveTimer) { clearTimeout(dragLeaveTimer); dragLeaveTimer = null }
const dt = e.dataTransfer
if (!dt?.files?.length) return
handleUpload(Array.from(dt.files))
}
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
}
}
async function handleDelete(path: string) {
await cloudStore.deleteItem(path)
}
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')
}
</script>