Merge branch 'master' into feat/player-fullscreen

This commit is contained in:
lucast committed 2026-03-07 14:45:58 +01:00
commit ddef57b802
3 files changed
+93 -17

No files matched your search

+60 -11
View File
@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Option } from "~/ui/SearchSelect.vue" import { type Option } from "~/ui/SearchSelect.vue"
type InputType = "text" | "file" | "search-select"
interface BaseField<T> { interface BaseField<T> {
key: keyof T key: keyof T
label?: string label?: string
@@ -12,6 +10,11 @@ interface TextField<T> extends BaseField<T> {
type?: "text" type?: "text"
} }
interface TextAreaField<T> extends BaseField<T> {
type?: "textarea"
rows: number
}
interface FileField<T> extends BaseField<T> { interface FileField<T> extends BaseField<T> {
type: "file" type: "file"
} }
@@ -26,11 +29,17 @@ interface SearchSelectArrayField<T> extends BaseField<T> {
fetchOptions: (q?: string) => Promise<Option[]> fetchOptions: (q?: string) => Promise<Option[]>
} }
interface CheckboxField<T> extends BaseField<T> {
type?: "checkbox"
}
export type Field<T = any> = export type Field<T = any> =
| TextField<T> | TextField<T>
| TextAreaField<T>
| FileField<T> | FileField<T>
| SearchSelectField<T> | SearchSelectField<T>
| SearchSelectArrayField<T> | SearchSelectArrayField<T>
| CheckboxField<T>
interface ModalFormProps<T> { interface ModalFormProps<T> {
item?: T item?: T
@@ -44,27 +53,60 @@ const open = defineModel("open", { required: true })
let localItem = reactive<any>({}) let localItem = reactive<any>({})
const getNestedValue = (obj: any, path: string) =>
path.split(".").reduce((acc, key) => acc?.[key], obj)
const setNestedValue = (obj: any, path: string, value: any) => {
const keys = path.split(".")
keys.reduce((acc, key, i) => {
if (i === keys.length - 1) acc[key] = value
else acc[key] ??= {}
return acc[key]
}, obj)
}
const expandDotKeys = (flat: Record<string, any>) => {
const result: any = {}
for (const [key, value] of Object.entries(flat)) {
setNestedValue(result, key, toRaw(value))
}
return result
}
const save = async () => {
const expanded = expandDotKeys(localItem)
Object.keys(expanded).forEach((k) => {
if (expanded[k] == undefined || expanded[k] == null) delete expanded[k]
})
if (await props.onSave(expanded)) open.value = false
}
watch( watch(
() => props.item, () => props.item,
(item) => { (item) => {
Object.keys(localItem).forEach((k) => delete localItem[k]) Object.keys(localItem).forEach((k) => delete localItem[k])
if (!item) return if (item)
for (const [key, value] of Object.entries(item))
for (const [key, value] of Object.entries(item)) {
localItem[key] = value localItem[key] = value
}
for (const field of props.fields) { for (const field of props.fields) {
if (field.type == "file") localItem[field.key as string] = null const key = field.key as string
if (field.type === "file") {
localItem[key] = null
} else if (field.type === "checkbox") {
localItem[key] = item
? (getNestedValue(item, key) ?? false)
: false
} else {
localItem[key] = item ? getNestedValue(item, key) : undefined
}
} }
}, },
{ immediate: true }, { immediate: true },
) )
const save = async () => {
if (await props.onSave(localItem)) open.value = false
}
</script> </script>
<template> <template>
@@ -84,12 +126,19 @@ const save = async () => {
:predefined="props.item?.[field.key]" :predefined="props.item?.[field.key]"
:multiple="field.type == 'search-select-array'" :multiple="field.type == 'search-select-array'"
/> />
<Checkbox
v-else-if="field.type == 'checkbox'"
:label="field.label || field.key"
@change="(v) => (localItem[field.key] = v)"
/>
<Input <Input
v-else v-else
:type="field.type" :type="field.type"
:key="field.key" :key="field.key"
:label="field.label || field.key" :label="field.label || field.key"
v-model:value="localItem[field.key]" v-model:value="localItem[field.key]"
:textarea="field.type == 'textarea'"
:rows="field.type == 'textarea' ? field.rows : 0"
full full
/> />
</template> </template>
+10 -4
View File
@@ -63,6 +63,8 @@ const modalFields: Field[] = [
type: "search-select", type: "search-select",
fetchOptions: albumFetchOptions, fetchOptions: albumFetchOptions,
}, },
{ key: "lyrics.text", label: "Lyrics", type: "textarea", rows: 6 },
{ key: "lyrics.synced", label: "Synced lyrics", type: "checkbox" },
] ]
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
@@ -73,7 +75,10 @@ const save = async (value: any) => {
try { try {
await cfetch("/admin/track", { await cfetch("/admin/track", {
method: "PATCH", method: "PATCH",
data: value, data: {
...value,
lyrics: JSON.stringify(value.lyrics),
},
params: { id: value._id }, params: { id: value._id },
forceFormData: true, forceFormData: true,
}) })
@@ -88,7 +93,10 @@ const save = async (value: any) => {
try { try {
await cfetch("/admin/track", { await cfetch("/admin/track", {
method: "POST", method: "POST",
data: value, data: {
...value,
lyrics: JSON.stringify(value.lyrics),
},
}) })
toast.show("Song added successfully", "success") toast.show("Song added successfully", "success")
@@ -137,8 +145,6 @@ const show = (id?: string) => {
showModal.value = true showModal.value = true
} }
watch(data, () => console.log(data))
</script> </script>
<template> <template>
+23 -2
View File
@@ -11,6 +11,8 @@ interface InputProps {
required?: boolean required?: boolean
autocomplete?: AutocompleteType autocomplete?: AutocompleteType
label?: string label?: string
textarea?: boolean
rows?: number
} }
const value = defineModel<string | File | File[] | null>("value") const value = defineModel<string | File | File[] | null>("value")
@@ -63,7 +65,7 @@ const onFileChange = (e: Event) => {
</span> </span>
<input <input
v-if="props.type != 'file'" v-if="props.type != 'file' && props.type != 'textarea'"
: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)"
@@ -76,6 +78,20 @@ const onFileChange = (e: Event) => {
:autocomplete="props.autocomplete" :autocomplete="props.autocomplete"
/> />
<textarea
v-else-if="props.textarea"
:placeholder="props.placeholder"
v-model="value"
@input="(e) => emit('input', e.target?.value)"
@focus="emit('focus')"
@blur="emit('blur')"
:name="props.name"
:id="props.name"
:required="props.required"
:autocomplete="props.autocomplete"
:rows="props.rows"
/>
<input <input
v-else v-else
@focus="emit('focus')" @focus="emit('focus')"
@@ -120,7 +136,8 @@ const onFileChange = (e: Event) => {
outline: 2px solid $color-accent; outline: 2px solid $color-accent;
} }
& input { & input,
& textarea {
width: 100%; width: 100%;
background: none; background: none;
@@ -132,6 +149,10 @@ const onFileChange = (e: Event) => {
} }
} }
& textarea:focus {
outline: none;
}
& input:focus { & input:focus {
outline: none; outline: none;
} }