Add vibrate, fix downloads, and fix nav bar

This commit is contained in:
2026-05-10 06:21:06 +00:00
parent 965bf6471f
commit 3e0025b792
9 changed files with 144 additions and 33 deletions

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.3",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@vueuse/core": "^14.3.0",
"async-mutex": "^0.5.0",
"class-variance-authority": "^0.7.1",
@@ -1723,6 +1724,15 @@
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/haptics": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz",
"integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.3.tgz",

View File

@@ -18,6 +18,7 @@
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.3",
"@capacitor/filesystem": "^8.1.2",
"@capacitor/haptics": "^8.0.2",
"@vueuse/core": "^14.3.0",
"async-mutex": "^0.5.0",
"class-variance-authority": "^0.7.1",

View File

@@ -20,8 +20,8 @@ watch(
</script>
<template>
<div class="flex flex-col h-[100dvh] bg-background overflow-hidden">
<main class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="fixed inset-0 flex flex-col bg-background overflow-hidden">
<main class="flex-1 flex flex-col min-h-0 overflow-hidden pb-14" style="padding-bottom: calc(3.5rem + env(safe-area-inset-bottom, 0px))">
<router-view />
</main>
<BottomTabBar />

View File

@@ -25,7 +25,7 @@ const activeTab = computed(() => {
</script>
<template>
<nav class="shrink-0 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-bottom">
<nav class="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80" style="padding-bottom: env(safe-area-inset-bottom, 0px)">
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in tabs"

View File

@@ -2,6 +2,7 @@
import { ref, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useApp } from '@/composables/useApp'
import { hapticLight, hapticMedium } from '@/composables/haptics'
import {
FolderOpen,
Download,
@@ -73,6 +74,7 @@ function handleClick() {
didLongPress.value = false
return
}
hapticLight()
emit('open')
}
@@ -86,6 +88,7 @@ function handleTouchStart(e: TouchEvent) {
didLongPress.value = false
longPressTimer.value = setTimeout(() => {
didLongPress.value = true
hapticMedium()
const touch = e.touches[0] || e.changedTouches[0]
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
}, 500)
@@ -106,6 +109,7 @@ function handleTouchEnd(e: TouchEvent) {
}
if (!touchMoved.value && !didLongPress.value) {
e.preventDefault()
hapticLight()
emit('open')
}
}

View File

@@ -11,6 +11,7 @@ import PreviewDialog from './PreviewDialog.vue'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { FolderOpen, AlertCircle, Loader2 } from 'lucide-vue-next'
import { hapticSuccess } from '@/composables/haptics'
const app = useApp()
const router = useRouter()
@@ -50,18 +51,34 @@ function handlePreview(item: FileItem) {
async function handleDownload(item: FileItem) {
const conn = app.activeConnection.value
if (!conn) return
try {
await app.downloadManager.downloadFile(conn, item.path, item.name, item.size)
} catch {
const blob = await conn.downloadFile(item.path)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = item.name
document.body.appendChild(a)
try {
const response = await fetch(`${conn.config.url.replace(/\/$/, '')}${item.path}`, {
headers: conn.config.username
? { Authorization: 'Basic ' + btoa(`${conn.config.username}:${conn.config.password || ''}`) }
: {},
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
a.href = url
a.click()
hapticSuccess()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
} catch (e: any) {
document.body.removeChild(a)
conn.error.value = `Download failed: ${e.message}`
}
}
@@ -72,20 +89,28 @@ async function handleDelete(item: FileItem) {
async function handleDownloadZip(item: FileItem) {
const conn = app.activeConnection.value
if (!conn) return
const a = document.createElement('a')
a.download = `${item.name}.zip`
document.body.appendChild(a)
try {
conn.isConnecting.value = true
const blob = await conn.downloadFolderAsZip(item.path, (current, total) => {
conn.uploadProgress.value = { file: `Zipping ${current}/${total}`, progress: current / total }
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${item.name}.zip`
document.body.appendChild(a)
a.click()
hapticSuccess()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
} catch (e: any) {
document.body.removeChild(a)
conn.error.value = e.message || 'Failed to download folder'
} finally {
conn.isConnecting.value = false

View File

@@ -0,0 +1,29 @@
import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics'
function isCapacitor() {
return typeof (window as any).Capacitor !== 'undefined'
}
export function hapticLight() {
if (isCapacitor()) {
Haptics.impact({ style: ImpactStyle.Light }).catch(() => {})
} else if (navigator.vibrate) {
navigator.vibrate(10)
}
}
export function hapticMedium() {
if (isCapacitor()) {
Haptics.impact({ style: ImpactStyle.Medium }).catch(() => {})
} else if (navigator.vibrate) {
navigator.vibrate(20)
}
}
export function hapticSuccess() {
if (isCapacitor()) {
Haptics.notification({ type: NotificationType.Success }).catch(() => {})
} else if (navigator.vibrate) {
navigator.vibrate([15, 50, 15])
}
}

View File

@@ -163,8 +163,8 @@ export class Connection {
if (!this.client) throw new Error('Not connected')
}
const normalizedPath = this.normalizePath(path)
const buffer = await this.client.getFileContents(normalizedPath)
if (typeof buffer === 'string') return new Blob([buffer])
const buffer = await this.client.getFileContents(normalizedPath, { format: 'binary' } as any)
if (typeof buffer === 'string') return new Blob([buffer], { type: 'application/octet-stream' })
return new Blob([buffer as ArrayBuffer])
}
@@ -178,7 +178,7 @@ export class Connection {
const zip = new JSZip()
const normalizedPath = this.normalizePath(folderPath)
const allFiles = this.collectDescendantFiles(normalizedPath)
const allFiles = await this.discoverAllFiles(normalizedPath)
for (let i = 0; i < allFiles.length; i++) {
const item = allFiles[i]
@@ -196,17 +196,50 @@ export class Connection {
return zip.generateAsync({ type: 'blob' })
}
private collectDescendantFiles(dirPath: string): FileItem[] {
const files: FileItem[] = []
const prefix = dirPath === '/' ? '/' : dirPath + '/'
private async discoverAllFiles(dirPath: string): Promise<FileItem[]> {
if (!this.client) return []
for (const item of Object.values(this.items.items)) {
if (item.type === 'file' && item.path.startsWith(prefix)) {
files.push(item)
const result: FileItem[] = []
const normalized = this.normalizePath(dirPath)
if (!this.items.isDirCached(normalized)) {
this.isConnecting.value = true
try {
const contents = await this.client.getDirectoryContents(normalized)
if (Array.isArray(contents)) {
const fileItems: FileItem[] = []
for (const item of contents) {
if (item.basename === '' || item.basename === '.') continue
const itemPath = this.normalizePath(item.filename)
fileItems.push({
path: itemPath,
name: item.basename,
parentPath: normalized,
type: item.type === 'directory' ? 'directory' : 'file',
size: item.size ?? 0,
modified: item.lastmod ?? '',
mimeType: item.type === 'directory' ? 'httpd/unix-directory' : (item.mime ?? 'application/octet-stream'),
cachedAt: Date.now(),
})
}
await this.items.setItems(fileItems, normalized)
}
} finally {
this.isConnecting.value = false
}
}
return files
const children = this.items.getChildren(normalized)
for (const child of children) {
if (child.type === 'file') {
result.push(child)
} else if (child.type === 'directory') {
const subFiles = await this.discoverAllFiles(child.path)
result.push(...subFiles)
}
}
return result
}
async createDirectory(name: string) {

View File

@@ -50,18 +50,27 @@ export class DownloadManager {
}
private async downloadToBrowser(connection: Connection, task: DownloadTask): Promise<string> {
const blob = await connection.downloadFile(task.filePath)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = task.fileName
document.body.appendChild(a)
try {
const blob = await connection.downloadFile(task.filePath)
const url = URL.createObjectURL(blob)
a.href = url
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
this.updateTask(task.id, { status: 'completed', progress: 1 })
return url
} catch (e) {
document.body.removeChild(a)
throw e
}
}
private blobToBase64(blob: Blob): Promise<string> {