diff --git a/app/components/Admin/ModalForm.vue b/app/components/Admin/ModalForm.vue new file mode 100644 index 0000000..adadc00 --- /dev/null +++ b/app/components/Admin/ModalForm.vue @@ -0,0 +1,116 @@ + + + + + {{ props.title }} + + + + + + + + Save + + + + + diff --git a/app/components/artist/Name.vue b/app/components/Artist/Name.vue similarity index 100% rename from app/components/artist/Name.vue rename to app/components/Artist/Name.vue diff --git a/app/composables/useApiFetch.ts b/app/composables/useApiFetch.ts index ffda4ce..ffc0241 100644 --- a/app/composables/useApiFetch.ts +++ b/app/composables/useApiFetch.ts @@ -3,9 +3,9 @@ import defu from "defu" export function useApiFetch( url: string | Request | Ref | (() => string | Request), - options: UseFetchOptions = {}, + options?: UseFetchOptions, authorize: boolean = true -) { +): ReturnType> { const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND if (!baseUrl) throw new Error("BACKEND URL not set") @@ -39,10 +39,7 @@ export function useApiFetch( }, } - const config = defu(options || {}, defaults) as UseFetchOptions< - DataT, - ErrorT - > + const config: UseFetchOptions = defu(options ?? {}, defaults) // @ts-ignore return useFetch(url, config) diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue new file mode 100644 index 0000000..5746e30 --- /dev/null +++ b/app/layouts/admin.vue @@ -0,0 +1,101 @@ + + + + Music - Admin + + + + Dashboard + + + + + Manage + + + Genres + + + Artists + + + Albums + + + Songs + + + + + Exit + + + Exit Admin Panel + + + + + + + + + + + diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 5eaa024..8f25c6c 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -11,6 +11,8 @@ const onSearchInput = (v: string) => { else router.replace("/search") }, 300) } + +const { data: user } = useApiFetch("/user") @@ -69,6 +71,20 @@ const onSearchInput = (v: string) => { Songs + + + Secret stuff + + + Go To Admin Panel + + diff --git a/app/pages/admin.vue b/app/pages/admin.vue new file mode 100644 index 0000000..f805b6b --- /dev/null +++ b/app/pages/admin.vue @@ -0,0 +1,9 @@ + + + + + diff --git a/app/pages/admin/albums.vue b/app/pages/admin/albums.vue new file mode 100644 index 0000000..8e75250 --- /dev/null +++ b/app/pages/admin/albums.vue @@ -0,0 +1,181 @@ + + + + Manage albums + + + + + Edit + Delete + + + + Add a new album + + + + + diff --git a/app/pages/admin/genre.vue b/app/pages/admin/genre.vue new file mode 100644 index 0000000..97fff31 --- /dev/null +++ b/app/pages/admin/genre.vue @@ -0,0 +1,122 @@ + + + + Manage genres + + + + + Edit + Delete + + + + Add a new genre + + + + + diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue new file mode 100644 index 0000000..e519b41 --- /dev/null +++ b/app/pages/admin/index.vue @@ -0,0 +1,3 @@ + + + diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index c200b6e..b047d8b 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -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); diff --git a/app/ui/Card.vue b/app/ui/Card.vue index 4f537a5..9374430 100644 --- a/app/ui/Card.vue +++ b/app/ui/Card.vue @@ -16,6 +16,7 @@ const props = defineProps() @use "@/styles/variables" as *; .card { + background-color: $color-background; border: 1px solid $color-border; border-radius: $border-radius-standard; padding: 1rem; diff --git a/app/ui/Input.vue b/app/ui/Input.vue index 48b43a7..d6884e3 100644 --- a/app/ui/Input.vue +++ b/app/ui/Input.vue @@ -10,22 +10,47 @@ interface InputProps { name?: string required?: boolean autocomplete?: AutocompleteType + label?: string } -const value = defineModel("value") +const value = defineModel("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() + +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) +} - - + + + + + + {{ props.label }} + + * () 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" /> + + diff --git a/app/ui/Modal.vue b/app/ui/Modal.vue new file mode 100644 index 0000000..4fbad62 --- /dev/null +++ b/app/ui/Modal.vue @@ -0,0 +1,42 @@ + + + + + + + + + + + + + diff --git a/app/ui/SearchSelect.vue b/app/ui/SearchSelect.vue new file mode 100644 index 0000000..e867cc1 --- /dev/null +++ b/app/ui/SearchSelect.vue @@ -0,0 +1,162 @@ + + + + + + + + + {{ opt.label }} + ✓ + + + + + + diff --git a/app/ui/Sidebar.vue b/app/ui/Sidebar.vue index 0b9862e..eef62f7 100644 --- a/app/ui/Sidebar.vue +++ b/app/ui/Sidebar.vue @@ -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; diff --git a/app/ui/Table.vue b/app/ui/Table.vue new file mode 100644 index 0000000..2a30774 --- /dev/null +++ b/app/ui/Table.vue @@ -0,0 +1,88 @@ + + + + + + + + {{ row.displayName || row.name }} + + + + + + + + + + {{ item[row.name] }} + + + + + + + + diff --git a/app/utils/cfetch.ts b/app/utils/cfetch.ts index f5d9245..dc8dcd7 100644 --- a/app/utils/cfetch.ts +++ b/app/utils/cfetch.ts @@ -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 => { 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 { diff --git a/app/utils/types.ts b/app/utils/types.ts index e4d02ab..2392393 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -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[] +}
{{ props.title }}
Music - Admin
Manage
Exit
Secret stuff
{{ item[row.name] }}