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:
Dorian
2026-03-04 23:05:01 +00:00
co-authored by Claude Opus 4.6
parent 173bf8fc0f
commit d7ff678e9d
26 changed files with 2053 additions and 265 deletions
@@ -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
View 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