Initial Commit

This commit is contained in:
2026-05-09 18:09:57 +00:00
commit 965bf6471f
119 changed files with 17016 additions and 0 deletions

29
src/App.vue Normal file
View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useApp } from '@/composables/useApp'
import BottomTabBar from '@/components/BottomTabBar.vue'
const router = useRouter()
const route = useRoute()
const app = useApp()
watch(
() => app.hasConnections(),
(hasConnections) => {
if (!hasConnections && route.path !== '/connections') {
router.push('/connections')
}
},
{ immediate: true },
)
</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">
<router-view />
</main>
<BottomTabBar />
</div>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useApp } from '@/composables/useApp'
import {
Search,
FolderPlus,
Upload,
LayoutGrid,
List,
ChevronDown,
Plus,
ArrowLeft,
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import CreateFolderDialog from './CreateFolderDialog.vue'
import UploadDialog from './UploadDialog.vue'
const router = useRouter()
const app = useApp()
const searchQuery = ref('')
const showCreateFolder = ref(false)
const showUpload = ref(false)
const connectionLabel = computed(() => {
return app.activeConnection.value?.config.name || 'Select a connection'
})
const activeConnId = computed(() => app.activeConnectionId.value)
const connectionList = computed(() => app.connections.connectionList)
defineEmits<{
(e: 'toggle-drawer'): void
}>()
</script>
<template>
<header
class="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-top"
>
<div class="flex items-center justify-between h-14 px-3 gap-2">
<div class="flex items-center gap-1 min-w-0">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="gap-1 px-2 min-w-0 max-w-[160px]">
<span class="text-sm font-semibold truncate">{{ connectionLabel }}</span>
<ChevronDown class="size-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<DropdownMenuItem
v-for="c in connectionList"
:key="c.id"
@click="app.setActiveConnection(c.id)"
class="gap-2"
>
<span class="truncate flex-1">{{ c.name }}</span>
<span v-if="c.id === activeConnId" class="text-[10px] text-primary font-medium">active</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="router.push('/connections')" class="gap-2">
<Plus class="size-3.5" />
Manage Connections
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="size-9" @click="showCreateFolder = true">
<FolderPlus class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-9" @click="showUpload = true">
<Upload class="size-4" />
</Button>
<div class="w-px h-5 bg-border mx-0.5" />
<Button
variant="ghost"
size="icon"
class="size-9"
@click="app.setViewMode(app.viewMode.value === 'grid' ? 'list' : 'grid')"
>
<LayoutGrid v-if="app.viewMode.value === 'list'" class="size-4" />
<List v-else class="size-4" />
</Button>
</div>
</div>
</header>
<CreateFolderDialog v-model:open="showCreateFolder" />
<UploadDialog v-model:open="showUpload" />
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useApp } from '@/composables/useApp'
import { FolderOpen, ArrowDownToLine, Network, type LucideIcon } from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const app = useApp()
const tabs = computed(() => {
const items: { id: string; label: string; icon: LucideIcon; path: string; badge?: number }[] = [
{ id: 'files', label: 'Files', icon: FolderOpen, path: '/' },
{ id: 'downloads', label: 'Downloads', icon: ArrowDownToLine, path: '/downloads', badge: app.downloadManager.activeDownloads.length },
{ id: 'connections', label: 'Connections', icon: Network, path: '/connections' },
]
return items
})
const activeTab = computed(() => {
if (route.path.startsWith('/downloads')) return 'downloads'
if (route.path.startsWith('/connections')) return 'connections'
return 'files'
})
</script>
<template>
<nav class="shrink-0 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-bottom">
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in tabs"
:key="tab.id"
@click="router.push(tab.path)"
class="relative flex flex-col items-center justify-center gap-0.5 min-w-0 flex-1 py-1 px-2 transition-colors"
:class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
>
<component :is="tab.icon" class="size-5" />
<span class="text-[10px] font-medium">{{ tab.label }}</span>
<span
v-if="tab.badge && tab.badge > 0"
class="absolute top-0.5 left-1/2 translate-x-2.5 min-w-[16px] h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold flex items-center justify-center"
>
{{ tab.badge }}
</span>
</button>
</div>
</nav>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useApp } from '@/composables/useApp'
import { useRouter } from 'vue-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { ChevronRight } from 'lucide-vue-next'
const app = useApp()
const router = useRouter()
function buildBreadcrumbs() {
const conn = app.activeConnection.value
if (!conn) return []
const path = conn.currentPath.value
const parts = path.split('/').filter(Boolean)
const crumbs = [{ name: 'Home', path: '/' }]
let current = ''
for (const part of parts) {
current += `/${part}`
crumbs.push({ name: part, path: current })
}
return crumbs
}
function navigate(path: string) {
router.push({ query: path === '/' ? {} : { path } })
}
</script>
<template>
<div class="px-3 py-2 overflow-x-auto">
<Breadcrumb>
<BreadcrumbList class="flex-nowrap text-sm">
<template v-for="(crumb, index) in buildBreadcrumbs()" :key="crumb.path">
<BreadcrumbItem class="shrink-0">
<BreadcrumbLink
v-if="index < buildBreadcrumbs().length - 1"
as="button"
@click="navigate(crumb.path)"
class="text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap"
>
{{ crumb.name }}
</BreadcrumbLink>
<BreadcrumbPage v-else class="whitespace-nowrap">
{{ crumb.name }}
</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index < buildBreadcrumbs().length - 1" class="shrink-0">
<ChevronRight class="size-3" />
</BreadcrumbSeparator>
</template>
</BreadcrumbList>
</Breadcrumb>
</div>
</template>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useApp } from '@/composables/useApp'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { AddConnectionInput } from '@/services/types'
const props = defineProps<{
open: boolean
editId?: string
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const app = useApp()
const name = ref('')
const url = ref('')
const username = ref('')
const password = ref('')
const isTesting = ref(false)
const testError = ref<string | null>(null)
const isSaving = ref(false)
const isEditing = !!props.editId
const saveError = ref<string | null>(null)
function setOpen(value: boolean) {
emit('update:open', value)
if (!value) {
name.value = ''
url.value = ''
username.value = ''
password.value = ''
testError.value = null
saveError.value = null
}
}
async function handleSave() {
if (!name.value.trim() || !url.value.trim()) return
isSaving.value = true
saveError.value = null
try {
const input: AddConnectionInput = {
name: name.value.trim(),
url: url.value.trim(),
username: username.value.trim() || undefined,
password: password.value || undefined,
}
if (isEditing && props.editId) {
await app.connections.updateConnection(props.editId, input)
} else {
const id = await app.connections.addConnection(input)
app.setActiveConnection(id)
}
setOpen(false)
} catch (e: any) {
saveError.value = e.message || 'Failed to save connection'
} finally {
isSaving.value = false
}
}
async function testConnection() {
if (!url.value.trim()) return
isTesting.value = true
testError.value = null
try {
const conn = app.connections.connections[props.editId || '']
if (!conn) {
const tempId = await app.connections.addConnection({
name: 'temp',
url: url.value.trim(),
username: username.value.trim() || undefined,
password: password.value || undefined,
})
const tempConn = app.connections.getConnection(tempId)
if (!tempConn) throw new Error('Failed to create temp connection')
const ok = await tempConn.testConnection()
await app.connections.deleteConnection(tempId)
if (!ok) throw new Error('Could not connect')
} else {
const ok = await conn.testConnection()
if (!ok) throw new Error('Could not connect')
}
} catch (e: any) {
testError.value = e.message || 'Connection failed'
} finally {
isTesting.value = false
}
}
</script>
<template>
<Dialog :open="open" @update:open="setOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ isEditing ? 'Edit Connection' : 'New Connection' }}</DialogTitle>
<DialogDescription>
{{ isEditing ? 'Update your WebDAV server details' : 'Add a new WebDAV server connection' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-2">
<Label for="conn-name">Name</Label>
<Input id="conn-name" v-model="name" placeholder="My Server" />
</div>
<div class="space-y-2">
<Label for="conn-url">Server URL</Label>
<Input id="conn-url" v-model="url" placeholder="https://webdav.example.com" />
</div>
<div class="space-y-2">
<Label for="conn-user">Username</Label>
<Input id="conn-user" v-model="username" placeholder="Optional" autocomplete="username" />
</div>
<div class="space-y-2">
<Label for="conn-pass">Password</Label>
<Input id="conn-pass" v-model="password" type="password" placeholder="Optional" autocomplete="current-password" />
</div>
<div v-if="testError" class="text-xs text-destructive bg-destructive/10 rounded-lg p-2">
{{ testError }}
</div>
<div v-if="saveError" class="text-xs text-destructive bg-destructive/10 rounded-lg p-2">
{{ saveError }}
</div>
</div>
<DialogFooter class="gap-2 sm:gap-0">
<Button variant="outline" @click="testConnection" :disabled="!url.trim() || isTesting">
{{ isTesting ? 'Testing...' : 'Test' }}
</Button>
<Button @click="handleSave" :disabled="!name.trim() || !url.trim() || isSaving">
{{ isSaving ? 'Saving...' : isEditing ? 'Update' : 'Add Connection' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useApp } from '@/composables/useApp'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const app = useApp()
const folderName = ref('')
const isCreating = ref(false)
function setOpen(value: boolean) {
emit('update:open', value)
if (!value) folderName.value = ''
}
async function create() {
if (!folderName.value.trim() || !app.activeConnection.value) return
isCreating.value = true
try {
await app.activeConnection.value.createDirectory(folderName.value.trim())
setOpen(false)
} finally {
isCreating.value = false
}
}
</script>
<template>
<Dialog :open="open" @update:open="setOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Folder</DialogTitle>
<DialogDescription>
Enter a name for the new folder in {{ app.activeConnection.value?.currentPath.value || '/' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-2 py-2">
<Label for="folder-name">Folder Name</Label>
<Input
id="folder-name"
v-model="folderName"
placeholder="New Folder"
@keydown.enter="create"
autofocus
/>
</div>
<DialogFooter>
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
<Button @click="create" :disabled="!folderName.trim() || isCreating">
{{ isCreating ? 'Creating...' : 'Create' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useApp } from '@/composables/useApp'
import {
FolderOpen,
Download,
FileArchive,
Pencil,
Trash2,
Eye,
} from 'lucide-vue-next'
const props = defineProps<{
itemPath: string
itemName: string
itemType: 'file' | 'directory'
mimeType: string
}>()
const emit = defineEmits<{
(e: 'rename'): void
(e: 'preview'): void
(e: 'download'): void
(e: 'downloadZip'): void
(e: 'delete'): void
(e: 'open'): void
}>()
const app = useApp()
const router = useRouter()
const menuOpen = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const didLongPress = ref(false)
const touchMoved = ref(false)
function canPreview() {
return props.mimeType.startsWith('image/') || props.mimeType.startsWith('video/') || props.mimeType.startsWith('audio/') || props.mimeType === 'application/pdf'
}
function showMenu(event: MouseEvent) {
const wrapper = menuWrapper.value
if (wrapper) {
const rect = wrapper.getBoundingClientRect()
menuX.value = event.clientX || rect.left
menuY.value = event.clientY || rect.bottom
} else {
menuX.value = event.clientX || 0
menuY.value = event.clientY || 0
}
menuOpen.value = true
setTimeout(() => {
document.addEventListener('click', closeMenu, { once: true })
}, 0)
}
function closeMenu() {
menuOpen.value = false
document.removeEventListener('click', closeMenu)
}
function doAction(fn: () => void) {
closeMenu()
fn()
}
function handleClick() {
if (didLongPress.value) {
didLongPress.value = false
return
}
emit('open')
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
showMenu(e)
}
function handleTouchStart(e: TouchEvent) {
touchMoved.value = false
didLongPress.value = false
longPressTimer.value = setTimeout(() => {
didLongPress.value = true
const touch = e.touches[0] || e.changedTouches[0]
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
}, 500)
}
function handleTouchMove() {
touchMoved.value = true
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
function handleTouchEnd(e: TouchEvent) {
if (longPressTimer.value) {
clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
if (!touchMoved.value && !didLongPress.value) {
e.preventDefault()
emit('open')
}
}
function clampX(x: number) {
return Math.min(x, typeof window !== 'undefined' ? window.innerWidth - 190 : 200)
}
function clampY(y: number) {
return Math.min(y, typeof window !== 'undefined' ? window.innerHeight - 250 : 200)
}
onUnmounted(() => {
if (longPressTimer.value) clearTimeout(longPressTimer.value)
})
const menuWrapper = ref<HTMLElement>()
</script>
<template>
<div
ref="menuWrapper"
@click="handleClick"
@contextmenu="handleContextMenu"
@touchstart.passive="handleTouchStart"
@touchmove.passive="handleTouchMove"
@touchend="handleTouchEnd"
>
<slot />
</div>
<Teleport to="body">
<div
v-if="menuOpen"
class="fixed z-[100] min-w-[180px] bg-card border border-border rounded-lg shadow-xl py-1"
:style="{ left: clampX(menuX) + 'px', top: clampY(menuY) + 'px' }"
@click.stop
>
<button
v-if="itemType === 'directory'"
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
@click="doAction(() => router.push({ query: { path: itemPath } }))"
>
<FolderOpen class="size-3.5 shrink-0" />
Open
</button>
<button
v-if="itemType === 'directory'"
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
@click="doAction(() => emit('downloadZip'))"
>
<FileArchive class="size-3.5 shrink-0" />
Download as ZIP
</button>
<button
v-else
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
@click="doAction(() => emit('download'))"
>
<Download class="size-3.5 shrink-0" />
Download
</button>
<button
v-if="itemType === 'file' && canPreview()"
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
@click="doAction(() => emit('preview'))"
>
<Eye class="size-3.5 shrink-0" />
Preview
</button>
<div class="h-px bg-border my-0.5 mx-2" />
<button
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
@click="doAction(() => emit('rename'))"
>
<Pencil class="size-3.5 shrink-0" />
Rename
</button>
<div class="h-px bg-border my-0.5 mx-2" />
<button
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-destructive/10 text-destructive transition-colors text-left"
@click="doAction(() => emit('delete'))"
>
<Trash2 class="size-3.5 shrink-0" />
Delete
</button>
</div>
</Teleport>
</template>

219
src/components/FileGrid.vue Normal file
View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useApp } from '@/composables/useApp'
import type { FileItem } from '@/services/types'
import FileGridItem from './FileGridItem.vue'
import FileListItem from './FileListItem.vue'
import FileContextMenu from './FileContextMenu.vue'
import RenameDialog from './RenameDialog.vue'
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'
const app = useApp()
const router = useRouter()
const showRename = ref(false)
const renameTarget = ref<{ path: string; name: string } | null>(null)
const showPreview = ref(false)
const previewTarget = ref<FileItem | null>(null)
const children = computed(() => {
const conn = app.activeConnection.value
if (!conn) return [] as FileItem[]
void conn.currentPath.value
void conn.items.items
return conn.items.getChildren(conn.currentPath.value)
})
function handleOpen(item: FileItem) {
if (item.type === 'directory') {
router.push({ query: { path: item.path } })
} else {
previewTarget.value = item
showPreview.value = true
}
}
function handleRename(item: FileItem) {
renameTarget.value = { path: item.path, name: item.name }
showRename.value = true
}
function handlePreview(item: FileItem) {
previewTarget.value = item
showPreview.value = true
}
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)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
async function handleDelete(item: FileItem) {
await app.activeConnection.value?.deleteItem(item.path)
}
async function handleDownloadZip(item: FileItem) {
const conn = app.activeConnection.value
if (!conn) return
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()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e: any) {
conn.error.value = e.message || 'Failed to download folder'
} finally {
conn.isConnecting.value = false
conn.uploadProgress.value = null
}
}
function handleDrop(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer?.files) {
app.activeConnection.value?.uploadFiles(Array.from(event.dataTransfer.files))
}
}
function handleRefresh() {
app.activeConnection.value?.loadDirectory(app.activeConnection.value.currentPath.value, true)
}
</script>
<template>
<div
class="flex-1 min-h-0 overflow-auto overscroll-contain"
@dragover.prevent
@drop="handleDrop"
>
<div
v-if="app.activeConnection.value?.isConnecting.value && children.length === 0"
class="flex flex-col items-center justify-center py-16 gap-4"
>
<Loader2 class="size-8 text-muted-foreground animate-spin" />
<p class="text-sm text-muted-foreground">Loading...</p>
</div>
<div
v-else-if="app.activeConnection.value?.error.value"
class="flex flex-col items-center justify-center py-16 text-center px-4"
>
<AlertCircle class="size-10 text-destructive/50 mb-3" />
<p class="text-muted-foreground mb-3 max-w-xs">{{ app.activeConnection.value?.error.value }}</p>
<Button variant="outline" size="sm" @click="handleRefresh">Retry</Button>
</div>
<div
v-else-if="children.length === 0"
class="flex flex-col items-center justify-center py-16 text-center px-4"
@drop.prevent="handleDrop"
>
<FolderOpen class="size-14 text-muted-foreground/20 mb-3" />
<p class="text-muted-foreground mb-1 font-medium">This folder is empty</p>
<p class="text-muted-foreground/50 text-sm">Drop files here or tap + to upload</p>
</div>
<template v-else>
<div
v-if="app.viewMode.value === 'grid'"
class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-1 p-2"
>
<FileContextMenu
v-for="item in children"
:key="item.path"
:item-path="item.path"
:item-name="item.name"
:item-type="item.type"
:mime-type="item.mimeType"
@open="handleOpen(item)"
@rename="handleRename(item)"
@preview="handlePreview(item)"
@download="handleDownload(item)"
@download-zip="handleDownloadZip(item)"
@delete="handleDelete(item)"
>
<FileGridItem
:name="item.name"
:type="item.type"
:mime-type="item.mimeType"
:size="item.size"
:is-selected="false"
/>
</FileContextMenu>
</div>
<div v-else class="py-1 flex flex-col gap-0.5 px-1">
<div class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-muted-foreground border-b border-border/50 mb-0.5 sticky top-0 bg-background z-10">
<div class="w-5 shrink-0" />
<span class="flex-1 font-medium">Name</span>
<span class="w-16 text-right shrink-0 font-medium">Size</span>
<span class="w-20 text-right shrink-0 hidden sm:block font-medium">Modified</span>
</div>
<FileContextMenu
v-for="item in children"
:key="item.path"
:item-path="item.path"
:item-name="item.name"
:item-type="item.type"
:mime-type="item.mimeType"
@open="handleOpen(item)"
@rename="handleRename(item)"
@preview="handlePreview(item)"
@download="handleDownload(item)"
@download-zip="handleDownloadZip(item)"
@delete="handleDelete(item)"
>
<FileListItem
:name="item.name"
:type="item.type"
:mime-type="item.mimeType"
:size="item.size"
:modified="item.modified"
:is-selected="false"
/>
</FileContextMenu>
</div>
</template>
</div>
<RenameDialog
v-if="renameTarget"
v-model:open="showRename"
:item-path="renameTarget.path"
:current-name="renameTarget.name"
/>
<PreviewDialog
v-if="previewTarget"
v-model:open="showPreview"
:file-path="previewTarget.path"
:file-name="previewTarget.name"
:mime-type="previewTarget.mimeType"
/>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { getFileIcon, formatFileSize } from '@/lib/utils'
import {
Folder,
FileImage,
FileVideo,
FileAudio,
FileText,
FileArchive,
FileCode2,
} from 'lucide-vue-next'
defineProps<{
name: string
type: 'file' | 'directory'
mimeType: string
size: number
isSelected: boolean
}>()
function getIconComponent(mimeType: string, type: string) {
if (type === 'directory') return Folder
const icon = getFileIcon(mimeType)
switch (icon) {
case 'image': return FileImage
case 'video': return FileVideo
case 'audio': return FileAudio
case 'file-text': return FileText
case 'archive': return FileArchive
case 'code': return FileCode2
default: return FileAudio
}
}
function getIconColor(mimeType: string, type: string) {
if (type === 'directory') return 'text-yellow-500'
const icon = getFileIcon(mimeType)
switch (icon) {
case 'image': return 'text-green-400'
case 'video': return 'text-red-400'
case 'audio': return 'text-purple-400'
case 'file-text': return 'text-blue-400'
case 'archive': return 'text-orange-400'
case 'code': return 'text-cyan-400'
default: return 'text-muted-foreground'
}
}
</script>
<template>
<div
class="group flex flex-col items-center p-2.5 rounded-lg select-none
transition-all duration-150 ease-out active:scale-[0.97]
border-2 border-transparent group-hover:bg-muted/60"
:class="{
'border-primary bg-primary/5': isSelected,
}"
>
<div
class="w-12 h-12 rounded-xl flex items-center justify-center mb-1.5"
:class="{
'bg-yellow-500/10': type === 'directory',
'bg-muted/50': type !== 'directory',
}"
>
<component
:is="getIconComponent(mimeType, type)"
class="size-6 transition-transform duration-200 group-active:scale-110"
:class="getIconColor(mimeType, type)"
/>
</div>
<div class="w-full text-center min-w-0 px-0.5">
<p class="text-[11px] leading-tight font-medium truncate" :title="name">
{{ name }}
</p>
</div>
<div v-if="type === 'file' && size > 0" class="mt-0.5">
<span class="text-[10px] text-muted-foreground/60">
{{ formatFileSize(size) }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { getFileIcon, formatFileSize, formatDate } from '@/lib/utils'
import {
Folder,
FileImage,
FileVideo,
FileAudio,
FileText,
FileArchive,
FileCode2,
} from 'lucide-vue-next'
defineProps<{
name: string
type: 'file' | 'directory'
mimeType: string
size: number
modified: string
isSelected: boolean
}>()
function getIconComponent(mimeType: string, type: string) {
if (type === 'directory') return Folder
const icon = getFileIcon(mimeType)
switch (icon) {
case 'image': return FileImage
case 'video': return FileVideo
case 'audio': return FileAudio
case 'file-text': return FileText
case 'archive': return FileArchive
case 'code': return FileCode2
default: return FileAudio
}
}
function getIconColor(mimeType: string, type: string) {
if (type === 'directory') return 'text-yellow-500'
const icon = getFileIcon(mimeType)
switch (icon) {
case 'image': return 'text-green-400'
case 'video': return 'text-red-400'
case 'audio': return 'text-purple-400'
case 'file-text': return 'text-blue-400'
case 'archive': return 'text-orange-400'
case 'code': return 'text-cyan-400'
default: return 'text-muted-foreground'
}
}
</script>
<template>
<div
class="flex items-center gap-2.5 px-3 py-2.5 mx-1 rounded-md select-none
transition-all duration-150 ease-out active:bg-muted/70
border-l-[3px] border-l-transparent group-hover:bg-muted/50"
:class="{
'border-l-primary bg-primary/5': isSelected,
}"
>
<component
:is="getIconComponent(mimeType, type)"
class="size-5 shrink-0"
:class="getIconColor(mimeType, type)"
/>
<span class="text-sm font-medium truncate flex-1" :title="name">{{ name }}</span>
<span class="text-xs text-muted-foreground w-16 text-right shrink-0">{{ type === 'directory' ? '--' : formatFileSize(size) }}</span>
<span class="text-xs text-muted-foreground w-20 text-right shrink-0 hidden sm:block">{{ formatDate(modified) }}</span>
</div>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useApp } from '@/composables/useApp'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { X, Download, ExternalLink } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
const props = defineProps<{
open: boolean
filePath: string
fileName: string
mimeType: string
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const app = useApp()
const previewUrl = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
function setOpen(value: boolean) {
emit('update:open', value)
if (!value) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
error.value = null
}
}
async function loadPreview() {
if (!props.filePath) return
const conn = app.activeConnection.value
if (!conn) return
isLoading.value = true
error.value = null
try {
const blob = await conn.downloadFile(props.filePath)
previewUrl.value = URL.createObjectURL(blob)
} catch (e: any) {
error.value = e.message || 'Failed to load preview'
} finally {
isLoading.value = false
}
}
watch(() => props.open, (val) => {
if (val) loadPreview()
})
function isImage() { return props.mimeType.startsWith('image/') }
function isVideo() { return props.mimeType.startsWith('video/') }
function isAudio() { return props.mimeType.startsWith('audio/') }
function isPdf() { return props.mimeType === 'application/pdf' }
function openInNewTab(url: string) { window.open(url, '_blank') }
async function handleDownload() {
const conn = app.activeConnection.value
if (!conn) return
try {
await app.downloadManager.downloadFile(conn, props.filePath, props.fileName, 0)
} catch {
const blob = await conn.downloadFile(props.filePath)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = props.fileName
document.body.appendChild(a); a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
</script>
<template>
<Dialog :open="open" @update:open="setOpen">
<DialogContent class="sm:max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden">
<div class="flex items-center justify-between p-3 border-b border-border shrink-0">
<DialogTitle class="text-sm font-medium truncate flex-1">{{ fileName }}</DialogTitle>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="size-8" @click="handleDownload">
<Download class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8" @click="openInNewTab(previewUrl!)" v-if="previewUrl">
<ExternalLink class="size-4" />
</Button>
<Button variant="ghost" size="icon" class="size-8" @click="setOpen(false)">
<X class="size-4" />
</Button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 flex items-center justify-center min-h-[200px]">
<div v-if="isLoading" class="w-full h-full flex items-center justify-center">
<Skeleton class="w-full h-48" />
</div>
<div v-else-if="error" class="text-center text-muted-foreground">
<p>{{ error }}</p>
</div>
<template v-else-if="previewUrl">
<img v-if="isImage()" :src="previewUrl" :alt="fileName" class="max-w-full max-h-[70vh] object-contain rounded" />
<video v-else-if="isVideo()" :src="previewUrl" controls class="max-w-full max-h-[70vh] rounded" />
<audio v-else-if="isAudio()" :src="previewUrl" controls class="w-full" />
<iframe v-else-if="isPdf()" :src="previewUrl" class="w-full h-[70vh] rounded" />
<div v-else class="text-center">
<p class="text-muted-foreground mb-3">No preview available</p>
<Button variant="outline" size="sm" @click="handleDownload">
<Download class="size-3.5 mr-1" /> Download
</Button>
</div>
</template>
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useApp } from '@/composables/useApp'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const props = defineProps<{
open: boolean
itemPath: string
currentName: string
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const app = useApp()
const newName = ref('')
const isRenaming = ref(false)
function setOpen(value: boolean) {
emit('update:open', value)
if (value) newName.value = props.currentName
}
async function rename() {
if (!newName.value.trim() || newName.value.trim() === props.currentName || !app.activeConnection.value) return
isRenaming.value = true
try {
await app.activeConnection.value.renameItem(props.itemPath, newName.value.trim())
setOpen(false)
} finally {
isRenaming.value = false
}
}
</script>
<template>
<Dialog :open="open" @update:open="setOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rename</DialogTitle>
<DialogDescription>
Enter a new name for "{{ currentName }}"
</DialogDescription>
</DialogHeader>
<div class="space-y-2 py-2">
<Label for="new-name">New Name</Label>
<Input id="new-name" v-model="newName" @keydown.enter="rename" autofocus />
</div>
<DialogFooter>
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
<Button @click="rename" :disabled="!newName.trim() || newName.trim() === currentName || isRenaming">
{{ isRenaming ? 'Renaming...' : 'Rename' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useApp } from '@/composables/useApp'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { Upload, X } from 'lucide-vue-next'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const app = useApp()
const fileInput = ref<HTMLInputElement>()
const selectedFiles = ref<File[]>([])
const isUploading = ref(false)
function setOpen(value: boolean) {
emit('update:open', value)
if (!value) selectedFiles.value = []
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) selectedFiles.value = Array.from(input.files)
}
function triggerFileInput() {
fileInput.value?.click()
}
function removeFile(index: number) {
selectedFiles.value.splice(index, 1)
}
async function upload() {
if (selectedFiles.value.length === 0 || !app.activeConnection.value) return
isUploading.value = true
try {
await app.activeConnection.value.uploadFiles(selectedFiles.value)
setOpen(false)
} finally {
isUploading.value = false
}
}
</script>
<template>
<Dialog :open="open" @update:open="setOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Files</DialogTitle>
<DialogDescription>
Upload files to {{ app.activeConnection.value?.currentPath.value || '/' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div
@click="triggerFileInput"
@dragover.prevent
@drop.prevent="(e) => {
if (e.dataTransfer?.files) selectedFiles = Array.from(e.dataTransfer.files)
}"
class="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
>
<Upload class="size-8 mx-auto text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">
Drag & drop files here or tap to browse
</p>
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelect" />
</div>
<div v-if="selectedFiles.length > 0" class="space-y-2 max-h-40 overflow-auto">
<div
v-for="(file, index) in selectedFiles"
:key="file.name"
class="flex items-center justify-between bg-muted rounded-md px-3 py-2"
>
<span class="text-sm truncate flex-1">{{ file.name }}</span>
<span class="text-xs text-muted-foreground ml-2 shrink-0">
{{ (file.size / 1024 / 1024).toFixed(1) }} MB
</span>
<Button variant="ghost" size="icon" class="size-6 ml-1" @click="removeFile(index)">
<X class="size-3" />
</Button>
</div>
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
<Button @click="upload" :disabled="selectedFiles.length === 0 || isUploading">
{{ isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}` }}
</Button>
</div>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="cn('', props.class)"
>
<slot />
</nav>
</template>

View File

@@ -0,0 +1,24 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { MoreHorizontalIcon } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('size-5 [&>svg]:size-4 flex items-center justify-center', props.class)"
>
<slot>
<MoreHorizontalIcon />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('gap-1 inline-flex items-center', props.class)"
>
<slot />
</li>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word', props.class)"
>
<slot />
</ol>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { ChevronRightIcon } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRightIcon class="cn-rtl-flip" />
</slot>
</li>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'icon': 'size-8',
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot
data-slot="context-menu"
v-bind="forwarded"
>
<slot />
</ContextMenuRoot>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span class="absolute right-2 pointer-events-none">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
data-slot="context-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
props.class,
)"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from 'reka-ui'
import { ContextMenuGroup } from 'reka-ui'
const props = defineProps<ContextMenuGroupProps>()
</script>
<template>
<ContextMenuGroup
data-slot="context-menu-group"
v-bind="props"
>
<slot />
</ContextMenuGroup>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ContextMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}>(), {
variant: 'default',
})
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
data-slot="context-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
</ContextMenuItem>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuLabel
data-slot="context-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="delegatedProps"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
>
<slot />
</ContextMenuLabel>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { ContextMenuPortalProps } from 'reka-ui'
import { ContextMenuPortal } from 'reka-ui'
const props = defineProps<ContextMenuPortalProps>()
</script>
<template>
<ContextMenuPortal
data-slot="context-menu-portal"
v-bind="props"
>
<slot />
</ContextMenuPortal>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
import {
ContextMenuRadioGroup,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<ContextMenuRadioGroupProps>()
const emits = defineEmits<ContextMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRadioGroup
data-slot="context-menu-radio-group"
v-bind="forwarded"
>
<slot />
</ContextMenuRadioGroup>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuRadioItem
data-slot="context-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span class="absolute right-2 pointer-events-none">
<ContextMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</ContextMenuItemIndicator>
</span>
<slot />
</ContextMenuRadioItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSeparator,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuSeparator
data-slot="context-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="context-menu-shortcut"
:class="cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
import {
ContextMenuSub,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<ContextMenuSubProps>()
const emits = defineEmits<ContextMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuSub
data-slot="context-menu-sub"
v-bind="forwarded"
>
<slot />
</ContextMenuSub>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuSubContent
data-slot="context-menu-sub-content"
v-bind="forwarded"
:class="
cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
props.class,
)
"
>
<slot />
</ContextMenuSubContent>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import {
ContextMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuSubTrigger
data-slot="context-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</ContextMenuSubTrigger>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { ContextMenuTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ContextMenuTrigger
data-slot="context-menu-trigger"
v-bind="forwardedProps"
:class="cn('select-none', props.class)"
>
<slot />
</ContextMenuTrigger>
</template>

View File

@@ -0,0 +1,14 @@
export { default as ContextMenu } from './ContextMenu.vue'
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
export { default as ContextMenuContent } from './ContextMenuContent.vue'
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
export { default as ContextMenuItem } from './ContextMenuItem.vue'
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
export { default as ContextMenuSub } from './ContextMenuSub.vue'
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
as-child
>
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
<XIcon />
<span class="sr-only">Close</span>
</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogClose } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(), {
showCloseButton: false,
})
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline">
Close
</Button>
</DialogClose>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('gap-2 flex flex-col', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { XIcon } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<XIcon class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
{
align: 'start',
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from 'reka-ui'
import { DropdownMenuGroup } from 'reka-ui'
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes['class']
inset?: boolean
variant?: 'default' | 'destructive'
}>(), {
variant: 'default',
})
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon } from 'lucide-vue-next'
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<span
class="absolute right-2 flex items-center justify-center pointer-events-none"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<CheckIcon />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSeparator,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
import {
DropdownMenuSub,
useForwardPropsEmits,
} from 'reka-ui'
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRightIcon } from 'lucide-vue-next'
import {
DropdownMenuSubTrigger,
useForwardProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, 'class', 'inset')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
props.class,
)"
>
<slot />
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,16 @@
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
export { DropdownMenuPortal } from 'reka-ui'

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ProgressIndicator,
ProgressRoot,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(),
{
modelValue: 0,
},
)
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="
cn(
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
props.class,
)
"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary size-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaViewport,
} from 'reka-ui'
import { cn } from '@/lib/utils'
import ScrollBar from './ScrollBar.vue'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaRoot
data-slot="scroll-area"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<ScrollAreaViewport
data-slot="scroll-area-viewport"
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
orientation: 'vertical',
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
:data-orientation="orientation"
v-bind="delegatedProps"
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="rounded-full relative flex-1 bg-border"
/>
</ScrollAreaScrollbar>
</template>

View File

@@ -0,0 +1,2 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<
SeparatorProps & { class?: HTMLAttributes['class'] }
>(), {
orientation: 'horizontal',
decorative: true,
})
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator
data-slot="separator"
v-bind="delegatedProps"
:class="
cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue'

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface SkeletonProps {
class?: HTMLAttributes['class']
}
const props = defineProps<SkeletonProps>()
</script>
<template>
<div
data-slot="skeleton"
:class="cn('bg-muted rounded-md animate-pulse', props.class)"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Skeleton } from './Skeleton.vue'

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SwitchRootProps & {
class?: HTMLAttributes['class']
size?: 'sm' | 'default'
}>(), {
size: 'default',
})
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
:data-size="size"
v-bind="forwarded"
:class="cn(
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from './Switch.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot
v-slot="slotProps"
data-slot="tooltip"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
sideOffset: 0,
})
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
data-slot="tooltip-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
>
<slot />
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const props = withDefaults(defineProps<TooltipProviderProps>(), {
delayDuration: 0,
})
</script>
<template>
<TooltipProvider v-bind="props">
<slot />
</TooltipProvider>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const props = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger
data-slot="tooltip-trigger"
v-bind="props"
>
<slot />
</TooltipTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'

View File

@@ -0,0 +1,8 @@
import { inject } from 'vue'
import type { App } from '@/services/app'
export function useApp(): App {
const app = inject<App>('app')
if (!app) throw new Error('App not properly initialized')
return app
}

View File

@@ -0,0 +1,92 @@
import { EventEmitter } from './event-emitter'
import type { StorageOpts, StoreEvents, StoreValueProxy } from './types'
export abstract class BaseStorage<E extends StoreEvents = StoreEvents> {
abstract listStores(): Promise<Array<string>>
abstract createStore<T = unknown>(storeName: string, opts: StorageOpts): Promise<BaseStore<T, E>>
abstract getStore<T = unknown>(storeName: string, opts?: StorageOpts): Promise<BaseStore<T, E> | null>
abstract createOrGetStore<T = unknown>(storeName: string, opts: StorageOpts): Promise<BaseStore<T, E>>
abstract deleteStore(storeName: string): Promise<void>
abstract deleteDatabase(): Promise<void>
}
export abstract class BaseStore<
K = unknown,
E extends StoreEvents = StoreEvents,
> extends EventEmitter<E> {
abstract keys(): Promise<Array<string>>
abstract get<T extends K>(key: string): Promise<T | undefined>
abstract set<T extends K>(key: string, value: T): Promise<void>
abstract delete(key: string): Promise<void>
async all<T extends K>(): Promise<{ [key: string]: T }> {
const keys = await this.keys()
const values = await Promise.all(keys.map((key) => this.get<T>(key)))
return keys.reduce(
(acc, key, index) => {
const value = values[index]
if (value !== undefined) acc[key] = value
return acc
},
{} as { [key: string]: T },
)
}
async clear(): Promise<void> {
const keys = await this.keys()
await Promise.all(keys.map((key) => this.delete(key)))
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) ? true : false
}
async batchGet<T extends K>(keys: Array<string>): Promise<Map<string, T>> {
const results = await Promise.all(
keys.map(async (key) => {
const value = await this.get(key)
return [key, value] as const
}),
)
return new Map(results.filter(([_, value]) => value !== undefined) as [string, T][])
}
async batchSet<T extends K>(items: Map<string, T>): Promise<void> {
await Promise.all(Array.from(items).map(([key, value]) => this.set(key, value)))
items.forEach((value, key) => this.emit('set', { key, value }))
}
async close(): Promise<void> {}
async getOrFail<T extends K>(key: string): Promise<T> {
const value = await this.get<T>(key)
if (typeof value === 'undefined') {
throw new Error(`Failed to get key "${key}"`)
}
return value
}
async getOrSet<T extends K>(key: string, setter: () => T | Promise<T>): Promise<T> {
let value = await this.get<T>(key)
if (typeof value === 'undefined') {
value = await setter()
await this.set(key, value)
}
return value
}
proxy<T extends K>(key: string): StoreValueProxy<T> {
const self = this
return {
get(): Promise<T | undefined> {
return self.get<T>(key)
},
getOrFail(): Promise<T> {
return self.getOrFail<T>(key)
},
async set(newValue: T): Promise<void> {
await self.set(key, newValue)
},
}
}
}

View File

@@ -0,0 +1,24 @@
type EventHandler = (...args: any[]) => void
export class EventEmitter<Events extends Record<string, any> = Record<string, any>> {
private listeners = new Map<string, Set<EventHandler>>()
on<K extends keyof Events & string>(event: K, handler: (payload: Events[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event)!.add(handler as EventHandler)
}
off<K extends keyof Events & string>(event: K, handler: (payload: Events[K]) => void): void {
this.listeners.get(event)?.delete(handler as EventHandler)
}
protected emit<K extends keyof Events & string>(event: K, payload: Events[K]): void {
this.listeners.get(event)?.forEach((handler) => handler(payload))
}
removeAllListeners(): void {
this.listeners.clear()
}
}

6
src/lib/storage/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { EventEmitter } from './event-emitter'
export { BaseStorage, BaseStore } from './base-store'
export { StoreInMemory } from './store-inmemory'
export { StoreInMemorySynced } from './store-inmemory-synced'
export { StorageIndexedDB, StoreIndexedDB } from './storage-indexeddb'
export type { StorageOpts, StoreRecords, StoreEvents, StoreValueProxy } from './types'

View File

@@ -0,0 +1,249 @@
import { BaseStorage, BaseStore } from './base-store'
import type { StorageOpts } from './types'
import { StoreInMemorySynced } from './store-inmemory-synced'
import { pack, unpack } from 'msgpackr'
import { openDB, deleteDB, type IDBPDatabase } from 'idb'
import { Mutex } from 'async-mutex'
export class StorageIndexedDB extends BaseStorage {
private db: IDBPDatabase
private dbName: string
private activeStores = new Set<StoreIndexedDB>()
private upgradeMutex = new Mutex()
private constructor(db: IDBPDatabase, dbName: string) {
super()
this.db = db
this.dbName = dbName
this.db.addEventListener('versionchange', () => {
this.db.close()
})
}
private async upgradeDb(upgrade: (db: IDBPDatabase) => void) {
const currentVersion = this.db.version
const currentDB = this.db
const newDb = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) { upgrade(db) },
blocked() { currentDB.close() },
blocking() {},
terminated() {},
})
this.db = newDb
return newDb
}
static async createOrOpen(dbName: string) {
try {
const existingDb = await openDB(dbName, undefined, {
blocked() {},
blocking() {},
terminated() {},
})
return new StorageIndexedDB(existingDb, dbName)
} catch {
const newDb = await openDB(dbName, 1, {
upgrade(_db) {},
})
return new StorageIndexedDB(newDb, dbName)
}
}
async listStores() {
await this.upgradeMutex.waitForUnlock()
return Array.from(this.db.objectStoreNames)
}
async createStore(storeName: string, opts: StorageOpts = {}) {
let store: StoreIndexedDB
{
const release = await this.upgradeMutex.acquire()
try {
if (!this.db.objectStoreNames.contains(storeName)) {
const newDb = await this.upgradeDb((db) => {
db.createObjectStore(storeName)
})
for (const s of this.activeStores) {
s.updateDatabaseReference(newDb)
}
}
store = await StoreIndexedDB.create(this.db, storeName, this.upgradeMutex, this)
this.activeStores.add(store)
} finally {
release()
}
}
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store
}
async getStore(storeName: string, opts: StorageOpts = {}) {
try {
await this.upgradeMutex.waitForUnlock()
if (!this.db.objectStoreNames.contains(storeName)) return null
const store = await StoreIndexedDB.create(this.db, storeName, this.upgradeMutex, this)
this.activeStores.add(store)
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store
} catch {
return null
}
}
async createOrGetStore(storeName: string, opts: StorageOpts = {}) {
const existing = await this.getStore(storeName, opts)
if (existing) return existing
return this.createStore(storeName, opts)
}
async deleteStore(storeName: string) {
const release = await this.upgradeMutex.acquire()
try {
if (this.db.objectStoreNames.contains(storeName)) {
const newDb = await this.upgradeDb((db) => {
db.deleteObjectStore(storeName)
})
for (const store of this.activeStores) {
store.updateDatabaseReference(newDb)
}
}
} finally {
release()
}
}
async deleteDatabase() {
this.activeStores.clear()
this.db.close()
await deleteDB(this.dbName)
}
removeActiveStore(store: StoreIndexedDB) {
this.activeStores.delete(store)
}
}
export class StoreIndexedDB extends BaseStore {
public db: IDBPDatabase
public storeName: string
public databaseUpgradeMutex: Mutex
private parentStorage?: StorageIndexedDB
constructor(
db: IDBPDatabase,
storeName: string,
databaseUpgradeMutex: Mutex,
parentStorage?: StorageIndexedDB,
) {
super()
this.db = db
this.storeName = storeName
this.databaseUpgradeMutex = databaseUpgradeMutex
this.parentStorage = parentStorage
}
updateDatabaseReference(newDb: IDBPDatabase) {
this.db = newDb
}
static async create(
db: IDBPDatabase,
storeName: string,
databaseUpgradeMutex: Mutex,
parentStorage?: StorageIndexedDB,
) {
return new this(db, storeName, databaseUpgradeMutex, parentStorage)
}
async all<T>() {
await this.databaseUpgradeMutex.waitForUnlock()
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const keys = await store.getAllKeys()
const values = await store.getAll()
await tx.done
return keys.reduce(
(acc, key, index) => {
acc[key.toString()] = unpack(values[index]) as T
return acc
},
{} as { [key: string]: T },
)
}
async keys() {
await this.databaseUpgradeMutex.waitForUnlock()
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const keys = await store.getAllKeys()
await tx.done
return keys.map((key) => key.toString())
}
async get<T>(key: string) {
await this.databaseUpgradeMutex.waitForUnlock()
const encodedValue = await this.db.get(this.storeName, key)
if (!encodedValue) return undefined as unknown as T
return unpack(encodedValue) as T
}
async set<T>(key: string, value: T) {
await this.databaseUpgradeMutex.waitForUnlock()
const encodedValue = pack(value)
await this.db.put(this.storeName, encodedValue, key)
this.emit('set', { key, value })
}
async delete(key: string) {
await this.databaseUpgradeMutex.waitForUnlock()
await this.db.delete(this.storeName, key)
this.emit('delete', { key })
}
async batchGet<T>(keys: string[]): Promise<Map<string, T>> {
await this.databaseUpgradeMutex.waitForUnlock()
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const results = await Promise.all(
keys.map(async (key) => {
const value = await store.get(key)
return [key, value ? (unpack(value) as T) : undefined] as const
}),
)
await tx.done
return new Map(results.filter(([_, value]) => value !== undefined) as [string, T][])
}
async batchSet<T>(items: Map<string, T>) {
await this.databaseUpgradeMutex.waitForUnlock()
const tx = this.db.transaction(this.storeName, 'readwrite')
const store = tx.objectStore(this.storeName)
await Promise.all(
Array.from(items).map(([key, value]) => {
const encodedValue = pack(value)
return store.put(encodedValue, key)
}),
)
await tx.done
items.forEach((value, key) => this.emit('set', { key, value }))
}
async clear() {
await this.databaseUpgradeMutex.waitForUnlock()
await this.db.clear(this.storeName)
this.emit('clear', undefined)
}
async has(key: string) {
await this.databaseUpgradeMutex.waitForUnlock()
const tx = this.db.transaction(this.storeName, 'readonly')
const store = tx.objectStore(this.storeName)
const result = await store.getKey(key)
await tx.done
return result !== undefined
}
async close() {
if (this.parentStorage) {
this.parentStorage.removeActiveStore(this)
}
}
}

View File

@@ -0,0 +1,70 @@
import { BaseStore } from './base-store'
import { StoreInMemory } from './store-inmemory'
export class StoreInMemorySynced extends BaseStore {
private inMemoryCache: StoreInMemory
private store: BaseStore
private constructor(inMemoryCache: StoreInMemory, store: BaseStore) {
super()
this.inMemoryCache = inMemoryCache
this.store = store
this.store.on('set', async (payload) => {
await this.inMemoryCache.set(payload.key, payload.value)
this.emit('set', { key: payload.key, value: payload.value })
})
this.store.on('delete', async (payload) => {
await this.inMemoryCache.delete(payload.key)
this.emit('delete', { key: payload.key })
})
this.store.on('clear', async () => {
await this.inMemoryCache.clear()
this.emit('clear', undefined)
})
}
static async create(store: BaseStore) {
const inMemoryCache = new StoreInMemory()
const memorySyncedStore = new this(inMemoryCache, store)
const entries = await store.all()
for (const [key, value] of Object.entries(entries)) {
await inMemoryCache.set(key, value)
}
return memorySyncedStore
}
async all<T>() {
return this.inMemoryCache.all<T>()
}
async keys() {
return this.inMemoryCache.keys()
}
async get<T>(key: string) {
return this.inMemoryCache.get<T>(key)
}
async set<T>(key: string, value: T) {
await this.store.set(key, value)
}
async delete(key: string) {
await this.store.delete(key)
}
async clear() {
await this.store.clear()
}
async has(key: string) {
return this.inMemoryCache.has(key)
}
async close() {
await this.store.close()
}
}

View File

@@ -0,0 +1,49 @@
import { BaseStore } from './base-store'
export class StoreInMemory extends BaseStore {
static from(): StoreInMemory {
return new StoreInMemory()
}
private store: Map<string, unknown>
constructor() {
super()
this.store = new Map()
}
async all<T>() {
const result: { [key: string]: T } = {}
for (const [key, value] of this.store.entries()) {
result[key] = value as T
}
return result
}
async keys() {
return Array.from(this.store.keys())
}
async get<T>(key: string) {
return this.store.get(key) as T
}
async set<T>(key: string, value: T) {
this.store.set(key, value)
this.emit('set', { key, value })
}
async delete(key: string) {
this.store.delete(key)
this.emit('delete', { key })
}
async clear() {
this.store.clear()
this.emit('clear', undefined)
}
async has(key: string) {
return this.store.has(key)
}
}

17
src/lib/storage/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export type StorageOpts = {
syncInMemory?: boolean
}
export type StoreRecords = Record<string, unknown>
export type StoreEvents = {
set: { key: string; value: any }
delete: { key: string }
clear: undefined
}
export type StoreValueProxy<T> = {
get(): Promise<T | undefined>
getOrFail(): Promise<T>
set(newValue: T): Promise<void>
}

32
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatFileSize(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 ? 0 : 1)} ${units[i]}`
}
export function formatDate(date: string | Date): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
export function getFileIcon(mimeType: string): string {
if (mimeType === 'httpd/unix-directory') return 'folder'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
if (mimeType.startsWith('audio/')) return 'audio'
if (mimeType.includes('pdf')) return 'file-text'
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return 'archive'
if (mimeType.includes('javascript') || mimeType.includes('typescript') || mimeType.includes('json')) return 'code'
return 'file'
}

31
src/main.ts Normal file
View File

@@ -0,0 +1,31 @@
import { createApp } from 'vue'
import { App } from './services/app'
import AppRoot from './App.vue'
import router from './router'
import './style.css'
async function bootstrap() {
try {
const app = await App.create(router)
const vueApp = createApp(AppRoot)
vueApp.provide('app', app)
vueApp.use(router)
vueApp.mount('#app')
} catch (e) {
console.error('Failed to initialize application:', e)
document.getElementById('app')!.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#0f172a;color:#f8fafc;font-family:system-ui;padding:20px;text-align:center">
<div>
<h2 style="margin-bottom:12px">Failed to start app</h2>
<p style="color:#94a3b8;font-size:14px;max-width:400px;margin:0 auto 16px;word-break:break-all">${String(e).replace(/</g, '&lt;')}</p>
<button onclick="indexedDB.deleteDatabase('filesharing_V0.0.1').then(() => location.reload())" style="background:#3b82f6;color:white;border:none;padding:10px 20px;border-radius:8px;cursor:pointer;font-size:14px">
Reset & Reload
</button>
</div>
</div>
`
}
}
bootstrap()

24
src/router/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/connections',
name: 'connections',
component: () => import('@/views/ConnectionsView.vue'),
},
{
path: '/downloads',
name: 'downloads',
component: () => import('@/views/DownloadsView.vue'),
},
],
})
export default router

80
src/services/app.ts Normal file
View File

@@ -0,0 +1,80 @@
import { ref, computed } from 'vue'
import type { Router } from 'vue-router'
import type { BaseStorage } from '@/lib/storage'
import { StorageIndexedDB } from '@/lib/storage'
import { Connections } from './connections'
import { DownloadManager } from './download-manager'
import type { Connection } from './connection'
export class App {
public router: Router
public storage: BaseStorage
public connections: Connections
public downloadManager: DownloadManager
public isDebugMode = ref(false)
public activeConnectionId = ref<string>('')
public viewMode = ref<'grid' | 'list'>('grid')
public activeConnection = computed((): Connection | undefined => {
if (!this.activeConnectionId.value) return undefined
return this.connections.getConnection(this.activeConnectionId.value)
})
constructor(
router: Router,
storage: BaseStorage,
connections: Connections,
downloadManager: DownloadManager,
) {
this.router = router
this.storage = storage
this.connections = connections
this.downloadManager = downloadManager
}
static async create(router: Router) {
const storage = await StorageIndexedDB.createOrOpen('filesharing_V0.0.1')
const connections = new Connections(storage)
await connections.start()
const downloadManager = new DownloadManager()
const app = new this(router, storage, connections, downloadManager)
const connectionIds = Object.keys(connections.connections)
if (connectionIds.length > 0) {
const settingsStore = await storage.createOrGetStore('app_settings', { syncInMemory: true })
const savedId = await settingsStore.get('activeConnectionId') as string | undefined
const savedMode = await settingsStore.get('viewMode') as 'grid' | 'list' | undefined
if (savedId && connections.getConnection(savedId)) {
app.activeConnectionId.value = savedId
} else if (connectionIds.length > 0) {
app.activeConnectionId.value = connectionIds[0]
}
if (savedMode) app.viewMode.value = savedMode
}
return app
}
setActiveConnection(id: string) {
this.activeConnectionId.value = id
this.connections.setLastAccessed(id)
}
setViewMode(mode: 'grid' | 'list') {
this.viewMode.value = mode
}
hasConnections(): boolean {
return Object.keys(this.connections.connections).length > 0
}
async ensureConnection() {
if (this.hasConnections()) return true
await this.router.push('/connections')
return false
}
}

261
src/services/connection.ts Normal file
View File

@@ -0,0 +1,261 @@
import { createClient, type WebDAVClient } from 'webdav'
import type { ConnectionConfig, FileItem } from './types'
import type { BaseStorage } from '@/lib/storage'
import type { Items } from './items'
import { ref } from 'vue'
export class Connection {
public config: ConnectionConfig
public items: Items
public isConnected = ref(false)
public isConnecting = ref(false)
public error = ref<string | null>(null)
public currentPath = ref('/')
public uploadProgress = ref<{ file: string; progress: number } | null>(null)
private client: WebDAVClient | null = null
private storage: BaseStorage
constructor(
config: ConnectionConfig,
items: Items,
storage: BaseStorage,
) {
this.config = config
this.items = items
this.storage = storage
this.connect()
}
connect() {
try {
this.client = createClient(this.config.url.replace(/\/$/, ''), {
username: this.config.username,
password: this.config.password,
})
this.isConnected.value = true
this.error.value = null
} catch (e: any) {
this.isConnected.value = false
this.error.value = e.message
}
}
disconnect() {
this.client = null
this.isConnected.value = false
}
async loadDirectory(path: string = '/', forceRefresh = false) {
const normalizedPath = this.normalizePath(path)
if (!forceRefresh && this.items.isDirCached(normalizedPath)) {
this.currentPath.value = normalizedPath
return
}
this.isConnecting.value = true
this.error.value = null
try {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected to server')
}
const contents = await this.client.getDirectoryContents(normalizedPath)
if (!Array.isArray(contents)) {
await this.items.setItems([], normalizedPath)
this.currentPath.value = normalizedPath
return
}
const items: FileItem[] = []
for (const item of contents) {
if (item.basename === '' || item.basename === '.') continue
const itemPath = this.normalizePath(item.filename)
items.push({
path: itemPath,
name: item.basename,
parentPath: normalizedPath,
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'),
etag: (item as any).etag,
cachedAt: Date.now(),
})
}
await this.items.setItems(items, normalizedPath)
this.currentPath.value = normalizedPath
} catch (e: any) {
this.error.value = e.message || 'Failed to load directory'
} finally {
this.isConnecting.value = false
}
}
async uploadFile(file: File, onProgress?: (progress: number) => void) {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected')
}
const baseUrl = this.config.url.replace(/\/$/, '')
const currentP = this.currentPath.value === '/' ? '' : this.currentPath.value
const targetPath = `${baseUrl}${currentP}/${file.name}`
return new Promise<void>((resolve, reject) => {
const reader = new FileReader()
reader.onload = async () => {
try {
const body = reader.result as ArrayBuffer
const xhr = new XMLHttpRequest()
if (onProgress) {
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total)
})
}
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve()
else reject(new Error(`Upload failed: ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
xhr.open('PUT', targetPath)
const headers = this.client!.getHeaders()
if (headers instanceof Headers) {
headers.forEach((value, key) => xhr.setRequestHeader(key, value))
} else {
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value as string)
}
}
xhr.send(body)
} catch (err) {
reject(err)
}
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsArrayBuffer(file)
})
}
async uploadFiles(files: File[], onFileProgress?: (name: string, progress: number) => void) {
for (const file of files) {
try {
await this.uploadFile(file, (progress) => {
if (onFileProgress) onFileProgress(file.name, progress)
})
} catch (e: any) {
this.error.value = `Failed to upload ${file.name}: ${e.message}`
throw e
}
}
await this.loadDirectory(this.currentPath.value, true)
}
async downloadFile(path: string): Promise<Blob> {
if (!this.client) {
this.connect()
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])
return new Blob([buffer as ArrayBuffer])
}
async downloadFolderAsZip(folderPath: string, onProgress?: (current: number, total: number) => void): Promise<Blob> {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected')
}
const { default: JSZip } = await import('jszip')
const zip = new JSZip()
const normalizedPath = this.normalizePath(folderPath)
const allFiles = this.collectDescendantFiles(normalizedPath)
for (let i = 0; i < allFiles.length; i++) {
const item = allFiles[i]
if (onProgress) onProgress(i + 1, allFiles.length)
try {
const blob = await this.downloadFile(item.path)
const relativePath = item.path.slice(normalizedPath.length).replace(/^\//, '')
zip.file(relativePath, blob)
} catch {
// Skip files that fail to download
}
}
return zip.generateAsync({ type: 'blob' })
}
private collectDescendantFiles(dirPath: string): FileItem[] {
const files: FileItem[] = []
const prefix = dirPath === '/' ? '/' : dirPath + '/'
for (const item of Object.values(this.items.items)) {
if (item.type === 'file' && item.path.startsWith(prefix)) {
files.push(item)
}
}
return files
}
async createDirectory(name: string) {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected')
}
const folderPath = `${this.currentPath.value === '/' ? '' : this.currentPath.value}/${name}`
await this.client.createDirectory(this.normalizePath(folderPath))
await this.loadDirectory(this.currentPath.value, true)
}
async deleteItem(path: string) {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected')
}
await this.client.deleteFile(this.normalizePath(path))
await this.items.removeItem(path)
}
async renameItem(oldPath: string, newName: string) {
if (!this.client) {
this.connect()
if (!this.client) throw new Error('Not connected')
}
const dirPath = oldPath.substring(0, oldPath.lastIndexOf('/')) || ''
const newPath = `${dirPath}/${newName}`
await this.client.moveFile(this.normalizePath(oldPath), this.normalizePath(newPath))
await this.items.renameItem(oldPath, newName)
await this.loadDirectory(dirPath || '/', true)
}
async testConnection(): Promise<boolean> {
try {
if (!this.client) {
this.connect()
if (!this.client) return false
}
await this.client.getDirectoryContents('/')
return true
} catch {
return false
}
}
normalizePath(path: string): string {
if (path === '' || path === '/') return '/'
let normalized = path.startsWith('/') ? path : `/${path}`
return normalized.length > 1 && normalized.endsWith('/') ? normalized.slice(0, -1) : normalized
}
}

View File

@@ -0,0 +1,91 @@
import type { BaseStorage } from '@/lib/storage'
import type { ConnectionConfig, AddConnectionInput } from './types'
import { Connection } from './connection'
import { Items } from './items'
import { shallowReactive } from 'vue'
export class Connections {
public connections = shallowReactive<Record<string, Connection>>({})
private storage: BaseStorage
private configStore!: Awaited<ReturnType<BaseStorage['createOrGetStore']>>
constructor(storage: BaseStorage) {
this.storage = storage
}
async start() {
this.configStore = await this.storage.createOrGetStore<ConnectionConfig>('connections', {
syncInMemory: true,
})
const configs = await this.configStore.all<ConnectionConfig>()
const entries = Object.entries(configs)
for (const [id, config] of entries) {
try {
await this.initConnection(config)
} catch (e) {
console.error(`Failed to initialize connection ${config.name}:`, e)
}
}
}
private async initConnection(config: ConnectionConfig) {
const itemsStore = await this.storage.createOrGetStore(
`items_${config.id}`,
{ syncInMemory: true },
)
const items = new Items(itemsStore)
await items.start()
this.connections[config.id] = new Connection(config, items, this.storage)
}
async addConnection(input: AddConnectionInput): Promise<string> {
const id = crypto.randomUUID()
const config: ConnectionConfig = {
...input,
id,
createdAt: Date.now(),
lastAccessedAt: Date.now(),
}
await this.initConnection(config)
await this.configStore.set(id, config)
return id
}
async deleteConnection(id: string) {
await this.configStore.delete(id)
await this.storage.deleteStore(`items_${id}`)
delete this.connections[id]
}
async updateConnection(id: string, updates: Partial<AddConnectionInput>) {
const existing = await this.configStore.get<ConnectionConfig>(id)
if (!existing) throw new Error(`Connection ${id} not found`)
const updated: ConnectionConfig = { ...existing, ...updates, lastAccessedAt: Date.now() }
await this.configStore.set(id, updated)
this.connections[id]?.disconnect()
delete this.connections[id]
await this.initConnection(updated)
}
getConnection(id: string): Connection | undefined {
return this.connections[id]
}
async setLastAccessed(id: string) {
const config = await this.configStore.get<ConnectionConfig>(id)
if (config) {
config.lastAccessedAt = Date.now()
await this.configStore.set(id, config)
}
}
get connectionList() {
return Object.values(this.connections).map((c) => c.config)
}
}

View File

@@ -0,0 +1,96 @@
import { ref } from 'vue'
import type { DownloadTask } from './types'
import type { Connection } from './connection'
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'
export class DownloadManager {
public tasks = ref<DownloadTask[]>([])
private isCapacitor = typeof (window as any).Capacitor !== 'undefined'
constructor() {}
async downloadFile(connection: Connection, filePath: string, fileName: string, size: number): Promise<string> {
const id = crypto.randomUUID()
const task: DownloadTask = {
id,
connectionId: connection.config.id,
filePath,
fileName,
size,
progress: 0,
status: 'downloading',
}
this.tasks.value = [...this.tasks.value, task]
try {
if (this.isCapacitor) {
return await this.downloadToDevice(connection, task)
} else {
return await this.downloadToBrowser(connection, task)
}
} catch (e: any) {
this.updateTask(id, { status: 'failed', error: e.message })
throw e
}
}
private async downloadToDevice(connection: Connection, task: DownloadTask): Promise<string> {
const blob = await connection.downloadFile(task.filePath)
const base64Data = await this.blobToBase64(blob)
const result = await Filesystem.writeFile({
path: `Downloads/${task.fileName}`,
data: base64Data,
directory: Directory.ExternalStorage,
})
this.updateTask(task.id, { status: 'completed', progress: 1, localPath: result.uri })
return result.uri
}
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)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
this.updateTask(task.id, { status: 'completed', progress: 1 })
return url
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
const base64 = (reader.result as string).split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
private updateTask(id: string, updates: Partial<DownloadTask>) {
this.tasks.value = this.tasks.value.map((t) =>
t.id === id ? { ...t, ...updates } : t,
)
}
cancelDownload(id: string) {
this.updateTask(id, { status: 'cancelled' })
}
clearCompleted() {
this.tasks.value = this.tasks.value.filter((t) => t.status !== 'completed' && t.status !== 'cancelled')
}
get activeDownloads() {
return this.tasks.value.filter((t) => t.status === 'downloading' || t.status === 'pending')
}
}

Some files were not shown because too many files have changed in this diff Show More