feat(spa): entitylistview shared list skeleton

This commit is contained in:
DumiFlex
2026-04-26 21:21:40 +03:00
parent 33542e1239
commit 5e8d0428d4
+177
View File
@@ -0,0 +1,177 @@
<script setup lang="ts" generic="T extends { id: string }">
import { computed, ref, watch } from "vue";
import DataTable from "primevue/datatable";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";
import { useConfirm } from "primevue/useconfirm";
import ConfirmDialog from "primevue/confirmdialog";
interface Filter { q?: string; favorites?: boolean; }
const props = defineProps<{
title: string;
subtitle: string;
newLabel: string;
newRoute: string;
items: T[];
loading: boolean;
filter: Filter;
emptyMessage: string;
}>();
const emit = defineEmits<{
fetch: [];
delete: [item: T];
bulkDelete: [items: T[]];
}>();
const confirm = useConfirm();
const search = ref(props.filter.q ?? "");
const filterPanelOpen = ref(false);
const selected = ref<T[]>([]);
watch(search, async (q) => {
props.filter.q = q || undefined;
emit("fetch");
});
const activeFilterCount = computed(() => {
let n = 0;
if (props.filter.q) n++;
if (props.filter.favorites) n++;
return n;
});
function clearFilters() {
search.value = "";
props.filter.q = undefined;
props.filter.favorites = false;
emit("fetch");
}
function rowClass(row: T): string {
return (row as T & { is_favorite?: boolean }).is_favorite ? "wp-row-favorite" : "";
}
function confirmBulkDelete() {
confirm.require({
message: `Delete ${selected.value.length} selected items?`,
header: "Confirm bulk delete",
icon: "pi pi-exclamation-triangle",
acceptClass: "p-button-danger",
accept: () => {
emit("bulkDelete", [...(selected.value as T[])]);
selected.value = [];
},
});
}
</script>
<template>
<div class="p-6 text-wp-text">
<ConfirmDialog />
<div class="flex items-end justify-between mb-4">
<div>
<h1 class="text-xl font-semibold m-0">{{ title }}</h1>
<p class="text-sm text-wp-text2 m-0 mt-1">{{ subtitle }}</p>
</div>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</div>
<div class="flex gap-2 mb-3 items-center flex-wrap">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" placeholder="Search by name…" class="w-64" />
</IconField>
<Button
:label="activeFilterCount ? `Filters (${activeFilterCount})` : 'Filters'"
icon="pi pi-filter"
:severity="activeFilterCount ? 'primary' : 'secondary'"
outlined size="small"
@click="filterPanelOpen = !filterPanelOpen"
/>
<span class="ml-auto text-xs text-wp-text2">{{ items.length }} item(s)</span>
</div>
<div v-if="filterPanelOpen" class="filter-panel mb-3">
<slot name="filter-panel" :filter="filter" :emit-fetch="() => emit('fetch')" />
</div>
<div v-if="activeFilterCount" class="flex gap-2 mb-3 items-center flex-wrap">
<span class="text-xs text-wp-text2">Active:</span>
<span v-if="filter.q" class="filter-chip filter-chip--search">
name: {{ filter.q }}
<i class="pi pi-times" @click="search = ''" />
</span>
<span v-if="filter.favorites" class="filter-chip filter-chip--favorite">
favorites only
<i class="pi pi-times" @click="filter.favorites = false; emit('fetch')" />
</span>
<Button label="Clear all" text size="small" @click="clearFilters" />
</div>
<div v-if="selected.length" class="flex justify-between items-center bulk-bar mb-3">
<span class="text-sm">
<strong class="text-wp-accent">{{ selected.length }}</strong> selected
</span>
<div class="flex gap-2">
<Button label="Clear" text size="small" @click="selected = []" />
<Button
label="Delete selected" icon="pi pi-trash" severity="danger" outlined size="small"
@click="confirmBulkDelete"
/>
</div>
</div>
<DataTable
v-model:selection="selected"
:value="items" :loading="loading" data-key="id"
:rows="20" :paginator="items.length > 20"
:row-class="rowClass"
>
<slot name="columns" />
<template #empty>
<div class="text-center p-8">
<img src="/wp/images/favicon.svg" alt="" class="mx-auto mb-3 opacity-70" style="width:64px;height:64px" />
<p class="text-wp-text2 mb-3">{{ emptyMessage }}</p>
<Button :label="newLabel" icon="pi pi-plus" severity="primary" @click="$router.push(newRoute)" />
</div>
</template>
</DataTable>
</div>
</template>
<style scoped>
.filter-panel {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 12px;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: 11px;
font-size: 11px;
border: 1px solid var(--wp-border);
background: var(--wp-bg2);
}
.filter-chip--search { color: var(--wp-accent); border-color: var(--wp-accent); background: var(--wp-accent-glow); }
.filter-chip--favorite { color: var(--wp-amber); border-color: var(--wp-amber); }
.filter-chip i { cursor: pointer; opacity: 0.7; }
.filter-chip i:hover { opacity: 1; }
.bulk-bar {
background: var(--wp-accent-glow);
border: 1px solid var(--wp-accent);
border-radius: var(--wp-radius);
padding: 8px 12px;
}
:deep(.wp-row-favorite) {
border-left: 2px solid var(--wp-amber);
}
</style>