Compare commits
20 Commits
86b41eb5e0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fd026c026
|
|||
|
df9fe970ab
|
|||
|
327aff6208
|
|||
|
eb6483c180
|
|||
|
bd878fceab
|
|||
|
9984bb804a
|
|||
|
c29c12feec
|
|||
|
16d284fe68
|
|||
|
ff2b41c2e7
|
|||
|
627c9fda99
|
|||
|
53b326fbad
|
|||
|
0b1badd4bb
|
|||
|
885aab0d9d
|
|||
| 05814a100a | |||
| 133558c4ab | |||
| b860ea95d3 | |||
| 7b39d48316 | |||
| 15389b9de1 | |||
| 3186fa16e5 | |||
| f56a235e86 |
@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
const defaultOpen = useCookie<boolean>("sidebar_state");
|
||||
</script>
|
||||
|
||||
|
||||
88
app/components/internal/PlaylistSelect.vue
Normal file
88
app/components/internal/PlaylistSelect.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import PlaylistsNotFound from '@/components/internal/playlists/select/PlaylistsNotFound.vue';
|
||||
import { usePlaylists } from '@/composeables/api/playlist-controller/playlist-controller';
|
||||
import { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
|
||||
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
|
||||
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
||||
import { useSidebar } from '../ui/sidebar';
|
||||
|
||||
const {
|
||||
open: sidebarOpen,
|
||||
} = useSidebar()
|
||||
|
||||
const { isLoading, isError, error, data } = usePlaylists();
|
||||
|
||||
const currentPlaylistStore = useCurrentPlaylistStore();
|
||||
|
||||
watch(data, (value) => {
|
||||
const newValue = value?.data[0];
|
||||
if (currentPlaylistStore.id === -1 && newValue) {
|
||||
currentPlaylistStore.load(newValue);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="currentPlaylistStore.id">
|
||||
<div v-if="sidebarOpen" class="hover:bg-sidebar-muted cursor-pointer rounded-md select-none">
|
||||
<SelectCustomTrigger class="w-full flex p-2 gap-2 items-center">
|
||||
<Frame borderRadius="round" background="primary" padding="dense" margin="none">
|
||||
<Music4 class="text-primary-foreground" :size="24" />
|
||||
</Frame>
|
||||
<div class="overflow-hidden text-start">
|
||||
<Skeleton v-if="isLoading" class="w-32 h-5 rounded-full" />
|
||||
<h4 v-else-if="data" class="text-xl font-semibold tracking-tight truncate">
|
||||
<!-- TODO: i18n -->
|
||||
{{data.data.find(playlist => playlist.id === currentPlaylistStore.id)?.title ||
|
||||
'No playlist selected'}}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<!-- TODO: Actual track count -->
|
||||
11 track(s)
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<ChevronsUpDown />
|
||||
</div>
|
||||
</SelectCustomTrigger>
|
||||
</div>
|
||||
<div v-if="!sidebarOpen">
|
||||
<div class="flex items-center">
|
||||
<SelectCustomTrigger as-child>
|
||||
<Frame borderRadius="round" background="primary" padding="dense" margin="none">
|
||||
<Music4 class="text-primary-foreground" :size="24" />
|
||||
</Frame>
|
||||
</SelectCustomTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<SelectContent align="start" side="right" :sideOffset="4">
|
||||
<div class="w-full">
|
||||
<div v-if="isLoading">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="isError">
|
||||
<SelectLabel>{{ error }}</SelectLabel>
|
||||
</div>
|
||||
<div v-else-if="data" class="w-full">
|
||||
<SelectLabel>Playlists</SelectLabel>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="item in data.data" :key="item.id" :value="item.id || -1">
|
||||
<span>{{ item.title }}</span>
|
||||
</SelectItem>
|
||||
<PlaylistsNotFound v-if="data.data.length === 0" />
|
||||
</SelectGroup>
|
||||
<SelectSeparator v-if="data.data.length > 0" />
|
||||
<PlaylistCreateDialog v-if="data.data.length > 0">
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="icon" class="w-full">
|
||||
<Plus />
|
||||
Create playlist
|
||||
</Button>
|
||||
</template>
|
||||
</PlaylistCreateDialog>
|
||||
</div>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
@ -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>
|
||||
27
app/components/internal/import/uploadentry/UploadEntry.vue
Normal file
27
app/components/internal/import/uploadentry/UploadEntry.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<SingleUploadEntry v-if="entry.type === 'TRACK'" :title="(entry as SingleTrackProgressAllOf).title || ''" :size="''"
|
||||
:format="(entry as SingleTrackProgressAllOf).format || ''" />
|
||||
<PlaylistUploadEntry v-if="entry.type === 'PLAYLIST'" :title="(entry as PlaylistProgressAllOf).playlistTitle || ''"
|
||||
:trackCount="(entry as PlaylistProgressAllOf).trackCount"
|
||||
:ytdlnStdout="(entry as PlaylistProgressAllOf).ytdlnStdout || ''"
|
||||
:status="(entry as PlaylistProgressAllOf).status || 'LOADING'" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PlaylistProgressAllOf, SingleTrackProgressAllOf, StreamProgress200Item } from '~/composeables/api/models';
|
||||
import PlaylistUploadEntry from './PlaylistUploadEntry.vue';
|
||||
import SingleUploadEntry from './SingleUploadEntry.vue';
|
||||
|
||||
interface Props {
|
||||
entry: StreamProgress200Item
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
download: []
|
||||
play: []
|
||||
}>();
|
||||
</script>
|
||||
@ -1,16 +1,16 @@
|
||||
<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">
|
||||
<Draggable />
|
||||
</div>
|
||||
<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="flex items-center 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]">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<p class="leading-7" v-if="date">
|
||||
<p class="max-w-[16ch]" v-if="date">
|
||||
{{ date }}
|
||||
</p>
|
||||
</div>
|
||||
@ -26,30 +26,35 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div class="max-w-20 max-h-20 w-20 h-20" v-if="imageUrl">
|
||||
<Separator orientation="vertical" class="cursor-pointer" data-no-drag @click="onPlayClick" />
|
||||
<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" />
|
||||
</div>
|
||||
</Frame>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import Draggable from '@/components/icon/Draggable.vue'
|
||||
import Frame from '@/components/ui/frame/Frame.vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
author: string
|
||||
selected?: boolean
|
||||
badges?: string[]
|
||||
imageUrl?: string
|
||||
date?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
onPlay: []
|
||||
}>();
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
authorLabel: 'Author',
|
||||
badges: () => []
|
||||
})
|
||||
|
||||
function onPlayClick(e: MouseEvent) {
|
||||
emit("onPlay")
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import * as z from "zod";
|
||||
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { toast } from "vue-sonner";
|
||||
import { getPlaylistsQueryKey, useCreatePlaylist } from "~/composeables/api/playlist-controller/playlist-controller";
|
||||
import { FormField } from "@/components/ui/form";
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
playlistName: z.string().min(2).max(50).default(''),
|
||||
}))
|
||||
|
||||
const { isFieldDirty, handleSubmit, resetForm } = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: createPlaylistMutation } = useCreatePlaylist({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getPlaylistsQueryKey() });
|
||||
toast('Successfully created playlist', {
|
||||
description: `Playlist created successfully`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Cannot create playlist', {
|
||||
description: 'Error occurred during playlist creation, please try again later.',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const playlistName = values['playlistName'];
|
||||
open.value = false;
|
||||
createPlaylistMutation({
|
||||
data: {
|
||||
title: playlistName,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="trigger">
|
||||
Create playlist
|
||||
</slot>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create name for your playlist.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="py-4">
|
||||
<FormField v-slot="{ componentField }" name="playlistName" :validate-on-blur="!isFieldDirty">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="shadcn" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { FileMusicIcon } from 'lucide-vue-next';
|
||||
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
||||
import Empty from '@/components/ui/empty/Empty.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 EmptyContent from '@/components/ui/empty/EmptyContent.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileMusicIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No playlists found</EmptyTitle>
|
||||
<EmptyDescription>No playlists found, create one right now!</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<PlaylistCreateDialog>
|
||||
<template #trigger>
|
||||
<Button>Create playlist</Button>
|
||||
</template>
|
||||
</PlaylistCreateDialog>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</template>
|
||||
@ -18,12 +18,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<Primitive data-slot="button" :as="as" :as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@ -4,7 +4,7 @@ import { cva } from "class-variance-authority"
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
18
app/components/ui/dialog/Dialog.vue
Normal file
18
app/components/ui/dialog/Dialog.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
app/components/ui/dialog/DialogClose.vue
Normal file
15
app/components/ui/dialog/DialogClose.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
46
app/components/ui/dialog/DialogContent.vue
Normal file
46
app/components/ui/dialog/DialogContent.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
app/components/ui/dialog/DialogDescription.vue
Normal file
23
app/components/ui/dialog/DialogDescription.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
app/components/ui/dialog/DialogFooter.vue
Normal file
15
app/components/ui/dialog/DialogFooter.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
app/components/ui/dialog/DialogHeader.vue
Normal file
17
app/components/ui/dialog/DialogHeader.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/dialog/DialogOverlay.vue
Normal file
21
app/components/ui/dialog/DialogOverlay.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
56
app/components/ui/dialog/DialogScrollContent.vue
Normal file
56
app/components/ui/dialog/DialogScrollContent.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
app/components/ui/dialog/DialogTitle.vue
Normal file
23
app/components/ui/dialog/DialogTitle.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
app/components/ui/dialog/DialogTrigger.vue
Normal file
15
app/components/ui/dialog/DialogTrigger.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
app/components/ui/dialog/index.ts
Normal file
10
app/components/ui/dialog/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
@ -2,16 +2,21 @@
|
||||
import type { DropdownMenuTriggerProps } 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 handleClick = (event: MouseEvent) => {
|
||||
if (props.onTriggerClick) {
|
||||
props.onTriggerClick(event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<DropdownMenuTrigger data-slot="dropdown-menu-trigger" v-bind="forwardedProps" @click="handleClick">
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</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>
|
||||
20
app/components/ui/empty/Empty.vue
Normal file
20
app/components/ui/empty/Empty.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty"
|
||||
:class="cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyContent.vue
Normal file
20
app/components/ui/empty/EmptyContent.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
:class="cn(
|
||||
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyDescription.vue
Normal file
20
app/components/ui/empty/EmptyDescription.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
app/components/ui/empty/EmptyHeader.vue
Normal file
20
app/components/ui/empty/EmptyHeader.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
:class="cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/empty/EmptyMedia.vue
Normal file
21
app/components/ui/empty/EmptyMedia.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
app/components/ui/empty/EmptyTitle.vue
Normal file
21
app/components/ui/empty/EmptyTitle.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
26
app/components/ui/empty/index.ts
Normal file
26
app/components/ui/empty/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyContent } from "./EmptyContent.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
export { default as EmptyTitle } from "./EmptyTitle.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
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";
|
||||
17
app/components/ui/form/FormControl.vue
Normal file
17
app/components/ui/form/FormControl.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { Slot } from "reka-ui"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Slot
|
||||
:id="formItemId"
|
||||
data-slot="form-control"
|
||||
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
|
||||
:aria-invalid="!!error"
|
||||
>
|
||||
<slot />
|
||||
</Slot>
|
||||
</template>
|
||||
21
app/components/ui/form/FormDescription.vue
Normal file
21
app/components/ui/form/FormDescription.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { formDescriptionId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
:id="formDescriptionId"
|
||||
data-slot="form-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
23
app/components/ui/form/FormItem.vue
Normal file
23
app/components/ui/form/FormItem.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useId } from "reka-ui"
|
||||
import { provide } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const id = useId()
|
||||
provide(FORM_ITEM_INJECTION_KEY, id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
:class="cn('grid gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
25
app/components/ui/form/FormLabel.vue
Normal file
25
app/components/ui/form/FormLabel.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const { error, formItemId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
:data-error="!!error"
|
||||
:class="cn(
|
||||
'data-[error=true]:text-destructive',
|
||||
props.class,
|
||||
)"
|
||||
:for="formItemId"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
23
app/components/ui/form/FormMessage.vue
Normal file
23
app/components/ui/form/FormMessage.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ErrorMessage } from "vee-validate"
|
||||
import { toValue } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useFormField } from "./useFormField"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const { name, formMessageId } = useFormField()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ErrorMessage
|
||||
:id="formMessageId"
|
||||
data-slot="form-message"
|
||||
as="p"
|
||||
:name="toValue(name)"
|
||||
:class="cn('text-destructive text-sm', props.class)"
|
||||
/>
|
||||
</template>
|
||||
7
app/components/ui/form/index.ts
Normal file
7
app/components/ui/form/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as FormControl } from "./FormControl.vue"
|
||||
export { default as FormDescription } from "./FormDescription.vue"
|
||||
export { default as FormItem } from "./FormItem.vue"
|
||||
export { default as FormLabel } from "./FormLabel.vue"
|
||||
export { default as FormMessage } from "./FormMessage.vue"
|
||||
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
export { Form, Field as FormField, FieldArray as FormFieldArray } from "vee-validate"
|
||||
4
app/components/ui/form/injectionKeys.ts
Normal file
4
app/components/ui/form/injectionKeys.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import type { InjectionKey } from "vue"
|
||||
|
||||
export const FORM_ITEM_INJECTION_KEY
|
||||
= Symbol() as InjectionKey<string>
|
||||
30
app/components/ui/form/useFormField.ts
Normal file
30
app/components/ui/form/useFormField.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { FieldContextKey } from "vee-validate"
|
||||
import { computed, inject } from "vue"
|
||||
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys"
|
||||
|
||||
export function useFormField() {
|
||||
const fieldContext = inject(FieldContextKey)
|
||||
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
|
||||
|
||||
if (!fieldContext)
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
|
||||
const { name, errorMessage: error, meta } = fieldContext
|
||||
const id = fieldItemContext
|
||||
|
||||
const fieldState = {
|
||||
valid: computed(() => meta.valid),
|
||||
isDirty: computed(() => meta.dirty),
|
||||
isTouched: computed(() => meta.touched),
|
||||
error,
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
26
app/components/ui/label/Label.vue
Normal file
26
app/components/ui/label/Label.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
app/components/ui/label/index.ts
Normal file
1
app/components/ui/label/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
@ -3,7 +3,7 @@ import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { FrameVariants } from "../frame"
|
||||
import { cn } from "@/lib/utils"
|
||||
import Frame from "@/components/ui/frame/Frame.vue"
|
||||
import Frame from "../frame/Frame.vue"
|
||||
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
|
||||
38
app/components/ui/progress/Progress.vue
Normal file
38
app/components/ui/progress/Progress.vue
Normal file
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProgressRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ProgressIndicator,
|
||||
ProgressRoot,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProgressRoot
|
||||
data-slot="progress"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ProgressIndicator
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
|
||||
/>
|
||||
</ProgressRoot>
|
||||
</template>
|
||||
1
app/components/ui/progress/index.ts
Normal file
1
app/components/ui/progress/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Progress } from "./Progress.vue"
|
||||
@ -1,27 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsUpDown, Download, Edit, Music4, Settings, Upload } from "lucide-vue-next"
|
||||
import EditAudio from "@/components/icon/EditAudio.vue";
|
||||
import PlaylistSelect from "@/components/internal/PlaylistSelect.vue";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import EditAudio from "@/components/icon/EditAudio.vue";
|
||||
import Frame from "@/components/ui/frame/Frame.vue";
|
||||
import Select from "@/components/ui/select/Select.vue";
|
||||
import SelectTrigger from "@/components/ui/select/SelectTrigger.vue";
|
||||
import SelectContent from "@/components/ui/select/SelectContent.vue";
|
||||
import SelectLabel from "@/components/ui/select/SelectLabel.vue";
|
||||
import SelectSeparator from "@/components/ui/select/SelectSeparator.vue";
|
||||
import SelectGroup from "@/components/ui/select/SelectGroup.vue";
|
||||
import SelectItem from "@/components/ui/select/SelectItem.vue";
|
||||
import SelectCustomTrigger from "@/components/ui/select/SelectCustomTrigger.vue";
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Download, Edit, Settings, Upload } from "lucide-vue-next";
|
||||
|
||||
const items = [
|
||||
{
|
||||
@ -50,62 +41,13 @@ const items = [
|
||||
},
|
||||
];
|
||||
|
||||
const {
|
||||
open,
|
||||
} = useSidebar()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<Select>
|
||||
<div v-if="open" class="hover:bg-sidebar-muted cursor-pointer rounded-md select-none">
|
||||
<SelectCustomTrigger class="w-full flex p-2 gap-2 items-center">
|
||||
<Frame borderRadius="round" background="primary" padding="dense" margin="none">
|
||||
<Music4 class="text-primary-foreground" :size="24" />
|
||||
</Frame>
|
||||
<div class="overflow-hidden text-start">
|
||||
<h4 class="text-xl font-semibold tracking-tight truncate">
|
||||
My playlist
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
11 track(s)
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<ChevronsUpDown />
|
||||
</div>
|
||||
</SelectCustomTrigger>
|
||||
</div>
|
||||
<div v-if="!open">
|
||||
<div class="flex items-center">
|
||||
<SelectCustomTrigger as-child>
|
||||
<Frame borderRadius="round" background="primary" padding="dense" margin="none">
|
||||
<Music4 class="text-primary-foreground" :size="24" />
|
||||
</Frame>
|
||||
</SelectCustomTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<SelectContent class="w-full">
|
||||
<SelectLabel>Playlists</SelectLabel>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectItem value="test">
|
||||
<span>Test</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="second">
|
||||
<span>Second</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="third">
|
||||
<span>Third</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="fourth">
|
||||
<span>Fourth</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<PlaylistSelect />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
|
||||
19
app/components/ui/sonner/Sonner.vue
Normal file
19
app/components/ui/sonner/Sonner.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ToasterProps } from "vue-sonner"
|
||||
import { Toaster as Sonner } from "vue-sonner"
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
class="toaster group"
|
||||
v-bind="props"
|
||||
:style="{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
1
app/components/ui/sonner/index.ts
Normal file
1
app/components/ui/sonner/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./Sonner.vue"
|
||||
17
app/components/ui/spinner/Spinner.vue
Normal file
17
app/components/ui/spinner/Spinner.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { Loader2Icon } from "lucide-vue-next"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
:class="cn('size-4 animate-spin', props.class)"
|
||||
/>
|
||||
</template>
|
||||
1
app/components/ui/spinner/index.ts
Normal file
1
app/components/ui/spinner/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Spinner } from "./Spinner.vue"
|
||||
26
app/composeables/api/axios-instance.ts
Normal file
26
app/composeables/api/axios-instance.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import Axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||
export const AXIOS_INSTANCE = Axios.create();
|
||||
|
||||
const axiosInstance = <T>(
|
||||
config: AxiosRequestConfig,
|
||||
options?: AxiosRequestConfig,
|
||||
): Promise<AxiosResponse<T, any>> => {
|
||||
const baseURL = useRuntimeConfig().public.apiBaseUrl;
|
||||
const source = Axios.CancelToken.source();
|
||||
const promise = AXIOS_INSTANCE({
|
||||
...config,
|
||||
...{
|
||||
...options,
|
||||
baseURL: baseURL,
|
||||
auth: {
|
||||
username: 'user1',
|
||||
password: 'password',
|
||||
}
|
||||
},
|
||||
cancelToken: source.token,
|
||||
});
|
||||
|
||||
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;
|
||||
10
app/composeables/api/models/addLocalTrackRequest.ts
Normal file
10
app/composeables/api/models/addLocalTrackRequest.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface AddLocalTrackRequest {
|
||||
source: Blob;
|
||||
}
|
||||
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;
|
||||
24
app/composeables/api/models/index.ts
Normal file
24
app/composeables/api/models/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export * from './addLocalTrackRequest';
|
||||
export * from './baseTrackProgress';
|
||||
export * from './baseTrackProgressType';
|
||||
export * from './playlistCreateRequest';
|
||||
export * from './playlistProgress';
|
||||
export * from './playlistProgressAllOf';
|
||||
export * from './playlistProgressAllOfStatus';
|
||||
export * from './playlistReadResponse';
|
||||
export * from './playlistTrackResponse';
|
||||
export * from './readParams';
|
||||
export * from './singleTrackProgress';
|
||||
export * from './singleTrackProgressAllOf';
|
||||
export * from './streamProgress200Item';
|
||||
export * from './trackBulkReorderRequest';
|
||||
export * from './trackResponse';
|
||||
export * from './uploadBody';
|
||||
export * from './youtubeTrackRequest';
|
||||
10
app/composeables/api/models/playlistCreateRequest.ts
Normal file
10
app/composeables/api/models/playlistCreateRequest.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface PlaylistCreateRequest {
|
||||
title?: string;
|
||||
}
|
||||
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;
|
||||
14
app/composeables/api/models/playlistReadResponse.ts
Normal file
14
app/composeables/api/models/playlistReadResponse.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface PlaylistReadResponse {
|
||||
id?: number;
|
||||
ownerId?: number;
|
||||
title?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
15
app/composeables/api/models/playlistTrackResponse.ts
Normal file
15
app/composeables/api/models/playlistTrackResponse.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface PlaylistTrackResponse {
|
||||
trackId?: number;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
audioPath?: string;
|
||||
durationSeconds?: number;
|
||||
fileName?: string;
|
||||
}
|
||||
10
app/composeables/api/models/readParams.ts
Normal file
10
app/composeables/api/models/readParams.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export type ReadParams = {
|
||||
document: 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;
|
||||
11
app/composeables/api/models/singleTrackProgressAllOf.ts
Normal file
11
app/composeables/api/models/singleTrackProgressAllOf.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export type SingleTrackProgressAllOf = {
|
||||
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
app/composeables/api/models/trackBulkReorderRequest.ts
Normal file
10
app/composeables/api/models/trackBulkReorderRequest.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface TrackBulkReorderRequest {
|
||||
trackIds?: number[];
|
||||
}
|
||||
16
app/composeables/api/models/trackResponse.ts
Normal file
16
app/composeables/api/models/trackResponse.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface TrackResponse {
|
||||
trackId?: number;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
audioPath?: string;
|
||||
fileFormat?: string;
|
||||
durationSeconds?: number;
|
||||
fileName?: string;
|
||||
}
|
||||
10
app/composeables/api/models/uploadBody.ts
Normal file
10
app/composeables/api/models/uploadBody.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export type UploadBody = {
|
||||
document: Blob;
|
||||
};
|
||||
10
app/composeables/api/models/youtubeTrackRequest.ts
Normal file
10
app/composeables/api/models/youtubeTrackRequest.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
|
||||
export interface YoutubeTrackRequest {
|
||||
youtubeUrl?: string;
|
||||
}
|
||||
162
app/composeables/api/playlist-controller/playlist-controller.ts
Normal file
162
app/composeables/api/playlist-controller/playlist-controller.ts
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQuery
|
||||
} from '@tanstack/vue-query';
|
||||
import type {
|
||||
DataTag,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationReturnType,
|
||||
UseQueryOptions,
|
||||
UseQueryReturnType
|
||||
} from '@tanstack/vue-query';
|
||||
|
||||
import {
|
||||
unref
|
||||
} from 'vue';
|
||||
import type {
|
||||
MaybeRef
|
||||
} from 'vue';
|
||||
|
||||
import type {
|
||||
PlaylistCreateRequest,
|
||||
PlaylistReadResponse
|
||||
} from '.././models';
|
||||
|
||||
import createPlaylistMutator from '.././axios-instance';
|
||||
import playlistsMutator from '.././axios-instance';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
export const createPlaylist = (
|
||||
playlistCreateRequest: MaybeRef<PlaylistCreateRequest>,
|
||||
options?: SecondParameter<typeof createPlaylistMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
playlistCreateRequest = unref(playlistCreateRequest);
|
||||
|
||||
return createPlaylistMutator<PlaylistReadResponse>(
|
||||
{url: `/playlist`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: playlistCreateRequest, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getCreatePlaylistMutationOptions = <TError = unknown,
|
||||
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> => {
|
||||
|
||||
const mutationKey = ['createPlaylist'];
|
||||
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 createPlaylist>>, {data: PlaylistCreateRequest}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return createPlaylist(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type CreatePlaylistMutationResult = NonNullable<Awaited<ReturnType<typeof createPlaylist>>>
|
||||
export type CreatePlaylistMutationBody = PlaylistCreateRequest
|
||||
export type CreatePlaylistMutationError = unknown
|
||||
|
||||
export const useCreatePlaylist = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createPlaylist>>, TError,{data: PlaylistCreateRequest}, TContext>, request?: SecondParameter<typeof createPlaylistMutator>}
|
||||
, queryClient?: QueryClient): UseMutationReturnType<
|
||||
Awaited<ReturnType<typeof createPlaylist>>,
|
||||
TError,
|
||||
{data: PlaylistCreateRequest},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getCreatePlaylistMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
export const playlists = (
|
||||
|
||||
options?: SecondParameter<typeof playlistsMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
|
||||
|
||||
return playlistsMutator<PlaylistReadResponse[]>(
|
||||
{url: `/playlists`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getPlaylistsQueryKey = () => {
|
||||
return [
|
||||
'playlists'
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
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 queryKey = getPlaylistsQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof playlists>>> = ({ signal }) => playlists(requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>
|
||||
}
|
||||
|
||||
export type PlaylistsQueryResult = NonNullable<Awaited<ReturnType<typeof playlists>>>
|
||||
export type PlaylistsQueryError = unknown
|
||||
|
||||
|
||||
|
||||
export function usePlaylists<TData = Awaited<ReturnType<typeof playlists>>, TError = unknown>(
|
||||
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof playlists>>, TError, TData>>, request?: SecondParameter<typeof playlistsMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getPlaylistsQueryOptions(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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
165
app/composeables/api/s-3-controller/s-3-controller.ts
Normal file
165
app/composeables/api/s-3-controller/s-3-controller.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQuery
|
||||
} from '@tanstack/vue-query';
|
||||
import type {
|
||||
DataTag,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationReturnType,
|
||||
UseQueryOptions,
|
||||
UseQueryReturnType
|
||||
} from '@tanstack/vue-query';
|
||||
|
||||
import {
|
||||
unref
|
||||
} from 'vue';
|
||||
import type {
|
||||
MaybeRef
|
||||
} from 'vue';
|
||||
|
||||
import type {
|
||||
ReadParams,
|
||||
UploadBody
|
||||
} from '.././models';
|
||||
|
||||
import uploadMutator from '.././axios-instance';
|
||||
import readMutator from '.././axios-instance';
|
||||
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
|
||||
|
||||
export const upload = (
|
||||
uploadBody: MaybeRef<UploadBody>,
|
||||
options?: SecondParameter<typeof uploadMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
uploadBody = unref(uploadBody);
|
||||
const formData = new FormData();
|
||||
formData.append(`document`, uploadBody.document)
|
||||
|
||||
return uploadMutator<string>(
|
||||
{url: `/upload`, method: 'POST',
|
||||
headers: {'Content-Type': 'multipart/form-data', },
|
||||
data: formData, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getUploadMutationOptions = <TError = unknown,
|
||||
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> => {
|
||||
|
||||
const mutationKey = ['upload'];
|
||||
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 upload>>, {data: UploadBody}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return upload(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type UploadMutationResult = NonNullable<Awaited<ReturnType<typeof upload>>>
|
||||
export type UploadMutationBody = UploadBody
|
||||
export type UploadMutationError = unknown
|
||||
|
||||
export const useUpload = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof upload>>, TError,{data: UploadBody}, TContext>, request?: SecondParameter<typeof uploadMutator>}
|
||||
, queryClient?: QueryClient): UseMutationReturnType<
|
||||
Awaited<ReturnType<typeof upload>>,
|
||||
TError,
|
||||
{data: UploadBody},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getUploadMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
export const read = (
|
||||
params: MaybeRef<ReadParams>,
|
||||
options?: SecondParameter<typeof readMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
params = unref(params);
|
||||
|
||||
return readMutator<string>(
|
||||
{url: `/read`, method: 'GET',
|
||||
params: unref(params), signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getReadQueryKey = (params?: MaybeRef<ReadParams>,) => {
|
||||
return [
|
||||
'read', ...(params ? [params]: [])
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
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 queryKey = getReadQueryKey(params);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof read>>> = ({ signal }) => read(params, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof read>>, TError, TData>
|
||||
}
|
||||
|
||||
export type ReadQueryResult = NonNullable<Awaited<ReturnType<typeof read>>>
|
||||
export type ReadQueryError = 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 readMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getReadQueryOptions(params,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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
351
app/composeables/api/track-controller/track-controller.ts
Normal file
351
app/composeables/api/track-controller/track-controller.ts
Normal file
@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Generated by orval v7.16.0 🍺
|
||||
* Do not edit manually.
|
||||
* OpenAPI definition
|
||||
* OpenAPI spec version: v0
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQuery
|
||||
} from '@tanstack/vue-query';
|
||||
import type {
|
||||
DataTag,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationReturnType,
|
||||
UseQueryOptions,
|
||||
UseQueryReturnType
|
||||
} from '@tanstack/vue-query';
|
||||
|
||||
import {
|
||||
computed,
|
||||
unref
|
||||
} from 'vue';
|
||||
import type {
|
||||
MaybeRef
|
||||
} from 'vue';
|
||||
|
||||
import type {
|
||||
AddLocalTrackRequest,
|
||||
PlaylistTrackResponse,
|
||||
TrackBulkReorderRequest,
|
||||
TrackResponse,
|
||||
YoutubeTrackRequest
|
||||
} from '.././models';
|
||||
|
||||
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];
|
||||
|
||||
|
||||
|
||||
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>,
|
||||
addLocalTrackRequest: MaybeRef<AddLocalTrackRequest>,
|
||||
options?: SecondParameter<typeof addLocalTrackMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
playlistId = unref(playlistId);
|
||||
addLocalTrackRequest = unref(addLocalTrackRequest);
|
||||
const formData = new FormData();
|
||||
formData.append(`source`, addLocalTrackRequest.source)
|
||||
|
||||
return addLocalTrackMutator<TrackResponse>(
|
||||
{url: `/playlist/${playlistId}/track/local`, method: 'POST',
|
||||
headers: {'Content-Type': 'multipart/form-data', },
|
||||
data: formData, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getAddLocalTrackMutationOptions = <TError = unknown,
|
||||
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> => {
|
||||
|
||||
const mutationKey = ['addLocalTrack'];
|
||||
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 addLocalTrack>>, {playlistId: number;data: AddLocalTrackRequest}> = (props) => {
|
||||
const {playlistId,data} = props ?? {};
|
||||
|
||||
return addLocalTrack(playlistId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type AddLocalTrackMutationResult = NonNullable<Awaited<ReturnType<typeof addLocalTrack>>>
|
||||
export type AddLocalTrackMutationBody = AddLocalTrackRequest
|
||||
export type AddLocalTrackMutationError = unknown
|
||||
|
||||
export const useAddLocalTrack = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof addLocalTrack>>, TError,{playlistId: number;data: AddLocalTrackRequest}, TContext>, request?: SecondParameter<typeof addLocalTrackMutator>}
|
||||
, queryClient?: QueryClient): UseMutationReturnType<
|
||||
Awaited<ReturnType<typeof addLocalTrack>>,
|
||||
TError,
|
||||
{playlistId: number;data: AddLocalTrackRequest},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getAddLocalTrackMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
export const bulkReorder = (
|
||||
playlistId: MaybeRef<number>,
|
||||
trackBulkReorderRequest: MaybeRef<TrackBulkReorderRequest>,
|
||||
options?: SecondParameter<typeof bulkReorderMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
playlistId = unref(playlistId);
|
||||
trackBulkReorderRequest = unref(trackBulkReorderRequest);
|
||||
|
||||
return bulkReorderMutator<void>(
|
||||
{url: `/playlist/${playlistId}/bulk-reorder`, method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', },
|
||||
data: trackBulkReorderRequest, signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const getBulkReorderMutationOptions = <TError = unknown,
|
||||
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> => {
|
||||
|
||||
const mutationKey = ['bulkReorder'];
|
||||
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 bulkReorder>>, {playlistId: number;data: TrackBulkReorderRequest}> = (props) => {
|
||||
const {playlistId,data} = props ?? {};
|
||||
|
||||
return bulkReorder(playlistId,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type BulkReorderMutationResult = NonNullable<Awaited<ReturnType<typeof bulkReorder>>>
|
||||
export type BulkReorderMutationBody = TrackBulkReorderRequest
|
||||
export type BulkReorderMutationError = unknown
|
||||
|
||||
export const useBulkReorder = <TError = unknown,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof bulkReorder>>, TError,{playlistId: number;data: TrackBulkReorderRequest}, TContext>, request?: SecondParameter<typeof bulkReorderMutator>}
|
||||
, queryClient?: QueryClient): UseMutationReturnType<
|
||||
Awaited<ReturnType<typeof bulkReorder>>,
|
||||
TError,
|
||||
{playlistId: number;data: TrackBulkReorderRequest},
|
||||
TContext
|
||||
> => {
|
||||
|
||||
const mutationOptions = getBulkReorderMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions, queryClient);
|
||||
}
|
||||
export const getPlaylistTracks = (
|
||||
playlistId: MaybeRef<number>,
|
||||
options?: SecondParameter<typeof getPlaylistTracksMutator>,signal?: AbortSignal
|
||||
) => {
|
||||
playlistId = unref(playlistId);
|
||||
|
||||
return getPlaylistTracksMutator<PlaylistTrackResponse[]>(
|
||||
{url: `/playlist/${playlistId}/tracks`, method: 'GET', signal
|
||||
},
|
||||
options);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetPlaylistTracksQueryKey = (playlistId?: MaybeRef<number>,) => {
|
||||
return [
|
||||
'playlist',playlistId,'tracks'
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
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 queryKey = getGetPlaylistTracksQueryKey(playlistId);
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPlaylistTracks>>> = ({ signal }) => getPlaylistTracks(playlistId, requestOptions, signal);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, enabled: computed(() => !!(unref(playlistId))), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPlaylistTracks>>, TError, TData>
|
||||
}
|
||||
|
||||
export type GetPlaylistTracksQueryResult = NonNullable<Awaited<ReturnType<typeof getPlaylistTracks>>>
|
||||
export type GetPlaylistTracksQueryError = 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 getPlaylistTracksMutator>}
|
||||
, queryClient?: QueryClient
|
||||
): UseQueryReturnType<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
|
||||
|
||||
const queryOptions = getGetPlaylistTracksQueryOptions(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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<Sonner />
|
||||
</ClientOnly>
|
||||
<div class="flex flex-1">
|
||||
<div class="w-full">
|
||||
<header v-if="slots.header">
|
||||
@ -13,6 +16,8 @@
|
||||
|
||||
<script setup>
|
||||
import { useSlots } from 'vue'
|
||||
import 'vue-sonner/style.css'
|
||||
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<Sonner />
|
||||
</ClientOnly>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<div class="flex flex-1">
|
||||
@ -19,7 +22,9 @@
|
||||
|
||||
<script setup>
|
||||
import { useSlots } from 'vue'
|
||||
import AppSidebar from '@/components/ui/sidebar/AppSidebar.vue';
|
||||
import SidebarInset from '@/components/ui/sidebar/SidebarInset.vue';
|
||||
import 'vue-sonner/style.css'
|
||||
import AppSidebar from '~/components/ui/sidebar/AppSidebar.vue';
|
||||
|
||||
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
|
||||
@ -1,71 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InputWithIcon } from '@/components/ui/input'
|
||||
import MusicCard from '@/components/ui/musiccard/MusicCard.vue'
|
||||
import { Outline } from '@/components/ui/outline'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
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('')
|
||||
|
||||
// Mock data
|
||||
const tracks = ref([
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const currentPlaylistStore = useCurrentPlaylistStore();
|
||||
|
||||
const { data: playlistTracks, isLoading, error, refetch } = useGetPlaylistTracks(
|
||||
computed(() => currentPlaylistStore.id),
|
||||
{
|
||||
id: 1,
|
||||
title: "Best of Chobits OST - Beyond",
|
||||
author: "ビヨンド",
|
||||
authorLabel: "Author",
|
||||
badges: ["mp3", "jpop", "anime"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
|
||||
date: "about 17 years ago",
|
||||
selected: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Summer Vibessssssssssssssssssssssssssssssssssssfawefjioawefjeaiofjeaoifjeaofejiaofejaiojfoaiss",
|
||||
author: "Various Artists",
|
||||
authorLabel: "Artists",
|
||||
badges: ["mp3", "summer", "mix"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
|
||||
date: "about 1 hour ago",
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Unknown Track",
|
||||
author: "Unknown Artist",
|
||||
authorLabel: "Author",
|
||||
badges: ["wav"],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Single Track",
|
||||
author: "Solo Artist",
|
||||
authorLabel: "Author",
|
||||
badges: [],
|
||||
imageUrl: "https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true",
|
||||
date: "recently added",
|
||||
selected: false
|
||||
query: {
|
||||
enabled: computed(() => currentPlaylistStore.id !== -1)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const { mutate: reorderTracks } = useMutation({
|
||||
mutationFn: ({ playlistId, trackIds }: { playlistId: number; trackIds: number[] }) =>
|
||||
bulkReorder(playlistId, { trackIds }),
|
||||
onMutate: async ({ playlistId, trackIds }) => {
|
||||
await queryClient.cancelQueries({ queryKey: getGetPlaylistTracksQueryKey(playlistId) });
|
||||
|
||||
const previousTracks = queryClient.getQueryData(getGetPlaylistTracksQueryKey(playlistId));
|
||||
|
||||
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), (old: any) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: trackIds.map(id => old.data.find((t: any) => t.trackId === id || t.id === id)),
|
||||
};
|
||||
});
|
||||
|
||||
return { previousTracks };
|
||||
},
|
||||
onError: (err, { playlistId }, context) => {
|
||||
console.log(err)
|
||||
if (context?.previousTracks) {
|
||||
queryClient.setQueryData(getGetPlaylistTracksQueryKey(playlistId), context.previousTracks);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getGetPlaylistTracksQueryKey(currentPlaylistStore.id) });
|
||||
},
|
||||
});
|
||||
|
||||
interface MappedTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
authorLabel: string;
|
||||
badges: string[];
|
||||
imageUrl: string | undefined;
|
||||
date: string;
|
||||
}
|
||||
|
||||
const mappedTracks = ref<MappedTrack[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
if (!playlistTracks.value) return []
|
||||
const tracks = playlistTracks.value.data
|
||||
|
||||
const result = tracks
|
||||
.map((track: TrackResponse, index: number) => ({
|
||||
id: track.trackId!,
|
||||
title: track.title || 'Unknown Track',
|
||||
author: track.artist || 'Unknown Artist',
|
||||
authorLabel: "Artist",
|
||||
badges: ['mp3'], // TODO: badges
|
||||
imageUrl: getDefaultImage(index), // TODO: imageUrl
|
||||
date: formatDate(''), // TODO: createdAt
|
||||
}));
|
||||
mappedTracks.value = result;
|
||||
})
|
||||
|
||||
const getDefaultImage = (index: number) => {
|
||||
const defaultImages = [
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-1.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-2.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-3.jpg?raw=true",
|
||||
"https://github.com/yavuzceliker/sample-images/blob/main/docs/image-5.jpg?raw=true"
|
||||
]
|
||||
return defaultImages[index % defaultImages.length]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'recently added'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
|
||||
return `${Math.floor(diffDays / 365)} years ago`
|
||||
}
|
||||
])
|
||||
|
||||
const filteredTracks = computed(() => {
|
||||
if (!searchValue.value) return tracks.value
|
||||
if (!searchValue.value) return mappedTracks.value
|
||||
|
||||
return tracks.value.filter(track =>
|
||||
return mappedTracks.value.filter(track =>
|
||||
track.title.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
||||
)
|
||||
})
|
||||
});
|
||||
|
||||
const selectTrack = (trackId: number) => {
|
||||
tracks.value.forEach(track => {
|
||||
track.selected = track.id === trackId
|
||||
})
|
||||
function onTrackOrderChange() {
|
||||
const trackIds = mappedTracks.value?.map(t => t.id);
|
||||
reorderTracks({
|
||||
playlistId: currentPlaylistStore.id,
|
||||
trackIds,
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => currentPlaylistStore.id, (newId) => {
|
||||
if (newId !== -1) {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -88,9 +150,29 @@ const selectTrack = (trackId: number) => {
|
||||
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
||||
id="user-search" class="w-full" />
|
||||
|
||||
<MusicCard v-for="track in filteredTracks" :key="track.id" :title="track.title" :author="track.author"
|
||||
<div>
|
||||
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||
<p>Loading tracks...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex justify-center items-center py-8">
|
||||
<p class="text-red-500">Error loading tracks: {{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!filteredTracks.length" class="flex justify-center items-center py-8">
|
||||
<p v-if="searchValue">No tracks found matching "{{ searchValue }}"</p>
|
||||
<p v-else>No tracks in this playlist</p>
|
||||
</div>
|
||||
|
||||
<VueDraggableNext v-model="mappedTracks" group="tracks" @change="onTrackOrderChange" item-key="id"
|
||||
class="space-y-2" v-else>
|
||||
<div v-for="track in mappedTracks" :key="track.id">
|
||||
<MusicCard :key="track.id" :title="track.title" :author="track.author"
|
||||
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
||||
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
|
||||
:date="track.date" />
|
||||
</div>
|
||||
</VueDraggableNext>
|
||||
</div>
|
||||
</div>
|
||||
<template #sidebar>
|
||||
<Outline padding="none" class="h-full" side="left">
|
||||
@ -104,3 +186,25 @@ const selectTrack = (trackId: number) => {
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.list-move {
|
||||
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import Outline from '@/components/ui/outline/Outline.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -1,7 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import Outline from '@/components/ui/outline/Outline.vue';
|
||||
import SidebarTrigger from '@/components/ui/sidebar/SidebarTrigger.vue';
|
||||
import Empty from '@/components/ui/empty/Empty.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>
|
||||
|
||||
<template>
|
||||
@ -12,25 +150,54 @@ import Frame from '@/components/ui/frame/Frame.vue';
|
||||
<div class="flex gap-8 w-full items-center">
|
||||
<SidebarTrigger :size="5" />
|
||||
<h2 class="scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
||||
Import
|
||||
Import tracks
|
||||
</h2>
|
||||
</div>
|
||||
</Outline>
|
||||
</template>
|
||||
<div class="w-full">
|
||||
<Frame>
|
||||
Hello
|
||||
</Frame>
|
||||
</div>
|
||||
<template #sidebar>
|
||||
<Outline padding="none" class="h-full" side="left">
|
||||
<Outline padding="dense" side="bottom">
|
||||
<p class="leading-7 not-first:mt-6 font-semibold">
|
||||
Metadata editor
|
||||
</p>
|
||||
</Outline>
|
||||
</Outline>
|
||||
<div class="w-full flex flex-col p-8">
|
||||
<FileUpload v-model="files" class="rounded-lg border border-dashed border-muted"
|
||||
@onChange="handleFileChange" :ongoing-uploads="ongoingUploads">
|
||||
<template #default>
|
||||
<FileUploadGrid />
|
||||
</template>
|
||||
<template #content>
|
||||
<h4 class="z-20 scroll-m-20 text-xl font-semibold tracking-tight">
|
||||
Drag and drop your audio files
|
||||
</h4>
|
||||
<p class="z-20 font-normal text-muted-foreground">
|
||||
or
|
||||
</p>
|
||||
<Button class="z-20" variant="destructive" @click="onYoutubeClick">
|
||||
<Play />
|
||||
From Youtube
|
||||
</Button>
|
||||
</template>
|
||||
</FileUpload>
|
||||
<div>
|
||||
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||
Uploaded files
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
|
||||
|
||||
<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>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
import { ref } from 'vue'
|
||||
import Dropzone from '~/components/ui/dropzone/Dropzone.vue'
|
||||
|
||||
const files = ref<File[]>([])
|
||||
|
||||
const handleDrop = (acceptedFiles: File[], event: DragEvent) => {
|
||||
console.log('Files dropped:', acceptedFiles)
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
console.error('Dropzone error:', error.message)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -18,9 +27,10 @@ import Frame from '@/components/ui/frame/Frame.vue';
|
||||
</Outline>
|
||||
</template>
|
||||
<div class="w-full">
|
||||
<Frame>
|
||||
Hello
|
||||
</Frame>
|
||||
<Dropzone :src="files" :accept="{
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.gif']
|
||||
}" :max-size="5 * 1024 * 1024" :min-size="1024" :max-files="3" @drop="handleDrop" @error="handleError"
|
||||
class="w-full max-w-md" />
|
||||
</div>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1">
|
||||
<NuxtLayout name="default">
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Outline } from '@/components/ui/outline';
|
||||
import Frame from '@/components/ui/frame/Frame.vue';
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLayout name="custom">
|
||||
<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>
|
||||
19
app/plugins/vue-query.ts
Normal file
19
app/plugins/vue-query.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export const DEFAULT_QUERIES_OPTIONS = {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: DEFAULT_QUERIES_OPTIONS,
|
||||
},
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })
|
||||
})
|
||||
24
app/stores/use-current-playlist-store.ts
Normal file
24
app/stores/use-current-playlist-store.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { PlaylistReadResponse } from '~/composeables/api/models'
|
||||
|
||||
export const useCurrentPlaylistStore = defineStore('current-playlist', {
|
||||
state: () => ({
|
||||
id: -1,
|
||||
ownerId: -1,
|
||||
title: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}),
|
||||
getters: {
|
||||
getId: (state) => state.id * 2,
|
||||
},
|
||||
actions: {
|
||||
load(response: PlaylistReadResponse) {
|
||||
this.id = response.id || -1;
|
||||
this.ownerId = response.ownerId || -1;
|
||||
this.title = response.title || '';
|
||||
this.createdAt = response.createdAt || '';
|
||||
this.updatedAt = response.updatedAt || '';
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -4,15 +4,23 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt'],
|
||||
shadcn: {
|
||||
prefix: '',
|
||||
componentDir: './components/ui'
|
||||
},
|
||||
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
|
||||
css: ['~/assets/css/tailwind.css'],
|
||||
vite: {
|
||||
plugins: [
|
||||
tailwindcss()
|
||||
]
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: process.env.API_BASE_URL
|
||||
}
|
||||
},
|
||||
shadcn: {
|
||||
prefix: '',
|
||||
componentDir: '@/components/ui'
|
||||
},
|
||||
pinia: {
|
||||
storesDirs: ['./app/stores/**'],
|
||||
},
|
||||
})
|
||||
38
orval.config.js
Normal file
38
orval.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'orval';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
'mp3-composer': {
|
||||
input: {
|
||||
target: 'http://localhost:8080/v3/api-docs',
|
||||
parserOptions: {
|
||||
resolve: {
|
||||
http: {
|
||||
headers: {
|
||||
Authorization:
|
||||
'Basic ' + Buffer.from(`${process.env.API_USERNAME}:${process.env.API_PASSWORD}`).toString('base64'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: './app/composeables/api/composer.ts',
|
||||
schemas: './app/composeables/api/models',
|
||||
mode: 'tags-split',
|
||||
client: 'vue-query',
|
||||
override: {
|
||||
mutator: {
|
||||
path: './app/composeables/api/axios-instance.ts',
|
||||
},
|
||||
operations: {
|
||||
streamProgress: {
|
||||
mutator: './app/composeables/api/event-source.ts',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
19
package.json
19
package.json
@ -13,22 +13,35 @@
|
||||
"@nuxt/fonts": "0.11.4",
|
||||
"@nuxt/icon": "2.1.0",
|
||||
"@nuxt/image": "1.11.0",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eventsource": "^4.1.0",
|
||||
"lucide-vue-next": "^0.548.0",
|
||||
"motion-v": "^1.8.1",
|
||||
"nuxt": "^4.2.0",
|
||||
"oidc-client-ts": "^3.3.0",
|
||||
"reka-ui": "^2.6.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reka-ui": "^2.7.0",
|
||||
"shadcn-nuxt": "2.3.2",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
"vue-draggable-next": "^2.3.0",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"orval": "^7.16.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user