basic file sharing
This commit is contained in:
@@ -11,12 +11,14 @@
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/yup": "^4.15.1",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"byte-size": "^9.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs-nuxt": "2.1.11",
|
||||
"eslint": "^9.0.0",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"nuxt": "^3.17.6",
|
||||
@@ -489,8 +491,12 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
|
||||
|
||||
"@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww=="],
|
||||
|
||||
"@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="],
|
||||
@@ -583,11 +589,11 @@
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.17", "", {}, "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="],
|
||||
"@vueuse/core": ["@vueuse/core@13.7.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.7.0", "@vueuse/shared": "13.7.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg=="],
|
||||
|
||||
"@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="],
|
||||
"@vueuse/metadata": ["@vueuse/metadata@13.7.0", "", {}, "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg=="],
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="],
|
||||
"@vueuse/shared": ["@vueuse/shared@13.7.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg=="],
|
||||
|
||||
"@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
|
||||
|
||||
@@ -813,6 +819,10 @@
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||
|
||||
"dayjs-nuxt": ["dayjs-nuxt@2.1.11", "", { "dependencies": { "@nuxt/kit": "^3.7.4", "dayjs": "^1.11.10" } }, "sha512-KDDNiET7KAKf6yzL3RaPWq5aV7ql9QTt5fIDYv+4eOegDmnEQGjwkKYADDystsKtPjt7QZerpVbhC96o3BIyqQ=="],
|
||||
|
||||
"db0": ["db0@0.3.2", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
const { entry } = defineProps<{ entry: DirectoryEntry }>();
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const onDrop = onDirectoryEntryDrop(entry, true);
|
||||
</script>
|
||||
@@ -12,7 +13,7 @@ const onDrop = onDirectoryEntryDrop(entry, true);
|
||||
<button
|
||||
class="bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none"
|
||||
@contextmenu.prevent
|
||||
@click="() => warrenStore.backCurrentPath()"
|
||||
@click="() => emit('back')"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="flex flex-row items-center">
|
||||
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
deleteWarrenDirectory,
|
||||
deleteWarrenFile,
|
||||
fetchFile,
|
||||
} from '~/lib/api/warrens';
|
||||
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '#shared/types';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
@@ -23,6 +19,11 @@ const { entry, disabled } = defineProps<{
|
||||
disabled: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [entry: DirectoryEntry];
|
||||
'entry-download': [entry: DirectoryEntry];
|
||||
}>();
|
||||
|
||||
const deleting = ref(false);
|
||||
const isCopied = computed(
|
||||
() =>
|
||||
@@ -63,30 +64,7 @@ async function openRenameDialog() {
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
if (warrenStore.loading || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
warrenStore.addToCurrentWarrenPath(entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType.startsWith('image/')) {
|
||||
const result = await fetchFile(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry.name
|
||||
);
|
||||
if (result.success) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
warrenStore.imageViewer.src = url;
|
||||
}
|
||||
}
|
||||
emit('entry-click', entry);
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
@@ -112,38 +90,22 @@ function onCopy() {
|
||||
);
|
||||
}
|
||||
|
||||
async function onDownload() {
|
||||
if (warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
function onShare() {
|
||||
useShareDialog().openDialog(entry);
|
||||
}
|
||||
|
||||
if (entry.fileType !== 'file') {
|
||||
toast.warning('Download', {
|
||||
description: 'Directory downloads are not supported yet',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.download = entry.name;
|
||||
anchor.href = getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
);
|
||||
anchor.rel = 'noopener';
|
||||
anchor.target = '_blank';
|
||||
|
||||
anchor.click();
|
||||
function onDownload() {
|
||||
emit('entry-download', entry);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<ContextMenuTrigger class="flex sm:w-52">
|
||||
<button
|
||||
:disabled="warrenStore.loading || disabled"
|
||||
:class="[
|
||||
'bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
|
||||
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
|
||||
isCopied && 'border-primary/50 border',
|
||||
]"
|
||||
draggable="true"
|
||||
@@ -151,38 +113,7 @@ async function onDownload() {
|
||||
@drop="onDrop"
|
||||
@click="onClick"
|
||||
>
|
||||
<div class="flex flex-row items-center">
|
||||
<Icon
|
||||
v-if="
|
||||
entry.fileType !== 'file' ||
|
||||
entry.mimeType == null ||
|
||||
!entry.mimeType.startsWith('image/')
|
||||
"
|
||||
class="size-6"
|
||||
:name="
|
||||
entry.fileType === 'file'
|
||||
? getFileIcon(entry.mimeType)
|
||||
: 'lucide:folder'
|
||||
"
|
||||
/>
|
||||
<object
|
||||
v-else
|
||||
:type="entry.mimeType"
|
||||
class="size-6 object-cover"
|
||||
width="24"
|
||||
height="24"
|
||||
:data="
|
||||
getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current!.path, entry.name)}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
class="size-6"
|
||||
:name="getFileIcon(entry.mimeType)"
|
||||
/>
|
||||
</object>
|
||||
</div>
|
||||
<DirectoryEntryIcon :entry />
|
||||
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-stretch gap-0 overflow-hidden text-left leading-6"
|
||||
@@ -198,11 +129,17 @@ async function onDownload() {
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem @select="openRenameDialog">
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="openRenameDialog"
|
||||
>
|
||||
<Icon name="lucide:pencil" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @select="onCopy">
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="onCopy"
|
||||
>
|
||||
<Icon name="lucide:copy" />
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
@@ -213,15 +150,28 @@ async function onDownload() {
|
||||
<Icon name="lucide:download" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="onShare"
|
||||
>
|
||||
<Icon name="lucide:share" />
|
||||
Share
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSeparator
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
/>
|
||||
|
||||
<ContextMenuItem @select="() => submitDelete(false)">
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="() => submitDelete(false)"
|
||||
>
|
||||
<Icon name="lucide:trash-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="entry.fileType === 'directory'"
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="() => submitDelete(true)"
|
||||
>
|
||||
<Icon
|
||||
|
||||
47
frontend/components/DirectoryEntryIcon.vue
Normal file
47
frontend/components/DirectoryEntryIcon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
const { entry } = defineProps<{
|
||||
entry: DirectoryEntry;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const warrenStore = useWarrenStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row items-center">
|
||||
<Icon
|
||||
v-if="
|
||||
entry.fileType !== 'file' ||
|
||||
entry.mimeType == null ||
|
||||
!entry.mimeType.startsWith('image/')
|
||||
"
|
||||
class="size-6"
|
||||
:name="
|
||||
entry.fileType === 'file'
|
||||
? getFileIcon(entry.mimeType)
|
||||
: 'lucide:folder'
|
||||
"
|
||||
/>
|
||||
<object
|
||||
v-else-if="warrenStore.current != null"
|
||||
:type="entry.mimeType"
|
||||
class="size-6 object-cover"
|
||||
width="24"
|
||||
height="24"
|
||||
:data="
|
||||
route.meta.layout === 'share'
|
||||
? getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${route.query.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
: getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon class="size-6" :name="getFileIcon(entry.mimeType)" />
|
||||
</object>
|
||||
<Icon v-else class="size-6" :name="getFileIcon(entry.mimeType)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,10 +2,22 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { DirectoryEntry } from '#shared/types';
|
||||
|
||||
const { entries, parent, isOverDropZone } = defineProps<{
|
||||
const {
|
||||
entries,
|
||||
parent,
|
||||
isOverDropZone,
|
||||
disableEntries = false,
|
||||
} = defineProps<{
|
||||
entries: DirectoryEntry[];
|
||||
parent: DirectoryEntry | null;
|
||||
isOverDropZone?: boolean;
|
||||
disableEntries?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [entry: DirectoryEntry];
|
||||
'entry-download': [entry: DirectoryEntry];
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { isLoading } = useLoadingIndicator();
|
||||
@@ -13,23 +25,39 @@ const { isLoading } = useLoadingIndicator();
|
||||
const sortedEntries = computed(() =>
|
||||
entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
function onEntryClicked(entry: DirectoryEntry) {
|
||||
emit('entry-click', entry);
|
||||
}
|
||||
|
||||
function onEntryDownload(entry: DirectoryEntry) {
|
||||
emit('entry-download', entry);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollArea class="h-full w-full">
|
||||
<ScrollArea class="flex h-full w-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="isOverDropZone"
|
||||
class="bg-background/50 pointer-events-none absolute flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<Icon class="size-16 animate-pulse" name="lucide:upload" />
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<DirectoryBackEntry v-if="parent != null" :entry="parent" />
|
||||
<div
|
||||
class="flex w-full flex-col gap-2 overflow-hidden sm:flex-row sm:flex-wrap"
|
||||
>
|
||||
<DirectoryBackEntry
|
||||
v-if="parent != null"
|
||||
:entry="parent"
|
||||
@back="() => emit('back')"
|
||||
/>
|
||||
<DirectoryEntry
|
||||
v-for="entry in sortedEntries"
|
||||
:key="entry.name"
|
||||
:entry="entry"
|
||||
:disabled="isLoading"
|
||||
:disabled="isLoading || disableEntries"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
95
frontend/components/SharesTable.vue
Normal file
95
frontend/components/SharesTable.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { deleteShare } from '~/lib/api/shares';
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
|
||||
const { shares } = defineProps<{
|
||||
shares: Share[];
|
||||
}>();
|
||||
|
||||
function onCopyClicked(share: Share) {
|
||||
const link = getShareLink(share);
|
||||
if (copyToClipboard(link)) {
|
||||
toast.success('Share', {
|
||||
description: 'Copied the link to the clipboard',
|
||||
});
|
||||
} else {
|
||||
console.log(`Here's the link to ${share.path}: ${link}`);
|
||||
toast.error('Share', {
|
||||
description: `Failed to copy the link to the clipboard. Logged it to the console instead.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteClicked(share: Share) {
|
||||
const result = await deleteShare(share.warrenId, share.id);
|
||||
|
||||
if (result.success) {
|
||||
refreshNuxtData('current-file-shares');
|
||||
toast.success('Share', {
|
||||
description: `Successfully deleted the share for ${result.share.path}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Share', {
|
||||
description: 'Failed to delete share',
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">ID</TableHead>
|
||||
<TableHead>Password</TableHead>
|
||||
<TableHead>Expiration</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead class="w-0 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="share in shares" :key="share.id">
|
||||
<TableCell class="font-medium" :title="share.id">
|
||||
<span class="hidden sm:block">
|
||||
{{ share.id }}
|
||||
</span>
|
||||
<span class="block sm:hidden"
|
||||
>{{ share.id.slice(0, 3) }}...{{ share.id.slice(-3) }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{{ share.password ? 'Yes' : 'No' }}</TableCell>
|
||||
<TableCell>{{
|
||||
share.expiresAt == null
|
||||
? 'Never'
|
||||
: $dayjs(share.expiresAt).format('MMM D, YYYY HH:mm')
|
||||
}}</TableCell>
|
||||
<TableCell>{{
|
||||
$dayjs(share.createdAt).format('MMM D, YYYY HH:mm')
|
||||
}}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="() => onCopyClicked(share)"
|
||||
><Icon name="lucide:copy"
|
||||
/></Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@click="() => onDeleteClicked(share)"
|
||||
><Icon name="lucide:trash-2"
|
||||
/></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</template>
|
||||
203
frontend/components/actions/ShareDialog.vue
Normal file
203
frontend/components/actions/ShareDialog.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useShareDialog } from '@/stores';
|
||||
import { toTypedSchema } from '@vee-validate/yup';
|
||||
import { useForm } from 'vee-validate';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { createShare, listShares } from '~/lib/api/shares';
|
||||
import { shareSchema } from '~/lib/schemas/share';
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
const dialog = useShareDialog();
|
||||
|
||||
const newScreen = ref(false);
|
||||
|
||||
const existingShares = ref<Share[]>([]);
|
||||
useAsyncData('current-file-shares', async () => {
|
||||
if (warrenStore.current == null || dialog.target == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await listShares(
|
||||
warrenStore.current.warrenId,
|
||||
joinPaths(warrenStore.current.path, dialog.target.name)
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.shares.length < 1) {
|
||||
newScreen.value = true;
|
||||
} else {
|
||||
newScreen.value = false;
|
||||
}
|
||||
|
||||
existingShares.value = result.shares;
|
||||
});
|
||||
dialog.$subscribe(async (_, value) => {
|
||||
if (value.target !== null) {
|
||||
await refreshNuxtData('current-file-shares');
|
||||
}
|
||||
});
|
||||
|
||||
function onOpenChange() {
|
||||
dialog.reset();
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (newScreen.value && existingShares.value.length > 0) {
|
||||
newScreen.value = false;
|
||||
} else {
|
||||
dialog.reset();
|
||||
}
|
||||
}
|
||||
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(shareSchema),
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
if (dialog.target == null || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createShare(
|
||||
warrenStore.current.warrenId,
|
||||
joinPaths(warrenStore.current.path, dialog.target.name),
|
||||
values.password ?? null,
|
||||
values.lifetime ?? null
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Share', {
|
||||
description: `Successfully created a share for ${dialog.target.name}`,
|
||||
});
|
||||
await refreshNuxtData('current-file-shares');
|
||||
newScreen.value = false;
|
||||
} else {
|
||||
toast.error('Share', {
|
||||
description: `Failed to create share`,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="dialog.target != null" @update:open="onOpenChange">
|
||||
<DialogTrigger as-child>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
v-if="dialog.target != null"
|
||||
class="w-full !max-w-[calc(100vw-8px)] sm:!max-w-[min(98vw,1000px)]"
|
||||
>
|
||||
<DialogHeader class="overflow-hidden">
|
||||
<DialogTitle class="truncate"
|
||||
>Share {{ dialog.target.name }}</DialogTitle
|
||||
>
|
||||
<DialogDescription
|
||||
>Create a shareable link to this
|
||||
{{ dialog.target.fileType }}</DialogDescription
|
||||
>
|
||||
</DialogHeader>
|
||||
|
||||
<SharesTable v-if="!newScreen" :shares="existingShares" />
|
||||
<form
|
||||
v-else
|
||||
id="share-form"
|
||||
class="grid gap-4"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
id="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<span
|
||||
v-if="
|
||||
form.values.password == null ||
|
||||
form.values.password.length < 1
|
||||
"
|
||||
>
|
||||
The share will not require a password
|
||||
</span>
|
||||
<span v-else>
|
||||
The share will require the specified password
|
||||
</span>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="lifetime">
|
||||
<FormItem>
|
||||
<FormLabel>Lifetime (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
class="flex w-full flex-row justify-between gap-1"
|
||||
>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
id="lifetime"
|
||||
type="number"
|
||||
class="w-full max-w-full min-w-0"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<span
|
||||
v-if="
|
||||
form.values.lifetime != null &&
|
||||
form.values.lifetime > 0
|
||||
"
|
||||
class="w-full"
|
||||
>
|
||||
The share will expire in
|
||||
{{
|
||||
$dayjs
|
||||
.duration({
|
||||
seconds: form.values.lifetime,
|
||||
})
|
||||
.humanize()
|
||||
}}
|
||||
</span>
|
||||
<span v-else> The share will be permanent </span>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" @click="onCancel">Cancel</Button>
|
||||
<Button v-if="newScreen" type="submit" form="share-form"
|
||||
>Share</Button
|
||||
>
|
||||
<Button v-else @click="() => (newScreen = true)">New</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -13,19 +13,37 @@ import type { UserWarren } from '~/shared/types/warrens';
|
||||
const props = defineProps<{
|
||||
userWarren: UserWarren;
|
||||
}>();
|
||||
let realUserWarrenState: UserWarren = JSON.parse(
|
||||
JSON.stringify(props.userWarren)
|
||||
);
|
||||
const userWarren = props.userWarren;
|
||||
|
||||
const adminStore = useAdminStore();
|
||||
|
||||
const updatePermissionsDebounced = useDebounceFn(
|
||||
async (userWarren: UserWarren) => {
|
||||
const result = await editUserWarren(userWarren);
|
||||
async (uw: UserWarren) => {
|
||||
const result = await editUserWarren(uw);
|
||||
|
||||
if (result.success) {
|
||||
for (const [key, value] of Object.entries(result.data)) {
|
||||
if (key in userWarren) {
|
||||
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
|
||||
userWarren[key] = value;
|
||||
}
|
||||
}
|
||||
realUserWarrenState = JSON.parse(JSON.stringify(result.data));
|
||||
toast.success('Permissions', {
|
||||
description: `Successfully updated the user's permissions`,
|
||||
});
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(realUserWarrenState)) {
|
||||
if (key in userWarren) {
|
||||
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
|
||||
userWarren[key] = value;
|
||||
}
|
||||
}
|
||||
userWarren.canCreateShares = realUserWarrenState.canCreateShares;
|
||||
|
||||
toast.error('Permissions', {
|
||||
description: `Failed to update the user's permissions`,
|
||||
});
|
||||
|
||||
16
frontend/components/ui/table/Table.vue
Normal file
16
frontend/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="table-container" class="relative w-full overflow-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableBody.vue
Normal file
17
frontend/components/ui/table/TableBody.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>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableCaption.vue
Normal file
17
frontend/components/ui/table/TableCaption.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>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
22
frontend/components/ui/table/TableCell.vue
Normal file
22
frontend/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
34
frontend/components/ui/table/TableEmpty.vue
Normal file
34
frontend/components/ui/table/TableEmpty.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import TableCell from "./TableCell.vue"
|
||||
import TableRow from "./TableRow.vue"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableFooter.vue
Normal file
17
frontend/components/ui/table/TableFooter.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>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableHead.vue
Normal file
17
frontend/components/ui/table/TableHead.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>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableHeader.vue
Normal file
17
frontend/components/ui/table/TableHeader.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>
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
:class="cn('[&_tr]:border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
17
frontend/components/ui/table/TableRow.vue
Normal file
17
frontend/components/ui/table/TableRow.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>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
9
frontend/components/ui/table/index.ts
Normal file
9
frontend/components/ui/table/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Table } from "./Table.vue"
|
||||
export { default as TableBody } from "./TableBody.vue"
|
||||
export { default as TableCaption } from "./TableCaption.vue"
|
||||
export { default as TableCell } from "./TableCell.vue"
|
||||
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||
export { default as TableFooter } from "./TableFooter.vue"
|
||||
export { default as TableHead } from "./TableHead.vue"
|
||||
export { default as TableHeader } from "./TableHeader.vue"
|
||||
export { default as TableRow } from "./TableRow.vue"
|
||||
10
frontend/components/ui/table/utils.ts
Normal file
10
frontend/components/ui/table/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Updater } from "@tanstack/vue-table"
|
||||
|
||||
import type { Ref } from "vue"
|
||||
import { isFunction } from "@tanstack/vue-table"
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
@@ -27,16 +27,7 @@ export function setAuthSession(value: {
|
||||
}) {
|
||||
useAuthSession().value = value;
|
||||
|
||||
let cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
|
||||
|
||||
const config = useRuntimeConfig().public;
|
||||
console.log('config', config);
|
||||
const cookieDomain = config.authCookieDomain;
|
||||
if (cookieDomain != null && cookieDomain.length > 0) {
|
||||
cookie += ` domain=${cookieDomain}`;
|
||||
}
|
||||
|
||||
console.log(cookie);
|
||||
const cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
|
||||
|
||||
document.cookie = cookie;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ await useAsyncData('warrens', async () => {
|
||||
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<ActionsShareDialog />
|
||||
<ImageViewer />
|
||||
<AppSidebar />
|
||||
<SidebarInset class="flex flex-col-reverse md:flex-col">
|
||||
|
||||
8
frontend/layouts/share.vue
Normal file
8
frontend/layouts/share.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<template>
|
||||
<main class="flex h-full w-full items-center justify-center">
|
||||
<ImageViewer />
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
176
frontend/lib/api/shares.ts
Normal file
176
frontend/lib/api/shares.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { ApiResponse } from '~/shared/types/api';
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
import { getApiHeaders } from '.';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
export async function getShare(
|
||||
shareId: string
|
||||
): Promise<
|
||||
{ success: true; share: Share; file: DirectoryEntry } | { success: false }
|
||||
> {
|
||||
const { data } = await useFetch<
|
||||
ApiResponse<{ share: Share; file: DirectoryEntry }>
|
||||
>(getApiUrl('warrens/files/get_share'), {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(false),
|
||||
body: JSON.stringify({
|
||||
shareId: shareId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { share, file } = data.value.data;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
share,
|
||||
file,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createShare(
|
||||
warrenId: string,
|
||||
path: string,
|
||||
password: string | null,
|
||||
lifetime: number | null
|
||||
): Promise<{ success: true; share: Share } | { success: false }> {
|
||||
const { data } = await useFetch<ApiResponse<Share>>(
|
||||
getApiUrl('warrens/files/create_share'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId: warrenId,
|
||||
path: path,
|
||||
lifetime: lifetime,
|
||||
password: password,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, share: data.value.data };
|
||||
}
|
||||
|
||||
export async function listShares(
|
||||
warrenId: string,
|
||||
path: string
|
||||
): Promise<{ success: true; shares: Share[] } | { success: false }> {
|
||||
const { data } = await useFetch<ApiResponse<Share[]>>(
|
||||
getApiUrl('warrens/files/list_shares'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId: warrenId,
|
||||
path: path,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, shares: data.value.data };
|
||||
}
|
||||
|
||||
export async function deleteShare(
|
||||
warrenId: string,
|
||||
shareId: string
|
||||
): Promise<{ success: true; share: Share } | { success: false }> {
|
||||
const { data } = await useFetch<ApiResponse<Share>>(
|
||||
getApiUrl('warrens/files/delete_share'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId: warrenId,
|
||||
shareId: shareId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, share: data.value.data };
|
||||
}
|
||||
|
||||
export async function listShareFiles(
|
||||
shareId: string,
|
||||
path: string,
|
||||
password: string | null
|
||||
): Promise<
|
||||
| { success: true; files: DirectoryEntry[]; parent: DirectoryEntry | null }
|
||||
| { success: false }
|
||||
> {
|
||||
const { data } = await useFetch<
|
||||
ApiResponse<{ files: DirectoryEntry[]; parent: DirectoryEntry | null }>
|
||||
>(getApiUrl('warrens/files/ls_share'), {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
shareId: shareId,
|
||||
path: path,
|
||||
password: password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { files, parent } = data.value.data;
|
||||
|
||||
return { success: true, files, parent };
|
||||
}
|
||||
|
||||
export async function fetchShareFile(
|
||||
shareId: string,
|
||||
path: string,
|
||||
password: string | null
|
||||
): Promise<{ success: true; data: Blob } | { success: false }> {
|
||||
const { data } = await useFetch<Blob>(
|
||||
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&path=${path}`),
|
||||
{
|
||||
method: 'GET',
|
||||
headers:
|
||||
password != null
|
||||
? {
|
||||
'X-Share-Password': password,
|
||||
}
|
||||
: {},
|
||||
responseType: 'blob',
|
||||
cache: 'default',
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.value,
|
||||
};
|
||||
}
|
||||
12
frontend/lib/schemas/share.ts
Normal file
12
frontend/lib/schemas/share.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { number, object, string } from 'yup';
|
||||
|
||||
export const shareSchema = object({
|
||||
password: string()
|
||||
.trim()
|
||||
.transform((s: string) => (s.length > 0 ? s : undefined))
|
||||
.optional(),
|
||||
lifetime: number()
|
||||
.positive()
|
||||
.transform((n) => (isNaN(n) ? undefined : n))
|
||||
.optional(),
|
||||
});
|
||||
@@ -14,8 +14,13 @@ export default defineNuxtConfig({
|
||||
'shadcn-nuxt',
|
||||
'@nuxtjs/color-mode',
|
||||
'@pinia/nuxt',
|
||||
'dayjs-nuxt',
|
||||
],
|
||||
|
||||
dayjs: {
|
||||
plugins: ['duration', 'relativeTime'],
|
||||
},
|
||||
|
||||
css: ['~/assets/css/tailwind.css', '~/assets/css/sonner.css'],
|
||||
|
||||
vite: {
|
||||
|
||||
@@ -18,12 +18,14 @@
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@pinia/nuxt": "^0.11.1",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/yup": "^4.15.1",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"byte-size": "^9.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs-nuxt": "2.1.11",
|
||||
"eslint": "^9.0.0",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"nuxt": "^3.17.6",
|
||||
|
||||
@@ -33,7 +33,6 @@ if (
|
||||
route.query.state &&
|
||||
typeof route.query.state === 'string'
|
||||
) {
|
||||
console.log('SEND');
|
||||
loggingIn.value = true;
|
||||
const { success } = await oidcLoginUser(
|
||||
route.query.code,
|
||||
|
||||
237
frontend/pages/share.vue
Normal file
237
frontend/pages/share.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script lang="ts" setup>
|
||||
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'share',
|
||||
});
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
const route = useRoute();
|
||||
|
||||
const share = await getShareFromQuery();
|
||||
const entries = ref<DirectoryEntry[] | null>(null);
|
||||
const parent = ref<DirectoryEntry | null>(null);
|
||||
const password = ref<string>('');
|
||||
const loading = ref<boolean>(false);
|
||||
|
||||
if (share != null) {
|
||||
warrenStore.setCurrentWarren(share.data.warrenId, '/');
|
||||
|
||||
if (!share.data.password) {
|
||||
await loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function getShareFromQuery(): Promise<{
|
||||
data: Share;
|
||||
file: DirectoryEntry;
|
||||
} | null> {
|
||||
const shareId = route.query.id;
|
||||
|
||||
if (shareId == null || typeof shareId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await getShare(shareId);
|
||||
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { data: result.share, file: result.file };
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
if (loading.value || share == null || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (share.file.fileType !== 'directory') {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const result = await listShareFiles(
|
||||
share.data.id,
|
||||
warrenStore.current.path,
|
||||
password.value.length > 0 ? password.value : null
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
|
||||
|
||||
if (share.data.expiresAt != null) {
|
||||
const dayjs = useDayjs();
|
||||
|
||||
const diff = dayjs(share.data.expiresAt).diff(dayjs()) / 1000;
|
||||
cookie += `Max-Age=${diff};`;
|
||||
}
|
||||
|
||||
document.cookie = cookie;
|
||||
|
||||
entries.value = result.files;
|
||||
parent.value = result.parent;
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function onEntryClicked(entry: DirectoryEntry) {
|
||||
if (warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = joinPaths(warrenStore.current.path, entry.name);
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
warrenStore.setCurrentWarrenPath(entryPath);
|
||||
await loadFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType.startsWith('image/')) {
|
||||
const result = await fetchShareFile(
|
||||
share!.data.id,
|
||||
entryPath,
|
||||
password.value.length > 0 ? password.value : null
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
warrenStore.imageViewer.src = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onBack() {
|
||||
if (warrenStore.backCurrentPath()) {
|
||||
await loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function onDowloadClicked() {
|
||||
if (share == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
share.file.name,
|
||||
getApiUrl(`warrens/files/cat_share?shareId=${share.data.id}&path=/`)
|
||||
);
|
||||
}
|
||||
|
||||
function onEntryDownload(entry: DirectoryEntry) {
|
||||
if (share == null || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
entry.name,
|
||||
getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="share != null"
|
||||
class="flex h-full w-full items-center justify-center px-2"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-full rounded-lg border transition-all',
|
||||
entries == null ? 'max-w-lg' : 'max-w-screen-xl',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-4 px-6 pt-6"
|
||||
>
|
||||
<div class="flex w-full flex-row">
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<h3 class="leading-none font-semibold">Share</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Created
|
||||
{{
|
||||
$dayjs(share.data.createdAt).format(
|
||||
'MMM D, YYYY HH:mm'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
<p>{{ share.file.name }}</p>
|
||||
<DirectoryEntryIcon
|
||||
:entry="{ ...share.file, name: '/' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-end">
|
||||
<Button
|
||||
:class="
|
||||
share.file.fileType !== 'file' &&
|
||||
entries == null &&
|
||||
'hidden'
|
||||
"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
@click="onDowloadClicked"
|
||||
><Icon name="lucide:download"
|
||||
/></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-6">
|
||||
<DirectoryList
|
||||
v-if="entries != null"
|
||||
:entries
|
||||
:parent
|
||||
:disable-entries="loading"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
@back="onBack"
|
||||
/>
|
||||
<div
|
||||
v-else-if="share.data.password"
|
||||
class="flex h-full flex-col justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="off"
|
||||
data-1p-ignore
|
||||
data-protonpass-ignore
|
||||
data-bwignore
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse items-end">
|
||||
<Button
|
||||
:disabled="loading || password.length < 1"
|
||||
@click="submitPassword"
|
||||
>Enter</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="bg-accent/20 rounded-md p-4">
|
||||
<p class="text-destructive-foreground">Failed to get share</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,7 +3,8 @@ import { useDropZone } from '@vueuse/core';
|
||||
import { toast } from 'vue-sonner';
|
||||
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
|
||||
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
|
||||
import { getWarrenDirectory } from '~/lib/api/warrens';
|
||||
import { fetchFile, getWarrenDirectory } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authenticated'],
|
||||
@@ -31,7 +32,10 @@ const dirData = useAsyncData(
|
||||
'current-directory',
|
||||
async () => {
|
||||
if (warrenStore.current == null) {
|
||||
return [];
|
||||
return {
|
||||
files: [],
|
||||
parent: null,
|
||||
};
|
||||
}
|
||||
|
||||
loadingIndicator.start();
|
||||
@@ -74,6 +78,57 @@ function onDrop(files: File[] | null, e: DragEvent) {
|
||||
uploadStore.dialogOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function onEntryClicked(entry: DirectoryEntry) {
|
||||
if (warrenStore.loading || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
warrenStore.addToCurrentWarrenPath(entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType.startsWith('image/')) {
|
||||
const result = await fetchFile(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry.name
|
||||
);
|
||||
if (result.success) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
warrenStore.imageViewer.src = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onEntryDownload(entry: DirectoryEntry) {
|
||||
if (warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileType !== 'file') {
|
||||
toast.warning('Download', {
|
||||
description: 'Directory downloads are not supported yet',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
entry.name,
|
||||
getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
warrenStore.backCurrentPath();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,6 +142,9 @@ function onDrop(files: File[] | null, e: DragEvent) {
|
||||
"
|
||||
:entries="dirData.files"
|
||||
:parent="dirData.parent"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
@back="onBack"
|
||||
/>
|
||||
</DirectoryListContextMenu>
|
||||
<RenameEntryDialog />
|
||||
|
||||
13
frontend/shared/types/shares.ts
Normal file
13
frontend/shared/types/shares.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type Share = {
|
||||
id: string;
|
||||
|
||||
creatorId: string;
|
||||
warrenId: string;
|
||||
|
||||
path: string;
|
||||
|
||||
password: boolean;
|
||||
|
||||
expiresAt: number | null;
|
||||
createdAt: number;
|
||||
};
|
||||
@@ -10,8 +10,14 @@ export type AdminWarrenData = WarrenData & {
|
||||
export type UserWarren = {
|
||||
userId: string;
|
||||
warrenId: string;
|
||||
|
||||
canListFiles: boolean;
|
||||
canReadFiles: boolean;
|
||||
canModifyFiles: boolean;
|
||||
canDeleteFiles: boolean;
|
||||
|
||||
canListShares: boolean;
|
||||
canCreateShares: boolean;
|
||||
canModifyShares: boolean;
|
||||
canDeleteShares: boolean;
|
||||
};
|
||||
|
||||
@@ -30,12 +30,14 @@ export const useWarrenStore = defineStore('warrens', {
|
||||
|
||||
this.current.path += path;
|
||||
},
|
||||
backCurrentPath() {
|
||||
backCurrentPath(): boolean {
|
||||
if (this.current == null || this.current.path === '/') {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.current.path = getParentPath(this.current.path);
|
||||
|
||||
return true;
|
||||
},
|
||||
setCurrentWarrenPath(path: string) {
|
||||
if (this.current == null) {
|
||||
@@ -89,3 +91,20 @@ export const useRenameDirectoryDialog = defineStore('rename_directory_dialog', {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useShareDialog = defineStore('share_dialog', {
|
||||
state: () => ({
|
||||
target: null as DirectoryEntry | null,
|
||||
password: '',
|
||||
}),
|
||||
actions: {
|
||||
openDialog(target: DirectoryEntry) {
|
||||
this.target = target;
|
||||
this.password = '';
|
||||
},
|
||||
reset() {
|
||||
this.target = null;
|
||||
this.password = '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
|
||||
export function getApiUrl(path: string): string {
|
||||
const API_BASE_URL = useRuntimeConfig().public.apiBase;
|
||||
return `${API_BASE_URL}/${path}`;
|
||||
@@ -19,3 +21,7 @@ export function routeWithWarrenName(warrenId: string, path: string): string {
|
||||
|
||||
return `${warrenName}${path}`;
|
||||
}
|
||||
|
||||
export function getShareLink(share: Share): string {
|
||||
return `${window.location.origin}/share?id=${share.id}`;
|
||||
}
|
||||
|
||||
@@ -75,3 +75,14 @@ export async function pasteFile(
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
export function downloadFile(fileName: string, href: string) {
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = href;
|
||||
anchor.download = fileName;
|
||||
anchor.rel = 'noopener';
|
||||
anchor.target = '_blank';
|
||||
|
||||
anchor.click();
|
||||
}
|
||||
|
||||
@@ -27,3 +27,13 @@ export function trim(str: string, char: string) {
|
||||
|
||||
return start > 0 || end < str.length ? str.substring(start, end) : str;
|
||||
}
|
||||
|
||||
export function copyToClipboard(content: string): boolean {
|
||||
navigator.clipboard.writeText(content);
|
||||
|
||||
return navigator.clipboard != null;
|
||||
}
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ export type UserWarrenPermissionKey =
|
||||
| 'canListFiles'
|
||||
| 'canReadFiles'
|
||||
| 'canModifyFiles'
|
||||
| 'canDeleteFiles';
|
||||
| 'canDeleteFiles'
|
||||
| 'canListShares'
|
||||
| 'canListShares'
|
||||
| 'canCreateShares'
|
||||
| 'canModifyShares'
|
||||
| 'canDeleteShares';
|
||||
|
||||
export function getUserWarrenPermissions(
|
||||
userWarren: UserWarren
|
||||
@@ -14,6 +19,10 @@ export function getUserWarrenPermissions(
|
||||
['canReadFiles', userWarren.canReadFiles],
|
||||
['canModifyFiles', userWarren.canModifyFiles],
|
||||
['canDeleteFiles', userWarren.canDeleteFiles],
|
||||
['canListShares', userWarren.canListShares],
|
||||
['canCreateShares', userWarren.canCreateShares],
|
||||
['canModifyShares', userWarren.canModifyShares],
|
||||
['canDeleteShares', userWarren.canDeleteShares],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,6 +31,10 @@ const PERMISSION_NAMES: Record<UserWarrenPermissionKey, string> = {
|
||||
canReadFiles: 'Read files',
|
||||
canModifyFiles: 'Modify files',
|
||||
canDeleteFiles: 'Delete files',
|
||||
canListShares: 'List shares',
|
||||
canCreateShares: 'Create shares',
|
||||
canModifyShares: 'Modify shares',
|
||||
canDeleteShares: 'Delete shares',
|
||||
};
|
||||
|
||||
export function getUserWarrenPermissionName(
|
||||
|
||||
Reference in New Issue
Block a user