Initial Commit
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
5
README.md
Normal 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
12
capacitor.config.ts
Normal 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
25
components.json
Normal 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
18
index.html
Normal 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
12063
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal 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
5
public/favicon.svg
Normal 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
24
public/icons.svg
Normal 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
29
src/App.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import BottomTabBar from '@/components/BottomTabBar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const app = useApp()
|
||||
|
||||
watch(
|
||||
() => app.hasConnections(),
|
||||
(hasConnections) => {
|
||||
if (!hasConnections && route.path !== '/connections') {
|
||||
router.push('/connections')
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-[100dvh] bg-background overflow-hidden">
|
||||
<main class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<router-view />
|
||||
</main>
|
||||
<BottomTabBar />
|
||||
</div>
|
||||
</template>
|
||||
101
src/components/AppHeader.vue
Normal file
101
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import {
|
||||
Search,
|
||||
FolderPlus,
|
||||
Upload,
|
||||
LayoutGrid,
|
||||
List,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
} from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import CreateFolderDialog from './CreateFolderDialog.vue'
|
||||
import UploadDialog from './UploadDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useApp()
|
||||
const searchQuery = ref('')
|
||||
const showCreateFolder = ref(false)
|
||||
const showUpload = ref(false)
|
||||
|
||||
const connectionLabel = computed(() => {
|
||||
return app.activeConnection.value?.config.name || 'Select a connection'
|
||||
})
|
||||
|
||||
const activeConnId = computed(() => app.activeConnectionId.value)
|
||||
|
||||
const connectionList = computed(() => app.connections.connectionList)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggle-drawer'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-top"
|
||||
>
|
||||
<div class="flex items-center justify-between h-14 px-3 gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" class="gap-1 px-2 min-w-0 max-w-[160px]">
|
||||
<span class="text-sm font-semibold truncate">{{ connectionLabel }}</span>
|
||||
<ChevronDown class="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" class="w-56">
|
||||
<DropdownMenuItem
|
||||
v-for="c in connectionList"
|
||||
:key="c.id"
|
||||
@click="app.setActiveConnection(c.id)"
|
||||
class="gap-2"
|
||||
>
|
||||
<span class="truncate flex-1">{{ c.name }}</span>
|
||||
<span v-if="c.id === activeConnId" class="text-[10px] text-primary font-medium">active</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="router.push('/connections')" class="gap-2">
|
||||
<Plus class="size-3.5" />
|
||||
Manage Connections
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="size-9" @click="showCreateFolder = true">
|
||||
<FolderPlus class="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-9" @click="showUpload = true">
|
||||
<Upload class="size-4" />
|
||||
</Button>
|
||||
<div class="w-px h-5 bg-border mx-0.5" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-9"
|
||||
@click="app.setViewMode(app.viewMode.value === 'grid' ? 'list' : 'grid')"
|
||||
>
|
||||
<LayoutGrid v-if="app.viewMode.value === 'list'" class="size-4" />
|
||||
<List v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CreateFolderDialog v-model:open="showCreateFolder" />
|
||||
<UploadDialog v-model:open="showUpload" />
|
||||
</template>
|
||||
48
src/components/BottomTabBar.vue
Normal file
48
src/components/BottomTabBar.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { FolderOpen, ArrowDownToLine, Network, type LucideIcon } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const app = useApp()
|
||||
|
||||
const tabs = computed(() => {
|
||||
const items: { id: string; label: string; icon: LucideIcon; path: string; badge?: number }[] = [
|
||||
{ id: 'files', label: 'Files', icon: FolderOpen, path: '/' },
|
||||
{ id: 'downloads', label: 'Downloads', icon: ArrowDownToLine, path: '/downloads', badge: app.downloadManager.activeDownloads.length },
|
||||
{ id: 'connections', label: 'Connections', icon: Network, path: '/connections' },
|
||||
]
|
||||
return items
|
||||
})
|
||||
|
||||
const activeTab = computed(() => {
|
||||
if (route.path.startsWith('/downloads')) return 'downloads'
|
||||
if (route.path.startsWith('/connections')) return 'connections'
|
||||
return 'files'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="shrink-0 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 safe-area-bottom">
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="router.push(tab.path)"
|
||||
class="relative flex flex-col items-center justify-center gap-0.5 min-w-0 flex-1 py-1 px-2 transition-colors"
|
||||
:class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
|
||||
>
|
||||
<component :is="tab.icon" class="size-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.label }}</span>
|
||||
<span
|
||||
v-if="tab.badge && tab.badge > 0"
|
||||
class="absolute top-0.5 left-1/2 translate-x-2.5 min-w-[16px] h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-bold flex items-center justify-center"
|
||||
>
|
||||
{{ tab.badge }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
61
src/components/BreadcrumbNav.vue
Normal file
61
src/components/BreadcrumbNav.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
const app = useApp()
|
||||
const router = useRouter()
|
||||
|
||||
function buildBreadcrumbs() {
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return []
|
||||
const path = conn.currentPath.value
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const crumbs = [{ name: 'Home', path: '/' }]
|
||||
let current = ''
|
||||
for (const part of parts) {
|
||||
current += `/${part}`
|
||||
crumbs.push({ name: part, path: current })
|
||||
}
|
||||
return crumbs
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push({ query: path === '/' ? {} : { path } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 py-2 overflow-x-auto">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList class="flex-nowrap text-sm">
|
||||
<template v-for="(crumb, index) in buildBreadcrumbs()" :key="crumb.path">
|
||||
<BreadcrumbItem class="shrink-0">
|
||||
<BreadcrumbLink
|
||||
v-if="index < buildBreadcrumbs().length - 1"
|
||||
as="button"
|
||||
@click="navigate(crumb.path)"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap"
|
||||
>
|
||||
{{ crumb.name }}
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbPage v-else class="whitespace-nowrap">
|
||||
{{ crumb.name }}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator v-if="index < buildBreadcrumbs().length - 1" class="shrink-0">
|
||||
<ChevronRight class="size-3" />
|
||||
</BreadcrumbSeparator>
|
||||
</template>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</template>
|
||||
151
src/components/ConnectionDialog.vue
Normal file
151
src/components/ConnectionDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { AddConnectionInput } from '@/services/types'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
editId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
|
||||
const name = ref('')
|
||||
const url = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isTesting = ref(false)
|
||||
const testError = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const isEditing = !!props.editId
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
emit('update:open', value)
|
||||
if (!value) {
|
||||
name.value = ''
|
||||
url.value = ''
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
testError.value = null
|
||||
saveError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.value.trim() || !url.value.trim()) return
|
||||
isSaving.value = true
|
||||
saveError.value = null
|
||||
try {
|
||||
const input: AddConnectionInput = {
|
||||
name: name.value.trim(),
|
||||
url: url.value.trim(),
|
||||
username: username.value.trim() || undefined,
|
||||
password: password.value || undefined,
|
||||
}
|
||||
|
||||
if (isEditing && props.editId) {
|
||||
await app.connections.updateConnection(props.editId, input)
|
||||
} else {
|
||||
const id = await app.connections.addConnection(input)
|
||||
app.setActiveConnection(id)
|
||||
}
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
saveError.value = e.message || 'Failed to save connection'
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!url.value.trim()) return
|
||||
isTesting.value = true
|
||||
testError.value = null
|
||||
try {
|
||||
const conn = app.connections.connections[props.editId || '']
|
||||
if (!conn) {
|
||||
const tempId = await app.connections.addConnection({
|
||||
name: 'temp',
|
||||
url: url.value.trim(),
|
||||
username: username.value.trim() || undefined,
|
||||
password: password.value || undefined,
|
||||
})
|
||||
const tempConn = app.connections.getConnection(tempId)
|
||||
if (!tempConn) throw new Error('Failed to create temp connection')
|
||||
const ok = await tempConn.testConnection()
|
||||
await app.connections.deleteConnection(tempId)
|
||||
if (!ok) throw new Error('Could not connect')
|
||||
} else {
|
||||
const ok = await conn.testConnection()
|
||||
if (!ok) throw new Error('Could not connect')
|
||||
}
|
||||
} catch (e: any) {
|
||||
testError.value = e.message || 'Connection failed'
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="setOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ isEditing ? 'Edit Connection' : 'New Connection' }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ isEditing ? 'Update your WebDAV server details' : 'Add a new WebDAV server connection' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<div class="space-y-2">
|
||||
<Label for="conn-name">Name</Label>
|
||||
<Input id="conn-name" v-model="name" placeholder="My Server" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="conn-url">Server URL</Label>
|
||||
<Input id="conn-url" v-model="url" placeholder="https://webdav.example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="conn-user">Username</Label>
|
||||
<Input id="conn-user" v-model="username" placeholder="Optional" autocomplete="username" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="conn-pass">Password</Label>
|
||||
<Input id="conn-pass" v-model="password" type="password" placeholder="Optional" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<div v-if="testError" class="text-xs text-destructive bg-destructive/10 rounded-lg p-2">
|
||||
{{ testError }}
|
||||
</div>
|
||||
<div v-if="saveError" class="text-xs text-destructive bg-destructive/10 rounded-lg p-2">
|
||||
{{ saveError }}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter class="gap-2 sm:gap-0">
|
||||
<Button variant="outline" @click="testConnection" :disabled="!url.trim() || isTesting">
|
||||
{{ isTesting ? 'Testing...' : 'Test' }}
|
||||
</Button>
|
||||
<Button @click="handleSave" :disabled="!name.trim() || !url.trim() || isSaving">
|
||||
{{ isSaving ? 'Saving...' : isEditing ? 'Update' : 'Add Connection' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
72
src/components/CreateFolderDialog.vue
Normal file
72
src/components/CreateFolderDialog.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
const folderName = ref('')
|
||||
const isCreating = ref(false)
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
emit('update:open', value)
|
||||
if (!value) folderName.value = ''
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!folderName.value.trim() || !app.activeConnection.value) return
|
||||
isCreating.value = true
|
||||
try {
|
||||
await app.activeConnection.value.createDirectory(folderName.value.trim())
|
||||
setOpen(false)
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="setOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for the new folder in {{ app.activeConnection.value?.currentPath.value || '/' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-2 py-2">
|
||||
<Label for="folder-name">Folder Name</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
v-model="folderName"
|
||||
placeholder="New Folder"
|
||||
@keydown.enter="create"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
|
||||
<Button @click="create" :disabled="!folderName.trim() || isCreating">
|
||||
{{ isCreating ? 'Creating...' : 'Create' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
203
src/components/FileContextMenu.vue
Normal file
203
src/components/FileContextMenu.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import {
|
||||
FolderOpen,
|
||||
Download,
|
||||
FileArchive,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
itemPath: string
|
||||
itemName: string
|
||||
itemType: 'file' | 'directory'
|
||||
mimeType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename'): void
|
||||
(e: 'preview'): void
|
||||
(e: 'download'): void
|
||||
(e: 'downloadZip'): void
|
||||
(e: 'delete'): void
|
||||
(e: 'open'): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
const router = useRouter()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
const menuX = ref(0)
|
||||
const menuY = ref(0)
|
||||
|
||||
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const didLongPress = ref(false)
|
||||
const touchMoved = ref(false)
|
||||
|
||||
function canPreview() {
|
||||
return props.mimeType.startsWith('image/') || props.mimeType.startsWith('video/') || props.mimeType.startsWith('audio/') || props.mimeType === 'application/pdf'
|
||||
}
|
||||
|
||||
function showMenu(event: MouseEvent) {
|
||||
const wrapper = menuWrapper.value
|
||||
if (wrapper) {
|
||||
const rect = wrapper.getBoundingClientRect()
|
||||
menuX.value = event.clientX || rect.left
|
||||
menuY.value = event.clientY || rect.bottom
|
||||
} else {
|
||||
menuX.value = event.clientX || 0
|
||||
menuY.value = event.clientY || 0
|
||||
}
|
||||
menuOpen.value = true
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeMenu, { once: true })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false
|
||||
document.removeEventListener('click', closeMenu)
|
||||
}
|
||||
|
||||
function doAction(fn: () => void) {
|
||||
closeMenu()
|
||||
fn()
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (didLongPress.value) {
|
||||
didLongPress.value = false
|
||||
return
|
||||
}
|
||||
emit('open')
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
showMenu(e)
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchMoved.value = false
|
||||
didLongPress.value = false
|
||||
longPressTimer.value = setTimeout(() => {
|
||||
didLongPress.value = true
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handleTouchMove() {
|
||||
touchMoved.value = true
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
if (!touchMoved.value && !didLongPress.value) {
|
||||
e.preventDefault()
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
|
||||
function clampX(x: number) {
|
||||
return Math.min(x, typeof window !== 'undefined' ? window.innerWidth - 190 : 200)
|
||||
}
|
||||
function clampY(y: number) {
|
||||
return Math.min(y, typeof window !== 'undefined' ? window.innerHeight - 250 : 200)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (longPressTimer.value) clearTimeout(longPressTimer.value)
|
||||
})
|
||||
|
||||
const menuWrapper = ref<HTMLElement>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="menuWrapper"
|
||||
@click="handleClick"
|
||||
@contextmenu="handleContextMenu"
|
||||
@touchstart.passive="handleTouchStart"
|
||||
@touchmove.passive="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="fixed z-[100] min-w-[180px] bg-card border border-border rounded-lg shadow-xl py-1"
|
||||
:style="{ left: clampX(menuX) + 'px', top: clampY(menuY) + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-if="itemType === 'directory'"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
||||
@click="doAction(() => router.push({ query: { path: itemPath } }))"
|
||||
>
|
||||
<FolderOpen class="size-3.5 shrink-0" />
|
||||
Open
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="itemType === 'directory'"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
||||
@click="doAction(() => emit('downloadZip'))"
|
||||
>
|
||||
<FileArchive class="size-3.5 shrink-0" />
|
||||
Download as ZIP
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
||||
@click="doAction(() => emit('download'))"
|
||||
>
|
||||
<Download class="size-3.5 shrink-0" />
|
||||
Download
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="itemType === 'file' && canPreview()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
||||
@click="doAction(() => emit('preview'))"
|
||||
>
|
||||
<Eye class="size-3.5 shrink-0" />
|
||||
Preview
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-border my-0.5 mx-2" />
|
||||
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
||||
@click="doAction(() => emit('rename'))"
|
||||
>
|
||||
<Pencil class="size-3.5 shrink-0" />
|
||||
Rename
|
||||
</button>
|
||||
|
||||
<div class="h-px bg-border my-0.5 mx-2" />
|
||||
|
||||
<button
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-destructive/10 text-destructive transition-colors text-left"
|
||||
@click="doAction(() => emit('delete'))"
|
||||
>
|
||||
<Trash2 class="size-3.5 shrink-0" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
219
src/components/FileGrid.vue
Normal file
219
src/components/FileGrid.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import type { FileItem } from '@/services/types'
|
||||
import FileGridItem from './FileGridItem.vue'
|
||||
import FileListItem from './FileListItem.vue'
|
||||
import FileContextMenu from './FileContextMenu.vue'
|
||||
import RenameDialog from './RenameDialog.vue'
|
||||
import PreviewDialog from './PreviewDialog.vue'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FolderOpen, AlertCircle, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const app = useApp()
|
||||
const router = useRouter()
|
||||
|
||||
const showRename = ref(false)
|
||||
const renameTarget = ref<{ path: string; name: string } | null>(null)
|
||||
const showPreview = ref(false)
|
||||
const previewTarget = ref<FileItem | null>(null)
|
||||
|
||||
const children = computed(() => {
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return [] as FileItem[]
|
||||
void conn.currentPath.value
|
||||
void conn.items.items
|
||||
return conn.items.getChildren(conn.currentPath.value)
|
||||
})
|
||||
|
||||
function handleOpen(item: FileItem) {
|
||||
if (item.type === 'directory') {
|
||||
router.push({ query: { path: item.path } })
|
||||
} else {
|
||||
previewTarget.value = item
|
||||
showPreview.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleRename(item: FileItem) {
|
||||
renameTarget.value = { path: item.path, name: item.name }
|
||||
showRename.value = true
|
||||
}
|
||||
|
||||
function handlePreview(item: FileItem) {
|
||||
previewTarget.value = item
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
async function handleDownload(item: FileItem) {
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return
|
||||
try {
|
||||
await app.downloadManager.downloadFile(conn, item.path, item.name, item.size)
|
||||
} catch {
|
||||
const blob = await conn.downloadFile(item.path)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = item.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: FileItem) {
|
||||
await app.activeConnection.value?.deleteItem(item.path)
|
||||
}
|
||||
|
||||
async function handleDownloadZip(item: FileItem) {
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return
|
||||
try {
|
||||
conn.isConnecting.value = true
|
||||
const blob = await conn.downloadFolderAsZip(item.path, (current, total) => {
|
||||
conn.uploadProgress.value = { file: `Zipping ${current}/${total}`, progress: current / total }
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${item.name}.zip`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e: any) {
|
||||
conn.error.value = e.message || 'Failed to download folder'
|
||||
} finally {
|
||||
conn.isConnecting.value = false
|
||||
conn.uploadProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer?.files) {
|
||||
app.activeConnection.value?.uploadFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
app.activeConnection.value?.loadDirectory(app.activeConnection.value.currentPath.value, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-auto overscroll-contain"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<div
|
||||
v-if="app.activeConnection.value?.isConnecting.value && children.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 gap-4"
|
||||
>
|
||||
<Loader2 class="size-8 text-muted-foreground animate-spin" />
|
||||
<p class="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="app.activeConnection.value?.error.value"
|
||||
class="flex flex-col items-center justify-center py-16 text-center px-4"
|
||||
>
|
||||
<AlertCircle class="size-10 text-destructive/50 mb-3" />
|
||||
<p class="text-muted-foreground mb-3 max-w-xs">{{ app.activeConnection.value?.error.value }}</p>
|
||||
<Button variant="outline" size="sm" @click="handleRefresh">Retry</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="children.length === 0"
|
||||
class="flex flex-col items-center justify-center py-16 text-center px-4"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<FolderOpen class="size-14 text-muted-foreground/20 mb-3" />
|
||||
<p class="text-muted-foreground mb-1 font-medium">This folder is empty</p>
|
||||
<p class="text-muted-foreground/50 text-sm">Drop files here or tap + to upload</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="app.viewMode.value === 'grid'"
|
||||
class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-1 p-2"
|
||||
>
|
||||
<FileContextMenu
|
||||
v-for="item in children"
|
||||
:key="item.path"
|
||||
:item-path="item.path"
|
||||
:item-name="item.name"
|
||||
:item-type="item.type"
|
||||
:mime-type="item.mimeType"
|
||||
@open="handleOpen(item)"
|
||||
@rename="handleRename(item)"
|
||||
@preview="handlePreview(item)"
|
||||
@download="handleDownload(item)"
|
||||
@download-zip="handleDownloadZip(item)"
|
||||
@delete="handleDelete(item)"
|
||||
>
|
||||
<FileGridItem
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:mime-type="item.mimeType"
|
||||
:size="item.size"
|
||||
:is-selected="false"
|
||||
/>
|
||||
</FileContextMenu>
|
||||
</div>
|
||||
|
||||
<div v-else class="py-1 flex flex-col gap-0.5 px-1">
|
||||
<div class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-muted-foreground border-b border-border/50 mb-0.5 sticky top-0 bg-background z-10">
|
||||
<div class="w-5 shrink-0" />
|
||||
<span class="flex-1 font-medium">Name</span>
|
||||
<span class="w-16 text-right shrink-0 font-medium">Size</span>
|
||||
<span class="w-20 text-right shrink-0 hidden sm:block font-medium">Modified</span>
|
||||
</div>
|
||||
|
||||
<FileContextMenu
|
||||
v-for="item in children"
|
||||
:key="item.path"
|
||||
:item-path="item.path"
|
||||
:item-name="item.name"
|
||||
:item-type="item.type"
|
||||
:mime-type="item.mimeType"
|
||||
@open="handleOpen(item)"
|
||||
@rename="handleRename(item)"
|
||||
@preview="handlePreview(item)"
|
||||
@download="handleDownload(item)"
|
||||
@download-zip="handleDownloadZip(item)"
|
||||
@delete="handleDelete(item)"
|
||||
>
|
||||
<FileListItem
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:mime-type="item.mimeType"
|
||||
:size="item.size"
|
||||
:modified="item.modified"
|
||||
:is-selected="false"
|
||||
/>
|
||||
</FileContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<RenameDialog
|
||||
v-if="renameTarget"
|
||||
v-model:open="showRename"
|
||||
:item-path="renameTarget.path"
|
||||
:current-name="renameTarget.name"
|
||||
/>
|
||||
|
||||
<PreviewDialog
|
||||
v-if="previewTarget"
|
||||
v-model:open="showPreview"
|
||||
:file-path="previewTarget.path"
|
||||
:file-name="previewTarget.name"
|
||||
:mime-type="previewTarget.mimeType"
|
||||
/>
|
||||
</template>
|
||||
85
src/components/FileGridItem.vue
Normal file
85
src/components/FileGridItem.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { getFileIcon, formatFileSize } from '@/lib/utils'
|
||||
import {
|
||||
Folder,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
mimeType: string
|
||||
size: number
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
function getIconComponent(mimeType: string, type: string) {
|
||||
if (type === 'directory') return Folder
|
||||
const icon = getFileIcon(mimeType)
|
||||
switch (icon) {
|
||||
case 'image': return FileImage
|
||||
case 'video': return FileVideo
|
||||
case 'audio': return FileAudio
|
||||
case 'file-text': return FileText
|
||||
case 'archive': return FileArchive
|
||||
case 'code': return FileCode2
|
||||
default: return FileAudio
|
||||
}
|
||||
}
|
||||
|
||||
function getIconColor(mimeType: string, type: string) {
|
||||
if (type === 'directory') return 'text-yellow-500'
|
||||
const icon = getFileIcon(mimeType)
|
||||
switch (icon) {
|
||||
case 'image': return 'text-green-400'
|
||||
case 'video': return 'text-red-400'
|
||||
case 'audio': return 'text-purple-400'
|
||||
case 'file-text': return 'text-blue-400'
|
||||
case 'archive': return 'text-orange-400'
|
||||
case 'code': return 'text-cyan-400'
|
||||
default: return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group flex flex-col items-center p-2.5 rounded-lg select-none
|
||||
transition-all duration-150 ease-out active:scale-[0.97]
|
||||
border-2 border-transparent group-hover:bg-muted/60"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': isSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl flex items-center justify-center mb-1.5"
|
||||
:class="{
|
||||
'bg-yellow-500/10': type === 'directory',
|
||||
'bg-muted/50': type !== 'directory',
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="getIconComponent(mimeType, type)"
|
||||
class="size-6 transition-transform duration-200 group-active:scale-110"
|
||||
:class="getIconColor(mimeType, type)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full text-center min-w-0 px-0.5">
|
||||
<p class="text-[11px] leading-tight font-medium truncate" :title="name">
|
||||
{{ name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="type === 'file' && size > 0" class="mt-0.5">
|
||||
<span class="text-[10px] text-muted-foreground/60">
|
||||
{{ formatFileSize(size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
src/components/FileListItem.vue
Normal file
69
src/components/FileListItem.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { getFileIcon, formatFileSize, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Folder,
|
||||
FileImage,
|
||||
FileVideo,
|
||||
FileAudio,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
type: 'file' | 'directory'
|
||||
mimeType: string
|
||||
size: number
|
||||
modified: string
|
||||
isSelected: boolean
|
||||
}>()
|
||||
|
||||
function getIconComponent(mimeType: string, type: string) {
|
||||
if (type === 'directory') return Folder
|
||||
const icon = getFileIcon(mimeType)
|
||||
switch (icon) {
|
||||
case 'image': return FileImage
|
||||
case 'video': return FileVideo
|
||||
case 'audio': return FileAudio
|
||||
case 'file-text': return FileText
|
||||
case 'archive': return FileArchive
|
||||
case 'code': return FileCode2
|
||||
default: return FileAudio
|
||||
}
|
||||
}
|
||||
|
||||
function getIconColor(mimeType: string, type: string) {
|
||||
if (type === 'directory') return 'text-yellow-500'
|
||||
const icon = getFileIcon(mimeType)
|
||||
switch (icon) {
|
||||
case 'image': return 'text-green-400'
|
||||
case 'video': return 'text-red-400'
|
||||
case 'audio': return 'text-purple-400'
|
||||
case 'file-text': return 'text-blue-400'
|
||||
case 'archive': return 'text-orange-400'
|
||||
case 'code': return 'text-cyan-400'
|
||||
default: return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 mx-1 rounded-md select-none
|
||||
transition-all duration-150 ease-out active:bg-muted/70
|
||||
border-l-[3px] border-l-transparent group-hover:bg-muted/50"
|
||||
:class="{
|
||||
'border-l-primary bg-primary/5': isSelected,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
:is="getIconComponent(mimeType, type)"
|
||||
class="size-5 shrink-0"
|
||||
:class="getIconColor(mimeType, type)"
|
||||
/>
|
||||
<span class="text-sm font-medium truncate flex-1" :title="name">{{ name }}</span>
|
||||
<span class="text-xs text-muted-foreground w-16 text-right shrink-0">{{ type === 'directory' ? '--' : formatFileSize(size) }}</span>
|
||||
<span class="text-xs text-muted-foreground w-20 text-right shrink-0 hidden sm:block">{{ formatDate(modified) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
122
src/components/PreviewDialog.vue
Normal file
122
src/components/PreviewDialog.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { X, Download, ExternalLink } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
filePath: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
const previewUrl = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
emit('update:open', value)
|
||||
if (!value) {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
error.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!props.filePath) return
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const blob = await conn.downloadFile(props.filePath)
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load preview'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (val) => {
|
||||
if (val) loadPreview()
|
||||
})
|
||||
|
||||
function isImage() { return props.mimeType.startsWith('image/') }
|
||||
function isVideo() { return props.mimeType.startsWith('video/') }
|
||||
function isAudio() { return props.mimeType.startsWith('audio/') }
|
||||
function isPdf() { return props.mimeType === 'application/pdf' }
|
||||
|
||||
function openInNewTab(url: string) { window.open(url, '_blank') }
|
||||
|
||||
async function handleDownload() {
|
||||
const conn = app.activeConnection.value
|
||||
if (!conn) return
|
||||
try {
|
||||
await app.downloadManager.downloadFile(conn, props.filePath, props.fileName, 0)
|
||||
} catch {
|
||||
const blob = await conn.downloadFile(props.filePath)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = props.fileName
|
||||
document.body.appendChild(a); a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="setOpen">
|
||||
<DialogContent class="sm:max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||
<div class="flex items-center justify-between p-3 border-b border-border shrink-0">
|
||||
<DialogTitle class="text-sm font-medium truncate flex-1">{{ fileName }}</DialogTitle>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="handleDownload">
|
||||
<Download class="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="openInNewTab(previewUrl!)" v-if="previewUrl">
|
||||
<ExternalLink class="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="size-8" @click="setOpen(false)">
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-4 flex items-center justify-center min-h-[200px]">
|
||||
<div v-if="isLoading" class="w-full h-full flex items-center justify-center">
|
||||
<Skeleton class="w-full h-48" />
|
||||
</div>
|
||||
<div v-else-if="error" class="text-center text-muted-foreground">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
<template v-else-if="previewUrl">
|
||||
<img v-if="isImage()" :src="previewUrl" :alt="fileName" class="max-w-full max-h-[70vh] object-contain rounded" />
|
||||
<video v-else-if="isVideo()" :src="previewUrl" controls class="max-w-full max-h-[70vh] rounded" />
|
||||
<audio v-else-if="isAudio()" :src="previewUrl" controls class="w-full" />
|
||||
<iframe v-else-if="isPdf()" :src="previewUrl" class="w-full h-[70vh] rounded" />
|
||||
<div v-else class="text-center">
|
||||
<p class="text-muted-foreground mb-3">No preview available</p>
|
||||
<Button variant="outline" size="sm" @click="handleDownload">
|
||||
<Download class="size-3.5 mr-1" /> Download
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
68
src/components/RenameDialog.vue
Normal file
68
src/components/RenameDialog.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
itemPath: string
|
||||
currentName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
const newName = ref('')
|
||||
const isRenaming = ref(false)
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
emit('update:open', value)
|
||||
if (value) newName.value = props.currentName
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
if (!newName.value.trim() || newName.value.trim() === props.currentName || !app.activeConnection.value) return
|
||||
isRenaming.value = true
|
||||
try {
|
||||
await app.activeConnection.value.renameItem(props.itemPath, newName.value.trim())
|
||||
setOpen(false)
|
||||
} finally {
|
||||
isRenaming.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="setOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for "{{ currentName }}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-2 py-2">
|
||||
<Label for="new-name">New Name</Label>
|
||||
<Input id="new-name" v-model="newName" @keydown.enter="rename" autofocus />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
|
||||
<Button @click="rename" :disabled="!newName.trim() || newName.trim() === currentName || isRenaming">
|
||||
{{ isRenaming ? 'Renaming...' : 'Rename' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
109
src/components/UploadDialog.vue
Normal file
109
src/components/UploadDialog.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useApp } from '@/composables/useApp'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Upload, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const app = useApp()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const isUploading = ref(false)
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
emit('update:open', value)
|
||||
if (!value) selectedFiles.value = []
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) selectedFiles.value = Array.from(input.files)
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (selectedFiles.value.length === 0 || !app.activeConnection.value) return
|
||||
isUploading.value = true
|
||||
try {
|
||||
await app.activeConnection.value.uploadFiles(selectedFiles.value)
|
||||
setOpen(false)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="setOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Files</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload files to {{ app.activeConnection.value?.currentPath.value || '/' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-2">
|
||||
<div
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="(e) => {
|
||||
if (e.dataTransfer?.files) selectedFiles = Array.from(e.dataTransfer.files)
|
||||
}"
|
||||
class="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<Upload class="size-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Drag & drop files here or tap to browse
|
||||
</p>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="handleFileSelect" />
|
||||
</div>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="space-y-2 max-h-40 overflow-auto">
|
||||
<div
|
||||
v-for="(file, index) in selectedFiles"
|
||||
:key="file.name"
|
||||
class="flex items-center justify-between bg-muted rounded-md px-3 py-2"
|
||||
>
|
||||
<span class="text-sm truncate flex-1">{{ file.name }}</span>
|
||||
<span class="text-xs text-muted-foreground ml-2 shrink-0">
|
||||
{{ (file.size / 1024 / 1024).toFixed(1) }} MB
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" class="size-6 ml-1" @click="removeFile(index)">
|
||||
<X class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="setOpen(false)">Cancel</Button>
|
||||
<Button @click="upload" :disabled="selectedFiles.length === 0 || isUploading">
|
||||
{{ isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}` }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
18
src/components/ui/breadcrumb/Breadcrumb.vue
Normal file
18
src/components/ui/breadcrumb/Breadcrumb.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
:class="cn('', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
24
src/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
24
src/components/ui/breadcrumb/BreadcrumbEllipsis.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { MoreHorizontalIcon } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('size-5 [&>svg]:size-4 flex items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontalIcon />
|
||||
</slot>
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
||||
</template>
|
||||
17
src/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
17
src/components/ui/breadcrumb/BreadcrumbItem.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
:class="cn('gap-1 inline-flex items-center', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
21
src/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
21
src/components/ui/breadcrumb/BreadcrumbLink.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
as: 'a',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="breadcrumb-link"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn('hover:text-foreground transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
17
src/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
17
src/components/ui/breadcrumb/BreadcrumbList.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
:class="cn('text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ol>
|
||||
</template>
|
||||
20
src/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
20
src/components/ui/breadcrumb/BreadcrumbPage.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
:class="cn('text-foreground font-normal', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
23
src/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal file
23
src/components/ui/breadcrumb/BreadcrumbSeparator.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
:class="cn('[&>svg]:size-3.5', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronRightIcon class="cn-rtl-flip" />
|
||||
</slot>
|
||||
</li>
|
||||
</template>
|
||||
7
src/components/ui/breadcrumb/index.ts
Normal file
7
src/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Breadcrumb } from './Breadcrumb.vue'
|
||||
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
|
||||
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
|
||||
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
|
||||
export { default as BreadcrumbList } from './BreadcrumbList.vue'
|
||||
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
|
||||
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
|
||||
31
src/components/ui/button/Button.vue
Normal file
31
src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
35
src/components/ui/button/index.ts
Normal file
35
src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
|
||||
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'xs': 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||
'sm': 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||
'lg': 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
'icon': 'size-8',
|
||||
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*=size-])]:size-3',
|
||||
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
18
src/components/ui/context-menu/ContextMenu.vue
Normal file
18
src/components/ui/context-menu/ContextMenu.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
|
||||
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuRootProps>()
|
||||
const emits = defineEmits<ContextMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot
|
||||
data-slot="context-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
40
src/components/ui/context-menu/ContextMenuCheckboxItem.vue
Normal file
40
src/components/ui/context-menu/ContextMenuCheckboxItem.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuCheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</ContextMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuCheckboxItem>
|
||||
</template>
|
||||
37
src/components/ui/context-menu/ContextMenuContent.vue
Normal file
37
src/components/ui/context-menu/ContextMenuContent.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-context-menu-content-available-height) origin-(--reka-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
15
src/components/ui/context-menu/ContextMenuGroup.vue
Normal file
15
src/components/ui/context-menu/ContextMenuGroup.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuGroupProps } from 'reka-ui'
|
||||
import { ContextMenuGroup } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuGroup
|
||||
data-slot="context-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuGroup>
|
||||
</template>
|
||||
38
src/components/ui/context-menu/ContextMenuItem.vue
Normal file
38
src/components/ui/context-menu/ContextMenuItem.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<ContextMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
const emits = defineEmits<ContextMenuItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuItem
|
||||
data-slot="context-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
22
src/components/ui/context-menu/ContextMenuLabel.vue
Normal file
22
src/components/ui/context-menu/ContextMenuLabel.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuLabel
|
||||
data-slot="context-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuLabel>
|
||||
</template>
|
||||
15
src/components/ui/context-menu/ContextMenuPortal.vue
Normal file
15
src/components/ui/context-menu/ContextMenuPortal.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuPortalProps } from 'reka-ui'
|
||||
import { ContextMenuPortal } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuPortalProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal
|
||||
data-slot="context-menu-portal"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
21
src/components/ui/context-menu/ContextMenuRadioGroup.vue
Normal file
21
src/components/ui/context-menu/ContextMenuRadioGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuRadioGroupProps>()
|
||||
const emits = defineEmits<ContextMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuRadioGroup>
|
||||
</template>
|
||||
40
src/components/ui/context-menu/ContextMenuRadioItem.vue
Normal file
40
src/components/ui/context-menu/ContextMenuRadioItem.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuItemIndicator,
|
||||
ContextMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<ContextMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="absolute right-2 pointer-events-none">
|
||||
<ContextMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</ContextMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</ContextMenuRadioItem>
|
||||
</template>
|
||||
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
21
src/components/ui/context-menu/ContextMenuSeparator.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuSeparator,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSeparator
|
||||
data-slot="context-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
17
src/components/ui/context-menu/ContextMenuShortcut.vue
Normal file
17
src/components/ui/context-menu/ContextMenuShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
:class="cn('text-muted-foreground group-focus/context-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
21
src/components/ui/context-menu/ContextMenuSub.vue
Normal file
21
src/components/ui/context-menu/ContextMenuSub.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuSubProps>()
|
||||
const emits = defineEmits<ContextMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSub
|
||||
data-slot="context-menu-sub"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuSub>
|
||||
</template>
|
||||
32
src/components/ui/context-menu/ContextMenuSubContent.vue
Normal file
32
src/components/ui/context-menu/ContextMenuSubContent.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-32 rounded-lg border p-1 shadow-lg duration-100 cn-menu-translucent z-50 origin-(--reka-context-menu-content-transform-origin) overflow-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuSubContent>
|
||||
</template>
|
||||
33
src/components/ui/context-menu/ContextMenuSubTrigger.vue
Normal file
33
src/components/ui/context-menu/ContextMenuSubTrigger.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuSubTriggerProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
ContextMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
</ContextMenuSubTrigger>
|
||||
</template>
|
||||
22
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
22
src/components/ui/context-menu/ContextMenuTrigger.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ContextMenuTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuTrigger
|
||||
data-slot="context-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('select-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
</template>
|
||||
14
src/components/ui/context-menu/index.ts
Normal file
14
src/components/ui/context-menu/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { default as ContextMenu } from './ContextMenu.vue'
|
||||
export { default as ContextMenuCheckboxItem } from './ContextMenuCheckboxItem.vue'
|
||||
export { default as ContextMenuContent } from './ContextMenuContent.vue'
|
||||
export { default as ContextMenuGroup } from './ContextMenuGroup.vue'
|
||||
export { default as ContextMenuItem } from './ContextMenuItem.vue'
|
||||
export { default as ContextMenuLabel } from './ContextMenuLabel.vue'
|
||||
export { default as ContextMenuRadioGroup } from './ContextMenuRadioGroup.vue'
|
||||
export { default as ContextMenuRadioItem } from './ContextMenuRadioItem.vue'
|
||||
export { default as ContextMenuSeparator } from './ContextMenuSeparator.vue'
|
||||
export { default as ContextMenuShortcut } from './ContextMenuShortcut.vue'
|
||||
export { default as ContextMenuSub } from './ContextMenuSub.vue'
|
||||
export { default as ContextMenuSubContent } from './ContextMenuSubContent.vue'
|
||||
export { default as ContextMenuSubTrigger } from './ContextMenuSubTrigger.vue'
|
||||
export { default as ContextMenuTrigger } from './ContextMenuTrigger.vue'
|
||||
19
src/components/ui/dialog/Dialog.vue
Normal file
19
src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
|
||||
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
src/components/ui/dialog/DialogClose.vue
Normal file
15
src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from 'reka-ui'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
src/components/ui/dialog/DialogContent.vue
Normal file
53
src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DialogOverlay from './DialogOverlay.vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class'], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
as-child
|
||||
>
|
||||
<Button variant="ghost" class="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span class="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
src/components/ui/dialog/DialogDescription.vue
Normal file
23
src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogDescription, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
27
src/components/ui/dialog/DialogFooter.vue
Normal file
27
src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogClose } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
17
src/components/ui/dialog/DialogHeader.vue
Normal file
17
src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('gap-2 flex flex-col', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/ui/dialog/DialogOverlay.vue
Normal file
21
src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogOverlay } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
60
src/components/ui/dialog/DialogScrollContent.vue
Normal file
60
src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { XIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
src/components/ui/dialog/DialogTitle.vue
Normal file
23
src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DialogTitle, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-base leading-none font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
src/components/ui/dialog/DialogTrigger.vue
Normal file
15
src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from 'reka-ui'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
src/components/ui/dialog/index.ts
Normal file
10
src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as DialogClose } from './DialogClose.vue'
|
||||
export { default as DialogContent } from './DialogContent.vue'
|
||||
export { default as DialogDescription } from './DialogDescription.vue'
|
||||
export { default as DialogFooter } from './DialogFooter.vue'
|
||||
export { default as DialogHeader } from './DialogHeader.vue'
|
||||
export { default as DialogOverlay } from './DialogOverlay.vue'
|
||||
export { default as DialogScrollContent } from './DialogScrollContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dropdown-menu"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
43
src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuCheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuCheckboxItem>
|
||||
</template>
|
||||
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
40
src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'start',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 cn-menu-translucent z-50 max-h-(--reka-dropdown-menu-content-available-height) w-(--reka-dropdown-menu-trigger-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto data-[state=closed]:overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuGroupProps } from 'reka-ui'
|
||||
import { DropdownMenuGroup } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuGroup
|
||||
data-slot="dropdown-menu-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuGroup>
|
||||
</template>
|
||||
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<DropdownMenuItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
inset?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'inset', 'variant', 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
data-slot="dropdown-menu-item"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
:data-variant="variant"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuLabel, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'inset')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuLabel>
|
||||
</template>
|
||||
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
21
src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuRadioGroup,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuRadioGroupProps>()
|
||||
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuRadioGroup>
|
||||
</template>
|
||||
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
44
src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { CheckIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const emits = defineEmits<DropdownMenuRadioItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span
|
||||
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<CheckIcon />
|
||||
</slot>
|
||||
</DropdownMenuItemIndicator>
|
||||
</span>
|
||||
<slot />
|
||||
</DropdownMenuRadioItem>
|
||||
</template>
|
||||
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
23
src/components/ui/dropdown-menu/DropdownMenuSeparator.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
data-slot="dropdown-menu-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
:class="cn('text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuSub,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuSubProps>()
|
||||
const emits = defineEmits<DropdownMenuSubEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</DropdownMenuSub>
|
||||
</template>
|
||||
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
27
src/components/ui/dropdown-menu/DropdownMenuSubContent.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
DropdownMenuSubContent,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<DropdownMenuSubContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
v-bind="forwarded"
|
||||
:class="cn('data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 cn-menu-translucent z-50 origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
</template>
|
||||
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
32
src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'inset')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<ChevronRightIcon class="cn-rtl-flip ml-auto" />
|
||||
</DropdownMenuSubTrigger>
|
||||
</template>
|
||||
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuTriggerProps } from 'reka-ui'
|
||||
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
16
src/components/ui/dropdown-menu/index.ts
Normal file
16
src/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { default as DropdownMenu } from './DropdownMenu.vue'
|
||||
|
||||
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
|
||||
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
|
||||
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
|
||||
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
|
||||
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
|
||||
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
|
||||
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
|
||||
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
|
||||
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
|
||||
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
|
||||
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
|
||||
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'
|
||||
export { DropdownMenuPortal } from 'reka-ui'
|
||||
31
src/components/ui/input/Input.vue
Normal file
31
src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
1
src/components/ui/input/index.ts
Normal file
1
src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue'
|
||||
26
src/components/ui/label/Label.vue
Normal file
26
src/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Label } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
src/components/ui/label/index.ts
Normal file
1
src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue'
|
||||
38
src/components/ui/progress/Progress.vue
Normal file
38
src/components/ui/progress/Progress.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProgressRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ProgressIndicator,
|
||||
ProgressRoot,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
data-slot="progress"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProgressIndicator
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary size-full flex-1 transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
1
src/components/ui/progress/index.ts
Normal file
1
src/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Progress } from './Progress.vue'
|
||||
33
src/components/ui/scroll-area/ScrollArea.vue
Normal file
33
src/components/ui/scroll-area/ScrollArea.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaViewport,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ScrollBar from './ScrollBar.vue'
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
data-slot="scroll-area"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative', props.class)"
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
27
src/components/ui/scroll-area/ScrollBar.vue
Normal file
27
src/components/ui/scroll-area/ScrollBar.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaScrollbarProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
orientation: 'vertical',
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
:data-orientation="orientation"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none', props.class)"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="rounded-full relative flex-1 bg-border"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
2
src/components/ui/scroll-area/index.ts
Normal file
2
src/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollBar } from './ScrollBar.vue'
|
||||
29
src/components/ui/separator/Separator.vue
Normal file
29
src/components/ui/separator/Separator.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Separator } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<
|
||||
SeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>(), {
|
||||
orientation: 'horizontal',
|
||||
decorative: true,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
1
src/components/ui/separator/index.ts
Normal file
1
src/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from './Separator.vue'
|
||||
17
src/components/ui/skeleton/Skeleton.vue
Normal file
17
src/components/ui/skeleton/Skeleton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = defineProps<SkeletonProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
:class="cn('bg-muted rounded-md animate-pulse', props.class)"
|
||||
/>
|
||||
</template>
|
||||
1
src/components/ui/skeleton/index.ts
Normal file
1
src/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from './Skeleton.vue'
|
||||
44
src/components/ui/switch/Switch.vue
Normal file
44
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwitchRootEmits, SwitchRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(defineProps<SwitchRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
size?: 'sm' | 'default'
|
||||
}>(), {
|
||||
size: 'default',
|
||||
})
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
:data-size="size"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
class="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
src/components/ui/switch/index.ts
Normal file
1
src/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from './Switch.vue'
|
||||
19
src/components/ui/tooltip/Tooltip.vue
Normal file
19
src/components/ui/tooltip/Tooltip.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
|
||||
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TooltipRootProps>()
|
||||
const emits = defineEmits<TooltipRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="tooltip"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
34
src/components/ui/tooltip/TooltipContent.vue
Normal file
34
src/components/ui/tooltip/TooltipContent.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
sideOffset: 0,
|
||||
})
|
||||
|
||||
const emits = defineEmits<TooltipContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
data-slot="tooltip-content"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn('data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm bg-foreground text-background z-50 w-fit max-w-xs origin-(--reka-tooltip-content-transform-origin)', props.class)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<TooltipArrow class="size-2.5 rotate-45 rounded-[2px] bg-foreground fill-foreground z-50 translate-y-[calc(-50%_-_2px)]" />
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</template>
|
||||
14
src/components/ui/tooltip/TooltipProvider.vue
Normal file
14
src/components/ui/tooltip/TooltipProvider.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipProviderProps } from 'reka-ui'
|
||||
import { TooltipProvider } from 'reka-ui'
|
||||
|
||||
const props = withDefaults(defineProps<TooltipProviderProps>(), {
|
||||
delayDuration: 0,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider v-bind="props">
|
||||
<slot />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
15
src/components/ui/tooltip/TooltipTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { TooltipTriggerProps } from 'reka-ui'
|
||||
import { TooltipTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TooltipTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipTrigger
|
||||
data-slot="tooltip-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</TooltipTrigger>
|
||||
</template>
|
||||
4
src/components/ui/tooltip/index.ts
Normal file
4
src/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Tooltip } from './Tooltip.vue'
|
||||
export { default as TooltipContent } from './TooltipContent.vue'
|
||||
export { default as TooltipProvider } from './TooltipProvider.vue'
|
||||
export { default as TooltipTrigger } from './TooltipTrigger.vue'
|
||||
8
src/composables/useApp.ts
Normal file
8
src/composables/useApp.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { inject } from 'vue'
|
||||
import type { App } from '@/services/app'
|
||||
|
||||
export function useApp(): App {
|
||||
const app = inject<App>('app')
|
||||
if (!app) throw new Error('App not properly initialized')
|
||||
return app
|
||||
}
|
||||
92
src/lib/storage/base-store.ts
Normal file
92
src/lib/storage/base-store.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { EventEmitter } from './event-emitter'
|
||||
import type { StorageOpts, StoreEvents, StoreValueProxy } from './types'
|
||||
|
||||
export abstract class BaseStorage<E extends StoreEvents = StoreEvents> {
|
||||
abstract listStores(): Promise<Array<string>>
|
||||
abstract createStore<T = unknown>(storeName: string, opts: StorageOpts): Promise<BaseStore<T, E>>
|
||||
abstract getStore<T = unknown>(storeName: string, opts?: StorageOpts): Promise<BaseStore<T, E> | null>
|
||||
abstract createOrGetStore<T = unknown>(storeName: string, opts: StorageOpts): Promise<BaseStore<T, E>>
|
||||
abstract deleteStore(storeName: string): Promise<void>
|
||||
abstract deleteDatabase(): Promise<void>
|
||||
}
|
||||
|
||||
export abstract class BaseStore<
|
||||
K = unknown,
|
||||
E extends StoreEvents = StoreEvents,
|
||||
> extends EventEmitter<E> {
|
||||
abstract keys(): Promise<Array<string>>
|
||||
abstract get<T extends K>(key: string): Promise<T | undefined>
|
||||
abstract set<T extends K>(key: string, value: T): Promise<void>
|
||||
abstract delete(key: string): Promise<void>
|
||||
|
||||
async all<T extends K>(): Promise<{ [key: string]: T }> {
|
||||
const keys = await this.keys()
|
||||
const values = await Promise.all(keys.map((key) => this.get<T>(key)))
|
||||
return keys.reduce(
|
||||
(acc, key, index) => {
|
||||
const value = values[index]
|
||||
if (value !== undefined) acc[key] = value
|
||||
return acc
|
||||
},
|
||||
{} as { [key: string]: T },
|
||||
)
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const keys = await this.keys()
|
||||
await Promise.all(keys.map((key) => this.delete(key)))
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) ? true : false
|
||||
}
|
||||
|
||||
async batchGet<T extends K>(keys: Array<string>): Promise<Map<string, T>> {
|
||||
const results = await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const value = await this.get(key)
|
||||
return [key, value] as const
|
||||
}),
|
||||
)
|
||||
return new Map(results.filter(([_, value]) => value !== undefined) as [string, T][])
|
||||
}
|
||||
|
||||
async batchSet<T extends K>(items: Map<string, T>): Promise<void> {
|
||||
await Promise.all(Array.from(items).map(([key, value]) => this.set(key, value)))
|
||||
items.forEach((value, key) => this.emit('set', { key, value }))
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async getOrFail<T extends K>(key: string): Promise<T> {
|
||||
const value = await this.get<T>(key)
|
||||
if (typeof value === 'undefined') {
|
||||
throw new Error(`Failed to get key "${key}"`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async getOrSet<T extends K>(key: string, setter: () => T | Promise<T>): Promise<T> {
|
||||
let value = await this.get<T>(key)
|
||||
if (typeof value === 'undefined') {
|
||||
value = await setter()
|
||||
await this.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
proxy<T extends K>(key: string): StoreValueProxy<T> {
|
||||
const self = this
|
||||
return {
|
||||
get(): Promise<T | undefined> {
|
||||
return self.get<T>(key)
|
||||
},
|
||||
getOrFail(): Promise<T> {
|
||||
return self.getOrFail<T>(key)
|
||||
},
|
||||
async set(newValue: T): Promise<void> {
|
||||
await self.set(key, newValue)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/lib/storage/event-emitter.ts
Normal file
24
src/lib/storage/event-emitter.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
type EventHandler = (...args: any[]) => void
|
||||
|
||||
export class EventEmitter<Events extends Record<string, any> = Record<string, any>> {
|
||||
private listeners = new Map<string, Set<EventHandler>>()
|
||||
|
||||
on<K extends keyof Events & string>(event: K, handler: (payload: Events[K]) => void): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set())
|
||||
}
|
||||
this.listeners.get(event)!.add(handler as EventHandler)
|
||||
}
|
||||
|
||||
off<K extends keyof Events & string>(event: K, handler: (payload: Events[K]) => void): void {
|
||||
this.listeners.get(event)?.delete(handler as EventHandler)
|
||||
}
|
||||
|
||||
protected emit<K extends keyof Events & string>(event: K, payload: Events[K]): void {
|
||||
this.listeners.get(event)?.forEach((handler) => handler(payload))
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.listeners.clear()
|
||||
}
|
||||
}
|
||||
6
src/lib/storage/index.ts
Normal file
6
src/lib/storage/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { EventEmitter } from './event-emitter'
|
||||
export { BaseStorage, BaseStore } from './base-store'
|
||||
export { StoreInMemory } from './store-inmemory'
|
||||
export { StoreInMemorySynced } from './store-inmemory-synced'
|
||||
export { StorageIndexedDB, StoreIndexedDB } from './storage-indexeddb'
|
||||
export type { StorageOpts, StoreRecords, StoreEvents, StoreValueProxy } from './types'
|
||||
249
src/lib/storage/storage-indexeddb.ts
Normal file
249
src/lib/storage/storage-indexeddb.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { BaseStorage, BaseStore } from './base-store'
|
||||
import type { StorageOpts } from './types'
|
||||
import { StoreInMemorySynced } from './store-inmemory-synced'
|
||||
import { pack, unpack } from 'msgpackr'
|
||||
import { openDB, deleteDB, type IDBPDatabase } from 'idb'
|
||||
import { Mutex } from 'async-mutex'
|
||||
|
||||
export class StorageIndexedDB extends BaseStorage {
|
||||
private db: IDBPDatabase
|
||||
private dbName: string
|
||||
private activeStores = new Set<StoreIndexedDB>()
|
||||
private upgradeMutex = new Mutex()
|
||||
|
||||
private constructor(db: IDBPDatabase, dbName: string) {
|
||||
super()
|
||||
this.db = db
|
||||
this.dbName = dbName
|
||||
this.db.addEventListener('versionchange', () => {
|
||||
this.db.close()
|
||||
})
|
||||
}
|
||||
|
||||
private async upgradeDb(upgrade: (db: IDBPDatabase) => void) {
|
||||
const currentVersion = this.db.version
|
||||
const currentDB = this.db
|
||||
const newDb = await openDB(this.dbName, currentVersion + 1, {
|
||||
upgrade(db) { upgrade(db) },
|
||||
blocked() { currentDB.close() },
|
||||
blocking() {},
|
||||
terminated() {},
|
||||
})
|
||||
this.db = newDb
|
||||
return newDb
|
||||
}
|
||||
|
||||
static async createOrOpen(dbName: string) {
|
||||
try {
|
||||
const existingDb = await openDB(dbName, undefined, {
|
||||
blocked() {},
|
||||
blocking() {},
|
||||
terminated() {},
|
||||
})
|
||||
return new StorageIndexedDB(existingDb, dbName)
|
||||
} catch {
|
||||
const newDb = await openDB(dbName, 1, {
|
||||
upgrade(_db) {},
|
||||
})
|
||||
return new StorageIndexedDB(newDb, dbName)
|
||||
}
|
||||
}
|
||||
|
||||
async listStores() {
|
||||
await this.upgradeMutex.waitForUnlock()
|
||||
return Array.from(this.db.objectStoreNames)
|
||||
}
|
||||
|
||||
async createStore(storeName: string, opts: StorageOpts = {}) {
|
||||
let store: StoreIndexedDB
|
||||
{
|
||||
const release = await this.upgradeMutex.acquire()
|
||||
try {
|
||||
if (!this.db.objectStoreNames.contains(storeName)) {
|
||||
const newDb = await this.upgradeDb((db) => {
|
||||
db.createObjectStore(storeName)
|
||||
})
|
||||
for (const s of this.activeStores) {
|
||||
s.updateDatabaseReference(newDb)
|
||||
}
|
||||
}
|
||||
store = await StoreIndexedDB.create(this.db, storeName, this.upgradeMutex, this)
|
||||
this.activeStores.add(store)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store
|
||||
}
|
||||
|
||||
async getStore(storeName: string, opts: StorageOpts = {}) {
|
||||
try {
|
||||
await this.upgradeMutex.waitForUnlock()
|
||||
if (!this.db.objectStoreNames.contains(storeName)) return null
|
||||
const store = await StoreIndexedDB.create(this.db, storeName, this.upgradeMutex, this)
|
||||
this.activeStores.add(store)
|
||||
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async createOrGetStore(storeName: string, opts: StorageOpts = {}) {
|
||||
const existing = await this.getStore(storeName, opts)
|
||||
if (existing) return existing
|
||||
return this.createStore(storeName, opts)
|
||||
}
|
||||
|
||||
async deleteStore(storeName: string) {
|
||||
const release = await this.upgradeMutex.acquire()
|
||||
try {
|
||||
if (this.db.objectStoreNames.contains(storeName)) {
|
||||
const newDb = await this.upgradeDb((db) => {
|
||||
db.deleteObjectStore(storeName)
|
||||
})
|
||||
for (const store of this.activeStores) {
|
||||
store.updateDatabaseReference(newDb)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDatabase() {
|
||||
this.activeStores.clear()
|
||||
this.db.close()
|
||||
await deleteDB(this.dbName)
|
||||
}
|
||||
|
||||
removeActiveStore(store: StoreIndexedDB) {
|
||||
this.activeStores.delete(store)
|
||||
}
|
||||
}
|
||||
|
||||
export class StoreIndexedDB extends BaseStore {
|
||||
public db: IDBPDatabase
|
||||
public storeName: string
|
||||
public databaseUpgradeMutex: Mutex
|
||||
private parentStorage?: StorageIndexedDB
|
||||
|
||||
constructor(
|
||||
db: IDBPDatabase,
|
||||
storeName: string,
|
||||
databaseUpgradeMutex: Mutex,
|
||||
parentStorage?: StorageIndexedDB,
|
||||
) {
|
||||
super()
|
||||
this.db = db
|
||||
this.storeName = storeName
|
||||
this.databaseUpgradeMutex = databaseUpgradeMutex
|
||||
this.parentStorage = parentStorage
|
||||
}
|
||||
|
||||
updateDatabaseReference(newDb: IDBPDatabase) {
|
||||
this.db = newDb
|
||||
}
|
||||
|
||||
static async create(
|
||||
db: IDBPDatabase,
|
||||
storeName: string,
|
||||
databaseUpgradeMutex: Mutex,
|
||||
parentStorage?: StorageIndexedDB,
|
||||
) {
|
||||
return new this(db, storeName, databaseUpgradeMutex, parentStorage)
|
||||
}
|
||||
|
||||
async all<T>() {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const tx = this.db.transaction(this.storeName, 'readonly')
|
||||
const store = tx.objectStore(this.storeName)
|
||||
const keys = await store.getAllKeys()
|
||||
const values = await store.getAll()
|
||||
await tx.done
|
||||
return keys.reduce(
|
||||
(acc, key, index) => {
|
||||
acc[key.toString()] = unpack(values[index]) as T
|
||||
return acc
|
||||
},
|
||||
{} as { [key: string]: T },
|
||||
)
|
||||
}
|
||||
|
||||
async keys() {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const tx = this.db.transaction(this.storeName, 'readonly')
|
||||
const store = tx.objectStore(this.storeName)
|
||||
const keys = await store.getAllKeys()
|
||||
await tx.done
|
||||
return keys.map((key) => key.toString())
|
||||
}
|
||||
|
||||
async get<T>(key: string) {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const encodedValue = await this.db.get(this.storeName, key)
|
||||
if (!encodedValue) return undefined as unknown as T
|
||||
return unpack(encodedValue) as T
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T) {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const encodedValue = pack(value)
|
||||
await this.db.put(this.storeName, encodedValue, key)
|
||||
this.emit('set', { key, value })
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
await this.db.delete(this.storeName, key)
|
||||
this.emit('delete', { key })
|
||||
}
|
||||
|
||||
async batchGet<T>(keys: string[]): Promise<Map<string, T>> {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const tx = this.db.transaction(this.storeName, 'readonly')
|
||||
const store = tx.objectStore(this.storeName)
|
||||
const results = await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const value = await store.get(key)
|
||||
return [key, value ? (unpack(value) as T) : undefined] as const
|
||||
}),
|
||||
)
|
||||
await tx.done
|
||||
return new Map(results.filter(([_, value]) => value !== undefined) as [string, T][])
|
||||
}
|
||||
|
||||
async batchSet<T>(items: Map<string, T>) {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const tx = this.db.transaction(this.storeName, 'readwrite')
|
||||
const store = tx.objectStore(this.storeName)
|
||||
await Promise.all(
|
||||
Array.from(items).map(([key, value]) => {
|
||||
const encodedValue = pack(value)
|
||||
return store.put(encodedValue, key)
|
||||
}),
|
||||
)
|
||||
await tx.done
|
||||
items.forEach((value, key) => this.emit('set', { key, value }))
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
await this.db.clear(this.storeName)
|
||||
this.emit('clear', undefined)
|
||||
}
|
||||
|
||||
async has(key: string) {
|
||||
await this.databaseUpgradeMutex.waitForUnlock()
|
||||
const tx = this.db.transaction(this.storeName, 'readonly')
|
||||
const store = tx.objectStore(this.storeName)
|
||||
const result = await store.getKey(key)
|
||||
await tx.done
|
||||
return result !== undefined
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.parentStorage) {
|
||||
this.parentStorage.removeActiveStore(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/lib/storage/store-inmemory-synced.ts
Normal file
70
src/lib/storage/store-inmemory-synced.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { BaseStore } from './base-store'
|
||||
import { StoreInMemory } from './store-inmemory'
|
||||
|
||||
export class StoreInMemorySynced extends BaseStore {
|
||||
private inMemoryCache: StoreInMemory
|
||||
private store: BaseStore
|
||||
|
||||
private constructor(inMemoryCache: StoreInMemory, store: BaseStore) {
|
||||
super()
|
||||
this.inMemoryCache = inMemoryCache
|
||||
this.store = store
|
||||
|
||||
this.store.on('set', async (payload) => {
|
||||
await this.inMemoryCache.set(payload.key, payload.value)
|
||||
this.emit('set', { key: payload.key, value: payload.value })
|
||||
})
|
||||
|
||||
this.store.on('delete', async (payload) => {
|
||||
await this.inMemoryCache.delete(payload.key)
|
||||
this.emit('delete', { key: payload.key })
|
||||
})
|
||||
|
||||
this.store.on('clear', async () => {
|
||||
await this.inMemoryCache.clear()
|
||||
this.emit('clear', undefined)
|
||||
})
|
||||
}
|
||||
|
||||
static async create(store: BaseStore) {
|
||||
const inMemoryCache = new StoreInMemory()
|
||||
const memorySyncedStore = new this(inMemoryCache, store)
|
||||
const entries = await store.all()
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
await inMemoryCache.set(key, value)
|
||||
}
|
||||
return memorySyncedStore
|
||||
}
|
||||
|
||||
async all<T>() {
|
||||
return this.inMemoryCache.all<T>()
|
||||
}
|
||||
|
||||
async keys() {
|
||||
return this.inMemoryCache.keys()
|
||||
}
|
||||
|
||||
async get<T>(key: string) {
|
||||
return this.inMemoryCache.get<T>(key)
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T) {
|
||||
await this.store.set(key, value)
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
await this.store.delete(key)
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.store.clear()
|
||||
}
|
||||
|
||||
async has(key: string) {
|
||||
return this.inMemoryCache.has(key)
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.store.close()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user