basic file sharing

This commit is contained in:
2025-08-29 15:32:23 +02:00
parent c8b52a5b3b
commit 284d805590
84 changed files with 3969 additions and 375 deletions

View File

@@ -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">

View File

@@ -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

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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`,
});

View 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>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View 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>

View 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>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<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>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<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>

View 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"

View 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
}