Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
@@ -0,0 +1,801 @@
<template>
<div class="relative h-full">
<!-- Main Content (Full Width) -->
<div class="space-y-4">
<!-- Toolbar - Sticky -->
<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">
<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"
@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"
@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">
<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">
<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"
>
<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">
<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>
<!-- 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"
>
<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"
@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">
<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 -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Asset</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{{ selectedAsset?.name }}"? This
action cannot be undone and will remove all associated tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
@click="handleDeleteAsset"
class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Asset
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<!-- Asset Detail Panel (Desktop) with slide animation -->
<Transition
enter-active-class="transition-transform duration-300 ease-out"
enter-from-class="translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-300 ease-in"
leave-from-class="translate-x-0"
leave-to-class="translate-x-full"
>
<div
v-if="showPanel && selectedAsset"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
>
<AssetDetailPanel
:project-id="projectId"
:asset-id="selectedAsset.id"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</div>
</Transition>
<!-- Asset Detail Panel (Mobile) -->
<Sheet v-model:open="showMobileDetail">
<SheetContent side="right" class="w-full sm:max-w-md p-0">
<AssetDetailPanel
v-if="selectedAsset"
:project-id="projectId"
:asset-id="selectedAsset.id"
@close="closeDetailPanel"
@edit="editAsset"
@delete="deleteAsset"
@create-task="handleCreateTask"
@select-task="handleSelectTask"
@create-note="handleCreateNote"
@upload-reference="handleUploadReference"
@publish-version="handlePublishVersion"
/>
</SheetContent>
</Sheet>
</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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import AssetCard from "./AssetCard.vue";
import AssetForm from "./AssetForm.vue";
import AssetDetailPanel from "./AssetDetailPanel.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 {
AssetCategory,
TaskStatus,
type Asset,
type AssetCreate,
type AssetUpdate,
} 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 taskStatusFilter = ref('')
// Thumbnail display state - with session storage
const showThumbnails = ref(
sessionStorage.getItem('assetBrowser.showThumbnails') === 'true'
);
// 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,
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 = (asset: Asset) => {
selectedAsset.value = asset;
showDeleteDialog.value = true;
};
const viewAssetTasks = (asset: Asset) => {
// TODO: Navigate to asset tasks view
console.log("View tasks for asset:", asset.name);
};
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 (!selectedAsset.value) return;
try {
await assetsStore.deleteAsset(selectedAsset.value.id);
showDeleteDialog.value = false;
selectedAsset.value = null;
toast({
title: "Asset deleted",
description: "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 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 handleCreateTask = () => {
// TODO: Navigate to task creation for this asset
console.log('Create task for asset:', selectedAsset.value?.name);
};
const handleSelectTask = (task: any) => {
// TODO: Open task detail panel
console.log('Select task:', task);
};
const handleCreateNote = () => {
// TODO: Open note creation dialog
console.log('Create note for asset:', selectedAsset.value?.name);
};
const handleUploadReference = () => {
// TODO: Open reference upload dialog
console.log('Upload reference for asset:', selectedAsset.value?.name);
};
const handlePublishVersion = () => {
// TODO: Open version publish dialog
console.log('Publish version for asset:', selectedAsset.value?.name);
};
// 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>