Compare commits
10 Commits
86b41eb5e0
...
53b326fbad
| Author | SHA1 | Date | |
|---|---|---|---|
|
53b326fbad
|
|||
|
0b1badd4bb
|
|||
|
885aab0d9d
|
|||
| 05814a100a | |||
| 133558c4ab | |||
| b860ea95d3 | |||
| 7b39d48316 | |||
| 15389b9de1 | |||
| 3186fa16e5 | |||
| f56a235e86 |
42
app/components/action/Draggable.vue
Normal file
42
app/components/action/Draggable.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useDraggable } from '@vue-dnd-kit/core';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const { index, source } = defineProps<{
|
||||||
|
index: number;
|
||||||
|
source: any[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { elementRef, handleDragStart, isOvered, isDragging } = useDraggable({
|
||||||
|
data: computed(() => ({
|
||||||
|
index,
|
||||||
|
source,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="elementRef" @pointerdown="handleDragStart" :class="{
|
||||||
|
'is-over': isOvered,
|
||||||
|
'is-dragging': isDragging,
|
||||||
|
}" class="draggable">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.draggable {
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-dragging {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-over {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
98
app/components/internal/PlaylistSelect.vue
Normal file
98
app/components/internal/PlaylistSelect.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PlaylistsNotFound from '@/components/internal/playlists/select/PlaylistsNotFound.vue';
|
||||||
|
import Frame from '@/components/ui/frame/Frame.vue';
|
||||||
|
import Select from '@/components/ui/select/Select.vue';
|
||||||
|
import SelectContent from '@/components/ui/select/SelectContent.vue';
|
||||||
|
import SelectCustomTrigger from '@/components/ui/select/SelectCustomTrigger.vue';
|
||||||
|
import SelectGroup from '@/components/ui/select/SelectGroup.vue';
|
||||||
|
import SelectItem from '@/components/ui/select/SelectItem.vue';
|
||||||
|
import SelectLabel from '@/components/ui/select/SelectLabel.vue';
|
||||||
|
import SelectSeparator from '@/components/ui/select/SelectSeparator.vue';
|
||||||
|
import { useSidebar } from '@/components/ui/sidebar';
|
||||||
|
import Skeleton from '@/components/ui/skeleton/Skeleton.vue';
|
||||||
|
import { usePlaylists } from '@/composeables/api/playlist-controller/playlist-controller';
|
||||||
|
import { ChevronsUpDown, Music4, Plus } from 'lucide-vue-next';
|
||||||
|
import Button from '../ui/button/Button.vue';
|
||||||
|
import PlaylistCreateDialog from './playlists/select/PlaylistCreateDialog.vue';
|
||||||
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store';
|
||||||
|
|
||||||
|
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">
|
||||||
|
<UiSpinner />
|
||||||
|
</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>
|
||||||
58
app/components/internal/import/uploadentry/UploadEntry.vue
Normal file
58
app/components/internal/import/uploadentry/UploadEntry.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<Frame margin="none" class="px-3 py-4 flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<AudioWaveform :size="32" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
<p class="font-medium">
|
||||||
|
{{ title }}
|
||||||
|
</p>
|
||||||
|
<UiButton variant="ghost" v-if="size">
|
||||||
|
<Pen />
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="size && format">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ size }}
|
||||||
|
</p>
|
||||||
|
<Dot />
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ format }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-2" v-if="progress">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{{ progress }}%
|
||||||
|
</p>
|
||||||
|
<UiProgress :modelValue="progress" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row" v-if="error">
|
||||||
|
<p class="text-sm text-destructive-foreground">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<UiButton variant="ghost">
|
||||||
|
<EllipsisVertical :size="32" />
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Frame from '@/components/ui/frame/Frame.vue'
|
||||||
|
import { AudioWaveform, Dot, EllipsisVertical, Pen } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
size?: string
|
||||||
|
format?: string
|
||||||
|
progress?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -6,11 +6,11 @@
|
|||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div class="w-fit flex-1 flex flex-col justify-between">
|
<div class="w-fit flex-1 flex flex-col justify-between">
|
||||||
<div class="w-full">
|
<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]">
|
<h4 class="scroll-m-20 text-xl font-semibold tracking-tight truncate max-w-[32ch]">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h4>
|
</h4>
|
||||||
<p class="leading-7" v-if="date">
|
<p class="max-w-[16ch]" v-if="date">
|
||||||
{{ date }}
|
{{ date }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
|
import { useForm } from "vee-validate";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { toast } from "vue-sonner";
|
||||||
|
import Button from '~/components/ui/button/Button.vue';
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/components/ui/form";
|
||||||
|
import Input from '~/components/ui/input/Input.vue';
|
||||||
|
import { getPlaylistsQueryKey, useCreatePlaylist } from "~/composeables/api/playlist-controller/playlist-controller";
|
||||||
|
|
||||||
|
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,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Button from '@/components/ui/button/Button.vue';
|
||||||
|
import Empty from '@/components/ui/empty/Empty.vue';
|
||||||
|
import EmptyContent from '@/components/ui/empty/EmptyContent.vue';
|
||||||
|
import EmptyDescription from '@/components/ui/empty/EmptyDescription.vue';
|
||||||
|
import EmptyHeader from '@/components/ui/empty/EmptyHeader.vue';
|
||||||
|
import EmptyMedia from '@/components/ui/empty/EmptyMedia.vue';
|
||||||
|
import EmptyTitle from '@/components/ui/empty/EmptyTitle.vue';
|
||||||
|
import { FileMusicIcon } from 'lucide-vue-next';
|
||||||
|
import PlaylistCreateDialog from './PlaylistCreateDialog.vue';
|
||||||
|
|
||||||
|
</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>
|
||||||
@ -7,23 +7,19 @@ import { cn } from "@/lib/utils"
|
|||||||
import { buttonVariants } from "."
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants["variant"]
|
variant?: ButtonVariants["variant"]
|
||||||
size?: ButtonVariants["size"]
|
size?: ButtonVariants["size"]
|
||||||
class?: HTMLAttributes["class"]
|
class?: HTMLAttributes["class"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: "button",
|
as: "button",
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive data-slot="button" :as="as" :as-child="asChild"
|
||||||
data-slot="button"
|
:class="cn(buttonVariants({ variant, size }), props.class)">
|
||||||
:as="as"
|
<slot />
|
||||||
:as-child="asChild"
|
</Primitive>
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</Primitive>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -4,36 +4,36 @@ import { cva } from "class-variance-authority"
|
|||||||
export { default as Button } from "./Button.vue"
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
"icon": "size-9",
|
"icon": "size-9",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
|
|||||||
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"
|
||||||
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>
|
||||||
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 { HTMLAttributes } from "vue"
|
||||||
import type { FrameVariants } from "../frame"
|
import type { FrameVariants } from "../frame"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import Frame from "@/components/ui/frame/Frame.vue"
|
import Frame from "../frame/Frame.vue"
|
||||||
|
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
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">
|
<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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarHeader,
|
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { Download, Edit, Settings, Upload } from "lucide-vue-next";
|
||||||
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";
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@ -50,62 +41,13 @@ const items = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const {
|
|
||||||
open,
|
|
||||||
} = useSidebar()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<Select>
|
<PlaylistSelect />
|
||||||
<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>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<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"
|
||||||
25
app/composeables/api/axios-instance.ts
Normal file
25
app/composeables/api/axios-instance.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||||
|
export const AXIOS_INSTANCE = Axios.create();
|
||||||
|
|
||||||
|
export 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: 'user',
|
||||||
|
password: 'password',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelToken: source.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
19
app/composeables/api/models/index.ts
Normal file
19
app/composeables/api/models/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './addLocalTrackRequest';
|
||||||
|
export * from './playlistCreateDTO';
|
||||||
|
export * from './playlistCreateRequest';
|
||||||
|
export * from './playlistReadDTO';
|
||||||
|
export * from './playlistReadResponse';
|
||||||
|
export * from './playlistTrackResponse';
|
||||||
|
export * from './readParams';
|
||||||
|
export * from './trackBulkReorderRequest';
|
||||||
|
export * from './trackReoderAfterRequest';
|
||||||
|
export * from './trackReorderAfterRequest';
|
||||||
|
export * from './trackResponse';
|
||||||
|
export * from './uploadBody';
|
||||||
10
app/composeables/api/models/playlistCreateDTO.ts
Normal file
10
app/composeables/api/models/playlistCreateDTO.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 PlaylistCreateDTO {
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
14
app/composeables/api/models/playlistReadDTO.ts
Normal file
14
app/composeables/api/models/playlistReadDTO.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 PlaylistReadDTO {
|
||||||
|
id?: number;
|
||||||
|
ownerId?: number;
|
||||||
|
title?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
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/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[];
|
||||||
|
}
|
||||||
11
app/composeables/api/models/trackReorderAfterRequest.ts
Normal file
11
app/composeables/api/models/trackReorderAfterRequest.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.16.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* OpenAPI definition
|
||||||
|
* OpenAPI spec version: v0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TrackReorderAfterRequest {
|
||||||
|
moveTrackId?: number;
|
||||||
|
targetTrackId?: number;
|
||||||
|
}
|
||||||
15
app/composeables/api/models/trackResponse.ts
Normal file
15
app/composeables/api/models/trackResponse.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 TrackResponse {
|
||||||
|
trackId?: number;
|
||||||
|
title?: string;
|
||||||
|
artist?: string;
|
||||||
|
audioPath?: 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;
|
||||||
|
};
|
||||||
161
app/composeables/api/playlist-controller/playlist-controller.ts
Normal file
161
app/composeables/api/playlist-controller/playlist-controller.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 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 { axiosInstance } from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const createPlaylist = (
|
||||||
|
playlistCreateRequest: MaybeRef<PlaylistCreateRequest>,
|
||||||
|
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistCreateRequest = unref(playlistCreateRequest);
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
): 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 axiosInstance>}
|
||||||
|
, 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 axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
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 axiosInstance>}
|
||||||
|
, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
164
app/composeables/api/s-3-controller/s-3-controller.ts
Normal file
164
app/composeables/api/s-3-controller/s-3-controller.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 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 { axiosInstance } from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const upload = (
|
||||||
|
uploadBody: MaybeRef<UploadBody>,
|
||||||
|
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
uploadBody = unref(uploadBody);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(`document`, uploadBody.document)
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
): 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 axiosInstance>}
|
||||||
|
, 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 axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
params = unref(params);
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
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 axiosInstance>}
|
||||||
|
, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
228
app/composeables/api/track-controller/track-controller.ts
Normal file
228
app/composeables/api/track-controller/track-controller.ts
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
} from '.././models';
|
||||||
|
|
||||||
|
import { axiosInstance } from '.././axios-instance';
|
||||||
|
|
||||||
|
|
||||||
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const addLocalTrack = (
|
||||||
|
playlistId: MaybeRef<number>,
|
||||||
|
addLocalTrackRequest: MaybeRef<AddLocalTrackRequest>,
|
||||||
|
options?: SecondParameter<typeof axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
addLocalTrackRequest = unref(addLocalTrackRequest);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(`source`, addLocalTrackRequest.source)
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
): 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 axiosInstance>}
|
||||||
|
, 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 axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
trackBulkReorderRequest = unref(trackBulkReorderRequest);
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
): 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 axiosInstance>}
|
||||||
|
, 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 axiosInstance>,signal?: AbortSignal
|
||||||
|
) => {
|
||||||
|
playlistId = unref(playlistId);
|
||||||
|
|
||||||
|
return axiosInstance<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 axiosInstance>}
|
||||||
|
) => {
|
||||||
|
|
||||||
|
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 axiosInstance>}
|
||||||
|
, 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>
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<Sonner />
|
||||||
|
</ClientOnly>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<header v-if="slots.header">
|
<header v-if="slots.header">
|
||||||
@ -13,6 +16,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSlots } from 'vue'
|
import { useSlots } from 'vue'
|
||||||
|
import Sonner from '~/components/ui/sonner/Sonner.vue';
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<Toaster />
|
||||||
|
</ClientOnly>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
@ -21,5 +24,8 @@
|
|||||||
import { useSlots } from 'vue'
|
import { useSlots } from 'vue'
|
||||||
import AppSidebar from '@/components/ui/sidebar/AppSidebar.vue';
|
import AppSidebar from '@/components/ui/sidebar/AppSidebar.vue';
|
||||||
import SidebarInset from '@/components/ui/sidebar/SidebarInset.vue';
|
import SidebarInset from '@/components/ui/sidebar/SidebarInset.vue';
|
||||||
|
import { Toaster } from '~/components/ui/sonner';
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -2,59 +2,108 @@
|
|||||||
import { Search } from 'lucide-vue-next'
|
import { Search } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { InputWithIcon } from '@/components/ui/input'
|
import { InputWithIcon } from '@/components/ui/input'
|
||||||
import MusicCard from '@/components/ui/musiccard/MusicCard.vue'
|
|
||||||
import { Outline } from '@/components/ui/outline'
|
import { Outline } from '@/components/ui/outline'
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
|
import MusicCard from '~/components/internal/musiccard/MusicCard.vue'
|
||||||
|
import { DnDOperations, useDroppable } from '@vue-dnd-kit/core'
|
||||||
|
import Draggable from '~/components/action/Draggable.vue'
|
||||||
|
import { useCurrentPlaylistStore } from '~/stores/use-current-playlist-store'
|
||||||
|
import { bulkReorder, getGetPlaylistTracksQueryKey } from '~/composeables/api/track-controller/track-controller'
|
||||||
|
import { useGetPlaylistTracks } from '~/composeables/api/track-controller/track-controller'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
const searchValue = ref('')
|
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,
|
query: {
|
||||||
title: "Best of Chobits OST - Beyond",
|
enabled: computed(() => currentPlaylistStore.id !== -1)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
])
|
)
|
||||||
|
|
||||||
|
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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const trackCount = computed(() => playlistTracks.value?.data.length || 0)
|
||||||
|
|
||||||
|
const mappedTracks = computed(() => {
|
||||||
|
if (!playlistTracks.value) return []
|
||||||
|
const tracks = playlistTracks.value.data
|
||||||
|
|
||||||
|
return tracks.map((track: any, index: number) => ({
|
||||||
|
id: track.trackId || track.id,
|
||||||
|
title: track.track?.title || track.title || 'Unknown Track',
|
||||||
|
author: track.track?.artist?.name || track.artist || 'Unknown Artist',
|
||||||
|
authorLabel: "Artist",
|
||||||
|
badges: track.track?.format ? [track.track.format] : ['mp3'],
|
||||||
|
imageUrl: track.track?.coverUrl || track.coverUrl || getDefaultImage(index),
|
||||||
|
date: formatDate(track.addedDate || track.createdAt),
|
||||||
|
selected: false
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
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(() => {
|
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.title.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||||
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
track.author.toLowerCase().includes(searchValue.value.toLowerCase()) ||
|
||||||
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
track.badges.some(badge => badge.toLowerCase().includes(searchValue.value.toLowerCase()))
|
||||||
@ -62,10 +111,36 @@ const filteredTracks = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectTrack = (trackId: number) => {
|
const selectTrack = (trackId: number) => {
|
||||||
tracks.value.forEach(track => {
|
mappedTracks.value.forEach(track => {
|
||||||
track.selected = track.id === trackId
|
track.selected = track.id === trackId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { elementRef: tracksRef } = useDroppable({
|
||||||
|
data: {
|
||||||
|
source: mappedTracks.value,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
onDrop: (store, payload) => {
|
||||||
|
DnDOperations.applyTransfer(store);
|
||||||
|
if (currentPlaylistStore.id === -1) return;
|
||||||
|
if (mappedTracks.value.length !== trackCount.value) return;
|
||||||
|
if (mappedTracks.value.some(t => !t)) return;
|
||||||
|
|
||||||
|
const trackIds = mappedTracks.value?.map(t => t.id);
|
||||||
|
reorderTracks({
|
||||||
|
playlistId: currentPlaylistStore.id,
|
||||||
|
trackIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => currentPlaylistStore.id, (newId) => {
|
||||||
|
if (newId !== -1) {
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -88,9 +163,31 @@ const selectTrack = (trackId: number) => {
|
|||||||
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
<InputWithIcon v-model="searchValue" :icon="Search" placeholder="Search..." type="search" icon-size="5"
|
||||||
id="user-search" class="w-full" />
|
id="user-search" class="w-full" />
|
||||||
|
|
||||||
<MusicCard v-for="track in filteredTracks" :key="track.id" :title="track.title" :author="track.author"
|
<div ref="tracksRef">
|
||||||
:authorLabel="track.authorLabel" :badges="track.badges" :imageUrl="track.imageUrl"
|
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
||||||
:date="track.date" :selected="track.selected" @click="selectTrack(track.id)" />
|
<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>
|
||||||
|
|
||||||
|
<div v-else ref="tracksRef" class="space-y-2">
|
||||||
|
<TransitionGroup name="list" ref="tracksRef">
|
||||||
|
<Draggable v-for="(track, index) in filteredTracks" :key="track.id" :index="index"
|
||||||
|
:source="mappedTracks">
|
||||||
|
<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)" />
|
||||||
|
</Draggable>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #sidebar>
|
<template #sidebar>
|
||||||
<Outline padding="none" class="h-full" side="left">
|
<Outline padding="none" class="h-full" side="left">
|
||||||
@ -104,3 +201,25 @@ const selectTrack = (trackId: number) => {
|
|||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Outline } from '@/components/ui/outline';
|
import { Outline } from '@/components/ui/outline';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import Frame from '@/components/ui/frame/Frame.vue';
|
import { Download, Play } from 'lucide-vue-next';
|
||||||
|
import UploadEntry from '~/components/internal/import/uploadentry/UploadEntry.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -12,25 +13,36 @@ import Frame from '@/components/ui/frame/Frame.vue';
|
|||||||
<div class="flex gap-8 w-full items-center">
|
<div class="flex gap-8 w-full items-center">
|
||||||
<SidebarTrigger :size="5" />
|
<SidebarTrigger :size="5" />
|
||||||
<h2 class="scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
<h2 class="scroll-m-20 text-3xl font-semibold tracking-tight transition-colors first:mt-0">
|
||||||
Import
|
Import tracks
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</Outline>
|
</Outline>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full">
|
<div class="w-full flex flex-col p-8">
|
||||||
<Frame>
|
<Outline class="rounded-xl bg-muted flex flex-col items-center justify-center gap-1">
|
||||||
Hello
|
<Download />
|
||||||
</Frame>
|
<h4 class="scroll-m-20 text-xl font-semibold tracking-tight">
|
||||||
</div>
|
Drag and drop your audio files
|
||||||
<template #sidebar>
|
</h4>
|
||||||
<Outline padding="none" class="h-full" side="left">
|
<p class="text-sm text-muted-foreground">
|
||||||
<Outline padding="dense" side="bottom">
|
or
|
||||||
<p class="leading-7 not-first:mt-6 font-semibold">
|
</p>
|
||||||
Metadata editor
|
<UiButton variant="destructive">
|
||||||
</p>
|
<Play />
|
||||||
</Outline>
|
From Youtube
|
||||||
|
</UiButton>
|
||||||
</Outline>
|
</Outline>
|
||||||
</template>
|
<div>
|
||||||
|
<h3 class="scroll-m-20 text-2xl font-semibold tracking-tight">
|
||||||
|
Uploaded files
|
||||||
|
</h3>
|
||||||
|
<div class="flex-row space-y-2">
|
||||||
|
<UploadEntry title="Test" size="3.8 MB" format="mp4" />
|
||||||
|
<UploadEntry title="Test" :progress="78" />
|
||||||
|
<UploadEntry title="Test" error="Uploading failed, please check your internet" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ definePageMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="custom">
|
<NuxtLayout name="clear">
|
||||||
<template #header>
|
<template #header>
|
||||||
<Outline side="bottom" padding="dense" class="w-full">
|
<Outline side="bottom" padding="dense" class="w-full">
|
||||||
<div class="flex gap-8 w-full items-center">
|
<div class="flex gap-8 w-full items-center">
|
||||||
|
|||||||
5
app/plugins/vue-dnd-kit.client.ts
Normal file
5
app/plugins/vue-dnd-kit.client.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import VueDnDKitPlugin from '@vue-dnd-kit/core';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.use(VueDnDKitPlugin);
|
||||||
|
});
|
||||||
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,7 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt'],
|
modules: ['@nuxt/fonts', '@nuxt/icon', '@nuxt/image', 'shadcn-nuxt', '@pinia/nuxt',],
|
||||||
shadcn: {
|
shadcn: {
|
||||||
prefix: '',
|
prefix: '',
|
||||||
componentDir: './components/ui'
|
componentDir: './components/ui'
|
||||||
@ -14,5 +14,13 @@ export default defineNuxtConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss()
|
tailwindcss()
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBaseUrl: process.env.API_BASE_URL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pinia: {
|
||||||
|
storesDirs: ['./app/stores/**'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
34
orval.config.js
Normal file
34
orval.config.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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',
|
||||||
|
name: 'axiosInstance',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
17
package.json
17
package.json
@ -13,22 +13,33 @@
|
|||||||
"@nuxt/fonts": "0.11.4",
|
"@nuxt/fonts": "0.11.4",
|
||||||
"@nuxt/icon": "2.1.0",
|
"@nuxt/icon": "2.1.0",
|
||||||
"@nuxt/image": "1.11.0",
|
"@nuxt/image": "1.11.0",
|
||||||
|
"@pinia/nuxt": "0.11.3",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@tanstack/vue-query": "^5.90.7",
|
||||||
|
"@vee-validate/zod": "^4.15.1",
|
||||||
|
"@vue-dnd-kit/core": "^1.7.0",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"lucide-vue-next": "^0.548.0",
|
"lucide-vue-next": "^0.548.0",
|
||||||
"nuxt": "^4.2.0",
|
"nuxt": "^4.2.0",
|
||||||
"oidc-client-ts": "^3.3.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",
|
"shadcn-nuxt": "2.3.2",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3",
|
||||||
|
"vue-sonner": "^2.0.9",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"orval": "^7.16.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user