Add vibrate, fix downloads, and fix nav bar
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
src/composables/haptics.ts
Normal file
29
src/composables/haptics.ts
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user