Files
LinkDesk/frontend/src/components/shot/ShotTableToolbar.vue
T
indigo d44398033a Add shot table column lock with two-pane horizontal scroll
Add a lock toggle to the shot table toolbar that freezes the
checkbox, thumbnail, and shot name columns. When locked, the table
splits into a fixed left pane and a horizontally scrollable right
pane so the scrollbar spans only the movable columns. Constrain the
table to fill the viewport height so the scrollbar stays at the
screen bottom, and add min-w-0 to the layout to keep horizontal
overflow inside the table instead of the page.

Also fix the asset Task Status filter to render task type groups by
using the object-aware TaskStatusBadge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:04:55 +08:00

385 lines
13 KiB
Vue

<template>
<div class="flex flex-col gap-4">
<!-- Main Toolbar Row -->
<div class="flex items-center justify-between gap-4">
<!-- Left Side - Filters -->
<div class="flex flex-wrap gap-2">
<!-- View Toggle -->
<div class="flex items-center border rounded-md h-8 p-0.5">
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'grid' }"
@click="$emit('update:view-mode', 'grid')"
class="h-7 px-2"
>
<LayoutGrid class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'list' }"
@click="$emit('update:view-mode', 'list')"
class="h-7 px-2"
>
<List class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
:class="{ 'bg-muted': viewMode === 'table' }"
@click="$emit('update:view-mode', 'table')"
class="h-7 px-2"
>
<Table2 class="h-4 w-4" />
</Button>
</div>
<!-- Episode Filter -->
<Popover v-if="episodes.length > 0">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="h-8 border-dashed">
<Film class="mr-2 h-4 w-4" />
Episode
<Badge
v-if="episodeFilter !== null"
variant="secondary"
class="ml-2 rounded-sm px-1 font-normal"
>
1
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="Search episode..." />
<CommandList>
<CommandEmpty>No episode found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="all"
@select="$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
v-for="episode in episodes"
:key="episode.id"
:value="episode.id.toString()"
@select="$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>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Task Status Filter (only for table view) -->
<ShotTaskStatusFilter
v-if="viewMode === 'table'"
:all-task-types="allTaskTypes"
:project-id="projectId"
@filter-changed="$emit('task-status-filter-changed', $event)"
/>
<!-- Column Visibility Control (only for table view) -->
<Popover v-if="viewMode === 'table'">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="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>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'default'"
: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>
</template>
</CommandGroup>
<CommandGroup>
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
All Task Types
</div>
<CommandSeparator />
<template v-for="column in allColumns">
<CommandItem
v-if="column.type == 'task'"
: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>
</template>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Task Columns Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
variant="outline"
size="sm"
@click="toggleAllTaskColumns"
class="h-8"
>
<ListTodo v-if="!allTaskColumnsVisible" class="h-4 w-4 mr-2" />
<ListX v-else class="h-4 w-4 mr-2" />
{{ allTaskColumnsVisible ? 'Hide' : 'Show' }} Tasks
</Button>
<!-- Detail Panel Enable/Disable Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
@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>
<!-- Lock First Columns Toggle Button (only for table view) -->
<Button
v-if="viewMode === 'table'"
@click="$emit('toggle-column-lock')"
:variant="isColumnsLocked ? 'default' : 'outline'"
size="sm"
:class="['h-8 w-8 p-0', isColumnsLocked ? 'bg-primary text-primary-foreground hover:bg-primary/90' : '']"
:title="isColumnsLocked ? 'Unlock columns' : 'Lock first columns'"
>
<Lock v-if="isColumnsLocked" class="h-4 w-4" />
<Unlock v-else class="h-4 w-4" />
</Button>
<!-- 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>
</div>
<!-- Right Side - Search and Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
<!-- Search -->
<div class="relative w-64">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
:model-value="search"
@update:model-value="debouncedSearch"
placeholder="Search shots..."
class="pl-9 h-8"
/>
</div>
<!-- Action Buttons -->
<Button @click="$emit('bulk-create')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Layers class="h-4 w-4" />
</Button>
<Button @click="$emit('create-shot')" size="sm" class="h-8 w-8 p-0">
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX, Lock, Unlock
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import ShotTaskStatusFilter from './ShotTaskStatusFilter.vue'
import type { VisibilityState } from '@tanstack/vue-table'
import type { Episode } from '@/services/episode'
import type { Shot } from '@/services/shot'
interface Props {
viewMode: 'grid' | 'list' | 'table'
episodeFilter: number | null
search: string
columnVisibility: VisibilityState
episodes: Episode[]
allTaskTypes: string[]
projectId: number
selectedShot: Shot | null
isDetailPanelEnabled: boolean
isColumnsLocked: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:view-mode': [value: 'grid' | 'list' | 'table']
'update:episode-filter': [value: number | null]
'update:search': [value: string]
'update:column-visibility': [value: VisibilityState]
'task-status-filter-changed': [value: string]
'toggle-detail-panel': []
'toggle-column-lock': []
'toggle-task-columns': []
'bulk-create': []
'create-shot': []
}>()
// Column definitions - computed to be reactive to allTaskTypes changes
const allColumns = computed(() => [
{ id: 'thumbnail', label: 'Thumbnail', type: 'default' },
{ id: 'name', label: 'Shot Name', type: 'default' },
{ id: 'episode', label: 'Episode', type: 'default' },
{ id: 'frames', label: 'Frames', type: 'default' },
{ id: 'status', label: 'Status', type: 'default' },
...props.allTaskTypes.map(taskType => ({
id: taskType,
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
type: 'task'
})),
])
// Computed
const hasFilters = computed(() => {
return (
props.episodeFilter !== null ||
props.search !== ''
)
})
const hiddenColumnsCount = computed(() => {
return allColumns.value.filter(col => props.columnVisibility[col.id] === false).length
})
// Check if all task columns are visible
const allTaskColumnsVisible = computed(() => {
const taskColumns = allColumns.value.filter(col => col.type === 'task')
return taskColumns.length > 0 && taskColumns.every(col => props.columnVisibility[col.id] !== false)
})
// 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)
}
// Methods
const toggleColumn = (columnId: string, value: any) => {
const newVisibility = { ...props.columnVisibility, [columnId]: value as boolean }
emit('update:column-visibility', newVisibility)
}
const toggleAllTaskColumns = () => {
const newVisibility = { ...props.columnVisibility }
const taskColumns = allColumns.value.filter(col => col.type === 'task')
// If all task columns are visible, hide them; otherwise show them
const shouldHide = allTaskColumnsVisible.value
taskColumns.forEach(col => {
newVisibility[col.id] = !shouldHide
})
emit('update:column-visibility', newVisibility)
}
const clearFilters = () => {
emit('update:episode-filter', null)
emit('update:search', '')
}
</script>