Files
LinkDesk/frontend/src/components/asset/AssetBrowser.vue
T
indigo e628c99498 Sticky table headers, layout parity, and reuse My Tasks on the shared table/toolbar
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>
2026-07-18 10:15:04 +08:00

799 lines
25 KiB
Vue

<template>
<div class="relative h-full flex flex-col">
<!-- Main Content -->
<div class="flex flex-col flex-1 min-h-0">
<!-- Toolbar -->
<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">
<AssetTableToolbar
v-if="customTaskTypesLoaded"
:view-mode="viewMode"
:category-filter="selectedCategory"
:search="searchQuery"
:column-visibility="columnVisibility"
:categories="categories"
:all-task-types="allTaskTypes"
:project-id="projectId"
:selected-asset="selectedAsset"
:is-detail-panel-enabled="isDetailPanelEnabled"
:show-thumbnails="showThumbnails"
:is-columns-locked="lockColumns"
@update:view-mode="viewMode = $event"
@update:category-filter="handleCategoryFilterChange"
@update:search="searchQuery = $event"
@update:column-visibility="handleColumnVisibilityChange"
@update:show-thumbnails="handleThumbnailToggle"
@task-status-filter-changed="handleTaskStatusFilter"
@toggle-detail-panel="toggleDetailPanelEnabled"
@toggle-column-lock="toggleColumnLock"
@create-asset="showCreateDialog = true"
/>
<div v-else class="flex items-center justify-center py-4">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Loading toolbar...</span>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"
></div>
<span class="text-muted-foreground">Loading assets...</span>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-center py-12 px-4 sm:px-6">
<AlertCircle class="h-12 w-12 mx-auto text-destructive mb-4" />
<h3 class="text-lg font-semibold mb-2">Failed to load assets</h3>
<p class="text-muted-foreground mb-4">{{ error }}</p>
<Button @click="loadAssets" variant="outline">
<RefreshCw class="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
<!-- Empty State -->
<div
v-else-if="
filteredAssets.length === 0 && !searchQuery && !selectedCategory
"
class="text-center py-12 px-4 sm:px-6"
>
<Package class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets yet</h3>
<p class="text-muted-foreground mb-4">
Create your first asset to get started
</p>
<Button @click="showCreateDialog = true">
<Plus class="h-4 w-4 mr-2" />
Create Asset
</Button>
</div>
<!-- No Results State -->
<div v-else-if="filteredAssets.length === 0" class="text-center py-12 px-4 sm:px-6">
<Search class="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 class="text-lg font-semibold mb-2">No assets found</h3>
<p class="text-muted-foreground mb-4">
Try adjusting your search or filter criteria
</p>
<Button @click="clearFilters" variant="outline"> Clear Filters </Button>
</div>
<!-- Assets Grid/List -->
<div v-else class="flex-1 min-h-0">
<!-- Grid View -->
<div
v-if="viewMode === 'grid'"
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-3 px-4 sm:px-6 h-full overflow-auto"
>
<AssetCard
v-for="asset in filteredAssets"
:key="asset.id"
:asset="asset"
:show-thumbnail="showThumbnails"
@select="selectAsset"
@edit="editAsset"
@delete="deleteAsset"
@view-tasks="viewAssetTasks"
/>
</div>
<!-- Table View -->
<AssetsDataTable
v-else-if="customTaskTypesLoaded"
:columns="assetColumns"
:data="filteredAssets"
:sorting="sorting"
:column-visibility="columnVisibility"
:all-task-types="allTaskTypes"
:lock-columns="lockColumns"
@update:sorting="sorting = $event"
@update:column-visibility="handleColumnVisibilityChange"
@update:rowSelection="handleRowSelectionChange"
@row-click="handleRowClick"
/>
<div v-else class="flex items-center justify-center py-12 px-4 sm:px-6">
<div class="flex items-center gap-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span class="text-muted-foreground">Loading table...</span>
</div>
</div>
</div>
<!-- Create Asset Dialog -->
<Dialog v-model:open="showCreateDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create New Asset</DialogTitle>
<DialogDescription>
Add a new asset to the project. Assets can be characters, props,
sets, or vehicles.
</DialogDescription>
</DialogHeader>
<AssetForm
:project-id="projectId"
:is-loading="isCreating"
@submit="handleCreateAsset"
@cancel="showCreateDialog = false"
/>
</DialogContent>
</Dialog>
<!-- Edit Asset Dialog -->
<Dialog v-model:open="showEditDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Edit Asset</DialogTitle>
<DialogDescription>
Update the asset information and settings.
</DialogDescription>
</DialogHeader>
<AssetForm
:project-id="projectId"
:asset="selectedAsset || undefined"
:is-loading="isUpdating"
@submit="handleUpdateAsset"
@cancel="showEditDialog = false"
/>
</DialogContent>
</Dialog>
<!-- Delete Confirmation Dialog -->
<AssetDeleteConfirmDialog
v-if="deletionInfo && assetToDelete"
:open="showDeleteDialog"
:asset-id="assetToDelete.id"
:asset-name="deletionInfo.asset_name"
@update:open="showDeleteDialog = $event"
@confirm-delete="handleDeleteAsset"
/>
</div>
<!-- Asset Detail Panel (Desktop + Mobile) -->
<DetailPanelOverlay
:visible="!!(showPanel && selectedAsset)"
v-model:mobile-open="showMobileDetail"
>
<TaskDetailPanel
v-if="selectedTaskId"
:key="selectedTaskId"
:task-id="selectedTaskId"
:initial-tab="selectedTaskTab"
@close="selectedTaskId = null"
@task-updated="loadAssets"
/>
<AssetDetailPanel
v-else-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
:all-task-types="allTaskTypes"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@select-task="handleSelectTask"
/>
</DetailPanelOverlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import {
Search,
Plus,
Package,
AlertCircle,
RefreshCw,
Users,
Building,
Car,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import DetailPanelOverlay from "@/components/shared/DetailPanelOverlay.vue";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.vue";
import AssetDeleteConfirmDialog from "./AssetDeleteConfirmDialog.vue";
import TaskDetailPanel from "@/components/task/TaskDetailPanel.vue";
import AssetsDataTable from "./AssetsDataTable.vue";
import AssetTableToolbar from "./AssetTableToolbar.vue";
import { createAssetColumns, type AssetColumnMeta } from "./columns";
import { useAssetsStore } from "@/stores/assets";
import { useAuthStore } from "@/stores/auth";
import { useTaskStatusesStore } from "@/stores/taskStatuses";
import { useDetailPanel } from "@/composables/useDetailPanel";
import {
assetService,
AssetCategory,
TaskStatus,
type Asset,
type AssetCreate,
type AssetUpdate,
type AssetDeletionInfo,
} from "@/services/asset";
import { useToast } from "@/components/ui/toast/use-toast";
import type { SortingState, VisibilityState } from '@tanstack/vue-table';
interface Props {
projectId: number;
}
const props = defineProps<Props>();
// Stores and composables
const assetsStore = useAssetsStore();
const authStore = useAuthStore();
const taskStatusesStore = useTaskStatusesStore();
const { toast } = useToast();
// TanStack Table state
const sorting = ref<SortingState>([])
const columnVisibility = ref<VisibilityState>({})
const rowSelection = ref<Record<string, boolean>>({})
// Computed for selected count
const selectedCount = computed(() => {
return Object.keys(rowSelection.value).length
})
const initializeColumnVisibility = () => {
const stored = sessionStorage.getItem('assetBrowser.columnVisibility')
if (stored) {
try {
const parsedVisibility = JSON.parse(stored)
// Ensure custom task types are included in stored visibility
const updatedVisibility = { ...parsedVisibility }
let hasChanges = false
for (const customType of customTaskTypes.value) {
if (!(customType in updatedVisibility)) {
updatedVisibility[customType] = true
hasChanges = true
}
}
columnVisibility.value = updatedVisibility
if (hasChanges) {
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(updatedVisibility))
}
} catch {
// Fall back to defaults
setDefaultColumnVisibility()
}
} else {
// Default visible columns
setDefaultColumnVisibility()
}
}
const setDefaultColumnVisibility = () => {
const defaultVisibility: Record<string, boolean> = {
name: true,
category: true,
status: true,
thumbnail: false,
modeling: true,
surfacing: true,
rigging: true,
description: true,
updatedAt: true
}
// Add custom task types to default visibility if they exist
for (const customType of customTaskTypes.value) {
defaultVisibility[customType] = true
}
columnVisibility.value = defaultVisibility
}
// Don't initialize immediately - wait for custom task types to load
// initializeColumnVisibility()
// Detail panel composable
const {
isDetailPanelEnabled,
selectedEntity: selectedAsset,
showMobileDetail,
showPanel,
toggleDetailPanelEnabled,
closeDetailPanel,
selectEntity: selectAsset,
handleRowClick: handleRowClickComposable
} = useDetailPanel<Asset>({
isDialogOpen: () => showCreateDialog.value || showEditDialog.value || showDeleteDialog.value,
sessionStorageKey: 'assetBrowser.detailPanelEnabled'
})
// Reactive state - default to list view to show task status
const viewMode = ref<"grid" | "list">("list");
const selectedCategory = ref<AssetCategory | "all">("all");
const searchQuery = ref("");
const showCreateDialog = ref(false);
const showEditDialog = ref(false);
const showDeleteDialog = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const assetToDelete = ref<Asset | null>(null);
const deletionInfo = ref<AssetDeletionInfo | null>(null);
const taskStatusFilter = ref('')
// Thumbnail display state - with session storage
const showThumbnails = ref(
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
);
// Lock (freeze) the first columns (select/thumbnail/name) for horizontal scroll
const lockColumns = ref(localStorage.getItem('asset-columns-locked') === 'true');
const toggleColumnLock = () => {
lockColumns.value = !lockColumns.value;
localStorage.setItem('asset-columns-locked', String(lockColumns.value));
};
// Computed properties
const assets = computed(() => assetsStore.assets);
const isLoading = computed(() => assetsStore.isLoading);
const error = computed(() => assetsStore.error);
const categories = [
{ value: AssetCategory.CHARACTERS, label: "Characters", icon: Users },
{ value: AssetCategory.PROPS, label: "Props", icon: Package },
{ value: AssetCategory.SETS, label: "Sets", icon: Building },
{ value: AssetCategory.VEHICLES, label: "Vehicles", icon: Car },
];
const filteredAssets = computed(() => {
let filtered = [...(assets.value || [])];
// Filter out soft deleted assets unless user is admin
if (!authStore.isAdmin) {
filtered = filtered.filter(asset => !asset.deleted_at);
}
// Filter by category
if (selectedCategory.value && selectedCategory.value !== "all") {
filtered = filtered.filter(
(asset) => asset.category === selectedCategory.value
);
}
// Filter by search query
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase().trim();
filtered = filtered.filter(
(asset) =>
asset.name.toLowerCase().includes(query) ||
asset.description?.toLowerCase().includes(query) ||
asset.category.toLowerCase().includes(query)
);
}
return filtered;
});
// Custom task types from project
const customTaskTypes = ref<string[]>([]);
const customTaskTypesLoaded = ref(false);
// All available task types for assets (standard + custom)
const allTaskTypes = computed(() => {
return ["modeling", "surfacing", "rigging", ...customTaskTypes.value];
});
// Asset columns for TanStack Table
const assetColumns = computed(() => {
const meta: AssetColumnMeta = {
projectId: props.projectId,
categories: categories,
onEdit: editAsset,
onDelete: deleteAsset,
onViewTasks: viewAssetTasks,
onTaskStatusUpdated: handleTaskStatusUpdate,
onTaskAssignmentUpdated: handleTaskAssignmentUpdated,
onBulkTaskStatusChange: handleBulkTaskStatusChange,
getSelectedCount: () => selectedCount.value,
getAllStatusOptions: () => taskStatusesStore.getAllStatusOptions(props.projectId)
}
return createAssetColumns(allTaskTypes.value, meta);
})
const loadAssets = async () => {
console.log("AssetBrowser - Loading assets for project:", props.projectId);
console.log(
"AssetBrowser - Current assets count before fetch:",
assetsStore.assets.length
);
try {
const options: any = {};
if (selectedCategory.value && selectedCategory.value !== 'all') {
options.category = selectedCategory.value;
}
if (taskStatusFilter.value) {
options.taskStatusFilter = taskStatusFilter.value;
}
// Convert TanStack Table sorting to API format
if (sorting.value.length > 0) {
const sort = sorting.value[0]
options.sortBy = sort.id;
options.sortDirection = sort.desc ? 'desc' : 'asc';
}
await assetsStore.fetchAssets(props.projectId, options);
console.log(
"AssetBrowser - Assets loaded successfully:",
assetsStore.assets.length
);
console.log("AssetBrowser - Assets data:", assetsStore.assets);
console.log("AssetBrowser - View mode:", viewMode.value);
console.log("AssetBrowser - Visible columns:", columnVisibility.value);
if (assetsStore.assets.length > 0) {
console.log("AssetBrowser - First asset task status:", assetsStore.assets[0].task_status);
console.log("AssetBrowser - First asset task details:", assetsStore.assets[0].task_details);
}
} catch (err) {
console.error("AssetBrowser - Failed to load assets:", err);
console.error("AssetBrowser - Error details:", err);
}
};
// TanStack Table event handlers
const handleColumnVisibilityChange = (visibility: VisibilityState) => {
columnVisibility.value = visibility
sessionStorage.setItem('assetBrowser.columnVisibility', JSON.stringify(visibility))
}
const handleRowSelectionChange = (selection: Record<string, boolean>) => {
rowSelection.value = selection
}
const handleRowClick = (asset: Asset, event: MouseEvent) => {
// Use the detail panel composable for row click handling
handleRowClickComposable(asset, event)
}
const handleCategoryFilterChange = (category: AssetCategory | 'all') => {
selectedCategory.value = category
}
const handleThumbnailToggle = (show: boolean) => {
showThumbnails.value = show
// Sync with column visibility
columnVisibility.value = {
...columnVisibility.value,
thumbnail: show
}
}
const handleBulkTaskStatusChange = async (taskType: string, newStatus: TaskStatus) => {
const selectedAssetIds = Object.keys(rowSelection.value).map(id => parseInt(id))
if (selectedAssetIds.length === 0) {
toast({
title: "No assets selected",
description: "Please select assets to update their task status.",
variant: "destructive",
})
return
}
try {
// Use the new bulk update method from the store
await assetsStore.bulkUpdateTaskStatus(selectedAssetIds, taskType, newStatus)
toast({
title: "Task status updated",
description: `Updated ${formatTaskType(taskType)} status for ${selectedAssetIds.length} asset(s).`,
})
} catch (err) {
toast({
title: "Failed to update task status",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
})
}
}
const editAsset = (asset: Asset) => {
selectedAsset.value = asset;
showEditDialog.value = true;
};
const deleteAsset = async (asset: Asset) => {
// Don't set selectedAsset here as it opens the detail panel
assetToDelete.value = asset;
try {
deletionInfo.value = await assetService.getAssetDeletionInfo(asset.id);
showDeleteDialog.value = true;
} catch (err) {
toast({
title: "Failed to get asset information",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
}
};
const viewAssetTasks = (asset: Asset) => {
// Open the asset's own detail panel (Infos tab, already the default, lists its tasks)
selectAsset(asset);
};
const handleCreateAsset = async (assetData: AssetCreate | AssetUpdate) => {
try {
isCreating.value = true;
// When creating, we know it's AssetCreate, but TypeScript needs the union type
await assetsStore.createAsset(props.projectId, assetData as AssetCreate);
showCreateDialog.value = false;
toast({
title: "Asset created",
description: `${(assetData as AssetCreate).name} has been created successfully.`,
});
} catch (err) {
toast({
title: "Failed to create asset",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
} finally {
isCreating.value = false;
}
};
const handleUpdateAsset = async (assetData: AssetCreate | AssetUpdate) => {
if (!selectedAsset.value) return;
try {
isUpdating.value = true;
// When updating, we know it's AssetUpdate, but TypeScript needs the union type
await assetsStore.updateAsset(selectedAsset.value.id, assetData as AssetUpdate);
showEditDialog.value = false;
selectedAsset.value = null;
toast({
title: "Asset updated",
description: "Asset has been updated successfully.",
});
} catch (err) {
toast({
title: "Failed to update asset",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
} finally {
isUpdating.value = false;
}
};
const handleDeleteAsset = async () => {
if (!assetToDelete.value) return;
try {
await assetsStore.deleteAsset(assetToDelete.value.id);
showDeleteDialog.value = false;
assetToDelete.value = null;
const taskCount = deletionInfo.value?.task_count || 0;
deletionInfo.value = null;
toast({
title: "Asset deleted",
description: taskCount > 0
? `Asset and ${taskCount} associated task${taskCount === 1 ? '' : 's'} deleted successfully.`
: "Asset has been deleted successfully.",
});
} catch (err) {
toast({
title: "Failed to delete asset",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
}
};
const handleTaskStatusUpdate = async (
assetId: number,
taskType: string,
newStatus: string
) => {
try {
// Update the asset in the store
await assetsStore.updateTaskStatus(assetId, taskType, newStatus as TaskStatus);
toast({
title: "Task status updated",
description: `${formatTaskType(taskType)} status updated successfully.`,
});
} catch (err) {
toast({
title: "Failed to update task status",
description: err instanceof Error ? err.message : "An error occurred",
variant: "destructive",
});
}
};
const handleTaskAssignmentUpdated = (assetId: number, taskType: string, userId: number | null) => {
// Update local state instead of reloading all assets
const asset = assetsStore.assets.find((a) => a.id === assetId);
if (asset && asset.task_details) {
const taskDetail = asset.task_details.find((detail) => detail.task_type === taskType);
if (taskDetail) {
taskDetail.assigned_user_id = userId ?? undefined;
}
}
toast({
title: "Task assignment updated",
description: userId
? `${formatTaskType(taskType)} task assigned successfully.`
: `${formatTaskType(taskType)} task unassigned successfully.`,
});
};
const clearFilters = () => {
selectedCategory.value = "all";
searchQuery.value = "";
taskStatusFilter.value = "";
loadAssets();
};
const handleTaskStatusFilter = (filter: string) => {
taskStatusFilter.value = filter;
loadAssets();
};
const formatTaskType = (taskType: string) => {
return taskType.charAt(0).toUpperCase() + taskType.slice(1);
};
// Detail panel event handlers
const selectedTaskId = ref<number | null>(null);
const selectedTaskTab = ref<string>('infos');
const handleSelectTask = (task: { id: number }, tab?: string) => {
selectedTaskId.value = task.id;
selectedTaskTab.value = tab || 'infos';
};
// Reset the task sub-panel whenever the asset selection changes (including close)
watch(selectedAsset, () => {
selectedTaskId.value = null;
selectedTaskTab.value = 'infos';
});
// Load custom task types from project
const loadCustomTaskTypes = async () => {
if (!props.projectId) return;
try {
const { projectService } = await import('@/services/project');
const project = await projectService.getProject(props.projectId);
customTaskTypes.value = project.custom_asset_task_types || [];
customTaskTypesLoaded.value = true;
// Initialize column visibility AFTER custom task types are loaded
initializeColumnVisibility();
} catch (error) {
console.error('Failed to load custom task types:', error);
customTaskTypesLoaded.value = true; // Mark as loaded even if failed
// Initialize with defaults even if loading fails
initializeColumnVisibility();
}
};
// Watchers
watch(
() => props.projectId,
async (newProjectId) => {
if (newProjectId) {
// Reset loaded flag when project changes
customTaskTypesLoaded.value = false;
// Load custom task types first, then assets
try {
await loadCustomTaskTypes();
} catch (err) {
console.warn('Could not load custom task types for new project:', err);
}
loadAssets();
}
},
{ immediate: true }
);
// Watch for custom task types changes and reinitialize column visibility
watch(customTaskTypes, (newCustomTypes) => {
if (newCustomTypes.length > 0) {
// Reinitialize column visibility to include new custom types
initializeColumnVisibility();
}
}, { deep: true });
// Clear selections when filters change
watch([selectedCategory, searchQuery], () => {
rowSelection.value = {};
});
// Save thumbnail toggle preference to session storage
watch(showThumbnails, (newValue) => {
sessionStorage.setItem('assetBrowser.showThumbnails', newValue.toString());
// Sync with column visibility
columnVisibility.value = {
...columnVisibility.value,
thumbnail: newValue
};
});
// Sync column visibility with thumbnail toggle
watch(() => columnVisibility.value.thumbnail, (newValue) => {
showThumbnails.value = newValue;
});
// Watch for filter changes and reload assets
watch([selectedCategory, taskStatusFilter], () => {
loadAssets();
});
// Lifecycle
onMounted(async () => {
if (props.projectId) {
// Load task statuses for bulk operations
taskStatusesStore.fetchProjectStatuses(props.projectId).catch(err => {
console.warn('Could not load task statuses:', err);
});
// Load custom task types FIRST (blocking) to ensure columns are created correctly
try {
await loadCustomTaskTypes();
} catch (err) {
console.warn('Could not load custom task types:', err);
// Continue without custom task types
customTaskTypesLoaded.value = true;
}
// Then load assets
loadAssets();
}
});
</script>