Implement local track upload
This commit is contained in:
@ -4,11 +4,13 @@ import { Motion } from "motion-v";
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { X } from "lucide-vue-next";
|
import { X } from "lucide-vue-next";
|
||||||
import Button from "../button/Button.vue";
|
import Button from "../button/Button.vue";
|
||||||
|
import Progress from "../progress/Progress.vue";
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
class?: HTMLAttributes["class"];
|
class?: HTMLAttributes["class"];
|
||||||
accept?: string;
|
accept?: string;
|
||||||
modelValue?: File[];
|
modelValue?: File[];
|
||||||
|
ongoingUploads?: Map<string, { file: File; progress: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<FileUploadProps>();
|
const props = defineProps<FileUploadProps>();
|
||||||
@ -49,12 +51,43 @@ function handleDrop(e: DragEvent) {
|
|||||||
const droppedFiles = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
const droppedFiles = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
|
||||||
if (droppedFiles.length) handleFileChange(droppedFiles);
|
if (droppedFiles.length) handleFileChange(droppedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(event: MouseEvent, index: number) {
|
function removeFile(event: MouseEvent, index: number) {
|
||||||
files.value.splice(index, 1);
|
files.value.splice(index, 1);
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
emit("update:modelValue", files.value);
|
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) => {
|
watch(() => props.modelValue, (newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
files.value = newVal;
|
files.value = newVal;
|
||||||
@ -68,7 +101,7 @@ watch(() => props.modelValue, (newVal) => {
|
|||||||
@drop.prevent="handleDrop" @mouseover="handleEnter" @mouseleave="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"
|
<div class="group/file relative block w-full cursor-pointer overflow-hidden rounded-lg p-10 bg-muted space-y-4"
|
||||||
@click="handleClick">
|
@click="handleClick">
|
||||||
<input ref="fileInputRef" type="file" class="hidden" :accept="accept" @change="onFileChange" />
|
<input ref="fileInputRef" type="file" class="hidden" :accept="accept" @change="onFileChange" multiple />
|
||||||
|
|
||||||
<!-- Grid pattern -->
|
<!-- Grid pattern -->
|
||||||
<div
|
<div
|
||||||
@ -76,33 +109,46 @@ watch(() => props.modelValue, (newVal) => {
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative mx-auto mt-10 w-full max-w-xl space-y-4">
|
<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 }"
|
<Motion v-for="(file, idx) in files" :key="`file-${idx}`" :initial="{ opacity: 0, scaleX: 0 }"
|
||||||
:animate="{ opacity: 1, scaleX: 1 }"
|
: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 md:h-24 text-muted-foreground bg-background">
|
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">
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
class="max-w-xs truncate text-base">
|
class="max-w-xs truncate text-base">
|
||||||
{{ file.name }}
|
{{ file.name }}
|
||||||
</Motion>
|
</Motion>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
class="shadow-input w-fit shrink-0 rounded-lg px-2 py-1 text-sm">
|
class="shadow-input w-fit shrink-0 rounded-lg px-2 py-1 text-sm">
|
||||||
{{ (file.size / (1024 * 1024)).toFixed(2) }} MB
|
{{ (file.size / (1024 * 1024)).toFixed(2) }} MB
|
||||||
</Motion>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-2 flex w-full flex-col items-start justify-between text-sm text-muted-foreground md:flex-row md:items-center">
|
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 }"
|
<Motion as="p" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
class="rounded-md bg-muted px-1.5 py-1 text-sm">
|
class="rounded-md bg-muted px-1.5 py-1 text-sm">
|
||||||
{{ file.type || "unknown type" }}
|
{{ file.type || "unknown type" }}
|
||||||
</Motion>
|
</Motion>
|
||||||
<Motion as="div" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }">
|
<Motion as="div" :initial="{ opacity: 0 }" :animate="{ opacity: 1 }"
|
||||||
<Button variant="ghost" @click="(e: MouseEvent) => removeFile(e, idx)">
|
v-if="!isFileUploading(file)">
|
||||||
|
<Button variant="ghost" @click="(e: MouseEvent) => removeFile(e, idx)"
|
||||||
|
:disabled="isFileUploading(file)">
|
||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
</Motion>
|
</Motion>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full" v-if="isFileUploading(file)">
|
||||||
|
<Progress :modelValue="getUploadProgress(file)" class="w-full" />
|
||||||
|
</div>
|
||||||
</Motion>
|
</Motion>
|
||||||
|
|
||||||
<template v-if="!files.length">
|
<template v-if="!files.length">
|
||||||
|
|||||||
@ -16,13 +16,15 @@ import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
|||||||
import FileUpload from '@/components/ui/file-upload/FileUpload.vue';
|
import FileUpload from '@/components/ui/file-upload/FileUpload.vue';
|
||||||
import FileUploadGrid from '@/components/ui/file-upload/FileUploadGrid.vue';
|
import FileUploadGrid from '@/components/ui/file-upload/FileUploadGrid.vue';
|
||||||
import Button from '@/components/ui/button/Button.vue';
|
import Button from '@/components/ui/button/Button.vue';
|
||||||
|
import { addLocalTrack } from '~/composeables/api/track-controller/track-controller';
|
||||||
|
|
||||||
const files = ref<File[]>([]);
|
const files = ref<File[]>([]);
|
||||||
|
|
||||||
const currentPlaylistStore = useCurrentPlaylistStore();
|
const currentPlaylistStore = useCurrentPlaylistStore();
|
||||||
const progressEntries = ref<Map<string, StreamProgress200Item>>(new Map());
|
const progressEntries = ref<Map<string, StreamProgress200Item>>(new Map());
|
||||||
let listener: EventSourceListener<StreamProgress200Item> | null = null;
|
let listener: EventSourceListener<StreamProgress200Item> | null = null;
|
||||||
|
|
||||||
|
const ongoingUploads = ref<Map<string, { file: File; progress: number }>>(new Map());
|
||||||
|
|
||||||
const sortedProgressEntries = computed(() => {
|
const sortedProgressEntries = computed(() => {
|
||||||
return Array.from(progressEntries.value.values())
|
return Array.from(progressEntries.value.values())
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@ -60,6 +62,69 @@ async function listenImports() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function onYoutubeClick(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
@ -91,7 +156,8 @@ onUnmounted(() => {
|
|||||||
</Outline>
|
</Outline>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full flex flex-col p-8">
|
<div class="w-full flex flex-col p-8">
|
||||||
<FileUpload v-model="files" class="rounded-lg border border-dashed border-muted" @onChange="">
|
<FileUpload v-model="files" class="rounded-lg border border-dashed border-muted"
|
||||||
|
@onChange="handleFileChange" :ongoing-uploads="ongoingUploads">
|
||||||
<template #default>
|
<template #default>
|
||||||
<FileUploadGrid />
|
<FileUploadGrid />
|
||||||
</template>
|
</template>
|
||||||
@ -113,8 +179,12 @@ onUnmounted(() => {
|
|||||||
Uploaded files
|
Uploaded files
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|
||||||
|
|
||||||
<UploadEntry v-for="entry in sortedProgressEntries" :key="entry.id" :entry="entry" />
|
<UploadEntry v-for="entry in sortedProgressEntries" :key="entry.id" :entry="entry" />
|
||||||
<Empty class="border border-dashed" v-if="progressEntries.size === 0">
|
|
||||||
|
<Empty class="border border-dashed"
|
||||||
|
v-if="progressEntries.size === 0 && ongoingUploads.size === 0">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<MegaphoneOff />
|
<MegaphoneOff />
|
||||||
|
|||||||
Reference in New Issue
Block a user