Format imports, partial implementation of import upload entries

This commit is contained in:
2026-01-06 02:37:03 +05:00
parent 16d284fe68
commit c29c12feec
17 changed files with 607 additions and 174 deletions

View File

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

View File

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

View File

@ -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>
Billing <RefreshCw class="mr-2 h-4 w-4" />
Retry
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem class="text-destructive">
Settings <Trash2 class="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem> </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>

View File

@ -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>
Billing <Download class="mr-2 h-4 w-4" />
Download
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem class="text-destructive">
Settings <Trash2 class="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem> </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>

View File

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

View File

@ -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(''),

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,12 +16,10 @@ 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/**'],
}, },