This commit is contained in:
lucast committed 2026-02-03 20:53:54 +01:00
commit b2a3bc458b
18 files changed
+933 -14

No files matched your search

+116
View File
@@ -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 -6
View File
@@ -3,9 +3,9 @@ import defu from "defu"
export function useApiFetch<DataT = any, ErrorT = any>(
url: string | Request | Ref<string | Request> | (() => string | Request),
options: UseFetchOptions<DataT, ErrorT> = {},
options?: UseFetchOptions<DataT, ErrorT>,
authorize: boolean = true
) {
): ReturnType<typeof useFetch<DataT, ErrorT>> {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
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<
DataT,
ErrorT
>
const config: UseFetchOptions<DataT, ErrorT> = defu(options ?? {}, defaults)
// @ts-ignore
return useFetch<DataT, ErrorT>(url, config)
+101
View File
@@ -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>
+16
View File
@@ -11,6 +11,8 @@ const onSearchInput = (v: string) => {
else router.replace("/search")
}, 300)
}
const { data: user } = useApiFetch<any>("/user")
</script>
<template>
@@ -69,6 +71,20 @@ const onSearchInput = (v: string) => {
Songs
</Button>
</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 #default>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
layout: "admin",
})
</script>
<template>
<NuxtPage />
</template>
+181
View File
@@ -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>
+122
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<script setup lang="ts"></script>
<template></template>
+1
View File
@@ -1,5 +1,6 @@
$color-accent: rgb(255, 69, 58);
$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-placeholder-text: rgba(255, 255, 255, 0.44);
$color-border: rgba(255, 255, 255, 0.16);
+1
View File
@@ -16,6 +16,7 @@ const props = defineProps<CardProps>()
@use "@/styles/variables" as *;
.card {
background-color: $color-background;
border: 1px solid $color-border;
border-radius: $border-radius-standard;
padding: 1rem;
+41 -3
View File
@@ -10,22 +10,47 @@ interface InputProps {
name?: string
required?: boolean
autocomplete?: AutocompleteType
label?: string
}
const value = defineModel("value")
const value = defineModel<string | File | File[] | null>("value")
const emit = defineEmits<{
(e: "input", value: string): void
(e: "file", value: File | File[] | null | undefined): void
(e: "focus"): void
(e: "blur"): void
}>()
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>
<template>
<div class="input-wrapper">
<label v-if="$slots.label" :for="props.name">
<slot name="label" />
<label v-if="$slots.label || props.label" :for="props.name">
<template v-if="!props.label">
<slot name="label" />
</template>
<template v-else>
{{ props.label }}
</template>
<span v-if="props.required" class="required">*</span>
</label>
<div
@@ -38,16 +63,29 @@ const props = defineProps<InputProps>()
</span>
<input
v-if="props.type != 'file'"
:placeholder="props.placeholder"
v-model="value"
@input="(e) => emit('input', e.target?.value)"
@focus="emit('focus')"
@blur="emit('blur')"
:type="props.type"
:name="props.name"
:id="props.name"
:required="props.required"
: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>
</template>
+42
View File
@@ -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>
+162
View File
@@ -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
View File
@@ -27,8 +27,8 @@
& aside {
height: 100%;
min-width: 300px;
max-width: 350px;
min-width: 275px;
max-width: 300px;
border-right: 1px solid $color-border;
padding: 1.5rem;
+88
View File
@@ -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
View File
@@ -1,10 +1,25 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
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 (
url: string,
options: AxiosRequestConfig = {},
authorize: boolean = true
options: RequestConfig = {},
authorize: boolean = true,
): Promise<AxiosResponse> => {
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
try {
+11
View File
@@ -17,3 +17,14 @@ export interface Artist extends Base {
export interface Genre extends Base {
name: string
}
export interface Track extends Base {
name: string
file: string
lyrics: {
synced: boolean
text: string
}
album: Album
artists: Artist[]
}