26807984ee
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).
180 lines
5.2 KiB
Vue
180 lines
5.2 KiB
Vue
<template>
|
|
<Card
|
|
class="group hover:shadow-md transition-all duration-200 cursor-pointer border-border/50 hover:border-border"
|
|
@click="$emit('select', shot)"
|
|
>
|
|
<CardHeader class="pb-3">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
|
<div class="flex-shrink-0">
|
|
<Camera class="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<CardTitle class="text-base truncate">{{ shot.name }}</CardTitle>
|
|
<p class="text-sm text-muted-foreground">
|
|
Frames {{ shot.frame_start }}-{{ shot.frame_end }}
|
|
<span class="ml-2">({{ frameCount }} frames)</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions Menu -->
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild @click.stop>
|
|
<Button variant="ghost" size="icon-sm" class="opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<MoreHorizontal class="h-4 w-4" />
|
|
<span class="sr-only">Shot actions</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem @click.stop="$emit('edit', shot)">
|
|
<Edit class="h-4 w-4 mr-2" />
|
|
Edit Shot
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem @click.stop="$emit('select', shot)">
|
|
<ListTodo class="h-4 w-4 mr-2" />
|
|
View Details
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
@click.stop="$emit('delete', shot)"
|
|
class="text-destructive focus:text-destructive"
|
|
>
|
|
<Trash2 class="h-4 w-4 mr-2" />
|
|
Delete Shot
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="pt-0">
|
|
<!-- Status Badge -->
|
|
<div class="flex items-center justify-between mb-3">
|
|
<Badge :variant="getStatusVariant(shot.status)" class="text-xs">
|
|
<div
|
|
class="w-2 h-2 rounded-full mr-1"
|
|
:class="getStatusColor(shot.status)"
|
|
></div>
|
|
{{ formatStatus(shot.status) }}
|
|
</Badge>
|
|
|
|
<!-- Task Count -->
|
|
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
|
<ListTodo class="h-3 w-3" />
|
|
<span>{{ shot.task_count }} tasks</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<p
|
|
v-if="shot.description"
|
|
class="text-sm text-muted-foreground line-clamp-2 mb-3"
|
|
>
|
|
{{ shot.description }}
|
|
</p>
|
|
|
|
<!-- Metadata -->
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>Created {{ formatDate(shot.created_at) }}</span>
|
|
<span v-if="shot.updated_at !== shot.created_at">
|
|
Updated {{ formatDate(shot.updated_at) }}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { Camera, MoreHorizontal, Edit, ListTodo, Trash2 } from 'lucide-vue-next'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { ShotStatus, type Shot } from '@/services/shot'
|
|
|
|
interface Props {
|
|
shot: Shot
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'select', shot: Shot): void
|
|
(e: 'edit', shot: Shot): void
|
|
(e: 'delete', shot: Shot): void
|
|
(e: 'view-tasks', shot: Shot): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
defineEmits<Emits>()
|
|
|
|
// Computed properties
|
|
const frameCount = computed(() => {
|
|
return props.shot.frame_end - props.shot.frame_start + 1
|
|
})
|
|
|
|
// Methods
|
|
const formatStatus = (status: ShotStatus) => {
|
|
return status.split('_').map(word =>
|
|
word.charAt(0).toUpperCase() + word.slice(1)
|
|
).join(' ')
|
|
}
|
|
|
|
const getStatusVariant = (status: ShotStatus) => {
|
|
switch (status) {
|
|
case ShotStatus.NOT_STARTED:
|
|
return 'secondary'
|
|
case ShotStatus.IN_PROGRESS:
|
|
return 'default'
|
|
case ShotStatus.ON_HOLD:
|
|
return 'outline'
|
|
case ShotStatus.COMPLETED:
|
|
return 'default'
|
|
case ShotStatus.APPROVED:
|
|
return 'default'
|
|
default:
|
|
return 'secondary'
|
|
}
|
|
}
|
|
|
|
const getStatusColor = (status: ShotStatus) => {
|
|
switch (status) {
|
|
case ShotStatus.NOT_STARTED:
|
|
return 'bg-gray-400'
|
|
case ShotStatus.IN_PROGRESS:
|
|
return 'bg-blue-500'
|
|
case ShotStatus.ON_HOLD:
|
|
return 'bg-yellow-500'
|
|
case ShotStatus.COMPLETED:
|
|
return 'bg-green-500'
|
|
case ShotStatus.APPROVED:
|
|
return 'bg-emerald-600'
|
|
default:
|
|
return 'bg-gray-400'
|
|
}
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
const date = new Date(dateString)
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style> |