basic file sharing
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user