Compare commits
10 Commits
53b326fbad
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fd026c026
|
|||
|
df9fe970ab
|
|||
|
327aff6208
|
|||
|
eb6483c180
|
|||
|
bd878fceab
|
|||
|
9984bb804a
|
|||
|
c29c12feec
|
|||
|
16d284fe68
|
|||
|
ff2b41c2e7
|
|||
|
627c9fda99
|
@ -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,42 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useDraggable } from '@vue-dnd-kit/core';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const { index, source } = defineProps<{
|
|
||||||
index: number;
|
|
||||||
source: any[];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { elementRef, handleDragStart, isOvered, isDragging } = useDraggable({
|
|
||||||
data: computed(() => ({
|
|
||||||
index,
|
|
||||||
source,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="elementRef" @pointerdown="handleDragStart" :class="{
|
|
||||||
'is-over': isOvered,
|
|
||||||
'is-dragging': isDragging,
|
|
||||||
}" class="draggable">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.draggable {
|
|
||||||
cursor: move;
|
|
||||||
user-select: none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-dragging {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-over {
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
border-top: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<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-else-if="hasProgress" />
|
||||||
|
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
<p class="font-medium">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="hasLoaded">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ trackCount }} track(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-2" v-if="hasProgress">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ progress }}%
|
||||||
|
</p>
|
||||||
|
<Progress :modelValue="progress" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="hasError">
|
||||||
|
<p class="text-sm text-destructive-foreground">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child @trigger-click="handleTriggerClick">
|
||||||
|
<Button variant="ghost">
|
||||||
|
<EllipsisVertical :size="40" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="w-56" align="start">
|
||||||
|
<DropdownMenuItem @click="openDialog">
|
||||||
|
<Eye class="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<RefreshCw class="mr-2 h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="text-destructive">
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog :open="isDialogOpen" @update:open="toggleDialog">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="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(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">{{ 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 setup lang="ts">
|
||||||
|
import {
|
||||||
|
CassetteTape,
|
||||||
|
Copy,
|
||||||
|
EllipsisVertical,
|
||||||
|
Eye,
|
||||||
|
FileQuestionMark,
|
||||||
|
ListMusicIcon,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { PlaylistProgressAllOfStatus } from '~/composeables/api/models';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
trackCount?: number
|
||||||
|
ytdlnStdout: string
|
||||||
|
status: PlaylistProgressAllOfStatus
|
||||||
|
progress?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: []
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDialogOpen = ref(false);
|
||||||
|
|
||||||
|
const hasLoaded = props.trackCount;
|
||||||
|
const hasProgress = props.progress !== undefined && props.progress > 0;
|
||||||
|
const hasError = props.error;
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
isDialogOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDialog = (value: boolean) => {
|
||||||
|
isDialogOpen.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTriggerClick = (event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (hasError) return 'bg-destructive';
|
||||||
|
if (props.status === 'FINISHED') return 'bg-green-500';
|
||||||
|
if (props.status === 'LOADING') return 'bg-blue-500';
|
||||||
|
if (hasProgress) return 'bg-amber-500';
|
||||||
|
return 'bg-gray-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (hasError) return 'Error';
|
||||||
|
if (props.status === 'FINISHED') return 'Completed';
|
||||||
|
if (props.status === 'LOADING') return 'Loading';
|
||||||
|
if (hasProgress) return 'In Progress';
|
||||||
|
return 'Pending';
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
254
app/components/internal/import/uploadentry/SingleUploadEntry.vue
Normal file
254
app/components/internal/import/uploadentry/SingleUploadEntry.vue
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<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-else-if="hasProgress" />
|
||||||
|
<FileQuestionMark :size="40" v-else-if="hasError" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
<p class="font-medium">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="hasLoaded">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ size }}
|
||||||
|
</p>
|
||||||
|
<Dot />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ format }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-2" v-if="hasProgress">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ progress }}%
|
||||||
|
</p>
|
||||||
|
<Progress :modelValue="progress" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="hasError">
|
||||||
|
<p class="text-sm text-destructive-foreground">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger as-child>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<EllipsisVertical :size="40" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent class="w-56" align="start">
|
||||||
|
<DropdownMenuItem @click="openDialog">
|
||||||
|
<Eye class="mr-2 h-4 w-4" />
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Download class="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem class="text-destructive">
|
||||||
|
<Trash2 class="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</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>
|
||||||
|
</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 {
|
||||||
|
AudioWaveform,
|
||||||
|
Disc3,
|
||||||
|
Dot,
|
||||||
|
Download,
|
||||||
|
EllipsisVertical,
|
||||||
|
Eye,
|
||||||
|
FileQuestionMark,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
size?: string
|
||||||
|
format?: 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;
|
||||||
|
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>
|
||||||
@ -1,58 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<Frame margin="none" class="px-3 py-4 flex items-center gap-2">
|
<SingleUploadEntry v-if="entry.type === 'TRACK'" :title="(entry as SingleTrackProgressAllOf).title || ''" :size="''"
|
||||||
<div>
|
:format="(entry as SingleTrackProgressAllOf).format || ''" />
|
||||||
<AudioWaveform :size="32" />
|
<PlaylistUploadEntry v-if="entry.type === 'PLAYLIST'" :title="(entry as PlaylistProgressAllOf).playlistTitle || ''"
|
||||||
</div>
|
:trackCount="(entry as PlaylistProgressAllOf).trackCount"
|
||||||
<div class="w-full">
|
:ytdlnStdout="(entry as PlaylistProgressAllOf).ytdlnStdout || ''"
|
||||||
<div class="flex flex-row items-center gap-1">
|
:status="(entry as PlaylistProgressAllOf).status || 'LOADING'" />
|
||||||
<p class="font-medium">
|
|
||||||
{{ title }}
|
|
||||||
</p>
|
|
||||||
<UiButton variant="ghost" v-if="size">
|
|
||||||
<Pen />
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row" v-if="size && format">
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ size }}
|
|
||||||
</p>
|
|
||||||
<Dot />
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ format }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-2" v-if="progress">
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ progress }}%
|
|
||||||
</p>
|
|
||||||
<UiProgress :modelValue="progress" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row" v-if="error">
|
|
||||||
<p class="text-sm text-destructive-foreground">
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<UiButton variant="ghost">
|
|
||||||
<EllipsisVertical :size="32" />
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Frame from '@/components/ui/frame/Frame.vue'
|
import type { PlaylistProgressAllOf, SingleTrackProgressAllOf, StreamProgress200Item } from '~/composeables/api/models';
|
||||||
import { AudioWaveform, Dot, EllipsisVertical, Pen } from 'lucide-vue-next'
|
import PlaylistUploadEntry from './PlaylistUploadEntry.vue';
|
||||||
|
import SingleUploadEntry from './SingleUploadEntry.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
entry: StreamProgress200Item
|
||||||
size?: string
|
|
||||||
format?: string
|
|
||||||
progress?: number
|
|
||||||
error?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
retry: []
|
||||||
|
download: []
|
||||||
|
play: []
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<Frame margin="none" class="px-2 py-3 flex h-fit gap-2 hover:bg-muted" :class="selected && 'bg-muted'">
|
<Frame margin="none" class="px-2 py-3 flex h-fit gap-2 hover:bg-muted">
|
||||||
<div class="text-[2rem] h-full">
|
<div class="text-[2rem] h-full">
|
||||||
<Draggable />
|
<Draggable />
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div class="w-fit flex-1 flex flex-col justify-between">
|
<div class="w-fit flex-1 flex flex-col justify-between cursor-pointer" data-no-drag @click="onPlayClick">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="flex items-start justify-between w-full">
|
<div class="flex items-start justify-between w-full">
|
||||||
<h4 class="scroll-m-20 text-xl font-semibold tracking-tight truncate max-w-[32ch]">
|
<h4 class="scroll-m-20 text-xl font-semibold tracking-tight truncate max-w-[32ch]">
|
||||||
@ -26,30 +26,35 @@
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" class="cursor-pointer" data-no-drag @click="onPlayClick" />
|
||||||
<div class="max-w-20 max-h-20 w-20 h-20" v-if="imageUrl">
|
<div class="max-w-20 max-h-20 w-20 h-20 cursor-pointer" data-no-drag v-if="imageUrl" @click="onPlayClick">
|
||||||
<NuxtImg class="object-cover w-full h-full rounded-md" :src="imageUrl" :alt="title" />
|
<NuxtImg class="object-cover w-full h-full rounded-md" :src="imageUrl" :alt="title" />
|
||||||
</div>
|
</div>
|
||||||
</Frame>
|
</Frame>
|
||||||
</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
|
||||||
author: string
|
author: string
|
||||||
selected?: boolean
|
|
||||||
badges?: string[]
|
badges?: string[]
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
date?: string
|
date?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
onPlay: []
|
||||||
|
}>();
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
authorLabel: 'Author',
|
authorLabel: 'Author',
|
||||||
badges: () => []
|
badges: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onPlayClick(e: MouseEvent) {
|
||||||
|
emit("onPlay")
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Button from '@/components/ui/button/Button.vue';
|
import { FileMusicIcon } from 'lucide-vue-next';
|
||||||
|
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
||||||
import Empty from '@/components/ui/empty/Empty.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 EmptyHeader from '@/components/ui/empty/EmptyHeader.vue';
|
||||||
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue';
|
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue';
|
||||||
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue';
|
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue';
|
||||||
import { FileMusicIcon } from 'lucide-vue-next';
|
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
|
||||||
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
import EmptyContent from '@/components/ui/empty/EmptyContent.vue';
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"
|
|||||||
export { default as Button } from "./Button.vue"
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@ -2,16 +2,21 @@
|
|||||||
import type { DropdownMenuTriggerProps } from "reka-ui"
|
import type { DropdownMenuTriggerProps } from "reka-ui"
|
||||||
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
|
||||||
|
|
||||||
const props = defineProps<DropdownMenuTriggerProps>()
|
const props = defineProps<DropdownMenuTriggerProps & {
|
||||||
|
onTriggerClick?: (event: MouseEvent) => void
|
||||||
|
}>()
|
||||||
|
|
||||||
const forwardedProps = useForwardProps(props)
|
const forwardedProps = useForwardProps(props)
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (props.onTriggerClick) {
|
||||||
|
props.onTriggerClick(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps" @click="handleClick">
|
||||||
data-slot="dropdown-menu-trigger"
|
<slot />
|
||||||
v-bind="forwardedProps"
|
</DropdownMenuTrigger>
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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>
|
||||||
198
app/components/ui/file-upload/FileUpload.vue
Normal file
198
app/components/ui/file-upload/FileUpload.vue
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import { Motion } from "motion-v";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { X } from "lucide-vue-next";
|
||||||
|
import Button from "../button/Button.vue";
|
||||||
|
import Progress from "../progress/Progress.vue";
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
accept?: string;
|
||||||
|
modelValue?: File[];
|
||||||
|
ongoingUploads?: Map<string, { file: File; progress: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FileUploadProps>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "onChange", files: File[]): void;
|
||||||
|
(e: "update:modelValue", files: File[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
const files = ref<File[]>(props.modelValue || []);
|
||||||
|
const isActive = ref<boolean>(false);
|
||||||
|
|
||||||
|
function handleFileChange(newFiles: File[]) {
|
||||||
|
files.value = [...files.value, ...newFiles];
|
||||||
|
emit("onChange", files.value);
|
||||||
|
emit("update:modelValue", files.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (!input.files) return;
|
||||||
|
handleFileChange(Array.from(input.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnter() {
|
||||||
|
isActive.value = true;
|
||||||
|
}
|
||||||
|
function handleLeave() {
|
||||||
|
isActive.value = false;
|
||||||
|
}
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
isActive.value = false;
|
||||||
|
const droppedFiles = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
||||||
|
if (droppedFiles.length) handleFileChange(droppedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(event: MouseEvent, index: number) {
|
||||||
|
files.value.splice(index, 1);
|
||||||
|
event.stopPropagation();
|
||||||
|
emit("update:modelValue", files.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadProgress(file: File): number {
|
||||||
|
if (!props.ongoingUploads) return 0;
|
||||||
|
|
||||||
|
for (const [_, upload] of props.ongoingUploads) {
|
||||||
|
if (
|
||||||
|
upload.file.name === file.name &&
|
||||||
|
upload.file.size === file.size &&
|
||||||
|
upload.file.lastModified === file.lastModified
|
||||||
|
) {
|
||||||
|
return upload.progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFileUploading(file: File): boolean {
|
||||||
|
if (!props.ongoingUploads) return false;
|
||||||
|
|
||||||
|
for (const [_, upload] of props.ongoingUploads) {
|
||||||
|
if (
|
||||||
|
upload.file.name === file.name &&
|
||||||
|
upload.file.size === file.size &&
|
||||||
|
upload.file.lastModified === file.lastModified
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
files.value = newVal;
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<div class="w-full" :class="[$props.class]" @dragover.prevent="handleEnter" @dragleave="handleLeave"
|
||||||
|
@drop.prevent="handleDrop" @mouseover="handleEnter" @mouseleave="handleLeave">
|
||||||
|
<div class="group/file relative block w-full cursor-pointer overflow-hidden rounded-lg p-10 bg-muted space-y-4"
|
||||||
|
@click="handleClick">
|
||||||
|
<input ref="fileInputRef" type="file" class="hidden" :accept="accept" @change="onFileChange" multiple />
|
||||||
|
|
||||||
|
<!-- Grid pattern -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto w-full max-w-xl space-y-8">
|
||||||
|
<Motion v-for="(file, idx) in files" :key="`file-${idx}`" :initial="{ opacity: 0, scaleX: 0 }"
|
||||||
|
:animate="{ opacity: 1, scaleX: 1 }"
|
||||||
|
class="relative z-40 mx-auto flex w-full flex-col items-start justify-start overflow-hidden rounded-md p-4 shadow-sm text-muted-foreground bg-background gap-2">
|
||||||
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
|
class="max-w-xs truncate text-base">
|
||||||
|
{{ file.name }}
|
||||||
|
</Motion>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
|
class="shadow-input w-fit shrink-0 rounded-lg px-2 py-1 text-sm">
|
||||||
|
{{ (file.size / (1024 * 1024)).toFixed(2) }} MB
|
||||||
|
</Motion>
|
||||||
|
<Motion as="div" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
|
v-if="isFileUploading(file)">
|
||||||
|
<span class="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800">
|
||||||
|
Uploading...
|
||||||
|
</span>
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col items-start justify-between text-sm text-muted-foreground md:flex-row md:items-center">
|
||||||
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
|
class="rounded-md bg-muted px-1.5 py-1 text-sm">
|
||||||
|
{{ file.type || "unknown type" }}
|
||||||
|
</Motion>
|
||||||
|
<Motion as="div" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
|
v-if="!isFileUploading(file)">
|
||||||
|
<Button variant="ghost" @click="(e: MouseEvent) => removeFile(e, idx)"
|
||||||
|
:disabled="isFileUploading(file)">
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
<div class="w-full" v-if="isFileUploading(file)">
|
||||||
|
<Progress :modelValue="getUploadProgress(file)" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</Motion>
|
||||||
|
|
||||||
|
<template v-if="!files.length">
|
||||||
|
<Motion as="div"
|
||||||
|
class="relative z-40 mx-auto mt-4 flex h-32 w-full max-w-32 items-center justify-center rounded-md shadow-[0px_10px_50px_rgba(0,0,0,0.1)] bg-background group-hover/file:shadow-2xl"
|
||||||
|
:initial="{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
opacity: 1,
|
||||||
|
}" :transition="{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20,
|
||||||
|
}" :animate="isActive
|
||||||
|
? {
|
||||||
|
x: 20,
|
||||||
|
y: -20,
|
||||||
|
opacity: 0.9,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
">
|
||||||
|
<Icon name="heroicons:arrow-up-tray-20-solid" size="20" />
|
||||||
|
</Motion>
|
||||||
|
|
||||||
|
<div class="absolute inset-0 z-30 mx-auto mt-4 flex h-32 w-full max-w-32 items-center justify-center rounded-md border border-dashed border-border bg-transparent transition-opacity"
|
||||||
|
:class="{ 'opacity-100': isActive, 'opacity-0': !isActive }" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex flex-col items-center justify-center gap-2">
|
||||||
|
<slot name="content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.group-hover\/file\:shadow-2xl:hover {
|
||||||
|
box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-opacity {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
35
app/components/ui/file-upload/FileUploadGrid.vue
Normal file
35
app/components/ui/file-upload/FileUploadGrid.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
|
||||||
|
interface FileUploadGridProps {
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<FileUploadGridProps>();
|
||||||
|
|
||||||
|
const ROWS = 11;
|
||||||
|
const COLUMNS = 41;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 scale-105 flex-wrap items-center justify-center gap-px bg-gray-100 dark:bg-neutral-900"
|
||||||
|
:class="[$props.class]"
|
||||||
|
>
|
||||||
|
<template v-for="row in ROWS">
|
||||||
|
<template
|
||||||
|
v-for="col in COLUMNS"
|
||||||
|
:key="`${row}-${col}`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-10 w-10 flex-shrink-0 rounded-[2px]"
|
||||||
|
:class="[
|
||||||
|
((row - 1) * COLUMNS + (col - 1)) % 2 === 0
|
||||||
|
? 'bg-gray-50 dark:bg-neutral-950'
|
||||||
|
: 'bg-gray-50 shadow-[0px_0px_1px_3px_rgba(255,255,255,1)_inset] dark:bg-neutral-950 dark:shadow-[0px_0px_1px_3px_rgba(0,0,0,1)_inset]',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
2
app/components/ui/file-upload/index.ts
Normal file
2
app/components/ui/file-upload/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as FileUpload } from "./FileUpload.vue";
|
||||||
|
export { default as FileUploadGrid } from "./FileUploadGrid.vue";
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import Axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
import Axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||||
export const AXIOS_INSTANCE = Axios.create();
|
export const AXIOS_INSTANCE = Axios.create();
|
||||||
|
|
||||||
export const axiosInstance = <T>(
|
const axiosInstance = <T>(
|
||||||
config: AxiosRequestConfig,
|
config: AxiosRequestConfig,
|
||||||
options?: AxiosRequestConfig,
|
options?: AxiosRequestConfig,
|
||||||
): Promise<AxiosResponse<T, any>> => {
|
): Promise<AxiosResponse<T, any>> => {
|
||||||
@ -13,7 +13,7 @@ export const axiosInstance = <T>(
|
|||||||
...options,
|
...options,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
auth: {
|
auth: {
|
||||||
username: 'user',
|
username: 'user1',
|
||||||
password: 'password',
|
password: 'password',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -23,3 +23,4 @@ export const axiosInstance = <T>(
|
|||||||
return promise;
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default axiosInstance;
|
||||||
|
|||||||
94
app/composeables/api/event-source.ts
Normal file
94
app/composeables/api/event-source.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import type { AxiosRequestConfig } from "axios";
|
||||||
|
import { ErrorEvent, EventSource } from 'eventsource'
|
||||||
|
|
||||||
|
type Unarray<T> = T extends Array<infer U> ? U : T;
|
||||||
|
|
||||||
|
export interface EventSourceListener<T> {
|
||||||
|
handle: (callback: (data: T) => void) => EventSourceListener<T>;
|
||||||
|
close: () => void;
|
||||||
|
onError: (callback: (error: ErrorEvent) => void) => EventSourceListener<T>;
|
||||||
|
onOpen: (callback: (event: Event) => void) => EventSourceListener<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSource = <T>(
|
||||||
|
config: AxiosRequestConfig,
|
||||||
|
options?: AxiosRequestConfig,
|
||||||
|
): Promise<EventSourceListener<Unarray<T>>> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const baseURL = useRuntimeConfig().public.apiBaseUrl;
|
||||||
|
const endpoint = config.url;
|
||||||
|
const eventSource = new EventSource(baseURL + endpoint, {
|
||||||
|
fetch: (input, init) => fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init.headers,
|
||||||
|
Authorization: 'Basic ' + btoa('user1' + ":" + 'password'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let messageCallback: ((data: Unarray<T>) => void) | null = null;
|
||||||
|
let errorCallback: ((error: ErrorEvent) => void) | null = null;
|
||||||
|
let openCallback: ((event: Event) => void) | null = null;
|
||||||
|
|
||||||
|
eventSource.addEventListener("message", (event: MessageEvent<any>) => {
|
||||||
|
if (messageCallback) {
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(event.data) as Unarray<T>;
|
||||||
|
messageCallback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing EventSource data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (event) => {
|
||||||
|
if (errorCallback) {
|
||||||
|
errorCallback(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('open', (event) => {
|
||||||
|
if (openCallback) {
|
||||||
|
openCallback(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener: EventSourceListener<Unarray<T>> = {
|
||||||
|
handle: (callback: (data: Unarray<T>) => void) => {
|
||||||
|
messageCallback = callback;
|
||||||
|
return listener;
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (callback: (error: ErrorEvent) => void) => {
|
||||||
|
errorCallback = callback;
|
||||||
|
return listener;
|
||||||
|
},
|
||||||
|
|
||||||
|
onOpen: (callback: (event: Event) => void) => {
|
||||||
|
openCallback = callback;
|
||||||
|
return listener;
|
||||||
|
},
|
||||||
|
|
||||||
|
close: () => {
|
||||||
|
eventSource.close();
|
||||||
|
messageCallback = null;
|
||||||
|
errorCallback = null;
|
||||||
|
openCallback = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(listener);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED && !errorCallback) {
|
||||||
|
reject(new Error('EventSource connection failed'));
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default eventSource;
|
||||||
16
app/composeables/api/models/baseTrackProgress.ts
Normal file
16
app/composeables/api/models/baseTrackProgress.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import type { BaseTrackProgressType } from './baseTrackProgressType';
|
||||||
|
|
||||||
|
export interface BaseTrackProgress {
|
||||||
|
id?: string;
|
||||||
|
playlistId?: number;
|
||||||
|
trackSourceId?: number;
|
||||||
|
userId?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
type?: BaseTrackProgressType;
|
||||||
|
}
|
||||||
16
app/composeables/api/models/baseTrackProgressType.ts
Normal file
16
app/composeables/api/models/baseTrackProgressType.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BaseTrackProgressType = typeof BaseTrackProgressType[keyof typeof BaseTrackProgressType];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const BaseTrackProgressType = {
|
||||||
|
PLAYLIST: 'PLAYLIST',
|
||||||
|
TRACK: 'TRACK',
|
||||||
|
EXTERNAL_TRACK: 'EXTERNAL_TRACK',
|
||||||
|
} as const;
|
||||||
@ -6,14 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './addLocalTrackRequest';
|
export * from './addLocalTrackRequest';
|
||||||
export * from './playlistCreateDTO';
|
export * from './baseTrackProgress';
|
||||||
|
export * from './baseTrackProgressType';
|
||||||
export * from './playlistCreateRequest';
|
export * from './playlistCreateRequest';
|
||||||
export * from './playlistReadDTO';
|
export * from './playlistProgress';
|
||||||
|
export * from './playlistProgressAllOf';
|
||||||
|
export * from './playlistProgressAllOfStatus';
|
||||||
export * from './playlistReadResponse';
|
export * from './playlistReadResponse';
|
||||||
export * from './playlistTrackResponse';
|
export * from './playlistTrackResponse';
|
||||||
export * from './readParams';
|
export * from './readParams';
|
||||||
|
export * from './singleTrackProgress';
|
||||||
|
export * from './singleTrackProgressAllOf';
|
||||||
|
export * from './streamProgress200Item';
|
||||||
export * from './trackBulkReorderRequest';
|
export * from './trackBulkReorderRequest';
|
||||||
export * from './trackReoderAfterRequest';
|
|
||||||
export * from './trackReorderAfterRequest';
|
|
||||||
export * from './trackResponse';
|
export * from './trackResponse';
|
||||||
export * from './uploadBody';
|
export * from './uploadBody';
|
||||||
|
export * from './youtubeTrackRequest';
|
||||||
10
app/composeables/api/models/playlistProgress.ts
Normal file
10
app/composeables/api/models/playlistProgress.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import type { BaseTrackProgress } from './baseTrackProgress';
|
||||||
|
import type { PlaylistProgressAllOf } from './playlistProgressAllOf';
|
||||||
|
|
||||||
|
export type PlaylistProgress = BaseTrackProgress & PlaylistProgressAllOf;
|
||||||
15
app/composeables/api/models/playlistProgressAllOf.ts
Normal file
15
app/composeables/api/models/playlistProgressAllOf.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import type { PlaylistProgressAllOfStatus } from './playlistProgressAllOfStatus';
|
||||||
|
|
||||||
|
export type PlaylistProgressAllOf = {
|
||||||
|
ytdlnStdout?: string;
|
||||||
|
overallProgress?: number;
|
||||||
|
trackCount?: number;
|
||||||
|
playlistTitle?: string;
|
||||||
|
status?: PlaylistProgressAllOfStatus;
|
||||||
|
};
|
||||||
15
app/composeables/api/models/playlistProgressAllOfStatus.ts
Normal file
15
app/composeables/api/models/playlistProgressAllOfStatus.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PlaylistProgressAllOfStatus = typeof PlaylistProgressAllOfStatus[keyof typeof PlaylistProgressAllOfStatus];
|
||||||
|
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const PlaylistProgressAllOfStatus = {
|
||||||
|
LOADING: 'LOADING',
|
||||||
|
FINISHED: 'FINISHED',
|
||||||
|
} as const;
|
||||||
@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generated by orval v7.16.0 🍺
|
|
||||||
* Do not edit manually.
|
|
||||||
* OpenAPI definition
|
|
||||||
* OpenAPI spec version: v0
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface PlaylistReadDTO {
|
|
||||||
id?: number;
|
|
||||||
ownerId?: number;
|
|
||||||
title?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
10
app/composeables/api/models/singleTrackProgress.ts
Normal file
10
app/composeables/api/models/singleTrackProgress.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import type { BaseTrackProgress } from './baseTrackProgress';
|
||||||
|
import type { SingleTrackProgressAllOf } from './singleTrackProgressAllOf';
|
||||||
|
|
||||||
|
export type SingleTrackProgress = BaseTrackProgress & SingleTrackProgressAllOf;
|
||||||
@ -5,6 +5,7 @@
|
|||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: v0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PlaylistCreateDTO {
|
export type SingleTrackProgressAllOf = {
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
format?: string;
|
||||||
|
};
|
||||||
10
app/composeables/api/models/streamProgress200Item.ts
Normal file
10
app/composeables/api/models/streamProgress200Item.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import type { PlaylistProgress } from './playlistProgress';
|
||||||
|
import type { SingleTrackProgress } from './singleTrackProgress';
|
||||||
|
|
||||||
|
export type StreamProgress200Item = PlaylistProgress | SingleTrackProgress;
|
||||||
@ -10,6 +10,7 @@ export interface TrackResponse {
|
|||||||
title?: string;
|
title?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
audioPath?: string;
|
audioPath?: string;
|
||||||
|
fileFormat?: string;
|
||||||
durationSeconds?: number;
|
durationSeconds?: number;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
* OpenAPI spec version: v0
|
* OpenAPI spec version: v0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TrackReorderAfterRequest {
|
export interface YoutubeTrackRequest {
|
||||||
moveTrackId?: number;
|
youtubeUrl?: string;
|
||||||
targetTrackId?: number;
|
|
||||||
}
|
}
|
||||||
@ -32,7 +32,8 @@ import type {
|
|||||||
PlaylistReadResponse
|
PlaylistReadResponse
|
||||||
} from '.././models';
|
} from '.././models';
|
||||||
|
|
||||||
import { axiosInstance } from '.././axios-instance';
|
import createPlaylistMutator from '.././axios-instance';
|
||||||
|
import playlistsMutator from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
@ -41,11 +42,11 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
|||||||
|
|
||||||
export const createPlaylist = (
|
export const createPlaylist = (
|
||||||
playlistCreateRequest: MaybeRef<PlaylistCreateRequest>,
|
playlistCreateRequest: MaybeRef<PlaylistCreateRequest>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof createPlaylistMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
playlistCreateRequest = unref(playlistCreateRequest);
|
playlistCreateRequest = unref(playlistCreateRequest);
|
||||||
|
|
||||||
return axiosInstance<PlaylistReadResponse>(
|
return createPlaylistMutator<PlaylistReadResponse>(
|
||||||
{url: `/playlist`, method: 'POST',
|
{url: `/playlist`, method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', },
|
headers: {'Content-Type': 'application/json', },
|
||||||
data: playlistCreateRequest, signal
|
data: playlistCreateRequest, signal
|
||||||
@ -56,7 +57,7 @@ export const createPlaylist = (
|
|||||||
|
|
||||||
|
|
||||||
export const getCreatePlaylistMutationOptions = <TError = unknown,
|
export const getCreatePlaylistMutationOptions = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext>, request?: SecondParameter<typeof createPlaylistMutator>}
|
||||||
): UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext> => {
|
): UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext> => {
|
||||||
|
|
||||||
const mutationKey = ['createPlaylist'];
|
const mutationKey = ['createPlaylist'];
|
||||||
@ -85,7 +86,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type CreatePlaylistMutationError = unknown
|
export type CreatePlaylistMutationError = unknown
|
||||||
|
|
||||||
export const useCreatePlaylist = <TError = unknown,
|
export const useCreatePlaylist = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext>, request?: SecondParameter<typeof createPlaylistMutator>}
|
||||||
, queryClient?: QueryClient): UseMutationReturnType<
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
Awaited<ReturnType<typeof createPlaylist>>,
|
Awaited<ReturnType<typeof createPlaylist>>,
|
||||||
TError,
|
TError,
|
||||||
@ -99,11 +100,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
}
|
}
|
||||||
export const playlists = (
|
export const playlists = (
|
||||||
|
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof playlistsMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
|
|
||||||
return axiosInstance<PlaylistReadResponse[]>(
|
return playlistsMutator<PlaylistReadResponse[]>(
|
||||||
{url: `/playlists`, method: 'GET', signal
|
{url: `/playlists`, method: 'GET', signal
|
||||||
},
|
},
|
||||||
options);
|
options);
|
||||||
@ -119,7 +120,7 @@ export const getPlaylistsQueryKey = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getPlaylistsQueryOptions = <TData = Awaited<ReturnType<typeof playlists>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
export const getPlaylistsQueryOptions = <TData = Awaited<ReturnType<typeof playlists>>, TError = unknown>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>>, request?: SecondParameter<typeof playlistsMutator>}
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
@ -143,7 +144,7 @@ export type PlaylistsQueryError = unknown
|
|||||||
|
|
||||||
|
|
||||||
export function usePlaylists<TData = Awaited<ReturnType<typeof playlists>>, TError = unknown>(
|
export function usePlaylists<TData = Awaited<ReturnType<typeof playlists>>, TError = unknown>(
|
||||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>>, request?: SecondParameter<typeof playlistsMutator>}
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
useQuery
|
||||||
|
} from '@tanstack/vue-query';
|
||||||
|
import type {
|
||||||
|
DataTag,
|
||||||
|
QueryClient,
|
||||||
|
QueryFunction,
|
||||||
|
QueryKey,
|
||||||
|
UseQueryOptions,
|
||||||
|
UseQueryReturnType
|
||||||
|
} from '@tanstack/vue-query';
|
||||||
|
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
unref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
MaybeRef
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StreamProgress200Item
|
||||||
|
} from '.././models';
|
||||||
|
|
||||||
|
import streamProgressMutator from '.././event-source';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const streamProgress = (
|
||||||
|
playlistId: MaybeRef<number>,
|
||||||
|
options?: SecondParameter<typeof streamProgressMutator>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
|
||||||
|
return streamProgressMutator<StreamProgress200Item[]>(
|
||||||
|
{url: `/importing/stream/${playlistId}`, method: 'GET', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getStreamProgressQueryKey = (playlistId?: MaybeRef<number>,) => {
|
||||||
|
return [
|
||||||
|
'importing','stream',playlistId
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const getStreamProgressQueryOptions = <TData = Awaited<ReturnType<typeof streamProgress>>, TError = unknown>(playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof streamProgress>>, TError, TData>>, request?: SecondParameter<typeof streamProgressMutator>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
|
|
||||||
|
const queryKey = getStreamProgressQueryKey(playlistId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const queryFn: QueryFunction<Awaited<ReturnType<typeof streamProgress>>> = ({ signal }) => streamProgress(playlistId, requestOptions, signal);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { queryKey, queryFn, enabled: computed(() => !!(unref(playlistId))), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof streamProgress>>, TError, TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StreamProgressQueryResult = NonNullable<Awaited<ReturnType<typeof streamProgress>>>
|
||||||
|
export type StreamProgressQueryError = unknown
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function useStreamProgress<TData = Awaited<ReturnType<typeof streamProgress>>, TError = unknown>(
|
||||||
|
playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof streamProgress>>, TError, TData>>, request?: SecondParameter<typeof streamProgressMutator>}
|
||||||
|
, queryClient?: QueryClient
|
||||||
|
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
const queryOptions = getStreamProgressQueryOptions(playlistId,options)
|
||||||
|
|
||||||
|
const query = useQuery(queryOptions, queryClient) as UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||||
|
|
||||||
|
query.queryKey = unref(queryOptions).queryKey as DataTag<QueryKey, TData, TError>;
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -32,7 +32,8 @@ import type {
|
|||||||
UploadBody
|
UploadBody
|
||||||
} from '.././models';
|
} from '.././models';
|
||||||
|
|
||||||
import { axiosInstance } from '.././axios-instance';
|
import uploadMutator from '.././axios-instance';
|
||||||
|
import readMutator from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
@ -41,13 +42,13 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
|||||||
|
|
||||||
export const upload = (
|
export const upload = (
|
||||||
uploadBody: MaybeRef<UploadBody>,
|
uploadBody: MaybeRef<UploadBody>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof uploadMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
uploadBody = unref(uploadBody);
|
uploadBody = unref(uploadBody);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(`document`, uploadBody.document)
|
formData.append(`document`, uploadBody.document)
|
||||||
|
|
||||||
return axiosInstance<string>(
|
return uploadMutator<string>(
|
||||||
{url: `/upload`, method: 'POST',
|
{url: `/upload`, method: 'POST',
|
||||||
headers: {'Content-Type': 'multipart/form-data', },
|
headers: {'Content-Type': 'multipart/form-data', },
|
||||||
data: formData, signal
|
data: formData, signal
|
||||||
@ -58,7 +59,7 @@ formData.append(`document`, uploadBody.document)
|
|||||||
|
|
||||||
|
|
||||||
export const getUploadMutationOptions = <TError = unknown,
|
export const getUploadMutationOptions = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext>, request?: SecondParameter<typeof uploadMutator>}
|
||||||
): UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext> => {
|
): UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext> => {
|
||||||
|
|
||||||
const mutationKey = ['upload'];
|
const mutationKey = ['upload'];
|
||||||
@ -87,7 +88,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type UploadMutationError = unknown
|
export type UploadMutationError = unknown
|
||||||
|
|
||||||
export const useUpload = <TError = unknown,
|
export const useUpload = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext>, request?: SecondParameter<typeof uploadMutator>}
|
||||||
, queryClient?: QueryClient): UseMutationReturnType<
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
Awaited<ReturnType<typeof upload>>,
|
Awaited<ReturnType<typeof upload>>,
|
||||||
TError,
|
TError,
|
||||||
@ -101,11 +102,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
}
|
}
|
||||||
export const read = (
|
export const read = (
|
||||||
params: MaybeRef<ReadParams>,
|
params: MaybeRef<ReadParams>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof readMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
params = unref(params);
|
params = unref(params);
|
||||||
|
|
||||||
return axiosInstance<string>(
|
return readMutator<string>(
|
||||||
{url: `/read`, method: 'GET',
|
{url: `/read`, method: 'GET',
|
||||||
params: unref(params), signal
|
params: unref(params), signal
|
||||||
},
|
},
|
||||||
@ -122,7 +123,7 @@ export const getReadQueryKey = (params?: MaybeRef<ReadParams>,) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getReadQueryOptions = <TData = Awaited<ReturnType<typeof read>>, TError = unknown>(params: MaybeRef<ReadParams>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof read>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
export const getReadQueryOptions = <TData = Awaited<ReturnType<typeof read>>, TError = unknown>(params: MaybeRef<ReadParams>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof read>>, TError, TData>>, request?: SecondParameter<typeof readMutator>}
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
@ -146,7 +147,7 @@ export type ReadQueryError = unknown
|
|||||||
|
|
||||||
|
|
||||||
export function useRead<TData = Awaited<ReturnType<typeof read>>, TError = unknown>(
|
export function useRead<TData = Awaited<ReturnType<typeof read>>, TError = unknown>(
|
||||||
params: MaybeRef<ReadParams>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof read>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
params: MaybeRef<ReadParams>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof read>>, TError, TData>>, request?: SecondParameter<typeof readMutator>}
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
|||||||
@ -32,27 +32,150 @@ import type {
|
|||||||
AddLocalTrackRequest,
|
AddLocalTrackRequest,
|
||||||
PlaylistTrackResponse,
|
PlaylistTrackResponse,
|
||||||
TrackBulkReorderRequest,
|
TrackBulkReorderRequest,
|
||||||
TrackResponse
|
TrackResponse,
|
||||||
|
YoutubeTrackRequest
|
||||||
} from '.././models';
|
} from '.././models';
|
||||||
|
|
||||||
import { axiosInstance } from '.././axios-instance';
|
import addYoutubeTrackMutator from '.././axios-instance';
|
||||||
|
import addYoutubeTrack1Mutator from '.././axios-instance';
|
||||||
|
import addLocalTrackMutator from '.././axios-instance';
|
||||||
|
import bulkReorderMutator from '.././axios-instance';
|
||||||
|
import getPlaylistTracksMutator from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const addLocalTrack = (
|
export const addYoutubeTrack = (
|
||||||
|
playlistId: MaybeRef<number>,
|
||||||
|
youtubeTrackRequest: MaybeRef<YoutubeTrackRequest>,
|
||||||
|
options?: SecondParameter<typeof addYoutubeTrackMutator>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
youtubeTrackRequest = unref(youtubeTrackRequest);
|
||||||
|
|
||||||
|
return addYoutubeTrackMutator<TrackResponse[]>(
|
||||||
|
{url: `/playlist/${playlistId}/track/youtube`, method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', },
|
||||||
|
data: youtubeTrackRequest, signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getAddYoutubeTrackMutationOptions = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack>>, TError,{playlistId: number;data: YoutubeTrackRequest}, TContext>, request?: SecondParameter<typeof addYoutubeTrackMutator>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack>>, TError,{playlistId: number;data: YoutubeTrackRequest}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['addYoutubeTrack'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof addYoutubeTrack>>, {playlistId: number;data: YoutubeTrackRequest}> = (props) => {
|
||||||
|
const {playlistId,data} = props ?? {};
|
||||||
|
|
||||||
|
return addYoutubeTrack(playlistId,data,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type AddYoutubeTrackMutationResult = NonNullable<Awaited<ReturnType<typeof addYoutubeTrack>>>
|
||||||
|
export type AddYoutubeTrackMutationBody = YoutubeTrackRequest
|
||||||
|
export type AddYoutubeTrackMutationError = unknown
|
||||||
|
|
||||||
|
export const useAddYoutubeTrack = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack>>, TError,{playlistId: number;data: YoutubeTrackRequest}, TContext>, request?: SecondParameter<typeof addYoutubeTrackMutator>}
|
||||||
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
|
Awaited<ReturnType<typeof addYoutubeTrack>>,
|
||||||
|
TError,
|
||||||
|
{playlistId: number;data: YoutubeTrackRequest},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getAddYoutubeTrackMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
export const addYoutubeTrack1 = (
|
||||||
|
playlistId: MaybeRef<number>,
|
||||||
|
sourceId: MaybeRef<number>,
|
||||||
|
options?: SecondParameter<typeof addYoutubeTrack1Mutator>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
sourceId = unref(sourceId);
|
||||||
|
|
||||||
|
return addYoutubeTrack1Mutator<TrackResponse[]>(
|
||||||
|
{url: `/playlist/${playlistId}/track/youtube/refresh/${sourceId}`, method: 'POST', signal
|
||||||
|
},
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const getAddYoutubeTrack1MutationOptions = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack1>>, TError,{playlistId: number;sourceId: number}, TContext>, request?: SecondParameter<typeof addYoutubeTrack1Mutator>}
|
||||||
|
): UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack1>>, TError,{playlistId: number;sourceId: number}, TContext> => {
|
||||||
|
|
||||||
|
const mutationKey = ['addYoutubeTrack1'];
|
||||||
|
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||||
|
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||||
|
options
|
||||||
|
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||||
|
: {mutation: { mutationKey, }, request: undefined};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<Awaited<ReturnType<typeof addYoutubeTrack1>>, {playlistId: number;sourceId: number}> = (props) => {
|
||||||
|
const {playlistId,sourceId} = props ?? {};
|
||||||
|
|
||||||
|
return addYoutubeTrack1(playlistId,sourceId,requestOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions }}
|
||||||
|
|
||||||
|
export type AddYoutubeTrack1MutationResult = NonNullable<Awaited<ReturnType<typeof addYoutubeTrack1>>>
|
||||||
|
|
||||||
|
export type AddYoutubeTrack1MutationError = unknown
|
||||||
|
|
||||||
|
export const useAddYoutubeTrack1 = <TError = unknown,
|
||||||
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addYoutubeTrack1>>, TError,{playlistId: number;sourceId: number}, TContext>, request?: SecondParameter<typeof addYoutubeTrack1Mutator>}
|
||||||
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
|
Awaited<ReturnType<typeof addYoutubeTrack1>>,
|
||||||
|
TError,
|
||||||
|
{playlistId: number;sourceId: number},
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
|
||||||
|
const mutationOptions = getAddYoutubeTrack1MutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
}
|
||||||
|
export const addLocalTrack = (
|
||||||
playlistId: MaybeRef<number>,
|
playlistId: MaybeRef<number>,
|
||||||
addLocalTrackRequest: MaybeRef<AddLocalTrackRequest>,
|
addLocalTrackRequest: MaybeRef<AddLocalTrackRequest>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof addLocalTrackMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
playlistId = unref(playlistId);
|
playlistId = unref(playlistId);
|
||||||
addLocalTrackRequest = unref(addLocalTrackRequest);
|
addLocalTrackRequest = unref(addLocalTrackRequest);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(`source`, addLocalTrackRequest.source)
|
formData.append(`source`, addLocalTrackRequest.source)
|
||||||
|
|
||||||
return axiosInstance<TrackResponse>(
|
return addLocalTrackMutator<TrackResponse>(
|
||||||
{url: `/playlist/${playlistId}/track/local`, method: 'POST',
|
{url: `/playlist/${playlistId}/track/local`, method: 'POST',
|
||||||
headers: {'Content-Type': 'multipart/form-data', },
|
headers: {'Content-Type': 'multipart/form-data', },
|
||||||
data: formData, signal
|
data: formData, signal
|
||||||
@ -63,7 +186,7 @@ formData.append(`source`, addLocalTrackRequest.source)
|
|||||||
|
|
||||||
|
|
||||||
export const getAddLocalTrackMutationOptions = <TError = unknown,
|
export const getAddLocalTrackMutationOptions = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext>, request?: SecondParameter<typeof addLocalTrackMutator>}
|
||||||
): UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext> => {
|
): UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext> => {
|
||||||
|
|
||||||
const mutationKey = ['addLocalTrack'];
|
const mutationKey = ['addLocalTrack'];
|
||||||
@ -92,7 +215,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type AddLocalTrackMutationError = unknown
|
export type AddLocalTrackMutationError = unknown
|
||||||
|
|
||||||
export const useAddLocalTrack = <TError = unknown,
|
export const useAddLocalTrack = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext>, request?: SecondParameter<typeof addLocalTrackMutator>}
|
||||||
, queryClient?: QueryClient): UseMutationReturnType<
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
Awaited<ReturnType<typeof addLocalTrack>>,
|
Awaited<ReturnType<typeof addLocalTrack>>,
|
||||||
TError,
|
TError,
|
||||||
@ -107,12 +230,12 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export const bulkReorder = (
|
export const bulkReorder = (
|
||||||
playlistId: MaybeRef<number>,
|
playlistId: MaybeRef<number>,
|
||||||
trackBulkReorderRequest: MaybeRef<TrackBulkReorderRequest>,
|
trackBulkReorderRequest: MaybeRef<TrackBulkReorderRequest>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof bulkReorderMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
playlistId = unref(playlistId);
|
playlistId = unref(playlistId);
|
||||||
trackBulkReorderRequest = unref(trackBulkReorderRequest);
|
trackBulkReorderRequest = unref(trackBulkReorderRequest);
|
||||||
|
|
||||||
return axiosInstance<void>(
|
return bulkReorderMutator<void>(
|
||||||
{url: `/playlist/${playlistId}/bulk-reorder`, method: 'POST',
|
{url: `/playlist/${playlistId}/bulk-reorder`, method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json', },
|
headers: {'Content-Type': 'application/json', },
|
||||||
data: trackBulkReorderRequest, signal
|
data: trackBulkReorderRequest, signal
|
||||||
@ -123,7 +246,7 @@ trackBulkReorderRequest = unref(trackBulkReorderRequest);
|
|||||||
|
|
||||||
|
|
||||||
export const getBulkReorderMutationOptions = <TError = unknown,
|
export const getBulkReorderMutationOptions = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof bulkReorderMutator>}
|
||||||
): UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext> => {
|
): UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext> => {
|
||||||
|
|
||||||
const mutationKey = ['bulkReorder'];
|
const mutationKey = ['bulkReorder'];
|
||||||
@ -152,7 +275,7 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
export type BulkReorderMutationError = unknown
|
export type BulkReorderMutationError = unknown
|
||||||
|
|
||||||
export const useBulkReorder = <TError = unknown,
|
export const useBulkReorder = <TError = unknown,
|
||||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof axiosInstance>}
|
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof bulkReorderMutator>}
|
||||||
, queryClient?: QueryClient): UseMutationReturnType<
|
, queryClient?: QueryClient): UseMutationReturnType<
|
||||||
Awaited<ReturnType<typeof bulkReorder>>,
|
Awaited<ReturnType<typeof bulkReorder>>,
|
||||||
TError,
|
TError,
|
||||||
@ -166,11 +289,11 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
|
|||||||
}
|
}
|
||||||
export const getPlaylistTracks = (
|
export const getPlaylistTracks = (
|
||||||
playlistId: MaybeRef<number>,
|
playlistId: MaybeRef<number>,
|
||||||
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
options?: SecondParameter<typeof getPlaylistTracksMutator>,signal?: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
playlistId = unref(playlistId);
|
playlistId = unref(playlistId);
|
||||||
|
|
||||||
return axiosInstance<PlaylistTrackResponse[]>(
|
return getPlaylistTracksMutator<PlaylistTrackResponse[]>(
|
||||||
{url: `/playlist/${playlistId}/tracks`, method: 'GET', signal
|
{url: `/playlist/${playlistId}/tracks`, method: 'GET', signal
|
||||||
},
|
},
|
||||||
options);
|
options);
|
||||||
@ -186,7 +309,7 @@ export const getGetPlaylistTracksQueryKey = (playlistId?: MaybeRef<number>,) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getGetPlaylistTracksQueryOptions = <TData = Awaited<ReturnType<typeof getPlaylistTracks>>, TError = unknown>(playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlaylistTracks>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
export const getGetPlaylistTracksQueryOptions = <TData = Awaited<ReturnType<typeof getPlaylistTracks>>, TError = unknown>(playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlaylistTracks>>, TError, TData>>, request?: SecondParameter<typeof getPlaylistTracksMutator>}
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||||
@ -210,7 +333,7 @@ export type GetPlaylistTracksQueryError = unknown
|
|||||||
|
|
||||||
|
|
||||||
export function useGetPlaylistTracks<TData = Awaited<ReturnType<typeof getPlaylistTracks>>, TError = unknown>(
|
export function useGetPlaylistTracks<TData = Awaited<ReturnType<typeof getPlaylistTracks>>, TError = unknown>(
|
||||||
playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlaylistTracks>>, TError, TData>>, request?: SecondParameter<typeof axiosInstance>}
|
playlistId: MaybeRef<number>, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getPlaylistTracks>>, TError, TData>>, request?: SecondParameter<typeof getPlaylistTracksMutator>}
|
||||||
, queryClient?: QueryClient
|
, queryClient?: QueryClient
|
||||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
<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'
|
||||||
|
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<Toaster />
|
<Sonner />
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
@ -22,10 +22,9 @@
|
|||||||
|
|
||||||
<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';
|
||||||
|
|
||||||
|
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Search } from 'lucide-vue-next'
|
|
||||||
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 MusicCard from '~/components/internal/musiccard/MusicCard.vue'
|
|
||||||
import { DnDOperations, useDroppable } from '@vue-dnd-kit/core'
|
|
||||||
import Draggable from '~/components/action/Draggable.vue'
|
|
||||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
|
||||||
import { bulkReorder, getGetPlaylistTracksQueryKey } from '~/composeables/api/track-controller/track-controller'
|
|
||||||
import { useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
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 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'
|
||||||
|
|
||||||
const searchValue = ref('')
|
const searchValue = ref('')
|
||||||
|
|
||||||
@ -56,23 +51,33 @@ const { mutate: reorderTracks } = useMutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface MappedTrack {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
authorLabel: string;
|
||||||
|
badges: string[];
|
||||||
|
imageUrl: string | undefined;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
const trackCount = computed(() => playlistTracks.value?.data.length || 0)
|
const mappedTracks = ref<MappedTrack[]>([])
|
||||||
|
|
||||||
const mappedTracks = computed(() => {
|
watchEffect(() => {
|
||||||
if (!playlistTracks.value) return []
|
if (!playlistTracks.value) return []
|
||||||
const tracks = playlistTracks.value.data
|
const tracks = playlistTracks.value.data
|
||||||
|
|
||||||
return tracks.map((track: any, index: number) => ({
|
const result = tracks
|
||||||
id: track.trackId || track.id,
|
.map((track: TrackResponse, index: number) => ({
|
||||||
title: track.track?.title || track.title || 'Unknown Track',
|
id: track.trackId!,
|
||||||
author: track.track?.artist?.name || track.artist || 'Unknown Artist',
|
title: track.title || 'Unknown Track',
|
||||||
authorLabel: "Artist",
|
author: track.artist || 'Unknown Artist',
|
||||||
badges: track.track?.format ? [track.track.format] : ['mp3'],
|
authorLabel: "Artist",
|
||||||
imageUrl: track.track?.coverUrl || track.coverUrl || getDefaultImage(index),
|
badges: ['mp3'], // TODO: badges
|
||||||
date: formatDate(track.addedDate || track.createdAt),
|
imageUrl: getDefaultImage(index), // TODO: imageUrl
|
||||||
selected: false
|
date: formatDate(''), // TODO: createdAt
|
||||||
}))
|
}));
|
||||||
|
mappedTracks.value = result;
|
||||||
})
|
})
|
||||||
|
|
||||||
const getDefaultImage = (index: number) => {
|
const getDefaultImage = (index: number) => {
|
||||||
@ -108,34 +113,16 @@ const filteredTracks = computed(() => {
|
|||||||
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||||
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
const selectTrack = (trackId: number) => {
|
|
||||||
mappedTracks.value.forEach(track => {
|
|
||||||
track.selected = track.id === trackId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { elementRef: tracksRef } = useDroppable({
|
|
||||||
data: {
|
|
||||||
source: mappedTracks.value,
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
onDrop: (store, payload) => {
|
|
||||||
DnDOperations.applyTransfer(store);
|
|
||||||
if (currentPlaylistStore.id === -1) return;
|
|
||||||
if (mappedTracks.value.length !== trackCount.value) return;
|
|
||||||
if (mappedTracks.value.some(t => !t)) return;
|
|
||||||
|
|
||||||
const trackIds = mappedTracks.value?.map(t => t.id);
|
|
||||||
reorderTracks({
|
|
||||||
playlistId: currentPlaylistStore.id,
|
|
||||||
trackIds,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onTrackOrderChange() {
|
||||||
|
const trackIds = mappedTracks.value?.map(t => t.id);
|
||||||
|
reorderTracks({
|
||||||
|
playlistId: currentPlaylistStore.id,
|
||||||
|
trackIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => currentPlaylistStore.id, (newId) => {
|
watch(() => currentPlaylistStore.id, (newId) => {
|
||||||
if (newId !== -1) {
|
if (newId !== -1) {
|
||||||
refetch()
|
refetch()
|
||||||
@ -163,7 +150,7 @@ watch(() => currentPlaylistStore.id, (newId) => {
|
|||||||
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
||||||
id="user-search" class="w-full" />
|
id="user-search" class="w-full" />
|
||||||
|
|
||||||
<div ref="tracksRef">
|
<div>
|
||||||
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||||
<p>Loading tracks...</p>
|
<p>Loading tracks...</p>
|
||||||
</div>
|
</div>
|
||||||
@ -177,16 +164,14 @@ watch(() => currentPlaylistStore.id, (newId) => {
|
|||||||
<p v-else>No tracks in this playlist</p>
|
<p v-else>No tracks in this playlist</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else ref="tracksRef" class="space-y-2">
|
<VueDraggableNext v-model="mappedTracks" group="tracks" @change="onTrackOrderChange" item-key="id"
|
||||||
<TransitionGroup name="list" ref="tracksRef">
|
class="space-y-2" v-else>
|
||||||
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
|
<div v-for="track in mappedTracks" :key="track.id">
|
||||||
:source="mappedTracks">
|
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
||||||
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
||||||
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
:date="track.date" />
|
||||||
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
|
</div>
|
||||||
</Draggable>
|
</VueDraggableNext>
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #sidebar>
|
<template #sidebar>
|
||||||
|
|||||||
@ -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,8 +1,145 @@
|
|||||||
<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 SidebarTrigger from '@/components/ui/sidebar/SidebarTrigger.vue';
|
||||||
import { Download, Play } from 'lucide-vue-next';
|
import Empty from '@/components/ui/empty/Empty.vue';
|
||||||
import UploadEntry from '~/components/internal/import/uploadentry/UploadEntry.vue';
|
import UploadEntry from '@/components/internal/import/uploadentry/UploadEntry.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 EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
|
||||||
|
|
||||||
|
import { MegaphoneOff, Play } from 'lucide-vue-next';
|
||||||
|
import type { EventSourceListener } from '~/composeables/api/event-source';
|
||||||
|
import type { StreamProgress200Item } from '~/composeables/api/models';
|
||||||
|
import { streamProgress } from '~/composeables/api/progress-sse-controller/progress-sse-controller';
|
||||||
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
||||||
|
import FileUpload from '@/components/ui/file-upload/FileUpload.vue';
|
||||||
|
import FileUploadGrid from '@/components/ui/file-upload/FileUploadGrid.vue';
|
||||||
|
import Button from '@/components/ui/button/Button.vue';
|
||||||
|
import { addLocalTrack } from '~/composeables/api/track-controller/track-controller';
|
||||||
|
|
||||||
|
const files = ref<File[]>([]);
|
||||||
|
const currentPlaylistStore = useCurrentPlaylistStore();
|
||||||
|
const progressEntries = ref<Map<string, StreamProgress200Item>>(new Map());
|
||||||
|
let listener: EventSourceListener<StreamProgress200Item> | null = null;
|
||||||
|
|
||||||
|
const ongoingUploads = ref<Map<string, { file: File; progress: number }>>(new Map());
|
||||||
|
|
||||||
|
const sortedProgressEntries = computed(() => {
|
||||||
|
return Array.from(progressEntries.value.values())
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timeA = a.timestamp || 0;
|
||||||
|
const timeB = b.timestamp || 0;
|
||||||
|
return timeB - timeA;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const unwatch = watch(() => currentPlaylistStore.id, (newId, oldId) => {
|
||||||
|
if (newId !== -1 && newId !== oldId) {
|
||||||
|
listenImports();
|
||||||
|
} else if (newId === -1) {
|
||||||
|
stopImports();
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const updateEntryFromProgress = (data: StreamProgress200Item) => {
|
||||||
|
if (!data.trackSourceId)
|
||||||
|
return;
|
||||||
|
progressEntries.value = new Map(progressEntries.value);
|
||||||
|
progressEntries.value.set(data.id!.toString(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listenImports() {
|
||||||
|
if (listener) {
|
||||||
|
listener.close();
|
||||||
|
}
|
||||||
|
streamProgress(currentPlaylistStore.id).then(listener => {
|
||||||
|
listener.handle(data => {
|
||||||
|
updateEntryFromProgress(data);
|
||||||
|
})
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileUpload(uploadedFiles: File[]) {
|
||||||
|
if (!currentPlaylistStore.id || currentPlaylistStore.id === -1) {
|
||||||
|
console.error('No playlist selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of uploadedFiles) {
|
||||||
|
const uploadId = generateUploadId(file);
|
||||||
|
|
||||||
|
ongoingUploads.value.set(uploadId, { file, progress: 0 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('source', file);
|
||||||
|
|
||||||
|
await addLocalTrack(
|
||||||
|
currentPlaylistStore.id,
|
||||||
|
{ source: file },
|
||||||
|
{
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||||
|
ongoingUploads.value.set(uploadId, {
|
||||||
|
file,
|
||||||
|
progress: Math.min(progress, 100)
|
||||||
|
});
|
||||||
|
|
||||||
|
ongoingUploads.value = new Map(ongoingUploads.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ongoingUploads.value.delete(uploadId);
|
||||||
|
ongoingUploads.value = new Map(ongoingUploads.value);
|
||||||
|
|
||||||
|
const index = files.value.findIndex(f =>
|
||||||
|
f.name === file.name &&
|
||||||
|
f.size === file.size &&
|
||||||
|
f.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
files.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload failed:', error);
|
||||||
|
ongoingUploads.value.delete(uploadId);
|
||||||
|
ongoingUploads.value = new Map(ongoingUploads.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUploadId(file: File): string {
|
||||||
|
return `${file.name}-${file.size}-${file.lastModified}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(changedFiles: File[]) {
|
||||||
|
files.value = changedFiles;
|
||||||
|
|
||||||
|
handleFileUpload(changedFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onYoutubeClick(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopImports() {
|
||||||
|
if (listener) {
|
||||||
|
listener.close();
|
||||||
|
listener = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopImports();
|
||||||
|
unwatch();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -19,27 +156,45 @@ import UploadEntry from '~/components/internal/import/uploadentry/UploadEntry.vu
|
|||||||
</Outline>
|
</Outline>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full flex flex-col p-8">
|
<div class="w-full flex flex-col p-8">
|
||||||
<Outline class="rounded-xl bg-muted flex flex-col items-center justify-center gap-1">
|
<FileUpload v-model="files" class="rounded-lg border border-dashed border-muted"
|
||||||
<Download />
|
@onChange="handleFileChange" :ongoing-uploads="ongoingUploads">
|
||||||
<h4 class="scroll-m-20 text-xl font-semibold tracking-tight">
|
<template #default>
|
||||||
Drag and drop your audio files
|
<FileUploadGrid />
|
||||||
</h4>
|
</template>
|
||||||
<p class="text-sm text-muted-foreground">
|
<template #content>
|
||||||
or
|
<h4 class="z-20 scroll-m-20 text-xl font-semibold tracking-tight">
|
||||||
</p>
|
Drag and drop your audio files
|
||||||
<UiButton variant="destructive">
|
</h4>
|
||||||
<Play />
|
<p class="z-20 font-normal text-muted-foreground">
|
||||||
From Youtube
|
or
|
||||||
</UiButton>
|
</p>
|
||||||
</Outline>
|
<Button class="z-20" variant="destructive" @click="onYoutubeClick">
|
||||||
|
<Play />
|
||||||
|
From Youtube
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FileUpload>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||||
Uploaded files
|
Uploaded files
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex-row space-y-2">
|
<div class="space-y-2">
|
||||||
<UploadEntry title="Test" size="3.8 MB" format="mp4" />
|
|
||||||
<UploadEntry title="Test" :progress="78" />
|
|
||||||
<UploadEntry title="Test" error="Uploading failed, please check your internet" />
|
<UploadEntry v-for="entry in sortedProgressEntries" :key="entry.id" :entry="entry" />
|
||||||
|
|
||||||
|
<Empty class="border border-dashed"
|
||||||
|
v-if="progressEntries.size === 0 && ongoingUploads.size === 0">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<MegaphoneOff />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No imports found</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Upload any track to see their progress.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
</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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import VueDnDKitPlugin from '@vue-dnd-kit/core';
|
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
|
||||||
nuxtApp.vueApp.use(VueDnDKitPlugin);
|
|
||||||
});
|
|
||||||
24
bun.lock
24
bun.lock
@ -12,13 +12,14 @@
|
|||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-query": "^5.90.7",
|
"@tanstack/vue-query": "^5.90.7",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vue-dnd-kit/core": "^1.7.0",
|
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"eventsource": "^4.1.0",
|
||||||
"lucide-vue-next": "^0.548.0",
|
"lucide-vue-next": "^0.548.0",
|
||||||
|
"motion-v": "^1.8.1",
|
||||||
"nuxt": "^4.2.0",
|
"nuxt": "^4.2.0",
|
||||||
"oidc-client-ts": "^3.3.0",
|
"oidc-client-ts": "^3.3.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
|
"vue-draggable-next": "^2.3.0",
|
||||||
"vue-router": "^4.6.3",
|
"vue-router": "^4.6.3",
|
||||||
"vue-sonner": "^2.0.9",
|
"vue-sonner": "^2.0.9",
|
||||||
"zod": "^4.1.12",
|
"zod": "^4.1.12",
|
||||||
@ -616,8 +618,6 @@
|
|||||||
|
|
||||||
"@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="],
|
"@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="],
|
||||||
|
|
||||||
"@vue-dnd-kit/core": ["@vue-dnd-kit/core@1.7.0", "", { "peerDependencies": { "@vueuse/core": "^13.1.0", "vue": "^3.5.13" } }, "sha512-6Otpo/9Fp/cX5EiUoN6XmIPtm/mOreAXi8clr9HgzVvDHD0sfkgVOFxCUiVbH46yAzr6KBU1Q1tjSWLBqua+5g=="],
|
|
||||||
|
|
||||||
"@vue-macros/common": ["@vue-macros/common@3.1.1", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-afW2DMjgCBVs33mWRlz7YsGHzoEEupnl0DK5ZTKsgziAlLh5syc5m+GM7eqeYrgiQpwMaVxa1fk73caCvPxyAw=="],
|
"@vue-macros/common": ["@vue-macros/common@3.1.1", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" }, "optionalPeers": ["vue"] }, "sha512-afW2DMjgCBVs33mWRlz7YsGHzoEEupnl0DK5ZTKsgziAlLh5syc5m+GM7eqeYrgiQpwMaVxa1fk73caCvPxyAw=="],
|
||||||
|
|
||||||
"@vue/babel-helper-vue-transform-on": ["@vue/babel-helper-vue-transform-on@1.5.0", "", {}, "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA=="],
|
"@vue/babel-helper-vue-transform-on": ["@vue/babel-helper-vue-transform-on@1.5.0", "", {}, "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA=="],
|
||||||
@ -1002,6 +1002,10 @@
|
|||||||
|
|
||||||
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
||||||
|
|
||||||
|
"eventsource": ["eventsource@4.1.0", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ=="],
|
||||||
|
|
||||||
|
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||||
|
|
||||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||||
|
|
||||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
@ -1048,6 +1052,8 @@
|
|||||||
|
|
||||||
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
|
||||||
|
|
||||||
|
"framer-motion": ["framer-motion@12.26.2", "", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
@ -1122,6 +1128,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||||
|
|
||||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||||
|
|
||||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||||
@ -1400,6 +1408,12 @@
|
|||||||
|
|
||||||
"mocked-exports": ["mocked-exports@0.1.1", "", {}, "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA=="],
|
"mocked-exports": ["mocked-exports@0.1.1", "", {}, "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA=="],
|
||||||
|
|
||||||
|
"motion-dom": ["motion-dom@12.26.2", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="],
|
||||||
|
|
||||||
|
"motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="],
|
||||||
|
|
||||||
|
"motion-v": ["motion-v@1.8.1", "", { "dependencies": { "framer-motion": "^12.25.0", "hey-listen": "^1.0.8", "motion-dom": "^12.23.23" }, "peerDependencies": { "@vueuse/core": ">=10.0.0", "vue": ">=3.0.0" } }, "sha512-OS6ve/vdNlrKTmCAHy+lxujIVTggjs9nbzl1auWiewy49FthkpCs5Wwpf40+55Ko3mbTajlKadkTlQYysrnL4A=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
@ -1766,6 +1780,8 @@
|
|||||||
|
|
||||||
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
|
"smob": ["smob@1.5.0", "", {}, "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig=="],
|
||||||
|
|
||||||
|
"sortablejs": ["sortablejs@1.15.6", "", {}, "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
@ -1972,6 +1988,8 @@
|
|||||||
|
|
||||||
"vue-devtools-stub": ["vue-devtools-stub@0.1.0", "", {}, "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ=="],
|
"vue-devtools-stub": ["vue-devtools-stub@0.1.0", "", {}, "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ=="],
|
||||||
|
|
||||||
|
"vue-draggable-next": ["vue-draggable-next@2.3.0", "", { "peerDependencies": { "sortablejs": "^1.14.0", "vue": "^3.5.17" } }, "sha512-ymbY0UIwfSdg0iDN/iyNNwUrTqZ/6KbPryzsvTNXBLuDCuOBdNijSK8yynNtmiSj6RapTPQfjLGQdJrZkzBd2w=="],
|
||||||
|
|
||||||
"vue-router": ["vue-router@4.6.3", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg=="],
|
"vue-router": ["vue-router@4.6.3", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg=="],
|
||||||
|
|
||||||
"vue-sonner": ["vue-sonner@2.0.9", "", { "peerDependencies": { "@nuxt/kit": "^4.0.3", "@nuxt/schema": "^4.0.3", "nuxt": "^4.0.3" }, "optionalPeers": ["@nuxt/kit", "@nuxt/schema", "nuxt"] }, "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw=="],
|
"vue-sonner": ["vue-sonner@2.0.9", "", { "peerDependencies": { "@nuxt/kit": "^4.0.3", "@nuxt/schema": "^4.0.3", "nuxt": "^4.0.3" }, "optionalPeers": ["@nuxt/kit", "@nuxt/schema", "nuxt"] }, "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw=="],
|
||||||
|
|||||||
@ -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,7 +16,11 @@ export default defineNuxtConfig({
|
|||||||
apiBaseUrl: process.env.API_BASE_URL
|
apiBaseUrl: process.env.API_BASE_URL
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
shadcn: {
|
||||||
|
prefix: '',
|
||||||
|
componentDir: '@/components/ui'
|
||||||
|
},
|
||||||
pinia: {
|
pinia: {
|
||||||
storesDirs: ['./app/stores/**'],
|
storesDirs: ['./app/stores/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -26,8 +26,12 @@ export default defineConfig({
|
|||||||
override: {
|
override: {
|
||||||
mutator: {
|
mutator: {
|
||||||
path: './app/composeables/api/axios-instance.ts',
|
path: './app/composeables/api/axios-instance.ts',
|
||||||
name: 'axiosInstance',
|
|
||||||
},
|
},
|
||||||
|
operations: {
|
||||||
|
streamProgress: {
|
||||||
|
mutator: './app/composeables/api/event-source.ts',
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
88
package.json
88
package.json
@ -1,45 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "hello-world",
|
"name": "hello-world",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/fonts": "0.11.4",
|
"@nuxt/fonts": "0.11.4",
|
||||||
"@nuxt/icon": "2.1.0",
|
"@nuxt/icon": "2.1.0",
|
||||||
"@nuxt/image": "1.11.0",
|
"@nuxt/image": "1.11.0",
|
||||||
"@pinia/nuxt": "0.11.3",
|
"@pinia/nuxt": "0.11.3",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-query": "^5.90.7",
|
"@tanstack/vue-query": "^5.90.7",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vue-dnd-kit/core": "^1.7.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"@vueuse/core": "^14.1.0",
|
"axios": "^1.13.2",
|
||||||
"axios": "^1.13.2",
|
"class-variance-authority": "^0.7.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"clsx": "^2.1.1",
|
||||||
"clsx": "^2.1.1",
|
"dotenv": "^17.2.3",
|
||||||
"dotenv": "^17.2.3",
|
"eventsource": "^4.1.0",
|
||||||
"lucide-vue-next": "^0.548.0",
|
"lucide-vue-next": "^0.548.0",
|
||||||
"nuxt": "^4.2.0",
|
"motion-v": "^1.8.1",
|
||||||
"oidc-client-ts": "^3.3.0",
|
"nuxt": "^4.2.0",
|
||||||
"pinia": "^3.0.4",
|
"oidc-client-ts": "^3.3.0",
|
||||||
"reka-ui": "^2.7.0",
|
"pinia": "^3.0.4",
|
||||||
"shadcn-nuxt": "2.3.2",
|
"reka-ui": "^2.7.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"shadcn-nuxt": "2.3.2",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tailwindcss": "^4.1.16",
|
||||||
"vee-validate": "^4.15.1",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vue": "^3.5.22",
|
"vee-validate": "^4.15.1",
|
||||||
"vue-router": "^4.6.3",
|
"vue": "^3.5.22",
|
||||||
"vue-sonner": "^2.0.9",
|
"vue-draggable-next": "^2.3.0",
|
||||||
"zod": "^4.1.12"
|
"vue-router": "^4.6.3",
|
||||||
},
|
"vue-sonner": "^2.0.9",
|
||||||
"devDependencies": {
|
"zod": "^4.1.12"
|
||||||
"orval": "^7.16.0",
|
},
|
||||||
"typescript": "^5.9.3"
|
"devDependencies": {
|
||||||
}
|
"orval": "^7.16.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user