e628c99498
Shot/Asset/Task data tables now keep their header row pinned while scrolling (both the locked two-pane and single-table modes), matching a common data- table expectation that was missing everywhere. Brings Asset and Task browsers to full structural parity with Shot's bounded- height layout (fixed toolbar, internally-scrolling table) instead of their previous page-scroll model with a sticky-positioned toolbar. AssetsDataTable gained the synced frozen/movable-pane scrolling it was missing entirely; TasksDataTable and both browsers/parent views got the same container model. The global My Tasks page (/tasks) now reuses TaskTableToolbar and TasksDataTable instead of its own ad hoc filter selects and TaskList, including the detail panel, bulk status/assignment context menu, and sticky header for free. Since it spans multiple projects, TaskTableToolbar gained an optional Project filter (only rendered when a project list is passed in, so the project-scoped Tasks page is unaffected); episode/assignee filters populate once a specific project is chosen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
436 lines
15 KiB
Vue
436 lines
15 KiB
Vue
<template>
|
|
<div class="px-4 h-full flex flex-col">
|
|
<div class="rounded-md border flex-1 min-h-0 overflow-hidden">
|
|
<!-- 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 class="sticky top-0 z-10 bg-background">
|
|
<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 class="sticky top-0 z-10 bg-background">
|
|
<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 class="sticky top-0 z-10 bg-background">
|
|
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
|
<TableHead
|
|
v-for="header in headerGroup.headers"
|
|
:key="header.id"
|
|
v-show="header.column.getIsVisible()"
|
|
:class="[
|
|
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-muted/50' : '',
|
|
header.column.id === 'select' ? 'w-12' : '',
|
|
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>
|
|
<template v-if="table.getRowModel().rows?.length">
|
|
<TableRow
|
|
v-for="row in table.getRowModel().rows"
|
|
:key="row.id"
|
|
:data-state="row.getIsSelected() ? 'selected' : undefined"
|
|
class="cursor-pointer hover:bg-muted/50"
|
|
: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 row.getAllCells()"
|
|
:key="cell.id"
|
|
v-show="cell.column.getIsVisible()"
|
|
v-memo="[cell.getValue(), cell.column.getIsVisible()]"
|
|
>
|
|
<FlexRender
|
|
:render="cell.column.columnDef.cell"
|
|
:props="cell.getContext()"
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
<template v-else>
|
|
<TableRow>
|
|
<TableCell :colspan="columns.length" class="h-24 text-center">
|
|
No results.
|
|
</TableCell>
|
|
</TableRow>
|
|
</template>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
|
|
import { ref, computed, watch } from 'vue'
|
|
import {
|
|
FlexRender,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
useVueTable,
|
|
type ColumnDef,
|
|
type HeaderGroup,
|
|
type Row,
|
|
type SortingState,
|
|
type VisibilityState,
|
|
} from '@tanstack/vue-table'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { type Shot } from '@/services/shot'
|
|
|
|
interface Props {
|
|
columns: ColumnDef<Shot>[]
|
|
data: Shot[]
|
|
sorting: SortingState
|
|
columnVisibility: VisibilityState
|
|
allTaskTypes: string[]
|
|
lockColumns?: boolean
|
|
}
|
|
|
|
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<{
|
|
'update:sorting': [sorting: SortingState]
|
|
'update:columnVisibility': [visibility: VisibilityState]
|
|
'update:rowSelection': [selection: Record<string, boolean>]
|
|
'row-click': [shot: Shot, event: MouseEvent]
|
|
'row-dblclick': [shot: Shot]
|
|
'selection-cleared': []
|
|
}>()
|
|
|
|
// Track the last selected row index for range selection
|
|
const lastSelectedIndex = ref<number | null>(null)
|
|
const isRangeSelecting = ref(false)
|
|
const rowSelection = ref<Record<string, boolean>>({})
|
|
|
|
const table = useVueTable({
|
|
get data() {
|
|
return props.data
|
|
},
|
|
get columns() {
|
|
return props.columns
|
|
},
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
enableRowSelection: true,
|
|
enableMultiRowSelection: true,
|
|
getRowId: (row) => String(row.id),
|
|
onSortingChange: (updaterOrValue) => {
|
|
const newSorting =
|
|
typeof updaterOrValue === 'function'
|
|
? updaterOrValue(props.sorting)
|
|
: updaterOrValue
|
|
emit('update:sorting', newSorting)
|
|
},
|
|
onColumnVisibilityChange: (updaterOrValue) => {
|
|
const newVisibility =
|
|
typeof updaterOrValue === 'function'
|
|
? updaterOrValue(props.columnVisibility)
|
|
: updaterOrValue
|
|
emit('update:columnVisibility', newVisibility)
|
|
},
|
|
// Re-add the onRowSelectionChange callback but make it work with our custom logic
|
|
onRowSelectionChange: (updaterOrValue) => {
|
|
const newSelection =
|
|
typeof updaterOrValue === 'function'
|
|
? updaterOrValue(rowSelection.value)
|
|
: updaterOrValue
|
|
rowSelection.value = newSelection
|
|
},
|
|
state: {
|
|
get sorting() {
|
|
return props.sorting
|
|
},
|
|
get columnVisibility() {
|
|
return props.columnVisibility
|
|
},
|
|
get rowSelection() {
|
|
return rowSelection.value
|
|
},
|
|
},
|
|
})
|
|
|
|
const handleRowClick = (shot: Shot, event: MouseEvent, row: any) => {
|
|
// If double-click handler will handle it, skip selection logic
|
|
if (event.detail === 2) {
|
|
return
|
|
}
|
|
|
|
// Check if we clicked on an interactive element (simplified check)
|
|
const target = event.target as HTMLElement
|
|
if (target) {
|
|
// Check if we clicked on a button, checkbox, or other interactive element
|
|
const interactiveElement = target.closest('button, input, select, textarea, a[href], [role="button"], [role="menuitem"]')
|
|
if (interactiveElement) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Handle selection based on modifier keys
|
|
handleRowSelection(row, event)
|
|
emit('row-click', shot, event)
|
|
}
|
|
|
|
const handleRowSelection = (row: any, event: MouseEvent) => {
|
|
const currentIndex = row.index
|
|
const allRows = table.getRowModel().rows
|
|
const shotId = String(row.id)
|
|
|
|
if (event.shiftKey && lastSelectedIndex.value !== null) {
|
|
// Prevent text selection when shift-clicking
|
|
event.preventDefault()
|
|
|
|
// Range selection
|
|
const startIndex = Math.min(lastSelectedIndex.value, currentIndex)
|
|
const endIndex = Math.max(lastSelectedIndex.value, currentIndex)
|
|
|
|
// Create new selection object
|
|
const newSelection: Record<string, boolean> = {}
|
|
|
|
// Select all rows in the range
|
|
for (let i = startIndex; i <= endIndex; i++) {
|
|
if (allRows[i]) {
|
|
newSelection[allRows[i].id] = true
|
|
}
|
|
}
|
|
|
|
rowSelection.value = newSelection
|
|
lastSelectedIndex.value = currentIndex
|
|
} else if (event.ctrlKey || event.metaKey) {
|
|
// Ctrl/Cmd + Click: Toggle individual selection (additional selection)
|
|
const newSelection: Record<string, boolean> = { ...rowSelection.value }
|
|
|
|
if (newSelection[shotId]) {
|
|
// Row is selected, deselect it
|
|
delete newSelection[shotId]
|
|
} else {
|
|
// Row is not selected, select it
|
|
newSelection[shotId] = true
|
|
}
|
|
|
|
rowSelection.value = newSelection
|
|
lastSelectedIndex.value = currentIndex
|
|
} else {
|
|
// Default: Single selection (clear others and select this one)
|
|
// This applies even if the row is already selected - it becomes the only selection
|
|
rowSelection.value = { [shotId]: true }
|
|
lastSelectedIndex.value = currentIndex
|
|
}
|
|
}
|
|
|
|
const handleMouseDown = (event: MouseEvent) => {
|
|
// Detect if this is a range selection operation
|
|
if (event.shiftKey) {
|
|
isRangeSelecting.value = true
|
|
// Prevent text selection immediately
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const handleMouseUp = () => {
|
|
// Reset range selecting state
|
|
isRangeSelecting.value = false
|
|
}
|
|
|
|
// Watch rowSelection changes and emit selection-change events
|
|
watch(
|
|
rowSelection,
|
|
(newSelection) => {
|
|
emit('update:rowSelection', newSelection)
|
|
},
|
|
{ deep: true }
|
|
)
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Prevent text selection during range selection */
|
|
.table-row-selectable.selecting {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Prevent text selection on shift key operations */
|
|
.table-row-selectable {
|
|
-webkit-touch-callout: none;
|
|
-webkit-user-select: none;
|
|
-khtml-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
|
|
/* Allow text selection for specific elements that should be selectable */
|
|
.table-row-selectable input,
|
|
.table-row-selectable textarea,
|
|
.table-row-selectable [contenteditable] {
|
|
-webkit-user-select: text;
|
|
-moz-user-select: text;
|
|
-ms-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>
|