mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 14:27:47 +00:00
feat(admin): smart dropdown positioning, filter & pagination fixes
- Add getDropdownPosition() to handle table dropdown placement dynamically - Remove overflow-hidden on admin-card so dropdowns aren't clipped - Fix totalPages calculation using total/limit instead of missing API field - Add role/status filters + full pagination to member page - Create .agents/admin-ui-pitfalls.md for future reference
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
# Admin Panel UI Pitfalls & Patterns
|
||||||
|
|
||||||
|
## 1. Dropdowns Inside Tables — Always Use Smart Positioning
|
||||||
|
|
||||||
|
**❌ Đừng bao giờ viết cứng** `top-full mt-1` hoặc `bottom-full mb-1` cho dropdown trong table:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- SAI — luôn mở xuống dưới, bị cắt ở hàng cuối -->
|
||||||
|
<div class="absolute right-0 top-full z-50 mt-1 bg-white">
|
||||||
|
|
||||||
|
<!-- SAI — luôn mở lên trên, che hàng đầu -->
|
||||||
|
<div class="absolute right-0 bottom-full mb-1 z-50 bg-white">
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Đúng — dùng computed position dựa trên index:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
|
||||||
|
function getDropdownPosition(index: number) {
|
||||||
|
const total = items.value?.length || 0
|
||||||
|
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
|
||||||
|
if (total <= 2 || index < total - 2) {
|
||||||
|
return 'top-full mt-1'
|
||||||
|
}
|
||||||
|
return 'bottom-full mb-1'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<td class="px-4 py-3.5 text-center relative">
|
||||||
|
<div class="inline-flex">
|
||||||
|
<button @click="menuOpen = menuOpen === id ? null : id">
|
||||||
|
<AppIcon name="ellipsis-vertical" />
|
||||||
|
</button>
|
||||||
|
<Transition name="dropdown-fade">
|
||||||
|
<div v-if="menuOpen === id" :class="[
|
||||||
|
'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
|
||||||
|
getDropdownPosition(items.indexOf(item))
|
||||||
|
]">
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule Checklist:
|
||||||
|
- [ ] Mỗi cell có dropdown cần `relative` wrapper
|
||||||
|
- [ ] Hàm `getDropdownPosition` nhận index từ `items.indexOf(item)`
|
||||||
|
- [ ] Dùng dynamic class binding, KHÔNG hardcode vị trí
|
||||||
|
- [ ] Kiểm tra khi item nằm ở hàng cuối cùng của bảng
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Table Clipping — Remove `overflow-hidden` on Card Wrappers
|
||||||
|
|
||||||
|
Khi table có dropdown/menu, `.admin-card overflow-hidden` sẽ cắt mất dropdown khi nó mở ra ngoài vùng chứa.
|
||||||
|
|
||||||
|
**✅ Luôn bỏ `overflow-hidden` hoặc override bằng `overflow-visible!`:**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Đúng — dropdown không bị cắt -->
|
||||||
|
<div class="admin-card overflow-visible!">
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Lưu ý Tailwind v4 syntax**: dùng `overflow-visible!` chứ KHÔNG phải `!overflow-visible`. Linter sẽ báo lỗi nếu viết sai.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Admin Light Mode — Hardcoded Colors Need Override
|
||||||
|
|
||||||
|
Các component admin dùng hardcode màu dark mode (`bg-[#131418]`, `text-white`, `border-white/6`) không tự động chuyển khi xem trong light mode. Cần xử lý theo thứ tự ưu tiên:
|
||||||
|
|
||||||
|
### Thứ tự xử lý đúng:
|
||||||
|
|
||||||
|
1. **Preference 1**: Đổi trực tiếp template sang light-mode colors (`bg-white`, `border-slate-200`, `text-slate-900`)
|
||||||
|
2. **Preference 2**: Thêm rule CSS vào `.agents/admin-light-palette.md` nếu nhiều nơi share style
|
||||||
|
3. **Last resort**: Dùng CSS selector override trong layout `<style>` — nhưng chỉ cho teleported elements (modal/dialog teleport ra body)
|
||||||
|
|
||||||
|
**❌ Không làm**: Viết CSS selector phức tạp như `[class*="bg-"][class*="#131418"]` — khó maintain và dễ break.
|
||||||
|
|
||||||
|
**✅ Làm**: Thay màu trong template ngay từ đầu:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Dark mode (default) -->
|
||||||
|
<div class="bg-[#131418] border-white/6 text-white">
|
||||||
|
|
||||||
|
<!-- Light mode — nên code sẵn hai bộ màu hoặc toggle -->
|
||||||
|
<div class="dark:bg-[#131418] dark:border-white/6 dark:text-white
|
||||||
|
bg-white border-slate-200 text-slate-900">
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Quy tắc**: Nếu class đã hardcode giá trị hex/dark theme, đó là bug waiting to happen trong light mode. Code luôn với cả 2 palette hoặc dùng utility classes Tailwind chuẩn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin Toggle Button — Use Brand Color
|
||||||
|
|
||||||
|
Component `AdminToggle.vue` cần dùng màu brand `#095DF2` (CineK Blue):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:class="model ? 'bg-[#095DF2]' : 'bg-slate-600'"
|
||||||
|
focus:ring-[#095DF2]/50
|
||||||
|
```
|
||||||
|
|
||||||
|
Không dùng vàng (`yellow-400`), xanh lá, hay bất kỳ màu nào khác cho state active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Admin Sort Icons — Yellow → Blue
|
||||||
|
|
||||||
|
Sort icons đang sort phải dùng màu brand `#095DF2`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
:class="sortBy === 'column' ? 'text-[#095DF2]' : 'opacity-50'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Không dùng `text-yellow-400`. Tương tự cho pagination active state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Common Pattern Reference
|
||||||
|
|
||||||
|
| Component | Class Pattern | Notes |
|
||||||
|
|-----------|--------------|-------|
|
||||||
|
| Sort icon active | `text-[#095DF2]` | Not yellow |
|
||||||
|
| Pagination active | `border-[#095DF2] bg-[#095DF2] text-white` | Center text white |
|
||||||
|
| Dropdown bg | `bg-white border-slate-200` | For light mode compatibility |
|
||||||
|
| Dropdown hover | `hover:bg-slate-100` | Not `hover:bg-white/5` |
|
||||||
|
| Delete menu item | `text-red-500 hover:bg-red-50` | Red-500 for light mode |
|
||||||
|
| Table header border | `border-bottom: 3px solid #eeee` | Inline style for precise control |
|
||||||
|
| Table row divider | `divide-y divide-[#eeee]` | Light gray separator |
|
||||||
|
| Cell padding | `px-4 py-3.5` | Consistent across all admin tables |
|
||||||
@@ -123,6 +123,17 @@ async function handleDeleteAll() {
|
|||||||
const movies = computed(() => data.value?.items || [])
|
const movies = computed(() => data.value?.items || [])
|
||||||
const totalPages = computed(() => data.value?.totalPages || 1)
|
const totalPages = computed(() => data.value?.totalPages || 1)
|
||||||
|
|
||||||
|
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
|
||||||
|
function getDropdownPosition(index: number) {
|
||||||
|
const total = movies.value?.length || 0
|
||||||
|
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
|
||||||
|
// Còn lại mở xuống dưới (mặc định)
|
||||||
|
if (total <= 2 || index < total - 2) {
|
||||||
|
return 'top-full mt-1'
|
||||||
|
}
|
||||||
|
return 'bottom-full mb-1'
|
||||||
|
}
|
||||||
|
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => {
|
||||||
const total = totalPages.value
|
const total = totalPages.value
|
||||||
const current = currentPage.value
|
const current = currentPage.value
|
||||||
@@ -233,7 +244,7 @@ const syncSourceOptions = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card overflow-hidden">
|
<div class="admin-card overflow-visible!">
|
||||||
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
|
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
|
||||||
md:grid-cols-2
|
md:grid-cols-2
|
||||||
xl:grid-cols-[minmax(280px,1fr)_180px_160px_150px]
|
xl:grid-cols-[minmax(280px,1fr)_180px_160px_150px]
|
||||||
@@ -390,8 +401,8 @@ const syncSourceOptions = [
|
|||||||
@update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" />
|
@update:model-value="(val: boolean) => { movie.active = val; toggleActive(movie) }" />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5 text-center">
|
<td class="px-4 py-3.5 text-center relative">
|
||||||
<div class="relative inline-flex">
|
<div class="inline-flex">
|
||||||
<button type="button" class="grid size-9 place-items-center rounded-lg
|
<button type="button" class="grid size-9 place-items-center rounded-lg
|
||||||
text-zinc-500 transition
|
text-zinc-500 transition
|
||||||
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
|
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
|
||||||
@@ -399,9 +410,10 @@ const syncSourceOptions = [
|
|||||||
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
||||||
</button>
|
</button>
|
||||||
<Transition name="dropdown-fade">
|
<Transition name="dropdown-fade">
|
||||||
<div v-if="menuOpen === movie.id" class="absolute right-0 top-full z-50 mt-1 min-w-40
|
<div v-if="menuOpen === movie.id" :class="[
|
||||||
rounded-lg border border-slate-200
|
'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
|
||||||
bg-white py-1 shadow-xl">
|
getDropdownPosition(movies.indexOf(movie))
|
||||||
|
]">
|
||||||
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
|
<NuxtLink :to="`/admin/phim/${movie.id}`" class="flex w-full items-center gap-2 px-3 py-2
|
||||||
text-sm text-slate-700 transition
|
text-sm text-slate-700 transition
|
||||||
hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null">
|
hover:bg-slate-100 hover:text-slate-900" @click="menuOpen = null">
|
||||||
|
|||||||
@@ -66,7 +66,23 @@ async function deleteMember(member: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const members = computed(() => data.value?.items || [])
|
const members = computed(() => data.value?.items || [])
|
||||||
const totalPages = computed(() => data.value?.totalPages || 1)
|
const totalPages = computed(() => {
|
||||||
|
const limit = 20
|
||||||
|
return Math.ceil((data.value?.total || 1) / limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3-dot menu state
|
||||||
|
const menuOpen = ref<number | null>(null)
|
||||||
|
|
||||||
|
// Helper để dropdown luôn hiển thị bên trong vùng nhìn thấy
|
||||||
|
function getDropdownPosition(index: number) {
|
||||||
|
const total = members.value?.length || 0
|
||||||
|
// Chỉ mở lên trên khi còn ít nhất 2 hàng phía dưới
|
||||||
|
if (total <= 2 || index < total - 2) {
|
||||||
|
return 'top-full mt-1'
|
||||||
|
}
|
||||||
|
return 'bottom-full mb-1'
|
||||||
|
}
|
||||||
|
|
||||||
const visiblePages = computed(() => {
|
const visiblePages = computed(() => {
|
||||||
const total = totalPages.value
|
const total = totalPages.value
|
||||||
@@ -108,7 +124,7 @@ const visiblePages = computed(() => {
|
|||||||
<p class="admin-section-subtitle mt-1">Quản lý tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
|
<p class="admin-section-subtitle mt-1">Quản lý tài khoản thành viên CineK ({{ data?.total || 0 }} thành viên)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card overflow-hidden">
|
<div class="admin-card overflow-visible!">
|
||||||
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
|
<div class="grid grid-cols-1 gap-3 border-b border-white/6 p-4
|
||||||
md:grid-cols-2 xl:grid-cols-[minmax(280px,1fr)_180px_160px] xl:items-center">
|
md:grid-cols-2 xl:grid-cols-[minmax(280px,1fr)_180px_160px] xl:items-center">
|
||||||
<div class="relative min-w-0 w-full">
|
<div class="relative min-w-0 w-full">
|
||||||
@@ -149,17 +165,9 @@ const visiblePages = computed(() => {
|
|||||||
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2">
|
<tr v-for="member in members" :key="member.id" class="transition hover:bg-white/2">
|
||||||
<td class="px-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}
|
<td class="px-4 py-3.5 text-center text-sm font-semibold text-slate-500">{{ members.indexOf(member) + 1 }}
|
||||||
</td>
|
</td>
|
||||||
<td class="min-w-0 px-4 py-3.5 text-center">
|
<td class="px-4 py-3.5 text-center">
|
||||||
<div class="flex items-center gap-3 justify-center">
|
|
||||||
<div
|
|
||||||
class="grid size-9 place-items-center rounded-full bg-linear-to-br from-slate-700 to-slate-800 text-sm font-bold text-slate-300 shrink-0">
|
|
||||||
{{ (member.name || member.email || '?').charAt(0).toUpperCase() }}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
|
<p class="truncate text-sm font-bold text-white">{{ member.name || 'Chưa có tên' }}</p>
|
||||||
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
<p class="truncate text-xs text-slate-400">{{ member.email }}</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5 text-center">
|
<td class="px-4 py-3.5 text-center">
|
||||||
<select :value="member.role"
|
<select :value="member.role"
|
||||||
@@ -178,13 +186,31 @@ const visiblePages = computed(() => {
|
|||||||
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
|
{{ new Date(member.createdAt).toLocaleDateString('vi-VN') }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3.5 text-center">
|
<td class="px-4 py-3.5 text-center">
|
||||||
|
<div class="relative inline-flex">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="grid size-8 place-items-center rounded-lg text-red-400 transition hover:bg-red-500/10"
|
class="grid size-9 place-items-center rounded-lg text-zinc-500 transition
|
||||||
:disabled="currentUser?.id === member.id"
|
hover:bg-slate-100 hover:text-zinc-700" title="Thao tác"
|
||||||
:title="currentUser?.id === member.id ? 'Không thể xoá chính mình' : 'Xoá thành viên'"
|
@click="menuOpen = menuOpen === member.id ? null : member.id">
|
||||||
@click="deleteMember(member)">
|
<AppIcon name="ellipsis-vertical" class="size-5 stroke-[2.5]" />
|
||||||
<AppIcon name="trash" class="size-4" />
|
|
||||||
</button>
|
</button>
|
||||||
|
<Transition name="dropdown-fade">
|
||||||
|
<div v-if="menuOpen === member.id" :class="[
|
||||||
|
'absolute right-0 min-w-40 rounded-lg border border-slate-200 bg-white py-1 shadow-xl z-50',
|
||||||
|
getDropdownPosition(members.indexOf(member))
|
||||||
|
]">
|
||||||
|
<button type="button"
|
||||||
|
v-if="currentUser?.id !== member.id"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-red-500 transition hover:bg-red-50"
|
||||||
|
@click="menuOpen = null; deleteMember(member)">
|
||||||
|
<AppIcon name="trash" class="size-4" />
|
||||||
|
Xoá thành viên
|
||||||
|
</button>
|
||||||
|
<div v-else class="px-3 py-2 text-xs text-slate-400">
|
||||||
|
Không thể xoá chính mình
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!members.length">
|
<tr v-if="!members.length">
|
||||||
|
|||||||
Reference in New Issue
Block a user