20 files changed
+1245
-13
No files matched your search
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type Option } from "~/ui/SearchSelect.vue"
|
||||||
|
|
||||||
|
type InputType = "text" | "file" | "search-select"
|
||||||
|
|
||||||
|
interface BaseField<T> {
|
||||||
|
key: keyof T
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextField<T> extends BaseField<T> {
|
||||||
|
type?: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileField<T> extends BaseField<T> {
|
||||||
|
type: "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSelectField<T> extends BaseField<T> {
|
||||||
|
type: "search-select"
|
||||||
|
fetchOptions: (q?: string) => Promise<Option[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSelectArrayField<T> extends BaseField<T> {
|
||||||
|
type: "search-select-array"
|
||||||
|
fetchOptions: (q?: string) => Promise<Option[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Field<T = any> =
|
||||||
|
| TextField<T>
|
||||||
|
| FileField<T>
|
||||||
|
| SearchSelectField<T>
|
||||||
|
| SearchSelectArrayField<T>
|
||||||
|
|
||||||
|
interface ModalFormProps<T> {
|
||||||
|
item?: T
|
||||||
|
fields: Field<T>[]
|
||||||
|
title?: string
|
||||||
|
onSave: (value: any) => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<ModalFormProps<any>>()
|
||||||
|
const open = defineModel("open", { required: true })
|
||||||
|
|
||||||
|
let localItem = reactive<any>({})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.item,
|
||||||
|
(item) => {
|
||||||
|
Object.keys(localItem).forEach((k) => delete localItem[k])
|
||||||
|
|
||||||
|
if (!item) return
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(item)) {
|
||||||
|
localItem[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of props.fields) {
|
||||||
|
if (field.type == "file") localItem[field.key as string] = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (await props.onSave(localItem)) open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal v-model:open="open" width="40%">
|
||||||
|
<p v-if="props.title" class="title">{{ props.title }}</p>
|
||||||
|
|
||||||
|
<form class="form" @submit.prevent="save">
|
||||||
|
<template v-for="field in props.fields">
|
||||||
|
<SearchSelect
|
||||||
|
v-if="
|
||||||
|
field.type == 'search-select' ||
|
||||||
|
field.type == 'search-select-array'
|
||||||
|
"
|
||||||
|
v-model:value="localItem[field.key]"
|
||||||
|
:fetch-options="field.fetchOptions"
|
||||||
|
:label="field.label || field.key"
|
||||||
|
:predefined="props.item?.[field.key]"
|
||||||
|
:multiple="field.type == 'search-select-array'"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:type="field.type"
|
||||||
|
:key="field.key"
|
||||||
|
:label="field.label || field.key"
|
||||||
|
v-model:value="localItem[field.key]"
|
||||||
|
full
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Button type="submit" full center-text>Save</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(20px);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File renamed without changes.
@@ -3,9 +3,9 @@ import defu from "defu"
|
|||||||
|
|
||||||
export function useApiFetch<DataT = any, ErrorT = any>(
|
export function useApiFetch<DataT = any, ErrorT = any>(
|
||||||
url: string | Request | Ref<string | Request> | (() => string | Request),
|
url: string | Request | Ref<string | Request> | (() => string | Request),
|
||||||
options: UseFetchOptions<DataT, ErrorT> = {},
|
options?: UseFetchOptions<DataT, ErrorT>,
|
||||||
authorize: boolean = true
|
authorize: boolean = true
|
||||||
) {
|
): ReturnType<typeof useFetch<DataT, ErrorT>> {
|
||||||
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
|
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
|
||||||
|
|
||||||
if (!baseUrl) throw new Error("BACKEND URL not set")
|
if (!baseUrl) throw new Error("BACKEND URL not set")
|
||||||
@@ -39,10 +39,7 @@ export function useApiFetch<DataT = any, ErrorT = any>(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = defu(options || {}, defaults) as UseFetchOptions<
|
const config: UseFetchOptions<DataT, ErrorT> = defu(options ?? {}, defaults)
|
||||||
DataT,
|
|
||||||
ErrorT
|
|
||||||
>
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return useFetch<DataT, ErrorT>(url, config)
|
return useFetch<DataT, ErrorT>(url, config)
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<Sidebar>
|
||||||
|
<template #sidebar>
|
||||||
|
<p class="title">Music - Admin</p>
|
||||||
|
|
||||||
|
<div class="category">
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:house"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="category">
|
||||||
|
<p>Manage</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin/genre"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:disc-3"
|
||||||
|
>
|
||||||
|
Genres
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin/artists"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:mic-vocal"
|
||||||
|
>
|
||||||
|
Artists
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin/albums"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:gallery-vertical-end"
|
||||||
|
>
|
||||||
|
Albums
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin/songs"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:music"
|
||||||
|
>
|
||||||
|
Songs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="category">
|
||||||
|
<p>Exit</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:settings-2"
|
||||||
|
>
|
||||||
|
Exit Admin Panel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #default>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
</Sidebar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.title {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
|
||||||
|
& > p {
|
||||||
|
@include fontSize(13px);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,8 @@ const onSearchInput = (v: string) => {
|
|||||||
else router.replace("/search")
|
else router.replace("/search")
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data: user } = useApiFetch<any>("/user")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,6 +71,20 @@ const onSearchInput = (v: string) => {
|
|||||||
Songs
|
Songs
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="user?.role == 'admin'" class="category">
|
||||||
|
<p>Secret stuff</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
full
|
||||||
|
variant="ghost"
|
||||||
|
to="/admin"
|
||||||
|
type="link"
|
||||||
|
icon-name="lucide:settings-2"
|
||||||
|
>
|
||||||
|
Go To Admin Panel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||||
|
import type { Row } from "~/ui/Table.vue"
|
||||||
|
|
||||||
|
const { data, refresh } = useApiFetch<Album[]>("/all/albums")
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const tableRows: Row[] = [
|
||||||
|
{
|
||||||
|
name: "_id",
|
||||||
|
displayName: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
displayName: "Cover",
|
||||||
|
type: "image",
|
||||||
|
prefix: `${BACKEND_SERVE}/static/`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayName: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "genre.name",
|
||||||
|
displayName: "Genre",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
import { type Option } from "~/ui/SearchSelect.vue"
|
||||||
|
|
||||||
|
const { data: artistData } = useApiFetch<Genre[]>("/all/artists")
|
||||||
|
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||||
|
return artistData.value.map<Option>((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
value: g._id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
|
||||||
|
const genreFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||||
|
return genreData.value.map<Option>((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
value: g._id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalFields: Field[] = [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "file", label: "Cover file", type: "file" },
|
||||||
|
{
|
||||||
|
key: "artists",
|
||||||
|
label: "Artists",
|
||||||
|
type: "search-select-array",
|
||||||
|
fetchOptions: artistFetchOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "genre",
|
||||||
|
label: "Genre",
|
||||||
|
type: "search-select",
|
||||||
|
fetchOptions: genreFetchOptions,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const showModal = ref<boolean>(false)
|
||||||
|
const item = ref<Track | undefined>(undefined)
|
||||||
|
|
||||||
|
const save = async (value: any) => {
|
||||||
|
if (value._id) {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/album", {
|
||||||
|
method: "PATCH",
|
||||||
|
data: value,
|
||||||
|
params: { id: value._id },
|
||||||
|
forceFormData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Album updated successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to update album", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/album", {
|
||||||
|
method: "POST",
|
||||||
|
data: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Album added successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to add album", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id Set it to a value to make it edit instead of creating a new one
|
||||||
|
*/
|
||||||
|
const show = (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
item.value = undefined
|
||||||
|
showModal.value = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const curItem: any = data.value.filter((i) => i._id == id)[0]
|
||||||
|
|
||||||
|
if (!curItem) return
|
||||||
|
|
||||||
|
const transformed = { ...curItem }
|
||||||
|
|
||||||
|
console.log(transformed)
|
||||||
|
|
||||||
|
if (transformed.genre)
|
||||||
|
transformed.genre = {
|
||||||
|
value: curItem.genre._id,
|
||||||
|
label: curItem.genre.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transformed.artists)
|
||||||
|
transformed.artists = transformed.artists.map((a: Artist) => ({
|
||||||
|
value: a._id,
|
||||||
|
label: a.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
item.value = transformed
|
||||||
|
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="title">Manage albums</h1>
|
||||||
|
|
||||||
|
<Table :rows="tableRows" :data="data" class="table">
|
||||||
|
<template #action="{ item, index }">
|
||||||
|
<div class="action">
|
||||||
|
<Button @click="show(item._id)">Edit</Button>
|
||||||
|
<Button @click="">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Button @click="show()">Add a new album</Button>
|
||||||
|
|
||||||
|
<AdminModalForm
|
||||||
|
v-model:open="showModal"
|
||||||
|
@save="save"
|
||||||
|
:fields="modalFields"
|
||||||
|
:item="item"
|
||||||
|
:title="item ? 'Edit album' : 'Add new album'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(24px);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||||
|
import type { Row } from "~/ui/Table.vue"
|
||||||
|
|
||||||
|
const { data, refresh } = useApiFetch<Track[]>("/all/artists")
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const tableRows: Row[] = [
|
||||||
|
{
|
||||||
|
name: "_id",
|
||||||
|
displayName: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
displayName: "Cover",
|
||||||
|
type: "image",
|
||||||
|
prefix: `${BACKEND_SERVE}/static/`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayName: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const modalFields: Field[] = [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "file", label: "Cover file", type: "file" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const showModal = ref<boolean>(false)
|
||||||
|
const item = ref<Track | undefined>(undefined)
|
||||||
|
|
||||||
|
const save = async (value: any) => {
|
||||||
|
if (value._id) {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/artist", {
|
||||||
|
method: "PATCH",
|
||||||
|
data: value,
|
||||||
|
params: { id: value._id },
|
||||||
|
forceFormData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Artist updated successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to update artist", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/artist", {
|
||||||
|
method: "POST",
|
||||||
|
data: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Artist added successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to add artist", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id Set it to a value to make it edit instead of creating a new one
|
||||||
|
*/
|
||||||
|
const show = (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
item.value = undefined
|
||||||
|
showModal.value = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const curItem: any = data.value.filter((i) => i._id == id)[0]
|
||||||
|
|
||||||
|
if (!curItem) return
|
||||||
|
|
||||||
|
item.value = curItem
|
||||||
|
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="title">Manage artists</h1>
|
||||||
|
|
||||||
|
<Table :rows="tableRows" :data="data" class="table">
|
||||||
|
<template #action="{ item, index }">
|
||||||
|
<div class="action">
|
||||||
|
<Button @click="show(item._id)">Edit</Button>
|
||||||
|
<Button @click="">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Button @click="show()">Add a new artist</Button>
|
||||||
|
|
||||||
|
<AdminModalForm
|
||||||
|
v-model:open="showModal"
|
||||||
|
@save="save"
|
||||||
|
:fields="modalFields"
|
||||||
|
:item="item"
|
||||||
|
:title="item ? 'Edit artist' : 'Add new artist'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(24px);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||||
|
import type { Row } from "~/ui/Table.vue"
|
||||||
|
|
||||||
|
const { data, refresh } = useApiFetch<Track[]>("/all/genres")
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const tableRows: Row[] = [
|
||||||
|
{
|
||||||
|
name: "_id",
|
||||||
|
displayName: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayName: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const modalFields: Field[] = [{ key: "name", label: "Name" }]
|
||||||
|
|
||||||
|
const showModal = ref<boolean>(false)
|
||||||
|
const item = ref<Track | undefined>(undefined)
|
||||||
|
|
||||||
|
const save = async (value: any) => {
|
||||||
|
if (value._id) {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/genre", {
|
||||||
|
method: "PATCH",
|
||||||
|
data: value,
|
||||||
|
params: { id: value._id },
|
||||||
|
forceFormData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Genre updated successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to update genre", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/genre", {
|
||||||
|
method: "POST",
|
||||||
|
data: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Genre added successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to add genre", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id Set it to a value to make it edit instead of creating a new one
|
||||||
|
*/
|
||||||
|
const show = (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
item.value = undefined
|
||||||
|
showModal.value = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const curItem: any = data.value.filter((i) => i._id == id)[0]
|
||||||
|
|
||||||
|
if (!curItem) return
|
||||||
|
|
||||||
|
item.value = curItem
|
||||||
|
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="title">Manage genres</h1>
|
||||||
|
|
||||||
|
<Table :rows="tableRows" :data="data" class="table">
|
||||||
|
<template #action="{ item, index }">
|
||||||
|
<div class="action">
|
||||||
|
<Button @click="show(item._id)">Edit</Button>
|
||||||
|
<Button @click="">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Button @click="show()">Add a new genre</Button>
|
||||||
|
|
||||||
|
<AdminModalForm
|
||||||
|
v-model:open="showModal"
|
||||||
|
@save="save"
|
||||||
|
:fields="modalFields"
|
||||||
|
:item="item"
|
||||||
|
:title="item ? 'Edit genre' : 'Add new genre'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(24px);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template></template>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Field } from "~/components/Admin/ModalForm.vue"
|
||||||
|
import type { Row } from "~/ui/Table.vue"
|
||||||
|
|
||||||
|
const { data, refresh } = useApiFetch<Album[]>("/all/tracks")
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const tableRows: Row[] = [
|
||||||
|
{
|
||||||
|
name: "_id",
|
||||||
|
displayName: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fileDir",
|
||||||
|
displayName: "Audio File",
|
||||||
|
type: "audio",
|
||||||
|
prefix: `${BACKEND_SERVE}/static/`,
|
||||||
|
suffix: "/original.m4a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "name",
|
||||||
|
displayName: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "album.name",
|
||||||
|
displayName: "Album",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
import { type Option } from "~/ui/SearchSelect.vue"
|
||||||
|
|
||||||
|
const { data: artistData } = useApiFetch<Genre[]>("/all/artists")
|
||||||
|
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||||
|
return artistData.value.map<Option>((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
value: g._id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: albumData } = useApiFetch<Genre[]>("/all/albums")
|
||||||
|
const albumFetchOptions = async (q?: string): Promise<Option[]> => {
|
||||||
|
return albumData.value.map<Option>((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
value: g._id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalFields: Field[] = [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "file", label: "Audio file", type: "file" },
|
||||||
|
{
|
||||||
|
key: "artists",
|
||||||
|
label: "Artists",
|
||||||
|
type: "search-select-array",
|
||||||
|
fetchOptions: artistFetchOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "album",
|
||||||
|
label: "Album",
|
||||||
|
type: "search-select",
|
||||||
|
fetchOptions: albumFetchOptions,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const showModal = ref<boolean>(false)
|
||||||
|
const item = ref<Track | undefined>(undefined)
|
||||||
|
|
||||||
|
const save = async (value: any) => {
|
||||||
|
if (value._id) {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/track", {
|
||||||
|
method: "PATCH",
|
||||||
|
data: value,
|
||||||
|
params: { id: value._id },
|
||||||
|
forceFormData: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Song updated successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to update song", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cfetch("/admin/track", {
|
||||||
|
method: "POST",
|
||||||
|
data: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.show("Song added successfully", "success")
|
||||||
|
} catch {
|
||||||
|
toast.show("Failed to add song", "error")
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id Set it to a value to make it edit instead of creating a new one
|
||||||
|
*/
|
||||||
|
const show = (id?: string) => {
|
||||||
|
if (!id) {
|
||||||
|
item.value = undefined
|
||||||
|
showModal.value = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const curItem: any = data.value.filter((i) => i._id == id)[0]
|
||||||
|
|
||||||
|
if (!curItem) return
|
||||||
|
|
||||||
|
const transformed = { ...curItem }
|
||||||
|
|
||||||
|
if (transformed.album)
|
||||||
|
transformed.album = {
|
||||||
|
value: curItem.album._id,
|
||||||
|
label: curItem.album.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transformed.artists)
|
||||||
|
transformed.artists = transformed.artists.map((a: Artist) => ({
|
||||||
|
value: a._id,
|
||||||
|
label: a.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
item.value = transformed
|
||||||
|
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(data, () => console.log(data))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="title">Manage songs</h1>
|
||||||
|
|
||||||
|
<Table :rows="tableRows" :data="data" class="table">
|
||||||
|
<template #action="{ item, index }">
|
||||||
|
<div class="action">
|
||||||
|
<Button @click="show(item._id)">Edit</Button>
|
||||||
|
<Button @click="">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Button @click="show()">Add a new song</Button>
|
||||||
|
|
||||||
|
<AdminModalForm
|
||||||
|
v-model:open="showModal"
|
||||||
|
@save="save"
|
||||||
|
:fields="modalFields"
|
||||||
|
:item="item"
|
||||||
|
:title="item ? 'Edit song' : 'Add new song'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
@include fontSize(24px);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
$color-accent: rgb(255, 69, 58);
|
$color-accent: rgb(255, 69, 58);
|
||||||
$color-background: oklch(14.1% 0.005 285.823); // #1a202c
|
$color-background: oklch(14.1% 0.005 285.823); // #1a202c
|
||||||
|
$color-secondary-background: oklch(26.862% 0.00003 271.152);
|
||||||
$color-text: rgba(255, 255, 255, 0.92);
|
$color-text: rgba(255, 255, 255, 0.92);
|
||||||
$color-placeholder-text: rgba(255, 255, 255, 0.44);
|
$color-placeholder-text: rgba(255, 255, 255, 0.44);
|
||||||
$color-border: rgba(255, 255, 255, 0.16);
|
$color-border: rgba(255, 255, 255, 0.16);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const props = defineProps<CardProps>()
|
|||||||
@use "@/styles/variables" as *;
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
background-color: $color-background;
|
||||||
border: 1px solid $color-border;
|
border: 1px solid $color-border;
|
||||||
border-radius: $border-radius-standard;
|
border-radius: $border-radius-standard;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
+40
-2
@@ -10,22 +10,47 @@ interface InputProps {
|
|||||||
name?: string
|
name?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
autocomplete?: AutocompleteType
|
autocomplete?: AutocompleteType
|
||||||
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = defineModel("value")
|
const value = defineModel<string | File | File[] | null>("value")
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "input", value: string): void
|
(e: "input", value: string): void
|
||||||
|
(e: "file", value: File | File[] | null | undefined): void
|
||||||
(e: "focus"): void
|
(e: "focus"): void
|
||||||
|
(e: "blur"): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps<InputProps>()
|
const props = defineProps<InputProps>()
|
||||||
|
|
||||||
|
const onFileChange = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const files = input.files
|
||||||
|
|
||||||
|
if (!files || files.length == 0) {
|
||||||
|
value.value = null
|
||||||
|
emit("file", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = files.length == 1 ? files[0] : Array.from(files)
|
||||||
|
|
||||||
|
value.value = result
|
||||||
|
emit("file", result)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<label v-if="$slots.label" :for="props.name">
|
<label v-if="$slots.label || props.label" :for="props.name">
|
||||||
|
<template v-if="!props.label">
|
||||||
<slot name="label" />
|
<slot name="label" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ props.label }}
|
||||||
|
</template>
|
||||||
|
|
||||||
<span v-if="props.required" class="required">*</span>
|
<span v-if="props.required" class="required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
@@ -38,16 +63,29 @@ const props = defineProps<InputProps>()
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
v-if="props.type != 'file'"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
@input="(e) => emit('input', e.target?.value)"
|
@input="(e) => emit('input', e.target?.value)"
|
||||||
@focus="emit('focus')"
|
@focus="emit('focus')"
|
||||||
|
@blur="emit('blur')"
|
||||||
:type="props.type"
|
:type="props.type"
|
||||||
:name="props.name"
|
:name="props.name"
|
||||||
:id="props.name"
|
:id="props.name"
|
||||||
:required="props.required"
|
:required="props.required"
|
||||||
:autocomplete="props.autocomplete"
|
:autocomplete="props.autocomplete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
@focus="emit('focus')"
|
||||||
|
@blur="emit('blur')"
|
||||||
|
@change="onFileChange"
|
||||||
|
:type="props.type"
|
||||||
|
:name="props.name"
|
||||||
|
:id="props.name"
|
||||||
|
:required="props.required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ width?: string }>()
|
||||||
|
|
||||||
|
const open = defineModel("open")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition>
|
||||||
|
<div class="modal-container" v-show="open" @click="open = false">
|
||||||
|
<Card @click.stop :width="props.width">
|
||||||
|
<slot />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-active,
|
||||||
|
.v-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-from,
|
||||||
|
.v-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
export interface Option {
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchSelectProps {
|
||||||
|
label?: string
|
||||||
|
fetchOptions: (q?: string) => Promise<Option[]>
|
||||||
|
predefined?: Option
|
||||||
|
multiple?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<SearchSelectProps>()
|
||||||
|
const value = defineModel<string | string[] | Option | Option[]>("value")
|
||||||
|
const selectedOptions = ref<Option[]>([])
|
||||||
|
|
||||||
|
const query = ref<string>("")
|
||||||
|
const options = ref<Option[]>([])
|
||||||
|
const show = ref<boolean>(false)
|
||||||
|
|
||||||
|
const isMultiple = computed(() => !!props.multiple)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!props.predefined) return
|
||||||
|
|
||||||
|
if (isMultiple.value) {
|
||||||
|
const items = Array.isArray(props.predefined)
|
||||||
|
? props.predefined
|
||||||
|
: [props.predefined]
|
||||||
|
|
||||||
|
value.value = items.map((i) => i.value)
|
||||||
|
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
query.value = items.map((o) => o.label).join(", ")
|
||||||
|
selectedOptions.value = items
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const item = Array.isArray(props.predefined)
|
||||||
|
? props.predefined[0]
|
||||||
|
: props.predefined
|
||||||
|
|
||||||
|
query.value = item.label
|
||||||
|
value.value = item.value
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => (options.value = []))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(query, async (q) => {
|
||||||
|
options.value = await (await props.fetchOptions(q)).slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelected = (opt: Option) => {
|
||||||
|
if (isMultiple.value && Array.isArray(value.value)) {
|
||||||
|
return value.value.includes(opt.value)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (item: Option) => {
|
||||||
|
if (isMultiple.value) {
|
||||||
|
const current = [...selectedOptions.value]
|
||||||
|
const idx = current.findIndex((o) => o.value === item.value)
|
||||||
|
|
||||||
|
if (idx === -1) current.push(item)
|
||||||
|
else current.splice(idx, 1)
|
||||||
|
|
||||||
|
selectedOptions.value = current
|
||||||
|
value.value = current.map((o) => o.value)
|
||||||
|
} else {
|
||||||
|
value.value = item.value
|
||||||
|
query.value = item.label
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocus = async () => {
|
||||||
|
if (isMultiple.value) query.value = ""
|
||||||
|
show.value = true
|
||||||
|
options.value = await (await props.fetchOptions()).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocusOut = () => {
|
||||||
|
show.value = false
|
||||||
|
|
||||||
|
if (Array.isArray(selectedOptions.value) && isMultiple.value) {
|
||||||
|
query.value = selectedOptions.value.map((o) => o.label).join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-select" tabindex="0" @focusout="onFocusOut">
|
||||||
|
<Input
|
||||||
|
v-model:value="query"
|
||||||
|
@focus="onFocus"
|
||||||
|
@focusout="onFocusOut"
|
||||||
|
:label="props.label"
|
||||||
|
type="text"
|
||||||
|
full
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-if="options.length && show" @mousedown.prevent>
|
||||||
|
<li
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
@mousedown.prevent="select(opt)"
|
||||||
|
>
|
||||||
|
<span>{{ opt.label }}</span>
|
||||||
|
<span v-if="isSelected(opt)" class="li-chip">✓</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/variables" as *;
|
||||||
|
|
||||||
|
.search-select {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
& ul,
|
||||||
|
& li {
|
||||||
|
border-radius: $border-radius-standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ul {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: $color-secondary-background;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $color-button-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.li-chip {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: $color-button-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+2
-2
@@ -27,8 +27,8 @@
|
|||||||
|
|
||||||
& aside {
|
& aside {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 300px;
|
min-width: 275px;
|
||||||
max-width: 350px;
|
max-width: 300px;
|
||||||
|
|
||||||
border-right: 1px solid $color-border;
|
border-right: 1px solid $color-border;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
export type Row = {
|
||||||
|
name: string
|
||||||
|
class?: string
|
||||||
|
width?: string
|
||||||
|
type?: "image" | "audio"
|
||||||
|
prefix?: string
|
||||||
|
suffix?: string
|
||||||
|
displayName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableProps = {
|
||||||
|
rows: Row[]
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, data } = defineProps<TableProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.name"
|
||||||
|
:class="row.class"
|
||||||
|
:style="{ width: row.width }"
|
||||||
|
>
|
||||||
|
{{ row.displayName || row.name }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, i) in data" :key="i">
|
||||||
|
<td v-for="(row, j) in rows" :key="j">
|
||||||
|
<slot :name="row.name" :item="item" :index="i">
|
||||||
|
<nuxt-img
|
||||||
|
v-if="row.type == 'image'"
|
||||||
|
:src="`${row.prefix}${item[row.name]}`"
|
||||||
|
/>
|
||||||
|
<audio
|
||||||
|
v-else-if="row.type == 'audio'"
|
||||||
|
controls
|
||||||
|
:src="`${row.prefix}${item[row.name]}${row.suffix}`"
|
||||||
|
/>
|
||||||
|
<p v-else>{{ item[row.name] }}</p>
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use "@/styles/variables" as *;
|
||||||
|
@use "@/styles/util" as *;
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border-collapse: collapse;
|
||||||
|
@include fontSize(15px);
|
||||||
|
|
||||||
|
& th,
|
||||||
|
& td {
|
||||||
|
border-bottom: 1px solid $color-border;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& thead {
|
||||||
|
}
|
||||||
|
|
||||||
|
& img {
|
||||||
|
height: 60px;
|
||||||
|
border-radius: $border-radius-standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
& audio {
|
||||||
|
max-width: 200px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+34
-3
@@ -1,10 +1,25 @@
|
|||||||
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
|
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
|
||||||
import defu from "defu"
|
import defu from "defu"
|
||||||
|
|
||||||
|
const hasFile = (value: any): boolean => {
|
||||||
|
if (!value || typeof value !== "object") return false
|
||||||
|
|
||||||
|
return Object.values(value).some(
|
||||||
|
(v) =>
|
||||||
|
v instanceof File ||
|
||||||
|
v instanceof Blob ||
|
||||||
|
(Array.isArray(v) && v.some((i) => i instanceof File)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestConfig = {
|
||||||
|
forceFormData?: boolean
|
||||||
|
} & AxiosRequestConfig
|
||||||
|
|
||||||
export const cfetch = async (
|
export const cfetch = async (
|
||||||
url: string,
|
url: string,
|
||||||
options: AxiosRequestConfig = {},
|
options: RequestConfig = {},
|
||||||
authorize: boolean = true
|
authorize: boolean = true,
|
||||||
): Promise<AxiosResponse> => {
|
): Promise<AxiosResponse> => {
|
||||||
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
|
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
|
||||||
|
|
||||||
@@ -26,7 +41,23 @@ export const cfetch = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: AxiosRequestConfig = defu(options, defaults)
|
const config: RequestConfig = defu(options, defaults)
|
||||||
|
|
||||||
|
if (config.data && (hasFile(config.data) || config.forceFormData)) {
|
||||||
|
const form = new FormData()
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(config.data)) {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
val.forEach((v) => form.append(key, v)) // key as-is, no []
|
||||||
|
} else if (val !== undefined && val !== null) {
|
||||||
|
form.append(key, val as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.data = form
|
||||||
|
if (!config.headers) config.headers = {}
|
||||||
|
delete config.headers["Content-Type"]
|
||||||
|
}
|
||||||
|
|
||||||
let response
|
let response
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,3 +17,14 @@ export interface Artist extends Base {
|
|||||||
export interface Genre extends Base {
|
export interface Genre extends Base {
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Track extends Base {
|
||||||
|
name: string
|
||||||
|
file: string
|
||||||
|
lyrics: {
|
||||||
|
synced: boolean
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
album: Album
|
||||||
|
artists: Artist[]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user