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>
This commit is contained in:
@@ -91,6 +91,7 @@
|
|||||||
<!-- Task Status Filter (only for list view) -->
|
<!-- Task Status Filter (only for list view) -->
|
||||||
<TaskStatusFilter
|
<TaskStatusFilter
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
|
:all-task-types="allTaskTypes"
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
@filter-changed="$emit('task-status-filter-changed', $event)"
|
@filter-changed="$emit('task-status-filter-changed', $event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,149 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<Popover>
|
||||||
<Select v-model="selectedFilter" @update:model-value="handleFilterChange">
|
<PopoverTrigger as-child>
|
||||||
<SelectTrigger class="w-[200px]">
|
<Button variant="outline" size="sm" class="h-8 border-dashed">
|
||||||
<SelectValue placeholder="Filter by task status" />
|
<ListFilter class="mr-2 h-4 w-4" />
|
||||||
</SelectTrigger>
|
Task Status
|
||||||
<SelectContent>
|
<Badge
|
||||||
<SelectItem value="all">All Tasks</SelectItem>
|
v-if="selectedFilters.length > 0"
|
||||||
<SelectGroup>
|
variant="secondary"
|
||||||
<SelectLabel>Modeling</SelectLabel>
|
class="ml-2 rounded-sm px-1 font-normal"
|
||||||
<SelectItem
|
>
|
||||||
v-for="status in allStatuses"
|
{{ selectedFilters.length }}
|
||||||
:key="`modeling:${status.id}`"
|
</Badge>
|
||||||
:value="`modeling:${status.id}`"
|
</Button>
|
||||||
>
|
</PopoverTrigger>
|
||||||
<div class="flex items-center gap-2">
|
<PopoverContent class="w-[220px] p-0" align="start">
|
||||||
<TaskStatusBadge :status="status.id as any" />
|
<Command>
|
||||||
<span>Modeling - {{ status.name }}</span>
|
<CommandInput placeholder="Search task status..." />
|
||||||
</div>
|
<CommandList>
|
||||||
</SelectItem>
|
<CommandEmpty>No status found.</CommandEmpty>
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Surfacing</SelectLabel>
|
|
||||||
<SelectItem
|
|
||||||
v-for="status in allStatuses"
|
|
||||||
:key="`surfacing:${status.id}`"
|
|
||||||
:value="`surfacing:${status.id}`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<TaskStatusBadge :status="status as any" />
|
|
||||||
<span>Surfacing - {{ status.name }}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Rigging</SelectLabel>
|
|
||||||
<SelectItem
|
|
||||||
v-for="status in allStatuses"
|
|
||||||
:key="`rigging:${status.id}`"
|
|
||||||
:value="`rigging:${status.id}`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<TaskStatusBadge :status="status as any" />
|
|
||||||
<span>Rigging - {{ status.name }}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
<!-- All Tasks Option -->
|
||||||
v-if="selectedFilter && selectedFilter !== 'all'"
|
<CommandGroup>
|
||||||
variant="ghost"
|
<CommandItem value="all" @select="clearAllFilters">
|
||||||
size="sm"
|
<div
|
||||||
@click="clearFilter"
|
:class="[
|
||||||
class="h-8 px-2"
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||||
>
|
selectedFilters.length === 0
|
||||||
<X class="h-4 w-4" />
|
? 'bg-primary text-primary-foreground'
|
||||||
</Button>
|
: 'opacity-50 [&_svg]:invisible'
|
||||||
</div>
|
]"
|
||||||
|
>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span>All Tasks</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
|
||||||
|
<!-- Task Type Groups -->
|
||||||
|
<CommandGroup v-for="taskType in allTaskTypes" :key="taskType">
|
||||||
|
<CommandSeparator />
|
||||||
|
<div class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{{ formatTaskType(taskType) }}
|
||||||
|
</div>
|
||||||
|
<CommandItem
|
||||||
|
v-for="status in allStatuses"
|
||||||
|
:key="`${taskType}:${status.id}`"
|
||||||
|
:value="`${taskType}:${status.id}`"
|
||||||
|
@select="toggleFilter(`${taskType}:${status.id}`)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
||||||
|
selectedFilters.includes(`${taskType}:${status.id}`)
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'opacity-50 [&_svg]:invisible'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<TaskStatusBadge :status="status as any" />
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { X } from 'lucide-vue-next'
|
import { ListFilter, Check } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
Select,
|
Popover,
|
||||||
SelectContent,
|
PopoverContent,
|
||||||
SelectGroup,
|
PopoverTrigger,
|
||||||
SelectItem,
|
} from '@/components/ui/popover'
|
||||||
SelectLabel,
|
import {
|
||||||
SelectTrigger,
|
Command,
|
||||||
SelectValue,
|
CommandEmpty,
|
||||||
} from '@/components/ui/select'
|
CommandGroup,
|
||||||
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import TaskStatusBadge from '@/components/status/TaskStatusBadge.vue'
|
||||||
import { TaskStatus } from '@/services/asset'
|
import { TaskStatus } from '@/services/asset'
|
||||||
import { customTaskStatusService, type CustomTaskStatus, type SystemTaskStatus } from '@/services/customTaskStatus'
|
|
||||||
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
import { useTaskStatusesStore } from '@/stores/taskStatuses'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
allTaskTypes: string[]
|
||||||
projectId?: number
|
projectId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'filter-changed', filter: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// Use the shared task statuses store
|
const emit = defineEmits<{
|
||||||
|
'filter-changed': [filter: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
const taskStatusesStore = useTaskStatusesStore()
|
const taskStatusesStore = useTaskStatusesStore()
|
||||||
|
const selectedFilters = ref<string[]>([])
|
||||||
|
|
||||||
const selectedFilter = ref('all')
|
|
||||||
|
|
||||||
// Get loading state from store
|
|
||||||
const isLoading = computed(() => {
|
|
||||||
return props.projectId ? taskStatusesStore.isLoading(props.projectId) : false
|
|
||||||
})
|
|
||||||
|
|
||||||
// System status options (fallback if no project ID)
|
|
||||||
const defaultStatusOptions = [
|
const defaultStatusOptions = [
|
||||||
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
{ id: TaskStatus.NOT_STARTED, name: 'Not Started', color: '', is_system: true },
|
||||||
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
{ id: TaskStatus.IN_PROGRESS, name: 'In Progress', color: '', is_system: true },
|
||||||
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
{ id: TaskStatus.SUBMITTED, name: 'Submitted', color: '', is_system: true },
|
||||||
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
{ id: TaskStatus.APPROVED, name: 'Approved', color: '', is_system: true },
|
||||||
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true }
|
{ id: TaskStatus.RETAKE, name: 'Retake', color: '', is_system: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Combine system and custom statuses
|
|
||||||
const allStatuses = computed(() => {
|
const allStatuses = computed(() => {
|
||||||
if (!props.projectId) {
|
if (!props.projectId) return defaultStatusOptions
|
||||||
return defaultStatusOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
const statusData = taskStatusesStore.getProjectStatuses(props.projectId)
|
||||||
if (!statusData) {
|
if (!statusData) return defaultStatusOptions
|
||||||
return defaultStatusOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert system statuses to the format expected by TaskStatusBadge
|
const systemStatusList = statusData.system_statuses.map(s => ({
|
||||||
const systemStatusList = statusData.system_statuses.map(status => ({
|
id: s.id, name: s.name, color: s.color, is_system: s.is_system,
|
||||||
id: status.id,
|
|
||||||
name: status.name,
|
|
||||||
color: status.color,
|
|
||||||
is_system: status.is_system
|
|
||||||
}))
|
}))
|
||||||
|
const customStatusList = statusData.statuses.map(s => ({
|
||||||
// Convert custom statuses to the format expected by TaskStatusBadge
|
id: s.id, name: s.name, color: s.color, is_system: false,
|
||||||
const customStatusList = statusData.statuses.map(status => ({
|
|
||||||
id: status.id,
|
|
||||||
name: status.name,
|
|
||||||
color: status.color,
|
|
||||||
is_system: false
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [...systemStatusList, ...customStatusList]
|
return [...systemStatusList, ...customStatusList]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load custom statuses when component mounts or projectId changes
|
|
||||||
const loadStatuses = async () => {
|
const loadStatuses = async () => {
|
||||||
if (!props.projectId) {
|
if (!props.projectId) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
await taskStatusesStore.fetchProjectStatuses(props.projectId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -151,25 +138,25 @@ const loadStatuses = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(loadStatuses)
|
||||||
loadStatuses()
|
watch(() => props.projectId, loadStatuses)
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.projectId, () => {
|
const toggleFilter = (filter: string) => {
|
||||||
loadStatuses()
|
const index = selectedFilters.value.indexOf(filter)
|
||||||
})
|
if (index > -1) {
|
||||||
|
selectedFilters.value.splice(index, 1)
|
||||||
const handleFilterChange = (filter: any) => {
|
} else {
|
||||||
if (!filter) return
|
selectedFilters.value.push(filter)
|
||||||
const filterStr = String(filter)
|
}
|
||||||
selectedFilter.value = filterStr
|
const apiFilter = selectedFilters.value.length > 0 ? selectedFilters.value.join(',') : ''
|
||||||
// Convert "all" to empty string for the API
|
|
||||||
const apiFilter = filterStr === 'all' ? '' : filterStr
|
|
||||||
emit('filter-changed', apiFilter)
|
emit('filter-changed', apiFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearFilter = () => {
|
const clearAllFilters = () => {
|
||||||
selectedFilter.value = 'all'
|
selectedFilters.value = []
|
||||||
emit('filter-changed', '')
|
emit('filter-changed', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatTaskType = (taskType: string) =>
|
||||||
|
taskType.charAt(0).toUpperCase() + taskType.slice(1)
|
||||||
</script>
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<SidebarInset class="flex-1 flex flex-col">
|
<SidebarInset class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-full">
|
<div class="relative h-full flex flex-col">
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="space-y-4">
|
<div class="flex flex-col flex-1 min-h-0">
|
||||||
<!-- Toolbar - Sticky -->
|
<!-- Toolbar -->
|
||||||
<div class="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
<div class="flex-shrink-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b pb-4 px-4 sm:px-6 pt-4 sm:pt-6 mb-4">
|
||||||
<ShotTableToolbar
|
<ShotTableToolbar
|
||||||
:view-mode="viewMode"
|
:view-mode="viewMode"
|
||||||
:episode-filter="episodeFilter"
|
:episode-filter="episodeFilter"
|
||||||
@@ -14,12 +14,14 @@
|
|||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:selected-shot="selectedShot"
|
:selected-shot="selectedShot"
|
||||||
:is-detail-panel-enabled="isDetailPanelEnabled"
|
:is-detail-panel-enabled="isDetailPanelEnabled"
|
||||||
|
:is-columns-locked="lockColumns"
|
||||||
@update:view-mode="viewMode = $event"
|
@update:view-mode="viewMode = $event"
|
||||||
@update:episode-filter="handleEpisodeFilterChange"
|
@update:episode-filter="handleEpisodeFilterChange"
|
||||||
@update:search="searchQuery = $event"
|
@update:search="searchQuery = $event"
|
||||||
@update:column-visibility="handleColumnVisibilityChange"
|
@update:column-visibility="handleColumnVisibilityChange"
|
||||||
@task-status-filter-changed="handleTaskStatusFilter"
|
@task-status-filter-changed="handleTaskStatusFilter"
|
||||||
@toggle-detail-panel="toggleDetailPanelEnabled"
|
@toggle-detail-panel="toggleDetailPanelEnabled"
|
||||||
|
@toggle-column-lock="toggleColumnLock"
|
||||||
@bulk-create="showBulkCreateDialog = true"
|
@bulk-create="showBulkCreateDialog = true"
|
||||||
@create-shot="showCreateDialog = true"
|
@create-shot="showCreateDialog = true"
|
||||||
/>
|
/>
|
||||||
@@ -76,11 +78,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Shots Grid/List/Table -->
|
<!-- Shots Grid/List/Table -->
|
||||||
<div v-else>
|
<div v-else class="flex-1 min-h-0">
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<div
|
<div
|
||||||
v-if="viewMode === 'grid'"
|
v-if="viewMode === 'grid'"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 px-4 sm:px-6 h-full overflow-auto"
|
||||||
>
|
>
|
||||||
<ShotCard
|
<ShotCard
|
||||||
v-for="shot in filteredShots"
|
v-for="shot in filteredShots"
|
||||||
@@ -93,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6">
|
<div v-else-if="viewMode === 'list'" class="space-y-2 px-4 sm:px-6 h-full overflow-auto">
|
||||||
<div
|
<div
|
||||||
v-for="shot in filteredShots"
|
v-for="shot in filteredShots"
|
||||||
:key="shot.id"
|
:key="shot.id"
|
||||||
@@ -148,6 +150,7 @@
|
|||||||
:sorting="sorting"
|
:sorting="sorting"
|
||||||
:column-visibility="columnVisibility"
|
:column-visibility="columnVisibility"
|
||||||
:all-task-types="allTaskTypes"
|
:all-task-types="allTaskTypes"
|
||||||
|
:lock-columns="lockColumns"
|
||||||
@update:sorting="sorting = $event"
|
@update:sorting="sorting = $event"
|
||||||
@update:column-visibility="handleColumnVisibilityChange"
|
@update:column-visibility="handleColumnVisibilityChange"
|
||||||
@update:rowSelection="handleRowSelectionChange"
|
@update:rowSelection="handleRowSelectionChange"
|
||||||
@@ -395,6 +398,13 @@ const columnVisibilityStore = useColumnVisibilityStore()
|
|||||||
const columnVisibility = ref<VisibilityState>({})
|
const columnVisibility = ref<VisibilityState>({})
|
||||||
const rowSelection = ref<Record<string, boolean>>({})
|
const rowSelection = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
|
||||||
|
const lockColumns = ref(localStorage.getItem('shot-columns-locked') === 'true')
|
||||||
|
const toggleColumnLock = () => {
|
||||||
|
lockColumns.value = !lockColumns.value
|
||||||
|
localStorage.setItem('shot-columns-locked', String(lockColumns.value))
|
||||||
|
}
|
||||||
|
|
||||||
// Computed for selected count
|
// Computed for selected count
|
||||||
const selectedCount = computed(() => {
|
const selectedCount = computed(() => {
|
||||||
return Object.keys(rowSelection.value).length
|
return Object.keys(rowSelection.value).length
|
||||||
|
|||||||
@@ -208,6 +208,19 @@
|
|||||||
<PanelRightOpen v-else class="h-4 w-4" />
|
<PanelRightOpen v-else class="h-4 w-4" />
|
||||||
</Button>
|
</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 -->
|
<!-- Clear Filters -->
|
||||||
<Button
|
<Button
|
||||||
v-if="hasFilters"
|
v-if="hasFilters"
|
||||||
@@ -250,7 +263,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
LayoutGrid, List, Table2, Search, Film, Plus, Layers,
|
||||||
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX
|
PanelRightClose, PanelRightOpen, Check, X, Settings2, ListTodo, ListX, Lock, Unlock
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -284,6 +297,7 @@ interface Props {
|
|||||||
projectId: number
|
projectId: number
|
||||||
selectedShot: Shot | null
|
selectedShot: Shot | null
|
||||||
isDetailPanelEnabled: boolean
|
isDetailPanelEnabled: boolean
|
||||||
|
isColumnsLocked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -295,6 +309,7 @@ const emit = defineEmits<{
|
|||||||
'update:column-visibility': [value: VisibilityState]
|
'update:column-visibility': [value: VisibilityState]
|
||||||
'task-status-filter-changed': [value: string]
|
'task-status-filter-changed': [value: string]
|
||||||
'toggle-detail-panel': []
|
'toggle-detail-panel': []
|
||||||
|
'toggle-column-lock': []
|
||||||
'toggle-task-columns': []
|
'toggle-task-columns': []
|
||||||
'bulk-create': []
|
'bulk-create': []
|
||||||
'create-shot': []
|
'create-shot': []
|
||||||
|
|||||||
@@ -1,7 +1,107 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-2 px-4">
|
<div class="px-4 h-full flex flex-col">
|
||||||
<div class="rounded-md border">
|
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
||||||
<Table>
|
<!-- Locked: two-pane layout (fixed left pane + horizontally scrollable right pane) -->
|
||||||
|
<template v-if="lockColumns">
|
||||||
|
<div v-if="hasRows" class="flex h-full">
|
||||||
|
<!-- Left pane: frozen columns (no horizontal scroll, vertical scroll hidden + synced) -->
|
||||||
|
<div ref="leftPane" class="flex-shrink-0 h-full overflow-y-auto scrollbar-hide border-r" @scroll="onLeftScroll">
|
||||||
|
<table class="caption-bottom text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
|
<TableHead
|
||||||
|
v-for="header in frozenHeaders(headerGroup)"
|
||||||
|
:key="header.id"
|
||||||
|
:style="frozenWidth(header.column.id)"
|
||||||
|
:class="header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : ''"
|
||||||
|
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
v-if="!header.isPlaceholder"
|
||||||
|
:render="header.column.columnDef.header"
|
||||||
|
:props="header.getContext()"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="row in table.getRowModel().rows"
|
||||||
|
:key="row.id"
|
||||||
|
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||||
|
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||||
|
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||||
|
@click="handleRowClick(row.original, $event, row)"
|
||||||
|
@dblclick="emit('row-dblclick', row.original)"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
v-for="cell in frozenCells(row)"
|
||||||
|
:key="cell.id"
|
||||||
|
:style="frozenWidth(cell.column.id)"
|
||||||
|
>
|
||||||
|
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right pane: movable columns (own horizontal scrollbar, only shown when needed) -->
|
||||||
|
<div ref="rightPane" class="flex-1 min-w-0 h-full overflow-auto" @scroll="onRightScroll">
|
||||||
|
<table class="min-w-full caption-bottom text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
|
<TableHead
|
||||||
|
v-for="header in movableHeaders(headerGroup)"
|
||||||
|
:key="header.id"
|
||||||
|
:class="[
|
||||||
|
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
||||||
|
header.column.id === 'actions' ? 'w-12' : '',
|
||||||
|
allTaskTypes.includes(header.column.id) ? 'w-[140px]' : '',
|
||||||
|
]"
|
||||||
|
@click="header.column.getCanSort() ? header.column.toggleSorting() : null"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
v-if="!header.isPlaceholder"
|
||||||
|
:render="header.column.columnDef.header"
|
||||||
|
:props="header.getContext()"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="row in table.getRowModel().rows"
|
||||||
|
:key="row.id"
|
||||||
|
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
||||||
|
class="cursor-pointer hover:bg-muted/50 h-12"
|
||||||
|
:class="{ 'bg-muted/30': row.getIsSelected(), 'table-row-selectable': true, 'selecting': isRangeSelecting }"
|
||||||
|
@click="handleRowClick(row.original, $event, row)"
|
||||||
|
@dblclick="emit('row-dblclick', row.original)"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseup="handleMouseUp"
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
v-for="cell in movableCells(row)"
|
||||||
|
:key="cell.id"
|
||||||
|
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
||||||
|
>
|
||||||
|
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-24 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No results.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Unlocked: standard single table -->
|
||||||
|
<Table v-else>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||||
<TableHead
|
<TableHead
|
||||||
@@ -69,13 +169,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
import { ref, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
FlexRender,
|
FlexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
useVueTable,
|
useVueTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
|
type HeaderGroup,
|
||||||
|
type Row,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
} from '@tanstack/vue-table'
|
} from '@tanstack/vue-table'
|
||||||
@@ -95,10 +197,51 @@ interface Props {
|
|||||||
sorting: SortingState
|
sorting: SortingState
|
||||||
columnVisibility: VisibilityState
|
columnVisibility: VisibilityState
|
||||||
allTaskTypes: string[]
|
allTaskTypes: string[]
|
||||||
|
lockColumns?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Frozen (locked) columns and their fixed widths (px), in column order.
|
||||||
|
const FROZEN: Record<string, number> = {
|
||||||
|
select: 48,
|
||||||
|
thumbnail: 96,
|
||||||
|
name: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFrozen = (id: string) => id in FROZEN
|
||||||
|
|
||||||
|
const frozenWidth = (id: string) => {
|
||||||
|
const w = FROZEN[id]
|
||||||
|
return w ? { width: `${w}px`, minWidth: `${w}px` } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column partitioning for the two-pane (locked) layout. Movable headers/cells
|
||||||
|
// respect column visibility; frozen ones are always shown.
|
||||||
|
const frozenHeaders = (group: HeaderGroup<Shot>) =>
|
||||||
|
group.headers.filter((h) => isFrozen(h.column.id) && h.column.getIsVisible())
|
||||||
|
const movableHeaders = (group: HeaderGroup<Shot>) =>
|
||||||
|
group.headers.filter((h) => !isFrozen(h.column.id) && h.column.getIsVisible())
|
||||||
|
const frozenCells = (row: Row<Shot>) =>
|
||||||
|
row.getVisibleCells().filter((c) => isFrozen(c.column.id))
|
||||||
|
const movableCells = (row: Row<Shot>) =>
|
||||||
|
row.getVisibleCells().filter((c) => !isFrozen(c.column.id))
|
||||||
|
|
||||||
|
const hasRows = computed(() => table.getRowModel().rows.length > 0)
|
||||||
|
|
||||||
|
// Sync vertical scroll between the two panes (right pane owns the scrollbars).
|
||||||
|
const leftPane = ref<HTMLElement>()
|
||||||
|
const rightPane = ref<HTMLElement>()
|
||||||
|
let syncing = false
|
||||||
|
const syncScroll = (from?: HTMLElement, to?: HTMLElement) => {
|
||||||
|
if (syncing || !from || !to) return
|
||||||
|
syncing = true
|
||||||
|
to.scrollTop = from.scrollTop
|
||||||
|
requestAnimationFrame(() => { syncing = false })
|
||||||
|
}
|
||||||
|
const onRightScroll = () => syncScroll(rightPane.value, leftPane.value)
|
||||||
|
const onLeftScroll = () => syncScroll(leftPane.value, rightPane.value)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:sorting': [sorting: SortingState]
|
'update:sorting': [sorting: SortingState]
|
||||||
'update:columnVisibility': [visibility: VisibilityState]
|
'update:columnVisibility': [visibility: VisibilityState]
|
||||||
@@ -280,4 +423,13 @@ watch(
|
|||||||
-ms-user-select: text;
|
-ms-user-select: text;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide the left pane's vertical scrollbar (its scroll is synced from the right pane) */
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome/Safari */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const props = defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative w-full overflow-auto">
|
<div class="relative w-full h-full overflow-auto">
|
||||||
<table :class="cn('w-full caption-bottom text-sm', props.class)">
|
<table :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||||
<slot />
|
<slot />
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
|
||||||
<!-- Shot Browser -->
|
<!-- Shot Browser -->
|
||||||
<div v-if="projectId">
|
<div v-if="projectId" class="flex-1 min-h-0 flex flex-col">
|
||||||
<ShotBrowser
|
<ShotBrowser
|
||||||
:project-id="projectId"
|
:project-id="projectId"
|
||||||
:selected-episode-id="selectedEpisodeId ?? undefined"
|
:selected-episode-id="selectedEpisodeId ?? undefined"
|
||||||
|
|||||||
Reference in New Issue
Block a user