Initial Commit
This commit is contained in:
29
src/App.vue
Normal file
29
src/App.vue
Normal 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>
|
||||
101
src/components/AppHeader.vue
Normal file
101
src/components/AppHeader.vue
Normal 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>
|
||||
48
src/components/BottomTabBar.vue
Normal file
48
src/components/BottomTabBar.vue
Normal 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>
|
||||
61
src/components/BreadcrumbNav.vue
Normal file
61
src/components/BreadcrumbNav.vue
Normal 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>
|
||||
151
src/components/ConnectionDialog.vue
Normal file
151
src/components/ConnectionDialog.vue
Normal 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>
|
||||
72
src/components/CreateFolderDialog.vue
Normal file
72
src/components/CreateFolderDialog.vue
Normal 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>
|
||||
203
src/components/FileContextMenu.vue
Normal file
203
src/components/FileContextMenu.vue
Normal 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
219
src/components/FileGrid.vue
Normal 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>
|
||||
85
src/components/FileGridItem.vue
Normal file
85
src/components/FileGridItem.vue
Normal 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>
|
||||
69
src/components/FileListItem.vue
Normal file
69
src/components/FileListItem.vue
Normal 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>
|
||||
122
src/components/PreviewDialog.vue
Normal file
122
src/components/PreviewDialog.vue
Normal 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>
|
||||
68
src/components/RenameDialog.vue
Normal file
68
src/components/RenameDialog.vue
Normal 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>
|
||||
109
src/components/UploadDialog.vue
Normal file
109
src/components/UploadDialog.vue
Normal 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>
|
||||
18
src/components/ui/breadcrumb/Breadcrumb.vue
Normal file
18
src/components/ui/breadcrumb/Breadcrumb.vue
Normal 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>
|
||||
24
src/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
24
src/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal 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>
|
||||
17
src/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
17
src/components/ui/breadcrumb/BreadcrumbItem.vue
Normal 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>
|
||||
21
src/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
21
src/components/ui/breadcrumb/BreadcrumbLink.vue
Normal 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>
|
||||
17
src/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
17
src/components/ui/breadcrumb/BreadcrumbList.vue
Normal 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>
|
||||
20
src/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
20
src/components/ui/breadcrumb/BreadcrumbPage.vue
Normal 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>
|
||||
23
src/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal file
23
src/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal 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>
|
||||
7
src/components/ui/breadcrumb/index.ts
Normal file
7
src/components/ui/breadcrumb/index.ts
Normal 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'
|
||||
31
src/components/ui/button/Button.vue
Normal file
31
src/components/ui/button/Button.vue
Normal 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>
|
||||
35
src/components/ui/button/index.ts
Normal file
35
src/components/ui/button/index.ts
Normal 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>
|
||||
18
src/components/ui/context-menu/ContextMenu.vue
Normal file
18
src/components/ui/context-menu/ContextMenu.vue
Normal 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>
|
||||
40
src/components/ui/context-menu/ContextMenuCheckboxItem.vue
Normal file
40
src/components/ui/context-menu/ContextMenuCheckboxItem.vue
Normal 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>
|
||||
37
src/components/ui/context-menu/ContextMenuContent.vue
Normal file
37
src/components/ui/context-menu/ContextMenuContent.vue
Normal 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>
|
||||
15
src/components/ui/context-menu/ContextMenuGroup.vue
Normal file
15
src/components/ui/context-menu/ContextMenuGroup.vue
Normal 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>
|
||||
38
src/components/ui/context-menu/ContextMenuItem.vue
Normal file
38
src/components/ui/context-menu/ContextMenuItem.vue
Normal 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>
|
||||
22
src/components/ui/context-menu/ContextMenuLabel.vue
Normal file
22
src/components/ui/context-menu/ContextMenuLabel.vue
Normal 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>
|
||||
15
src/components/ui/context-menu/ContextMenuPortal.vue
Normal file
15
src/components/ui/context-menu/ContextMenuPortal.vue
Normal 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>
|
||||
21
src/components/ui/context-menu/ContextMenuRadioGroup.vue
Normal file
21
src/components/ui/context-menu/ContextMenuRadioGroup.vue
Normal 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>
|
||||
40
src/components/ui/context-menu/ContextMenuRadioItem.vue
Normal file
40
src/components/ui/context-menu/ContextMenuRadioItem.vue
Normal 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>
|
||||
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal 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>
|
||||
17
src/components/ui/context-menu/ContextMenuShortcut.vue
Normal file
17
src/components/ui/context-menu/ContextMenuShortcut.vue
Normal 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>
|
||||
21
src/components/ui/context-menu/ContextMenuSub.vue
Normal file
21
src/components/ui/context-menu/ContextMenuSub.vue
Normal 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>
|
||||
32
src/components/ui/context-menu/ContextMenuSubContent.vue
Normal file
32
src/components/ui/context-menu/ContextMenuSubContent.vue
Normal 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>
|
||||
33
src/components/ui/context-menu/ContextMenuSubTrigger.vue
Normal file
33
src/components/ui/context-menu/ContextMenuSubTrigger.vue
Normal 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>
|
||||
22
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
22
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal 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>
|
||||
14
src/components/ui/context-menu/index.ts
Normal file
14
src/components/ui/context-menu/index.ts
Normal 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'
|
||||
19
src/components/ui/dialog/Dialog.vue
Normal file
19
src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
15
src/components/ui/dialog/DialogClose.vue
Normal file
15
src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
53
src/components/ui/dialog/DialogContent.vue
Normal file
53
src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
23
src/components/ui/dialog/DialogDescription.vue
Normal file
23
src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
27
src/components/ui/dialog/DialogFooter.vue
Normal file
27
src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
17
src/components/ui/dialog/DialogHeader.vue
Normal file
17
src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
21
src/components/ui/dialog/DialogOverlay.vue
Normal file
21
src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
60
src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
src/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||
23
src/components/ui/dialog/DialogTitle.vue
Normal file
23
src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
15
src/components/ui/dialog/DialogTrigger.vue
Normal file
15
src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
10
src/components/ui/dialog/index.ts
Normal file
10
src/components/ui/dialog/index.ts
Normal 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'
|
||||
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal 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>
|
||||
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal 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>
|
||||
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal 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>
|
||||
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal 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>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal 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>
|
||||
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal 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>
|
||||
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal 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>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal 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>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal 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>
|
||||
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal 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>
|
||||
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal 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>
|
||||
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal 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>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal 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>
|
||||
16
src/components/ui/dropdown-menu/index.ts
Normal file
16
src/components/ui/dropdown-menu/index.ts
Normal 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'
|
||||
31
src/components/ui/input/Input.vue
Normal file
31
src/components/ui/input/Input.vue
Normal 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>
|
||||
1
src/components/ui/input/index.ts
Normal file
1
src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
26
src/components/ui/label/Label.vue
Normal file
26
src/components/ui/label/Label.vue
Normal 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>
|
||||
1
src/components/ui/label/index.ts
Normal file
1
src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
38
src/components/ui/progress/Progress.vue
Normal file
38
src/components/ui/progress/Progress.vue
Normal 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>
|
||||
1
src/components/ui/progress/index.ts
Normal file
1
src/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Progress } from './Progress.vue'
|
||||
33
src/components/ui/scroll-area/ScrollArea.vue
Normal file
33
src/components/ui/scroll-area/ScrollArea.vue
Normal 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>
|
||||
27
src/components/ui/scroll-area/ScrollBar.vue
Normal file
27
src/components/ui/scroll-area/ScrollBar.vue
Normal 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>
|
||||
2
src/components/ui/scroll-area/index.ts
Normal file
2
src/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollBar } from './ScrollBar.vue'
|
||||
29
src/components/ui/separator/Separator.vue
Normal file
29
src/components/ui/separator/Separator.vue
Normal 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>
|
||||
1
src/components/ui/separator/index.ts
Normal file
1
src/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
17
src/components/ui/skeleton/Skeleton.vue
Normal file
17
src/components/ui/skeleton/Skeleton.vue
Normal 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>
|
||||
1
src/components/ui/skeleton/index.ts
Normal file
1
src/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from './Skeleton.vue'
|
||||
44
src/components/ui/switch/Switch.vue
Normal file
44
src/components/ui/switch/Switch.vue
Normal 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>
|
||||
1
src/components/ui/switch/index.ts
Normal file
1
src/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
19
src/components/ui/tooltip/Tooltip.vue
Normal file
19
src/components/ui/tooltip/Tooltip.vue
Normal 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>
|
||||
34
src/components/ui/tooltip/TooltipContent.vue
Normal file
34
src/components/ui/tooltip/TooltipContent.vue
Normal 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>
|
||||
14
src/components/ui/tooltip/TooltipProvider.vue
Normal file
14
src/components/ui/tooltip/TooltipProvider.vue
Normal 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>
|
||||
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal 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>
|
||||
4
src/components/ui/tooltip/index.ts
Normal file
4
src/components/ui/tooltip/index.ts
Normal 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'
|
||||
8
src/composables/useApp.ts
Normal file
8
src/composables/useApp.ts
Normal 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
|
||||
}
|
||||
92
src/lib/storage/base-store.ts
Normal file
92
src/lib/storage/base-store.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/lib/storage/event-emitter.ts
Normal file
24
src/lib/storage/event-emitter.ts
Normal 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
6
src/lib/storage/index.ts
Normal 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'
|
||||
249
src/lib/storage/storage-indexeddb.ts
Normal file
249
src/lib/storage/storage-indexeddb.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/lib/storage/store-inmemory-synced.ts
Normal file
70
src/lib/storage/store-inmemory-synced.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
49
src/lib/storage/store-inmemory.ts
Normal file
49
src/lib/storage/store-inmemory.ts
Normal 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
17
src/lib/storage/types.ts
Normal 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
32
src/lib/utils.ts
Normal 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
31
src/main.ts
Normal 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, '<')}</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
24
src/router/index.ts
Normal 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
80
src/services/app.ts
Normal 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
261
src/services/connection.ts
Normal 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
|
||||
}
|
||||
}
|
||||
91
src/services/connections.ts
Normal file
91
src/services/connections.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
96
src/services/download-manager.ts
Normal file
96
src/services/download-manager.ts
Normal 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
Reference in New Issue
Block a user