d44398033a
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>
385 lines
13 KiB
Vue
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> |