Phase 3: Unify controls across shot/asset/task toolbars and detail panels

Extracts duplication that built up as shot/asset/task features reached
parity: CheckableCommandItem/ColumnToggleList replace 14+ hand-rolled
checkbox-list blocks, shared toolbar pieces (debounced search, detail-panel
toggle, clear-filters, segmented view/context toggle) replace copy-pasted
markup in the three table toolbars, icon-only buttons standardize on the
icon-sm size, TaskBulkActionsMenu's Assign To submenu matches Set Status,
and a shared DetailPanelOverlay/Header/Loading/Error shell backs all three
detail panels (adding a previously-missing error state to the task panel).
This commit is contained in:
2026-07-18 02:15:55 +08:00
parent cd3628a255
commit 26807984ee
34 changed files with 714 additions and 1146 deletions
+52 -180
View File
@@ -15,35 +15,11 @@
</Button>
<!-- Context Filter Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'all'}"
@click="$emit('update:context-filter', 'all')"
class="h-7 px-2"
>
All
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted':contextFilter === 'shots'}"
@click="$emit('update:context-filter', 'shots')"
class="h-7 px-2"
>
<Film class="h-4 w-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{'bg-muted': contextFilter === 'assets'}"
@click="$emit('update:context-filter', 'assets')"
class="h-7 px-2"
>
<Package class="h-4 w-4 mr-1" />
</Button>
</div>
<SegmentedToggle
:options="contextFilterOptions"
:model-value="contextFilter"
@update:model-value="$emit('update:context-filter', $event as 'all' | 'shots' | 'assets')"
/>
<!-- Status Filter -->
<Popover>
<PopoverTrigger as-child>
@@ -65,24 +41,15 @@
<CommandList>
<CommandEmpty>No status found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="status in statusOptions"
:key="status.value"
:value="status.value"
@select="toggleStatusFilter(status.value)"
:model-value="statusFilter.includes(status.value)"
@update:model-value="toggleStatusFilter(status.value)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
statusFilter.includes(status.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ status.label }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -110,24 +77,15 @@
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="type in taskTypes"
:key="type"
:value="type"
@select="toggleTypeFilter(type)"
:model-value="typeFilter.includes(type)"
@update:model-value="toggleTypeFilter(type)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
typeFilter.includes(type)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="capitalize">{{ type.replace(/_/g, ' ') }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -155,40 +113,22 @@
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
value="all"
@select="$emit('update:episode-filter', null)"
:model-value="episodeFilter === null"
@update:model-value="$emit('update:episode-filter', null)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === null
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>All Episodes</span>
</CommandItem>
<CommandItem
</CheckableCommandItem>
<CheckableCommandItem
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$emit('update:episode-filter', episode.id)"
:model-value="episodeFilter === episode.id"
@update:model-value="$emit('update:episode-filter', episode.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
episodeFilter === episode.id
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ episode.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -216,24 +156,15 @@
<CommandList>
<CommandEmpty>No assignee found.</CommandEmpty>
<CommandGroup>
<CommandItem
<CheckableCommandItem
v-for="assignee in assignees"
:key="assignee.id"
:value="assignee.id.toString()"
@select="toggleAssigneeFilter(assignee.id)"
:model-value="assigneeFilter.includes(assignee.id)"
@update:model-value="toggleAssigneeFilter(assignee.id)"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
assigneeFilter.includes(assignee.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ assignee.name }}</span>
</CommandItem>
</CheckableCommandItem>
</CommandGroup>
</CommandList>
</Command>
@@ -241,64 +172,19 @@
</Popover>
<!-- Column Visibility -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="ml-auto h-8 border-dashed">
<Settings2 class="mr-2 h-4 w-4" />
View
<Badge
v-if="hiddenColumnsCount > 0"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
{{ hiddenColumnsCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="end">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="column in allColumns"
:key="column.id"
:value="column.id"
@select="toggleColumn(column.id, !(columnVisibility[column.id] !== false))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
columnVisibility[column.id] !== false
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span>{{ column.label }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div class="ml-auto">
<ColumnToggleList
:columns="allColumns"
:model-value="columnVisibility"
@update:model-value="$emit('update:column-visibility', $event)"
/>
</div>
<!-- Detail Panel Enable/Disable Toggle Button -->
<Button
@click="$emit('toggle-detail-panel')"
:variant="isDetailPanelEnabled ? 'default' : 'outline'"
size="sm"
:class="[
'h-8 w-8 p-0',
isDetailPanelEnabled ? 'bg-primary text-primary-foreground hover:bg-primary/90' : ''
]"
:title="isDetailPanelEnabled ? 'Disable Auto Detail Panel' : 'Enable Auto Detail Panel'"
>
<PanelRightClose v-if="isDetailPanelEnabled" class="h-4 w-4" />
<PanelRightOpen v-else class="h-4 w-4" />
</Button>
<DetailPanelToggleButton
:enabled="isDetailPanelEnabled"
@toggle="$emit('toggle-detail-panel')"
/>
<!-- Search -->
<div class="relative flex-1">
@@ -312,23 +198,14 @@
</div>
<!-- Clear Filters -->
<Button
v-if="hasFilters"
variant="ghost"
size="sm"
class="h-8 px-2 lg:px-3"
@click="clearFilters"
>
Reset
<X class="ml-2 h-4 w-4" />
</Button>
<ClearFiltersButton v-if="hasFilters" @clear="clearFilters" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User, Settings2, Check, X, PanelRightClose, PanelRightOpen } from 'lucide-vue-next'
import { computed } from 'vue'
import { Search, ListFilter, Tag, Film, Package, User } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
@@ -342,9 +219,14 @@ import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CheckableCommandItem,
} from '@/components/ui/command'
import ColumnToggleList from '@/components/shared/ColumnToggleList.vue'
import SegmentedToggle from '@/components/shared/SegmentedToggle.vue'
import DetailPanelToggleButton from '@/components/shared/DetailPanelToggleButton.vue'
import ClearFiltersButton from '@/components/shared/ClearFiltersButton.vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
@@ -387,6 +269,12 @@ const statusOptions = [
{ value: 'retake', label: 'Retake' },
]
const contextFilterOptions = [
{ value: 'all', label: 'All' },
{ value: 'shots', icon: Film },
{ value: 'assets', icon: Package },
]
// Column definitions
const allColumns = [
{ id: 'name', label: 'Task Name' },
@@ -414,19 +302,8 @@ const hasFilters = computed(() => {
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.filter(col => props.columnVisibility[col.id] === false).length
})
// Debounced search
let searchTimeout: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = (value: string | number) => {
const searchValue = typeof value === 'string' ? value : String(value)
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
emit('update:search', searchValue)
}, 300)
}
const { debouncedSearch } = useDebouncedSearch((value) => emit('update:search', value))
// Methods
const toggleStatusFilter = (status: string) => {
@@ -450,11 +327,6 @@ const toggleAssigneeFilter = (assigneeId: number) => {
emit('update:assignee-filter', newFilter)
}
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleMyTasksFilter = () => {
const newValue = !props.myTasksFilter
emit('update:my-tasks-filter', newValue)