Format imports, partial implementation of import upload entries
This commit is contained in:
@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
const defaultOpen = useCookie<boolean>("sidebar_state");
|
||||
</script>
|
||||
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
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 { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
|
||||
import Button from '../ui/button/Button.vue';
|
||||
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
|
||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
||||
import { useSidebar } from '../ui/sidebar';
|
||||
|
||||
const {
|
||||
open: sidebarOpen,
|
||||
@ -68,7 +58,7 @@ watch(data, (value) => {
|
||||
<SelectContent align="start" side="right" :sideOffset="4">
|
||||
<div class="w-full">
|
||||
<div v-if="isLoading">
|
||||
<UiSpinner />
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="isError">
|
||||
<SelectLabel>{{ error }}</SelectLabel>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<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>
|
||||
<ListMusicIcon :size="40" v-if="hasLoaded" />
|
||||
<CassetteTape :size="40" v-if="hasProgress" />
|
||||
<FileQuestionMark :size="40" v-if="hasError" />
|
||||
<CassetteTape :size="40" v-else-if="hasProgress" />
|
||||
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
@ -24,7 +24,7 @@
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ progress }}%
|
||||
</p>
|
||||
<UiProgress :modelValue="progress" />
|
||||
<Progress :modelValue="progress" />
|
||||
</div>
|
||||
<div class="flex flex-row" v-if="hasError">
|
||||
<p class="text-sm text-destructive-foreground">
|
||||
@ -35,48 +35,110 @@
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<UiButton variant="ghost">
|
||||
<Button variant="ghost">
|
||||
<EllipsisVertical :size="40" />
|
||||
</UiButton>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56" align="start">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
Profile
|
||||
<DropdownMenuItem @click="openDialog">
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Billing
|
||||
<RefreshCw class="mr-2 h-4 w-4" />
|
||||
Retry
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Settings
|
||||
<DropdownMenuItem class="text-destructive">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import { CassetteTape, Dot, EllipsisVertical, FileQuestionMark, ListMusicIcon } from 'lucide-vue-next';
|
||||
CassetteTape,
|
||||
Copy,
|
||||
Dot,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
FileQuestionMark,
|
||||
ListMusicIcon,
|
||||
RefreshCw,
|
||||
Trash2
|
||||
} from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@ -84,13 +146,68 @@ interface Props {
|
||||
type?: string
|
||||
progress?: number
|
||||
error?: string
|
||||
playlistProgressData?: {
|
||||
playlistId: number
|
||||
trackSourceId: number
|
||||
userId: number
|
||||
timestamp: number
|
||||
ytdlnStdout: string
|
||||
overallProgress: number
|
||||
status: 'LOADING' | 'FINISHED'
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
progress: 0
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>();
|
||||
|
||||
const isDialogOpen = ref(false);
|
||||
|
||||
const hasLoaded = props.trackCount && props.type;
|
||||
const hasProgress = props.progress;
|
||||
const hasProgress = props.progress !== undefined && props.progress > 0;
|
||||
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>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<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>
|
||||
<Disc3 :size="40" v-if="hasLoaded" />
|
||||
<AudioWaveform :size="40" v-if="hasProgress" />
|
||||
<FileQuestionMark :size="40" v-if="hasError" />
|
||||
<AudioWaveform :size="40" v-else-if="hasProgress" />
|
||||
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
@ -28,7 +28,7 @@
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ progress }}%
|
||||
</p>
|
||||
<UiProgress :modelValue="progress" />
|
||||
<Progress :modelValue="progress" />
|
||||
</div>
|
||||
<div class="flex flex-row" v-if="hasError">
|
||||
<p class="text-sm text-destructive-foreground">
|
||||
@ -39,44 +39,156 @@
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<UiButton variant="ghost">
|
||||
<Button variant="ghost">
|
||||
<EllipsisVertical :size="40" />
|
||||
</UiButton>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56" align="start">
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
Profile
|
||||
<DropdownMenuItem @click="openDialog">
|
||||
<Eye class="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Billing
|
||||
<Download class="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Settings
|
||||
<DropdownMenuItem class="text-destructive">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Keyboard shortcuts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import { AudioWaveform, Disc3, Dot, EllipsisVertical, FileQuestionMark } from 'lucide-vue-next';
|
||||
AudioWaveform,
|
||||
Disc3,
|
||||
Dot,
|
||||
Download,
|
||||
EllipsisVertical,
|
||||
Eye,
|
||||
FileQuestionMark,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Trash2
|
||||
} from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@ -85,13 +197,67 @@ interface Props {
|
||||
type?: string
|
||||
progress?: number
|
||||
error?: string
|
||||
trackProgressData?: {
|
||||
playlistId?: number
|
||||
trackSourceId: number
|
||||
userId: number
|
||||
timestamp: number
|
||||
title: string
|
||||
format: string
|
||||
}
|
||||
}
|
||||
|
||||
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 hasProgress = props.progress;
|
||||
const hasProgress = props.progress !== undefined && props.progress > 0;
|
||||
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>
|
||||
|
||||
@ -34,10 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Draggable from '@/components/icon/Draggable.vue'
|
||||
import Frame from '@/components/ui/frame/Frame.vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
|
||||
@ -3,21 +3,10 @@ import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
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 { FormField } from "@/components/ui/form";
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
playlistName: z.string().min(2).max(50).default(''),
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
<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 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>
|
||||
import { useSlots } from 'vue'
|
||||
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
@ -22,10 +22,8 @@
|
||||
|
||||
<script setup>
|
||||
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 AppSidebar from '~/components/ui/sidebar/AppSidebar.vue';
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
<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 { Search } from 'lucide-vue-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 { bulkReorder, getGetPlaylistTracksQueryKey, useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import Outline from '@/components/ui/outline/Outline.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Download, Play } from 'lucide-vue-next';
|
||||
import PlaylistUploadEntry from '~/components/internal/import/uploadentry/PlaylistUploadEntry.vue';
|
||||
import SingleUploadEntry from '~/components/internal/import/uploadentry/SingleUploadEntry.vue';
|
||||
import PlaylistUploadEntry from '@/components/internal/import/uploadentry/PlaylistUploadEntry.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>
|
||||
|
||||
<template>
|
||||
@ -28,10 +28,10 @@ import SingleUploadEntry from '~/components/internal/import/uploadentry/SingleUp
|
||||
<p class="text-sm text-muted-foreground">
|
||||
or
|
||||
</p>
|
||||
<UiButton variant="destructive">
|
||||
<Button variant="destructive">
|
||||
<Play />
|
||||
From Youtube
|
||||
</UiButton>
|
||||
</Button>
|
||||
</Outline>
|
||||
<div>
|
||||
<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" :progress="78" />
|
||||
<SingleUploadEntry title="Test" error="Uploading failed, please check your internet" />
|
||||
<PlaylistUploadEntry title="Test" :trackCount="3" type="youtube" />
|
||||
<PlaylistUploadEntry title="Test" :progress="73" type="youtube" />
|
||||
<PlaylistUploadEntry title="My Playlist" :trackCount="10" type="YouTube" :progress="75"
|
||||
: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>
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import { ref } from 'vue'
|
||||
import Dropzone from '~/components/ui/dropzone/Dropzone.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>
|
||||
|
||||
<template>
|
||||
@ -18,9 +27,10 @@ import Frame from '@/components/ui/frame/Frame.vue';
|
||||
</Outline>
|
||||
</template>
|
||||
<div class="w-full">
|
||||
<Frame>
|
||||
Hello
|
||||
</Frame>
|
||||
<Dropzone :src="files" :accept="{
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif']
|
||||
}" :max-size="5 * 1024 * 1024" :min-size="1024" :max-files="3" @drop="handleDrop" @error="handleError"
|
||||
class="w-full max-w-md" />
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</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>
|
||||
<div class="flex-1">
|
||||
<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',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
|
||||
shadcn: {
|
||||
prefix: '',
|
||||
componentDir: './components/ui'
|
||||
},
|
||||
css: ['~/assets/css/tailwind.css'],
|
||||
vite: {
|
||||
plugins: [
|
||||
@ -20,12 +16,10 @@ export default defineNuxtConfig({
|
||||
apiBaseUrl: process.env.API_BASE_URL
|
||||
}
|
||||
},
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
extensions: ['.vue'],
|
||||
shadcn: {
|
||||
prefix: '',
|
||||
componentDir: '@/components/ui'
|
||||
},
|
||||
],
|
||||
pinia: {
|
||||
storesDirs: ['./app/stores/**'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user