diff --git a/backend/routers/projects.py b/backend/routers/projects.py index d6ce1b0..66f77dd 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -367,7 +367,8 @@ async def list_project_members( joined_at=member.joined_at, user_email=member.user.email, user_first_name=member.user.first_name, - user_last_name=member.user.last_name + user_last_name=member.user.last_name, + user_avatar_url=member.user.avatar_url ) result.append(member_data) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 6d4c0bf..a4df7d4 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -455,7 +455,8 @@ async def create_task( "shot_name": db_task.shot.name if db_task.shot else None, "asset_name": db_task.asset.name if db_task.asset else None, "assigned_user_name": f"{db_task.assigned_user.first_name} {db_task.assigned_user.last_name}" if db_task.assigned_user else None, - "assigned_user_email": db_task.assigned_user.email if db_task.assigned_user else None + "assigned_user_email": db_task.assigned_user.email if db_task.assigned_user else None, + "assigned_user_avatar_url": db_task.assigned_user.avatar_url if db_task.assigned_user else None } return TaskResponse(**task_data) @@ -744,7 +745,8 @@ async def get_task( "shot_name": task.shot.name if task.shot else None, "asset_name": task.asset.name if task.asset else None, "assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None, - "assigned_user_email": task.assigned_user.email if task.assigned_user else None + "assigned_user_email": task.assigned_user.email if task.assigned_user else None, + "assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None } return TaskResponse(**task_data) @@ -862,7 +864,8 @@ async def update_task( "shot_name": task.shot.name if task.shot else None, "asset_name": task.asset.name if task.asset else None, "assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None, - "assigned_user_email": task.assigned_user.email if task.assigned_user else None + "assigned_user_email": task.assigned_user.email if task.assigned_user else None, + "assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None } return TaskResponse(**task_data) @@ -951,7 +954,8 @@ async def update_task_status( "shot_name": task.shot.name if task.shot else None, "asset_name": task.asset.name if task.asset else None, "assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None, - "assigned_user_email": task.assigned_user.email if task.assigned_user else None + "assigned_user_email": task.assigned_user.email if task.assigned_user else None, + "assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None } return TaskResponse(**task_data) @@ -1049,7 +1053,8 @@ async def assign_task( "shot_name": task.shot.name if task.shot else None, "asset_name": task.asset.name if task.asset else None, "assigned_user_name": f"{task.assigned_user.first_name} {task.assigned_user.last_name}" if task.assigned_user else None, - "assigned_user_email": task.assigned_user.email if task.assigned_user else None + "assigned_user_email": task.assigned_user.email if task.assigned_user else None, + "assigned_user_avatar_url": task.assigned_user.avatar_url if task.assigned_user else None } return TaskResponse(**task_data) @@ -1130,6 +1135,7 @@ async def get_task_notes( "user_first_name": note.user.first_name, "user_last_name": note.user.last_name, "user_email": note.user.email, + "user_avatar_url": note.user.avatar_url, "child_notes": [] } @@ -1208,6 +1214,7 @@ async def create_task_note( "user_first_name": db_note.user.first_name, "user_last_name": db_note.user.last_name, "user_email": db_note.user.email, + "user_avatar_url": db_note.user.avatar_url, "child_notes": [] } @@ -1256,6 +1263,7 @@ async def update_task_note( "user_first_name": note.user.first_name, "user_last_name": note.user.last_name, "user_email": note.user.email, + "user_avatar_url": note.user.avatar_url, "child_notes": [] } diff --git a/backend/schemas/project.py b/backend/schemas/project.py index 1b7c0a0..7e13eb5 100644 --- a/backend/schemas/project.py +++ b/backend/schemas/project.py @@ -211,6 +211,7 @@ class ProjectMemberResponse(ProjectMemberBase): user_email: str user_first_name: str user_last_name: str + user_avatar_url: Optional[str] = None class Config: from_attributes = True diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 25c535e..3ce06d4 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -57,6 +57,7 @@ class TaskResponse(TaskBase): asset_name: Optional[str] = None assigned_user_name: Optional[str] = None assigned_user_email: Optional[str] = None + assigned_user_avatar_url: Optional[str] = None class Config: from_attributes = True @@ -78,6 +79,7 @@ class TaskListResponse(BaseModel): asset_name: Optional[str] = None assigned_user_id: Optional[int] = None assigned_user_name: Optional[str] = None + assigned_user_avatar_url: Optional[str] = None created_at: datetime updated_at: datetime @@ -110,6 +112,7 @@ class ProductionNoteResponse(ProductionNoteBase): user_first_name: str user_last_name: str user_email: str + user_avatar_url: Optional[str] = None # Child notes for threading child_notes: Optional[List['ProductionNoteResponse']] = None diff --git a/frontend/src/components/activity/ActivityFeed.vue b/frontend/src/components/activity/ActivityFeed.vue index 4179a34..516322a 100644 --- a/frontend/src/components/activity/ActivityFeed.vue +++ b/frontend/src/components/activity/ActivityFeed.vue @@ -48,11 +48,11 @@ {{ getInitials(activity.user) }} @@ -275,12 +275,7 @@ function navigateToProject(projectId: number) { router.push(`/projects/${projectId}`) } -function getAvatarUrl(url: string | null | undefined, userId?: number): string { - if (!url) return '' - // If it's already a full URL, return it - if (url.startsWith('http')) return url - // Use direct static file serving - const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `/${cleanUrl}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl } = useAvatarUrl() diff --git a/frontend/src/components/asset/AssetDeleteConfirmDialog.vue b/frontend/src/components/asset/AssetDeleteConfirmDialog.vue index 6643f43..e15981e 100644 --- a/frontend/src/components/asset/AssetDeleteConfirmDialog.vue +++ b/frontend/src/components/asset/AssetDeleteConfirmDialog.vue @@ -93,7 +93,7 @@ >
{{ user.name }}
-
{{ user.email }} • {{ user.role }}
+
{{ user.email }} ??{{ user.role }}
{{ user.task_count }} task{{ user.task_count === 1 ? '' : 's' }}
diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 9c4da8e..393495d 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -34,11 +34,11 @@ {{ userInitials }} @@ -116,14 +116,9 @@ const userInitials = computed(() => { return `${user.value.first_name.charAt(0)}${user.value.last_name.charAt(0)}`.toUpperCase() }) -const getAvatarUrl = (url: string | null | undefined) => { - if (!url) return '' - // If it's already a full URL, return it - if (url.startsWith('http')) return url - // Use direct static file serving - const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `/${cleanUrl}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl } = useAvatarUrl() // Generate breadcrumbs based on current route with enhanced context const breadcrumbs = ref([]) diff --git a/frontend/src/components/project/ProjectMemberManagement.vue b/frontend/src/components/project/ProjectMemberManagement.vue index adbf25f..df9f5e0 100644 --- a/frontend/src/components/project/ProjectMemberManagement.vue +++ b/frontend/src/components/project/ProjectMemberManagement.vue @@ -43,11 +43,11 @@ {{ getUserInitials(member) }} @@ -378,22 +378,9 @@ const getUserInitials = (member: ProjectMember) => { return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase() } -const getAvatarUrl = (userIdOrUrl: string | number | null | undefined) => { - if (!userIdOrUrl) return '' - // If it's a number, we don't have the avatar URL, so we can't display avatars - if (typeof userIdOrUrl === 'number') { - return '' - } - // If it's already a full URL, return it - if (userIdOrUrl.startsWith('http')) return userIdOrUrl - // Check if it looks like a user ID (numeric string) - if (/^\d+$/.test(userIdOrUrl)) { - return '' - } - // Use direct static file serving for avatar URLs - const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `/${cleanUrl}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl() const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { diff --git a/frontend/src/components/shot/EditableTaskStatus.vue b/frontend/src/components/shot/EditableTaskStatus.vue index 7950700..4540bbb 100644 --- a/frontend/src/components/shot/EditableTaskStatus.vue +++ b/frontend/src/components/shot/EditableTaskStatus.vue @@ -39,24 +39,50 @@ - +
Assign Task
- -
- Project ID: {{ projectId }}, Members: {{ projectMembers.length }} + +
+ + + {{ getUserInitials(assignedUser) }} + +
+ {{ assignedUser.user_first_name }} {{ assignedUser.user_last_name }} + Current +
+ +
+ + +
+ +
@@ -80,37 +106,30 @@ @@ -144,8 +163,9 @@ import { PopoverTrigger, } from '@/components/ui/popover' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { User, UserX } from 'lucide-vue-next' +import { User, UserX, Search, Check, X } from 'lucide-vue-next' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import { TaskStatus } from '@/services/shot' import { taskService } from '@/services/task' @@ -183,6 +203,20 @@ const isUpdating = ref(false) const isAssigning = ref(false) const isLoadingMembers = ref(false) const projectMembers = ref([]) +const searchQuery = ref('') + +// Filtered project members based on search query +const filteredProjectMembers = computed(() => { + if (!searchQuery.value.trim()) { + return projectMembers.value + } + const query = searchQuery.value.toLowerCase().trim() + return projectMembers.value.filter(member => { + const fullName = `${member.user_first_name || ''} ${member.user_last_name || ''}`.toLowerCase() + const departmentRole = member.department_role?.toLowerCase() || '' + return fullName.includes(query) || departmentRole.includes(query) + }) +}) // Get loading state from store const isLoadingStatuses = computed(() => taskStatusesStore.isLoading(props.projectId)) @@ -257,10 +291,9 @@ const getUserInitials = (member: ProjectMember): string => { return (first + last).toUpperCase() } -// Get avatar URL -const getAvatarUrl = (userId: number): string => { - return `https://api.dicebear.com/7.x/initials/svg?seed=${userId}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl() // Fetch custom statuses for the project using store const fetchStatuses = async () => { @@ -353,7 +386,10 @@ const handleAssignUser = async (userId: number | null) => { emit('assignment-updated', props.shotId, props.taskType, userId) } - // Note: Popover will close naturally when clicking outside or on assignment + // Close popover by simulating click outside after assignment + setTimeout(() => { + document.querySelector('[data-state="open"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }, 100) } catch (error) { console.error('Failed to assign task:', error) } finally { diff --git a/frontend/src/components/shot/ShotColumnVisibilityControl.vue b/frontend/src/components/shot/ShotColumnVisibilityControl.vue index 8d5f0de..9340168 100644 --- a/frontend/src/components/shot/ShotColumnVisibilityControl.vue +++ b/frontend/src/components/shot/ShotColumnVisibilityControl.vue @@ -46,17 +46,6 @@ Episode
- -
- - Frame Range -
-
- - Other Columns - -
- - Description -
-
-
diff --git a/frontend/src/components/shot/ShotTableToolbar.vue b/frontend/src/components/shot/ShotTableToolbar.vue index 562e50e..29ec7e2 100644 --- a/frontend/src/components/shot/ShotTableToolbar.vue +++ b/frontend/src/components/shot/ShotTableToolbar.vue @@ -305,10 +305,8 @@ const allColumns = computed(() => [ { id: 'thumbnail', label: 'Thumbnail', type: 'default' }, { id: 'name', label: 'Shot Name', type: 'default' }, { id: 'episode', label: 'Episode', type: 'default' }, - { id: 'frameRange', label: 'Frame Range', type: 'default' }, { id: 'frames', label: 'Frames', type: 'default' }, { id: 'status', label: 'Status', type: 'default' }, - { id: 'description', label: 'Description', type: 'default' }, ...props.allTaskTypes.map(taskType => ({ id: taskType, label: taskType.charAt(0).toUpperCase() + taskType.slice(1), diff --git a/frontend/src/components/shot/columns.ts b/frontend/src/components/shot/columns.ts index c4bcc80..44795ab 100644 --- a/frontend/src/components/shot/columns.ts +++ b/frontend/src/components/shot/columns.ts @@ -74,8 +74,8 @@ export const createShotColumns = ( id: 'thumbnail', header: 'Thumbnail', cell: () => { - return h('div', { class: 'w-20 h-11 bg-muted flex items-center justify-center' }, [ - h(Camera, { class: 'h-6 w-6 text-muted-foreground' }), + return h('div', { class: 'w-20 h-11 bg-gray-500/20 flex items-center justify-center' }, [ + h(Camera, { class: 'h-6 w-6 text-muted-foreground text-gray-900' }), ]) }, enableSorting: false, @@ -121,16 +121,6 @@ export const createShotColumns = ( return h(Badge, { variant: 'outline', class: 'text-xs' }, () => episodeName) }, }, - // Frame Range column (not sortable) - { - id: 'frameRange', - header: 'Frame Range', - cell: ({ row }) => { - const shot = row.original - return h('span', { class: 'text-sm' }, `${shot.frame_start}-${shot.frame_end}`) - }, - enableSorting: false, - }, // Frames column (frame count) { id: 'frames', diff --git a/frontend/src/components/task/AttachmentCard.vue b/frontend/src/components/task/AttachmentCard.vue index fe8e257..3f324a1 100644 --- a/frontend/src/components/task/AttachmentCard.vue +++ b/frontend/src/components/task/AttachmentCard.vue @@ -64,7 +64,7 @@
{{ getUserInitials(attachment.user_first_name, attachment.user_last_name) }} @@ -84,6 +84,9 @@ import { Badge } from '@/components/ui/badge' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import type { TaskAttachment } from '@/services/task' import { apiClient } from '@/services/api' +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getInitialsAvatarUrl } = useAvatarUrl() const props = defineProps<{ attachment: TaskAttachment diff --git a/frontend/src/components/task/NoteItem.vue b/frontend/src/components/task/NoteItem.vue index ecdb193..741f8d6 100644 --- a/frontend/src/components/task/NoteItem.vue +++ b/frontend/src/components/task/NoteItem.vue @@ -6,11 +6,11 @@ {{ getInitials(note.user_first_name, note.user_last_name) }} @@ -194,9 +194,7 @@ async function handleDelete() { } } -function getAvatarUrl(userId: number | null | undefined) { - // For NoteItem, we don't have the avatar URL, so we can't display avatars - // This would require updating the backend API to include avatar URLs in note responses - return '' -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl() diff --git a/frontend/src/components/task/SubmissionCard.vue b/frontend/src/components/task/SubmissionCard.vue index 6a133f9..6e6ae0b 100644 --- a/frontend/src/components/task/SubmissionCard.vue +++ b/frontend/src/components/task/SubmissionCard.vue @@ -45,7 +45,7 @@
{{ getUserInitials(submission.user_first_name, submission.user_last_name) }} diff --git a/frontend/src/components/task/TaskDetailPanel.vue b/frontend/src/components/task/TaskDetailPanel.vue index 0689479..01202ba 100644 --- a/frontend/src/components/task/TaskDetailPanel.vue +++ b/frontend/src/components/task/TaskDetailPanel.vue @@ -136,7 +136,7 @@
{{ getAssignedUserInitials(task.assigned_user_name) }} @@ -228,7 +228,7 @@
{{ getUserInitials(member) }} @@ -478,12 +478,9 @@ function getUserInitials(member: ProjectMember): string { return `${member.user_first_name.charAt(0)}${member.user_last_name.charAt(0)}`.toUpperCase() } -function getAvatarUrl(url: string | null | undefined) { - if (!url) return '' - if (url.startsWith('http')) return url - const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `http://localhost:8000/${cleanUrl}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl } = useAvatarUrl() function getAssignedUserInitials(name: string) { const parts = name.split(' ') diff --git a/frontend/src/components/task/TaskList.vue b/frontend/src/components/task/TaskList.vue index 241818d..96da93c 100644 --- a/frontend/src/components/task/TaskList.vue +++ b/frontend/src/components/task/TaskList.vue @@ -105,11 +105,11 @@ {{ getAssignedUserInitials(task.assigned_user_name) }} @@ -268,11 +268,11 @@ {{ member.user_first_name.charAt(0) }}{{ member.user_last_name.charAt(0) }} @@ -581,22 +581,9 @@ function clearFilters() { sortBy.value = 'deadline' } -function getAvatarUrl(userIdOrUrl: string | number | null | undefined) { - if (!userIdOrUrl) return '' - // If it's a number, we don't have the avatar URL, so we can't display avatars - if (typeof userIdOrUrl === 'number') { - return '' - } - // If it's already a full URL, return it - if (userIdOrUrl.startsWith('http')) return userIdOrUrl - // Check if it looks like a user ID (numeric string) - if (/^\d+$/.test(userIdOrUrl)) { - return '' - } - // Use direct static file serving for avatar URLs - const cleanUrl = userIdOrUrl.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `/${cleanUrl}` -} +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl() function getAssignedUserInitials(name: string) { const parts = name.split(' ') diff --git a/frontend/src/components/ui/table/TableCell.vue b/frontend/src/components/ui/table/TableCell.vue index 8ff4a7f..97c0f1c 100644 --- a/frontend/src/components/ui/table/TableCell.vue +++ b/frontend/src/components/ui/table/TableCell.vue @@ -11,7 +11,7 @@ const props = defineProps<{ Avatar @@ -90,6 +90,7 @@ import { Upload, X, Loader2, Camera } from 'lucide-vue-next' import { useToast } from '@/components/ui/toast/use-toast' import { userService } from '@/services/user' import { useAuthStore } from '@/stores/auth' +import { useAvatarUrl } from '@/composables/useAvatarUrl' interface Props { @@ -118,14 +119,7 @@ const initials = computed(() => { return (first + last).toUpperCase() || '?' }) -const getAvatarUrl = (url: string | null | undefined) => { - if (!url) return '' - // If it's already a full URL, return it - if (url.startsWith('http')) return url - // Use direct static file serving - const cleanUrl = url.replace(/^backend[\/\\]/, '').replace(/\\/g, '/') - return `/${cleanUrl}` -} +const { getAvatarUrl } = useAvatarUrl() const triggerFileInput = () => { fileInput.value?.click() diff --git a/frontend/src/components/user/UserApprovalCard.vue b/frontend/src/components/user/UserApprovalCard.vue index 2b5dbc1..3d5d3a1 100644 --- a/frontend/src/components/user/UserApprovalCard.vue +++ b/frontend/src/components/user/UserApprovalCard.vue @@ -4,7 +4,7 @@
- + {{ userInitials }}
@@ -53,6 +53,9 @@ import { Badge } from '@/components/ui/badge' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Check, X } from 'lucide-vue-next' import type { User } from '@/types/auth' +import { useAvatarUrl } from '@/composables/useAvatarUrl' + +const { getInitialsAvatarUrl } = useAvatarUrl() interface Props { user: User diff --git a/frontend/src/components/user/UserDeleteConfirmDialog.vue b/frontend/src/components/user/UserDeleteConfirmDialog.vue index 258d3ab..181e80a 100644 --- a/frontend/src/components/user/UserDeleteConfirmDialog.vue +++ b/frontend/src/components/user/UserDeleteConfirmDialog.vue @@ -12,7 +12,7 @@
{{ getUserInitials(user) }} @@ -90,6 +90,9 @@