Format imports, partial implementation of import upload entries
This commit is contained in:
@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
|
||||||
|
|
||||||
const defaultOpen = useCookie<boolean>("sidebar_state");
|
const defaultOpen = useCookie<boolean>("sidebar_state");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PlaylistsNotFound from '@/components/internal/playlists/select/PlaylistsNotFound.vue';
|
import PlaylistsNotFound from '@/components/internal/playlists/select/PlaylistsNotFound.vue';
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
|
||||||
import Select from '@/components/ui/select/Select.vue';
|
|
||||||
import SelectContent from '@/components/ui/select/SelectContent.vue';
|
|
||||||
import SelectCustomTrigger from '@/components/ui/select/SelectCustomTrigger.vue';
|
|
||||||
import SelectGroup from '@/components/ui/select/SelectGroup.vue';
|
|
||||||
import SelectItem from '@/components/ui/select/SelectItem.vue';
|
|
||||||
import SelectLabel from '@/components/ui/select/SelectLabel.vue';
|
|
||||||
import SelectSeparator from '@/components/ui/select/SelectSeparator.vue';
|
|
||||||
import { useSidebar } from '@/components/ui/sidebar';
|
|
||||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue';
|
|
||||||
import { usePlaylists } from '@/composeables/api/playlist-controller/playlist-controller';
|
import { usePlaylists } from '@/composeables/api/playlist-controller/playlist-controller';
|
||||||
import { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
|
import { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
|
||||||
import Button from '../ui/button/Button.vue';
|
|
||||||
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
|
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
|
||||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
||||||
|
import { useSidebar } from '../ui/sidebar';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
open: sidebarOpen,
|
open: sidebarOpen,
|
||||||
@ -68,7 +58,7 @@ watch(data, (value) => {
|
|||||||
<SelectContent align="start" side="right" :sideOffset="4">
|
<SelectContent align="start" side="right" :sideOffset="4">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div v-if="isLoading">
|
<div v-if="isLoading">
|
||||||
<UiSpinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="isError">
|
<div v-else-if="isError">
|
||||||
<SelectLabel>{{ error }}</SelectLabel>
|
<SelectLabel>{{ error }}</SelectLabel>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Frame margin="none" class="px-3 py-4 flex items-center gap-2">
|
<Frame margin="none" class="px-3 py-4 flex items-center gap-2 cursor-pointer" @click="openDialog">
|
||||||
<div>
|
<div>
|
||||||
<ListMusicIcon :size="40" v-if="hasLoaded" />
|
<ListMusicIcon :size="40" v-if="hasLoaded" />
|
||||||
<CassetteTape :size="40" v-if="hasProgress" />
|
<CassetteTape :size="40" v-else-if="hasProgress" />
|
||||||
<FileQuestionMark :size="40" v-if="hasError" />
|
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-1">
|
||||||
@ -24,7 +24,7 @@
|
|||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ progress }}%
|
{{ progress }}%
|
||||||
</p>
|
</p>
|
||||||
<UiProgress :modelValue="progress" />
|
<Progress :modelValue="progress" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row" v-if="hasError">
|
<div class="flex flex-row" v-if="hasError">
|
||||||
<p class="text-sm text-destructive-foreground">
|
<p class="text-sm text-destructive-foreground">
|
||||||
@ -35,48 +35,110 @@
|
|||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<UiButton variant="ghost">
|
<Button variant="ghost">
|
||||||
<EllipsisVertical :size="40" />
|
<EllipsisVertical :size="40" />
|
||||||
</UiButton>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="w-56" align="start">
|
<DropdownMenuContent class="w-56" align="start">
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuItem @click="openDialog">
|
||||||
<DropdownMenuGroup>
|
<Eye class="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem>
|
View Details
|
||||||
Profile
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
Billing
|
Retry
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem class="text-destructive">
|
||||||
Settings
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
<DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
Keyboard shortcuts
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog :open="isDialogOpen">
|
||||||
|
<DialogContent class="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Playlist Upload Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Detailed information about this playlist upload
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">Basic Information</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<p class="text-sm">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div :class="`h-2 w-2 rounded-full ${getStatusColor()}`" />
|
||||||
|
<span class="text-sm">{{ getStatusText() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Track Count</Label>
|
||||||
|
<p class="text-sm">{{ trackCount }} tracks</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<p class="text-sm">{{ type }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="playlistProgressData?.ytdlnStdout" class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold">yt-dlp Output</h3>
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
@click="copyToClipboard(playlistProgressData.ytdlnStdout)">
|
||||||
|
<Copy class="mr-2 h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-muted rounded-md p-4">
|
||||||
|
<pre class="text-xs whitespace-pre-wrap overflow-x-auto max-h-60">{{
|
||||||
|
playlistProgressData.ytdlnStdout }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasError" class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-destructive">Error Details</h3>
|
||||||
|
<div class="bg-destructive/10 rounded-md p-4">
|
||||||
|
<p class="text-sm text-destructive">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="isDialogOpen = false">Close</Button>
|
||||||
|
<Button @click="handleRetry">
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Retry Upload
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Frame>
|
</Frame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
CassetteTape,
|
||||||
DropdownMenuContent,
|
Copy,
|
||||||
DropdownMenuGroup,
|
Dot,
|
||||||
DropdownMenuItem,
|
EllipsisVertical,
|
||||||
DropdownMenuLabel,
|
Eye,
|
||||||
DropdownMenuShortcut,
|
FileQuestionMark,
|
||||||
DropdownMenuTrigger
|
ListMusicIcon,
|
||||||
} from '@/components/ui/dropdown-menu';
|
RefreshCw,
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
Trash2
|
||||||
import { CassetteTape, Dot, EllipsisVertical, FileQuestionMark, ListMusicIcon } from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
@ -84,13 +146,68 @@ interface Props {
|
|||||||
type?: string
|
type?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
error?: string
|
error?: string
|
||||||
|
playlistProgressData?: {
|
||||||
|
playlistId: number
|
||||||
|
trackSourceId: number
|
||||||
|
userId: number
|
||||||
|
timestamp: number
|
||||||
|
ytdlnStdout: string
|
||||||
|
overallProgress: number
|
||||||
|
status: 'LOADING' | 'FINISHED'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
progress: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: []
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDialogOpen = ref(false);
|
||||||
|
|
||||||
const hasLoaded = props.trackCount && props.type;
|
const hasLoaded = props.trackCount && props.type;
|
||||||
const hasProgress = props.progress;
|
const hasProgress = props.progress !== undefined && props.progress > 0;
|
||||||
const hasError = props.error;
|
const hasError = props.error;
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
isDialogOpen.value = true;
|
||||||
|
console.log(isDialogOpen.value)
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (hasError) return 'bg-destructive';
|
||||||
|
if (props.playlistProgressData?.status === 'FINISHED') return 'bg-green-500';
|
||||||
|
if (props.playlistProgressData?.status === 'LOADING') return 'bg-blue-500';
|
||||||
|
if (hasProgress) return 'bg-amber-500';
|
||||||
|
return 'bg-gray-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (hasError) return 'Error';
|
||||||
|
if (props.playlistProgressData?.status === 'FINISHED') return 'Completed';
|
||||||
|
if (props.playlistProgressData?.status === 'LOADING') return 'Loading';
|
||||||
|
if (hasProgress) return 'In Progress';
|
||||||
|
return 'Pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
console.log('Copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
emit('retry');
|
||||||
|
isDialogOpen.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<Frame margin="none" class="px-3 py-4 flex items-center gap-2">
|
<Frame margin="none" class="px-3 py-4 flex items-center gap-2 cursor-pointer" @click="openDialog">
|
||||||
<div>
|
<div>
|
||||||
<Disc3 :size="40" v-if="hasLoaded" />
|
<Disc3 :size="40" v-if="hasLoaded" />
|
||||||
<AudioWaveform :size="40" v-if="hasProgress" />
|
<AudioWaveform :size="40" v-else-if="hasProgress" />
|
||||||
<FileQuestionMark :size="40" v-if="hasError" />
|
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-1">
|
||||||
@ -28,7 +28,7 @@
|
|||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{{ progress }}%
|
{{ progress }}%
|
||||||
</p>
|
</p>
|
||||||
<UiProgress :modelValue="progress" />
|
<Progress :modelValue="progress" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row" v-if="hasError">
|
<div class="flex flex-row" v-if="hasError">
|
||||||
<p class="text-sm text-destructive-foreground">
|
<p class="text-sm text-destructive-foreground">
|
||||||
@ -39,44 +39,156 @@
|
|||||||
<div>
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<UiButton variant="ghost">
|
<Button variant="ghost">
|
||||||
<EllipsisVertical :size="40" />
|
<EllipsisVertical :size="40" />
|
||||||
</UiButton>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent class="w-56" align="start">
|
<DropdownMenuContent class="w-56" align="start">
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuItem @click="openDialog">
|
||||||
<DropdownMenuGroup>
|
<Eye class="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuItem>
|
View Details
|
||||||
Profile
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<Download class="mr-2 h-4 w-4" />
|
||||||
Billing
|
Download
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem class="text-destructive">
|
||||||
Settings
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
<DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
Keyboard shortcuts
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog v-model:show="isDialogOpen">
|
||||||
|
<DialogContent class="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Track Upload Details</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Detailed information about this track upload
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">Track Information</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Title</Label>
|
||||||
|
<p class="text-sm">{{ title }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Format</Label>
|
||||||
|
<p class="text-sm">{{ format }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Size</Label>
|
||||||
|
<p class="text-sm">{{ size }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<p class="text-sm">{{ type }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trackProgressData" class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">Upload Details</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Progress</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm">{{ progress }}%</p>
|
||||||
|
<Progress :modelValue="progress" class="w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Timestamp</Label>
|
||||||
|
<p class="text-sm">{{ formatTimestamp(trackProgressData.timestamp) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>User ID</Label>
|
||||||
|
<p class="text-sm">{{ trackProgressData.userId }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Track Source ID</Label>
|
||||||
|
<p class="text-sm">{{ trackProgressData.trackSourceId }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trackProgressData.title || trackProgressData.format" class="space-y-2">
|
||||||
|
<Label>Additional Information</Label>
|
||||||
|
<div class="bg-muted rounded-md p-3 space-y-1">
|
||||||
|
<div v-if="trackProgressData.title" class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium">Original Title:</span>
|
||||||
|
<span class="text-sm">{{ trackProgressData.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="trackProgressData.format" class="flex justify-between">
|
||||||
|
<span class="text-sm font-medium">Source Format:</span>
|
||||||
|
<span class="text-sm">{{ trackProgressData.format }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold">Status</h3>
|
||||||
|
<div class="flex items-center gap-3 p-4 rounded-lg border">
|
||||||
|
<div :class="`h-3 w-3 rounded-full ${getStatusColor()}`" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ getStatusText() }}</p>
|
||||||
|
<p v-if="progress" class="text-sm text-muted-foreground">
|
||||||
|
Upload progress: {{ progress }}%
|
||||||
|
</p>
|
||||||
|
<p v-if="trackProgressData?.timestamp" class="text-sm text-muted-foreground">
|
||||||
|
Last updated: {{ formatTimestamp(trackProgressData.timestamp) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasError" class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-destructive">Error Details</h3>
|
||||||
|
<div class="bg-destructive/10 rounded-md p-4">
|
||||||
|
<p class="text-sm text-destructive">{{ error }}</p>
|
||||||
|
<Button v-if="hasError" variant="outline" size="sm" class="mt-2" @click="handleRetry">
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Retry Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="isDialogOpen = false">Close</Button>
|
||||||
|
<Button variant="secondary" @click="downloadTrack">
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download Track
|
||||||
|
</Button>
|
||||||
|
<Button @click="openInPlayer">
|
||||||
|
<Play class="mr-2 h-4 w-4" />
|
||||||
|
Open in Player
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Frame>
|
</Frame>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
AudioWaveform,
|
||||||
DropdownMenuContent,
|
Disc3,
|
||||||
DropdownMenuGroup,
|
Dot,
|
||||||
DropdownMenuItem,
|
Download,
|
||||||
DropdownMenuLabel,
|
EllipsisVertical,
|
||||||
DropdownMenuShortcut,
|
Eye,
|
||||||
DropdownMenuTrigger
|
FileQuestionMark,
|
||||||
} from '@/components/ui/dropdown-menu';
|
Play,
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
RefreshCw,
|
||||||
import { AudioWaveform, Disc3, Dot, EllipsisVertical, FileQuestionMark } from 'lucide-vue-next';
|
Trash2
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
@ -85,13 +197,67 @@ interface Props {
|
|||||||
type?: string
|
type?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
error?: string
|
error?: string
|
||||||
|
trackProgressData?: {
|
||||||
|
playlistId?: number
|
||||||
|
trackSourceId: number
|
||||||
|
userId: number
|
||||||
|
timestamp: number
|
||||||
|
title: string
|
||||||
|
format: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
progress: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: []
|
||||||
|
download: []
|
||||||
|
play: []
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDialogOpen = ref(false);
|
||||||
|
|
||||||
const hasLoaded = props.size && props.format && props.type;
|
const hasLoaded = props.size && props.format && props.type;
|
||||||
const hasProgress = props.progress;
|
const hasProgress = props.progress !== undefined && props.progress > 0;
|
||||||
const hasError = props.error;
|
const hasError = props.error;
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
isDialogOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (hasError) return 'bg-destructive';
|
||||||
|
if (hasLoaded && props.progress === 100) return 'bg-green-500';
|
||||||
|
if (hasProgress) return 'bg-blue-500';
|
||||||
|
return 'bg-gray-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (hasError) return 'Error';
|
||||||
|
if (hasLoaded && props.progress === 100) return 'Upload Complete';
|
||||||
|
if (hasProgress) return 'Uploading';
|
||||||
|
return 'Pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return 'N/A';
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
emit('retry');
|
||||||
|
isDialogOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTrack = () => {
|
||||||
|
emit('download');
|
||||||
|
isDialogOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInPlayer = () => {
|
||||||
|
emit('play');
|
||||||
|
isDialogOpen.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -34,10 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import Draggable from '@/components/icon/Draggable.vue'
|
import Draggable from '@/components/icon/Draggable.vue'
|
||||||
import Frame from '@/components/ui/frame/Frame.vue'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@ -3,21 +3,10 @@ import { toTypedSchema } from "@vee-validate/zod";
|
|||||||
import { useForm } from "vee-validate";
|
import { useForm } from "vee-validate";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import Button from '~/components/ui/button/Button.vue';
|
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
|
||||||
import Input from '~/components/ui/input/Input.vue';
|
|
||||||
import { getPlaylistsQueryKey, useCreatePlaylist } from "~/composeables/api/playlist-controller/playlist-controller";
|
import { getPlaylistsQueryKey, useCreatePlaylist } from "~/composeables/api/playlist-controller/playlist-controller";
|
||||||
|
import { FormField } from "@/components/ui/form";
|
||||||
|
|
||||||
const formSchema = toTypedSchema(z.object({
|
const formSchema = toTypedSchema(z.object({
|
||||||
playlistName: z.string().min(2).max(50).default(''),
|
playlistName: z.string().min(2).max(50).default(''),
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from '@/components/ui/button/Button.vue';
|
|
||||||
import Empty from '@/components/ui/empty/Empty.vue';
|
|
||||||
import EmptyContent from '@/components/ui/empty/EmptyContent.vue';
|
|
||||||
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
|
|
||||||
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue';
|
|
||||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue';
|
|
||||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue';
|
|
||||||
import { FileMusicIcon } from 'lucide-vue-next';
|
import { FileMusicIcon } from 'lucide-vue-next';
|
||||||
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
||||||
|
|
||||||
|
|||||||
207
app/components/ui/dropzone/Dropzone.vue
Normal file
207
app/components/ui/dropzone/Dropzone.vue
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useDropZone } from '@vueuse/core'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Upload } from 'lucide-vue-next'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface DropzoneProps {
|
||||||
|
accept?: Record<string, string[]>
|
||||||
|
maxFiles?: number
|
||||||
|
maxSize?: number
|
||||||
|
minSize?: number
|
||||||
|
disabled?: boolean
|
||||||
|
src?: File[]
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DropzoneProps>(), {
|
||||||
|
maxFiles: 1,
|
||||||
|
accept: () => ({}),
|
||||||
|
src: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
drop: [acceptedFiles: File[], event: DragEvent]
|
||||||
|
error: [error: Error]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const files = ref<File[]>(props.src || [])
|
||||||
|
const error = ref<string>('')
|
||||||
|
|
||||||
|
const renderBytes = (bytes: number): string => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
let size = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)}${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file: File): string => {
|
||||||
|
if (props.minSize && file.size < props.minSize) {
|
||||||
|
return `File must be at least ${renderBytes(props.minSize)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.maxSize && file.size > props.maxSize) {
|
||||||
|
return `File must be less than ${renderBytes(props.maxSize)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.accept && Object.keys(props.accept).length > 0) {
|
||||||
|
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||||
|
const mimeType = file.type
|
||||||
|
|
||||||
|
const isAccepted = Object.entries(props.accept).some(([mime, extensions]) => {
|
||||||
|
return (
|
||||||
|
mimeType === mime ||
|
||||||
|
extensions.some((ext) => fileExtension === ext.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
const acceptedTypes = Object.keys(props.accept).join(', ')
|
||||||
|
return `File type not accepted. Accepted types: ${acceptedTypes}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (droppedFiles: File[] | null, event: DragEvent) => {
|
||||||
|
if (!droppedFiles || props.disabled) return
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
const validFiles: File[] = []
|
||||||
|
const newFiles: File[] = []
|
||||||
|
|
||||||
|
droppedFiles.slice(0, props.maxFiles).forEach((file) => {
|
||||||
|
const validationError = validateFile(file)
|
||||||
|
if (validationError) {
|
||||||
|
error.value = validationError
|
||||||
|
emit('error', new Error(validationError))
|
||||||
|
} else {
|
||||||
|
validFiles.push(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
newFiles.push(...validFiles)
|
||||||
|
|
||||||
|
if (props.maxFiles === 1) {
|
||||||
|
files.value = newFiles
|
||||||
|
} else {
|
||||||
|
const remainingSlots = props.maxFiles - files.value.length
|
||||||
|
files.value = [...files.value, ...newFiles.slice(0, remainingSlots)]
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('drop', newFiles, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropZoneRef = ref<HTMLElement>()
|
||||||
|
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||||
|
onDrop,
|
||||||
|
dataTypes: props.accept ? Object.keys(props.accept) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.multiple = props.maxFiles > 1
|
||||||
|
input.accept = props.accept ? Object.keys(props.accept).join(',') : '*/*'
|
||||||
|
|
||||||
|
input.onchange = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files) {
|
||||||
|
onDrop(Array.from(target.files), e as unknown as DragEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLabelItems = 3
|
||||||
|
const fileListLabel = computed(() => {
|
||||||
|
if (files.value.length === 0) return ''
|
||||||
|
|
||||||
|
if (files.value.length > maxLabelItems) {
|
||||||
|
const firstThree = files.value.slice(0, maxLabelItems).map(f => f.name)
|
||||||
|
const remaining = files.value.length - maxLabelItems
|
||||||
|
return `${new Intl.ListFormat('en').format(firstThree)} and ${remaining} more`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.ListFormat('en').format(files.value.map(f => f.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const captionText = computed(() => {
|
||||||
|
let caption = ''
|
||||||
|
|
||||||
|
if (Object.keys(props.accept).length > 0) {
|
||||||
|
caption += 'Accepts '
|
||||||
|
caption += new Intl.ListFormat('en').format(Object.keys(props.accept))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.minSize && props.maxSize) {
|
||||||
|
caption += ` between ${renderBytes(props.minSize)} and ${renderBytes(props.maxSize)}`
|
||||||
|
} else if (props.minSize) {
|
||||||
|
caption += ` at least ${renderBytes(props.minSize)}`
|
||||||
|
} else if (props.maxSize) {
|
||||||
|
caption += ` less than ${renderBytes(props.maxSize)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return caption
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
files,
|
||||||
|
clearFiles: () => { files.value = [] },
|
||||||
|
addFiles: (newFiles: File[]) => {
|
||||||
|
const remainingSlots = props.maxFiles - files.value.length
|
||||||
|
files.value = [...files.value, ...newFiles.slice(0, remainingSlots)]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button ref="dropZoneRef" :class="cn(
|
||||||
|
'relative h-auto w-full flex-col overflow-hidden p-8',
|
||||||
|
isOverDropZone && 'outline-none ring-1 ring-ring',
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
" :disabled="disabled" type="button" variant="outline" @click="handleClick">
|
||||||
|
<div v-if="files.length === 0" class="flex flex-col items-center justify-center">
|
||||||
|
<div class="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<Upload :size="16" />
|
||||||
|
</div>
|
||||||
|
<p class="my-2 w-full truncate text-wrap font-medium text-sm">
|
||||||
|
Upload {{ maxFiles === 1 ? 'a file' : 'files' }}
|
||||||
|
</p>
|
||||||
|
<p class="w-full truncate text-wrap text-muted-foreground text-xs">
|
||||||
|
Drag and drop or click to upload
|
||||||
|
</p>
|
||||||
|
<p v-if="captionText" class="text-wrap text-muted-foreground text-xs">
|
||||||
|
{{ captionText }}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col items-center justify-center">
|
||||||
|
<div class="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||||
|
<Upload :size="16" />
|
||||||
|
</div>
|
||||||
|
<p class="my-2 w-full truncate font-medium text-sm">
|
||||||
|
{{ fileListLabel }}
|
||||||
|
</p>
|
||||||
|
<p class="w-full text-wrap text-muted-foreground text-xs">
|
||||||
|
Drag and drop or click to replace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</p>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSlots } from 'vue'
|
import { useSlots } from 'vue'
|
||||||
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|||||||
@ -22,10 +22,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSlots } from 'vue'
|
import { useSlots } from 'vue'
|
||||||
import AppSidebar from '@/components/ui/sidebar/AppSidebar.vue';
|
|
||||||
import SidebarInset from '@/components/ui/sidebar/SidebarInset.vue';
|
|
||||||
import { Toaster } from '~/components/ui/sonner';
|
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
|
import AppSidebar from '~/components/ui/sidebar/AppSidebar.vue';
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { InputWithIcon } from '@/components/ui/input'
|
|
||||||
import { Outline } from '@/components/ui/outline'
|
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { Search } from 'lucide-vue-next'
|
import { Search } from 'lucide-vue-next'
|
||||||
import { VueDraggableNext } from 'vue-draggable-next'
|
import { VueDraggableNext } from 'vue-draggable-next'
|
||||||
import MusicCard from '~/components/internal/musiccard/MusicCard.vue'
|
import MusicCard from '@/components/internal/musiccard/MusicCard.vue'
|
||||||
import type { TrackResponse } from '~/composeables/api/models'
|
import type { TrackResponse } from '~/composeables/api/models'
|
||||||
import { bulkReorder, getGetPlaylistTracksQueryKey, useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
import { bulkReorder, getGetPlaylistTracksQueryKey, useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
||||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Outline } from '@/components/ui/outline';
|
import Outline from '@/components/ui/outline/Outline.vue';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Outline } from '@/components/ui/outline';
|
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
||||||
import { Download, Play } from 'lucide-vue-next';
|
import { Download, Play } from 'lucide-vue-next';
|
||||||
import PlaylistUploadEntry from '~/components/internal/import/uploadentry/PlaylistUploadEntry.vue';
|
import PlaylistUploadEntry from '@/components/internal/import/uploadentry/PlaylistUploadEntry.vue';
|
||||||
import SingleUploadEntry from '~/components/internal/import/uploadentry/SingleUploadEntry.vue';
|
import SingleUploadEntry from '@/components/internal/import/uploadentry/SingleUploadEntry.vue';
|
||||||
|
import Outline from '@/components/ui/outline/Outline.vue';
|
||||||
|
import SidebarTrigger from '@/components/ui/sidebar/SidebarTrigger.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -28,10 +28,10 @@ import SingleUploadEntry from '~/components/internal/import/uploadentry/SingleUp
|
|||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
or
|
or
|
||||||
</p>
|
</p>
|
||||||
<UiButton variant="destructive">
|
<Button variant="destructive">
|
||||||
<Play />
|
<Play />
|
||||||
From Youtube
|
From Youtube
|
||||||
</UiButton>
|
</Button>
|
||||||
</Outline>
|
</Outline>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||||
@ -41,8 +41,21 @@ import SingleUploadEntry from '~/components/internal/import/uploadentry/SingleUp
|
|||||||
<SingleUploadEntry title="Test" size="3.8 MB" format="mp4" type="file" />
|
<SingleUploadEntry title="Test" size="3.8 MB" format="mp4" type="file" />
|
||||||
<SingleUploadEntry title="Test" :progress="78" />
|
<SingleUploadEntry title="Test" :progress="78" />
|
||||||
<SingleUploadEntry title="Test" error="Uploading failed, please check your internet" />
|
<SingleUploadEntry title="Test" error="Uploading failed, please check your internet" />
|
||||||
<PlaylistUploadEntry title="Test" :trackCount="3" type="youtube" />
|
<PlaylistUploadEntry title="My Playlist" :trackCount="10" type="YouTube" :progress="75"
|
||||||
<PlaylistUploadEntry title="Test" :progress="73" type="youtube" />
|
:playlistProgressData="{
|
||||||
|
playlistId: 1,
|
||||||
|
trackSourceId: 2,
|
||||||
|
userId: 3,
|
||||||
|
timestamp: 123456,
|
||||||
|
ytdlnStdout: `[youtube] Extracting URL: https://www.youtube.com/playlist?list=PL1234567890
|
||||||
|
[youtube:playlist] PL1234567890: Downloading 10 videos
|
||||||
|
[download] Destination: My Playlist [PL1234567890].mp3
|
||||||
|
[ExtractAudio] Destination: My Playlist [PL1234567890].mp3
|
||||||
|
[info] Download completed: My Playlist [PL1234567890].mp3
|
||||||
|
[info] 10 files downloaded successfully`,
|
||||||
|
overallProgress: 34,
|
||||||
|
status: 'LOADING'
|
||||||
|
}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Outline } from '@/components/ui/outline';
|
import { ref } from 'vue'
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import Dropzone from '~/components/ui/dropzone/Dropzone.vue'
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
|
||||||
|
const files = ref<File[]>([])
|
||||||
|
|
||||||
|
const handleDrop = (acceptedFiles: File[], event: DragEvent) => {
|
||||||
|
console.log('Files dropped:', acceptedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (error: Error) => {
|
||||||
|
console.error('Dropzone error:', error.message)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -18,9 +27,10 @@ import Frame from '@/components/ui/frame/Frame.vue';
|
|||||||
</Outline>
|
</Outline>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Frame>
|
<Dropzone :src="files" :accept="{
|
||||||
Hello
|
'image/*': ['.png', '.jpg', '.jpeg', '.gif']
|
||||||
</Frame>
|
}" :max-size="5 * 1024 * 1024" :min-size="1024" :max-files="3" @drop="handleDrop" @error="handleError"
|
||||||
|
class="w-full max-w-md" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,3 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Outline } from '@/components/ui/outline';
|
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<NuxtLayout name="default">
|
<NuxtLayout name="default">
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Outline } from '@/components/ui/outline';
|
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
layout: false,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NuxtLayout name="clear">
|
|
||||||
<template #header>
|
|
||||||
<Outline side="bottom" padding="dense" class="w-full">
|
|
||||||
<div class="flex gap-8 w-full items-center">
|
|
||||||
<h2 class="scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
|
||||||
Tracks
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</Outline>
|
|
||||||
</template>
|
|
||||||
<div class="w-full">
|
|
||||||
<Frame>
|
|
||||||
Hello
|
|
||||||
</Frame>
|
|
||||||
</div>
|
|
||||||
</NuxtLayout>
|
|
||||||
</template>
|
|
||||||
@ -5,10 +5,6 @@ export default defineNuxtConfig({
|
|||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
|
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
|
||||||
shadcn: {
|
|
||||||
prefix: '',
|
|
||||||
componentDir: './components/ui'
|
|
||||||
},
|
|
||||||
css: ['~/assets/css/tailwind.css'],
|
css: ['~/assets/css/tailwind.css'],
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [
|
plugins: [
|
||||||
@ -20,13 +16,11 @@ export default defineNuxtConfig({
|
|||||||
apiBaseUrl: process.env.API_BASE_URL
|
apiBaseUrl: process.env.API_BASE_URL
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
components: [
|
shadcn: {
|
||||||
{
|
prefix: '',
|
||||||
path: '~/components',
|
componentDir: '@/components/ui'
|
||||||
extensions: ['.vue'],
|
},
|
||||||
},
|
|
||||||
],
|
|
||||||
pinia: {
|
pinia: {
|
||||||
storesDirs: ['./app/stores/**'],
|
storesDirs: ['./app/stores/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user