Files
music-v2/app/ui/Table.vue
T
2026-10-06 03:00:17 +02:00

214 lines
4.0 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { GET_AUDIO_FILE, GET_FILE } from "@/utils/file"
defineOptions({ inheritAttrs: false })
export type Row = {
name: string
class?: string
width?: string
type?: "image" | "audio"
displayName?: string
}
type TableProps = {
rows: Row[]
data: any
pagination?: boolean
pageSize?: number
search?: boolean
}
const props = withDefaults(defineProps<TableProps>(), {
pagination: false,
pageSize: 10,
search: false,
})
const currentPage = ref(1)
const query = ref("")
const data = computed(() => (Array.isArray(props.data) ? props.data : []))
const pageSize = computed(() => Math.max(1, Math.floor(props.pageSize)))
const filteredData = computed(() => {
const normalizedQuery = query.value.trim().toLocaleLowerCase()
const indexedData = data.value.map((item: any, index: number) => ({
item,
index,
}))
if (!props.search || !normalizedQuery) return indexedData
return indexedData.filter(({ item }: { item: any }) =>
props.rows.some((row) =>
String(item[row.name] ?? "")
.toLocaleLowerCase()
.includes(normalizedQuery),
),
)
})
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredData.value.length / pageSize.value)),
)
const pagedData = computed(() => {
if (!props.pagination) return filteredData.value
const start = (currentPage.value - 1) * pageSize.value
return filteredData.value.slice(start, start + pageSize.value)
})
watch(query, () => {
currentPage.value = 1
})
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count
})
</script>
<template>
<Input
v-if="props.search"
v-model:value="query"
type="search"
placeholder="Search table"
class="table-search"
/>
<table v-bind="$attrs" class="table">
<thead>
<tr>
<th
v-for="row in props.rows"
:key="row.name"
:class="row.class"
:style="{ width: row.width }"
>
{{ row.displayName || row.name }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in pagedData"
:key="entry.index"
>
<td v-for="(row, j) in props.rows" :key="j">
<slot
:name="row.name"
:item="entry.item"
:index="entry.index"
>
<nuxt-img
v-if="row.type == 'image'"
:src="GET_FILE(entry.item[row.name])"
/>
<audio
v-else-if="row.type == 'audio'"
controls
:src="GET_AUDIO_FILE(entry.item[row.name])"
/>
<p v-else>{{ entry.item[row.name] }}</p>
</slot>
</td>
</tr>
</tbody>
</table>
<nav
v-if="props.pagination && pageCount > 1"
class="pagination"
aria-label="Table pagination"
>
<button
type="button"
:disabled="currentPage === 1"
aria-label="Previous page"
@click="currentPage--"
>
Previous
</button>
<button
v-for="page in pageCount"
:key="page"
type="button"
:aria-label="`Page ${page}`"
:aria-current="currentPage === page ? 'page' : undefined"
:class="{ active: currentPage === page }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
:disabled="currentPage === pageCount"
aria-label="Next page"
@click="currentPage++"
>
Next
</button>
</nav>
</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;
}
}
.table-search {
margin-bottom: 0.75rem;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
& button {
padding: 0.4rem 0.65rem;
border-radius: $border-radius-standard;
background-color: $color-button;
cursor: pointer;
}
& button:hover:not(:disabled),
& button.active {
background-color: $color-button-hover;
}
& button:disabled {
cursor: default;
opacity: 0.5;
}
}
</style>