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/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.3.3",
|
"@capacitor/core": "^8.3.3",
|
||||||
"@capacitor/filesystem": "^8.1.2",
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/haptics": "^8.0.2",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -1723,6 +1724,15 @@
|
|||||||
"@capacitor/core": ">=8.0.0"
|
"@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": {
|
"node_modules/@capacitor/ios": {
|
||||||
"version": "8.3.3",
|
"version": "8.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.3.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.3.3",
|
"@capacitor/core": "^8.3.3",
|
||||||
"@capacitor/filesystem": "^8.1.2",
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/haptics": "^8.0.2",
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-[100dvh] bg-background 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">
|
<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 />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<BottomTabBar />
|
<BottomTabBar />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const activeTab = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, onUnmounted } from 'vue'
|
import { ref, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useApp } from '@/composables/useApp'
|
import { useApp } from '@/composables/useApp'
|
||||||
|
import { hapticLight, hapticMedium } from '@/composables/haptics'
|
||||||
import {
|
import {
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Download,
|
Download,
|
||||||
@@ -73,6 +74,7 @@ function handleClick() {
|
|||||||
didLongPress.value = false
|
didLongPress.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
hapticLight()
|
||||||
emit('open')
|
emit('open')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ function handleTouchStart(e: TouchEvent) {
|
|||||||
didLongPress.value = false
|
didLongPress.value = false
|
||||||
longPressTimer.value = setTimeout(() => {
|
longPressTimer.value = setTimeout(() => {
|
||||||
didLongPress.value = true
|
didLongPress.value = true
|
||||||
|
hapticMedium()
|
||||||
const touch = e.touches[0] || e.changedTouches[0]
|
const touch = e.touches[0] || e.changedTouches[0]
|
||||||
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
|
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -106,6 +109,7 @@ function handleTouchEnd(e: TouchEvent) {
|
|||||||
}
|
}
|
||||||
if (!touchMoved.value && !didLongPress.value) {
|
if (!touchMoved.value && !didLongPress.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
hapticLight()
|
||||||
emit('open')
|
emit('open')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import PreviewDialog from './PreviewDialog.vue'
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FolderOpen, AlertCircle, Loader2 } from 'lucide-vue-next'
|
import { FolderOpen, AlertCircle, Loader2 } from 'lucide-vue-next'
|
||||||
|
import { hapticSuccess } from '@/composables/haptics'
|
||||||
|
|
||||||
const app = useApp()
|
const app = useApp()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -50,18 +51,34 @@ function handlePreview(item: FileItem) {
|
|||||||
async function handleDownload(item: FileItem) {
|
async function handleDownload(item: FileItem) {
|
||||||
const conn = app.activeConnection.value
|
const conn = app.activeConnection.value
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.download = item.name
|
||||||
|
document.body.appendChild(a)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.downloadManager.downloadFile(conn, item.path, item.name, item.size)
|
const response = await fetch(`${conn.config.url.replace(/\/$/, '')}${item.path}`, {
|
||||||
} catch {
|
headers: conn.config.username
|
||||||
const blob = await conn.downloadFile(item.path)
|
? { 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)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = item.name
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
a.click()
|
||||||
|
|
||||||
|
hapticSuccess()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, 1000)
|
||||||
|
} catch (e: any) {
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
URL.revokeObjectURL(url)
|
conn.error.value = `Download failed: ${e.message}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,20 +89,28 @@ async function handleDelete(item: FileItem) {
|
|||||||
async function handleDownloadZip(item: FileItem) {
|
async function handleDownloadZip(item: FileItem) {
|
||||||
const conn = app.activeConnection.value
|
const conn = app.activeConnection.value
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.download = `${item.name}.zip`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
conn.isConnecting.value = true
|
conn.isConnecting.value = true
|
||||||
const blob = await conn.downloadFolderAsZip(item.path, (current, total) => {
|
const blob = await conn.downloadFolderAsZip(item.path, (current, total) => {
|
||||||
conn.uploadProgress.value = { file: `Zipping ${current}/${total}`, progress: current / total }
|
conn.uploadProgress.value = { file: `Zipping ${current}/${total}`, progress: current / total }
|
||||||
})
|
})
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${item.name}.zip`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
a.click()
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
hapticSuccess()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, 1000)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
document.body.removeChild(a)
|
||||||
conn.error.value = e.message || 'Failed to download folder'
|
conn.error.value = e.message || 'Failed to download folder'
|
||||||
} finally {
|
} finally {
|
||||||
conn.isConnecting.value = false
|
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')
|
if (!this.client) throw new Error('Not connected')
|
||||||
}
|
}
|
||||||
const normalizedPath = this.normalizePath(path)
|
const normalizedPath = this.normalizePath(path)
|
||||||
const buffer = await this.client.getFileContents(normalizedPath)
|
const buffer = await this.client.getFileContents(normalizedPath, { format: 'binary' } as any)
|
||||||
if (typeof buffer === 'string') return new Blob([buffer])
|
if (typeof buffer === 'string') return new Blob([buffer], { type: 'application/octet-stream' })
|
||||||
return new Blob([buffer as ArrayBuffer])
|
return new Blob([buffer as ArrayBuffer])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ export class Connection {
|
|||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
|
|
||||||
const normalizedPath = this.normalizePath(folderPath)
|
const normalizedPath = this.normalizePath(folderPath)
|
||||||
const allFiles = this.collectDescendantFiles(normalizedPath)
|
const allFiles = await this.discoverAllFiles(normalizedPath)
|
||||||
|
|
||||||
for (let i = 0; i < allFiles.length; i++) {
|
for (let i = 0; i < allFiles.length; i++) {
|
||||||
const item = allFiles[i]
|
const item = allFiles[i]
|
||||||
@@ -196,17 +196,50 @@ export class Connection {
|
|||||||
return zip.generateAsync({ type: 'blob' })
|
return zip.generateAsync({ type: 'blob' })
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectDescendantFiles(dirPath: string): FileItem[] {
|
private async discoverAllFiles(dirPath: string): Promise<FileItem[]> {
|
||||||
const files: FileItem[] = []
|
if (!this.client) return []
|
||||||
const prefix = dirPath === '/' ? '/' : dirPath + '/'
|
|
||||||
|
|
||||||
for (const item of Object.values(this.items.items)) {
|
const result: FileItem[] = []
|
||||||
if (item.type === 'file' && item.path.startsWith(prefix)) {
|
const normalized = this.normalizePath(dirPath)
|
||||||
files.push(item)
|
|
||||||
|
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) {
|
async createDirectory(name: string) {
|
||||||
|
|||||||
@@ -50,18 +50,27 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async downloadToBrowser(connection: Connection, task: DownloadTask): Promise<string> {
|
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')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
|
||||||
a.download = task.fileName
|
a.download = task.fileName
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
this.updateTask(task.id, { status: 'completed', progress: 1 })
|
try {
|
||||||
return url
|
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> {
|
private blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
|||||||
Reference in New Issue
Block a user