Initial Commit

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

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-pwa
dist-ssr
dev-dist
*.local
# Editor directories and files
.vscode/*
.vscode
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

12
capacitor.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.filesharing.app',
appName: 'FileSharing',
webDir: 'dist',
server: {
androidScheme: 'https',
},
};
export default config;

25
components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "reka-nova",
"font": "geist-sans",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f172a" />
<meta name="description" content="FileSharing - Google Drive-like file manager with WebDAV backend" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>FileSharing</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

12063
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "www",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"cap:init": "npx cap init",
"cap:add:android": "npx cap add android",
"cap:add:ios": "npx cap add ios",
"cap:sync": "npx cap sync",
"cap:open:android": "npx cap open android",
"cap:open:ios": "npx cap open ios"
},
"dependencies": {
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.3",
"@capacitor/filesystem": "^8.1.2",
"@vueuse/core": "^14.3.0",
"async-mutex": "^0.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
"jszip": "^3.10.1",
"lucide-vue-next": "^1.0.0",
"msgpackr": "^2.0.1",
"pinia": "^3.0.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.9.7",
"shadcn-vue": "^2.6.2",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vite-plugin-pwa": "^1.3.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4",
"webdav": "^5.10.0",
"workbox-window": "^7.4.1"
},
"devDependencies": {
"@capacitor/android": "^8.3.3",
"@capacitor/cli": "^8.3.3",
"@capacitor/ios": "^8.3.3",
"@tailwindcss/vite": "^4.3.0",
"@types/jszip": "^3.4.0",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vue-tsc": "^3.2.7"
}
}

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#2563eb"/>
<path d="M7 11C7 9.89543 7.89543 9 9 9H13L15 11H23C24.1046 11 25 11.8954 25 13V21C25 22.1046 24.1046 23 23 23H9C7.89543 23 7 22.1046 7 21V11Z" fill="#7dd3fc"/>
<path d="M23 13H9V21C9 22.1046 9.89543 23 11 23H21C22.1046 23 23 22.1046 23 21V13Z" fill="#38bdf8"/>
</svg>

After

Width:  |  Height:  |  Size: 423 B

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

29
src/App.vue Normal file
View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useApp } from '@/composables/useApp'
import BottomTabBar from '@/components/BottomTabBar.vue'
const router = useRouter()
const route = useRoute()
const app = useApp()
watch(
() => app.hasConnections(),
(hasConnections) => {
if (!hasConnections && route.path !== '/connections') {
router.push('/connections')
}
},
{ immediate: true },
)
</script>
<template>
<div class="flex flex-col h-[100dvh] bg-background overflow-hidden">
<main class="flex-1 flex flex-col min-h-0 overflow-hidden">
<router-view />
</main>
<BottomTabBar />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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