Compare commits

...

2 Commits

Author SHA1 Message Date
indigo 0dd37d1706 Add CLAUDE.md and AGENTS.md with codebase architecture and dev commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 04:03:47 +08:00
indigo 19bc4e8a23 Add function for sidebar shot column switch 2026-03-07 09:33:44 +08:00
19 changed files with 809 additions and 26 deletions
+59
View File
@@ -0,0 +1,59 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Project Overview
LinkDesk is a VFX/animation production management system: FastAPI backend + Vue 3 frontend, run as separate dev servers with a Vite proxy bridging them.
## Architecture
**Backend** (`backend/`) — FastAPI + SQLAlchemy (SQLite by default, set via `DATABASE_URL` env var).
- `main.py` — entry point; registers all routers and mounts `/uploads` static dir.
- `database.py` — SQLAlchemy engine and `get_db` session dependency.
- `models/` — ORM models: project, shot, asset, task, episode, user, notification, activity, api_key.
- `routers/` — one file per resource, each prefixed in `main.py`.
- `schemas/` — Pydantic request/response schemas (separate from ORM models).
- `utils/` — shared helpers (file handling, notifications).
**Frontend** (`frontend/src/`) — Vue 3 + TypeScript + Pinia + Vue Router + shadcn-vue + TanStack Table.
- `router/index.ts` — routes with `requiresAuth`, role (`roles: [...]`), and admin-only (`adminPermission: 'required'`) guards.
- `stores/` — Pinia stores per domain (auth, projects, assets, tasks, episodes, notifications, user, settings, taskStatuses).
- `services/` — axios wrappers per resource; `api.ts` is the base client. All calls use `/api` prefix, proxied by Vite to `http://localhost:8000`.
- `components/` — domain folders (`asset/`, `shot/`, `project/`, `layout/`, `auth/`, `episode/`, `task/`) + `ui/` for shadcn primitives.
- `views/` — page-level components; project detail uses nested child routes under `/projects/:projectId`.
- `composables/` — shared composition logic.
- `types/` — shared TypeScript interfaces.
**Auth**: JWT access + refresh tokens in `localStorage`. `api.ts` interceptor auto-refreshes on 401. Router guard initializes auth from stored token on first navigation.
**Roles**: `coordinator`, `director`, `developer` + `isAdmin` flag. Admins can access any role-gated route.
## Commands
### Backend
```bash
cd backend
# Windows: .venv\Scripts\activate | bash/mac: source .venv/bin/activate
uvicorn main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
npm run type-check # vue-tsc --noEmit
npm run build
```
### First-time setup
Copy `backend/.env.example` to `backend/.env` and set `SECRET_KEY`. The database schema is created automatically on first run via `Base.metadata.create_all`.
## Coding conventions
- Backend: follow existing router/schema/model separation. Pydantic schemas live in `schemas/`, ORM models in `models/`. Never mix them.
- Frontend: services call the API; stores hold state; components consume stores. Don't call `apiClient` directly from components.
- Use `@/` alias for all frontend imports (maps to `frontend/src/`).
- Match existing style exactly — don't refactor adjacent code while fixing something.
- Minimum code that solves the problem. No speculative features or abstractions.
+118
View File
@@ -0,0 +1,118 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LinkDesk is a VFX/animation production management system. It has a FastAPI backend and a Vue 3 frontend, running as separate dev servers with a Vite proxy.
## Architecture
**Backend** (`backend/`) — FastAPI + SQLAlchemy (SQLite by default, configurable via `DATABASE_URL`).
- `main.py` — app entry point; mounts routers and static uploads.
- `database.py` — SQLAlchemy engine and `get_db` dependency.
- `models/` — SQLAlchemy ORM models (project, shot, asset, task, episode, user, notification, activity, api_key).
- `routers/` — one file per resource; each router is `include_router`'d with a prefix in `main.py`.
- `schemas/` — Pydantic request/response schemas (separate from models).
- `utils/` — shared helpers (file handling, notifications).
**Frontend** (`frontend/src/`) — Vue 3 + TypeScript + Pinia + Vue Router + shadcn-vue (Radix UI) + TanStack Table.
- `main.ts` — app bootstrap.
- `router/index.ts` — route definitions; guards enforce `requiresAuth`, role-based (`roles: [...]`), and admin-only (`adminPermission: 'required'`) access.
- `stores/` — Pinia stores (auth, projects, assets, tasks, episodes, notifications, user, settings, taskStatuses).
- `services/` — axios wrappers per resource (`api.ts` is the base client). All calls go through `/api` which Vite proxies to `http://localhost:8000`.
- `components/` — organized by domain (`asset/`, `shot/`, `project/`, `layout/`, `auth/`, `episode/`, `task/`) plus `ui/` for shadcn primitives.
- `views/` — page-level components; project detail uses nested child routes under `/projects/:projectId`.
- `composables/` — shared Vue composition logic (`useDetailPanel`, `useAvatarUrl`).
- `types/` — shared TypeScript interfaces.
**Auth flow**: JWT access + refresh tokens stored in `localStorage`. `api.ts` interceptor auto-refreshes on 401. Router guard initializes auth from stored token on first navigation.
**Role model**: `coordinator`, `director`, `developer` roles + `isAdmin` flag. Admin can access any role-gated route.
## Commands
### Backend
```bash
cd backend
# Activate venv first (Windows)
.venv\Scripts\activate # or: source .venv/bin/activate on bash
uvicorn main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
npm run type-check # TypeScript check (vue-tsc --noEmit)
npm run build
```
### Setup
Copy `.env.example` to `.env` in `backend/` and set `SECRET_KEY`. Database auto-creates on first run via SQLAlchemy `create_all`.
---
# CLAUDE.md — 12-rule template
These rules apply to every task in this project unless explicitly overridden.
Bias: caution over speed on non-trivial work. Use judgment on trivial tasks.
## Rule 1 — Think Before Coding
State assumptions explicitly. If uncertain, ask rather than guess.
Present multiple interpretations when ambiguity exists.
Push back when a simpler approach exists.
Stop when confused. Name what's unclear.
## Rule 2 — Simplicity First
Minimum code that solves the problem. Nothing speculative.
No features beyond what was asked. No abstractions for single-use code.
Test: would a senior engineer say this is overcomplicated? If yes, simplify.
## Rule 3 — Surgical Changes
Touch only what you must. Clean up only your own mess.
Don't "improve" adjacent code, comments, or formatting.
Don't refactor what isn't broken. Match existing style.
## Rule 4 — Goal-Driven Execution
Define success criteria. Loop until verified.
Don't follow steps. Define success and iterate.
Strong success criteria let you loop independently.
## Rule 5 — Use the model only for judgment calls
Use me for: classification, drafting, summarization, extraction.
Do NOT use me for: routing, retries, deterministic transforms.
If code can answer, code answers.
## Rule 6 — Token budgets are not advisory
Per-task: 4,000 tokens. Per-session: 30,000 tokens.
If approaching budget, summarize and start fresh.
Surface the breach. Do not silently overrun.
## Rule 7 — Surface conflicts, don't average them
If two patterns contradict, pick one (more recent / more tested).
Explain why. Flag the other for cleanup.
Don't blend conflicting patterns.
## Rule 8 — Read before you write
Before adding code, read exports, immediate callers, shared utilities.
"Looks orthogonal" is dangerous. If unsure why code is structured a way, ask.
## Rule 9 — Tests verify intent, not just behavior
Tests must encode WHY behavior matters, not just WHAT it does.
A test that can't fail when business logic changes is wrong.
## Rule 10 — Checkpoint after every significant step
Summarize what was done, what's verified, what's left.
Don't continue from a state you can't describe back.
If you lose track, stop and restate.
## Rule 11 — Match the codebase's conventions, even if you disagree
Conformance > taste inside the codebase.
If you genuinely think a convention is harmful, surface it. Don't fork silently.
## Rule 12 — Fail loud
"Completed" is wrong if anything was skipped silently.
"Tests pass" is wrong if any were skipped.
Default to surfacing uncertainty, not hiding it.
@@ -277,5 +277,5 @@ function navigateToProject(projectId: number) {
import { useAvatarUrl } from '@/composables/useAvatarUrl' import { useAvatarUrl } from '@/composables/useAvatarUrl'
const { getAvatarUrl } = useAvatarUrl() const { getAvatarUrl, getInitialsAvatarUrl } = useAvatarUrl()
</script> </script>
+12 -1
View File
@@ -36,7 +36,10 @@
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
<!-- View Settings Section - Only show on shot/project pages -->
<SidebarColumnSwitch v-if="userRole !== 'developer' && isOnShotPage" />
<!-- Projects Section --> <!-- Projects Section -->
<!-- <SidebarGroup v-if="userRole !== 'developer'"> <!-- <SidebarGroup v-if="userRole !== 'developer'">
<SidebarGroupLabel>Projects</SidebarGroupLabel> <SidebarGroupLabel>Projects</SidebarGroupLabel>
@@ -91,6 +94,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -122,14 +126,21 @@ import {
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ProjectSwitcher from './ProjectSwitcher.vue' import ProjectSwitcher from './ProjectSwitcher.vue'
import UserMenu from './UserMenu.vue' import UserMenu from './UserMenu.vue'
import SidebarColumnSwitch from '@/components/ui/sidebar/SidebarColumnSwitch.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const { state } = useSidebar() const { state } = useSidebar()
const route = useRoute()
const user = computed(() => authStore.user) const user = computed(() => authStore.user)
const userRole = computed(() => authStore.user?.role || 'artist') const userRole = computed(() => authStore.user?.role || 'artist')
// Check if sidebar is collapsed // Check if sidebar is collapsed
const isCollapsed = computed(() => state.value === 'collapsed') const isCollapsed = computed(() => state.value === 'collapsed')
// Check if on shot page - show column switch only on shot pages
const isOnShotPage = computed(() => {
return route.path.includes('/shots') || route.path.includes('/project/')
})
const props = withDefaults(defineProps<SidebarProps>(), { const props = withDefaults(defineProps<SidebarProps>(), {
collapsible: "icon", collapsible: "icon",
}) })
@@ -165,7 +165,7 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { User, UserX, Search, Check, X } from 'lucide-vue-next' import { User, Search, Check, X } from 'lucide-vue-next'
import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue' import TaskStatusBadge from '@/components/task/TaskStatusBadge.vue'
import { TaskStatus } from '@/services/shot' import { TaskStatus } from '@/services/shot'
import { taskService } from '@/services/task' import { taskService } from '@/services/task'
+22 -13
View File
@@ -166,7 +166,7 @@
leave-to-class="translate-x-full" leave-to-class="translate-x-full"
> >
<div <div
v-if="showPanel" v-if="showPanel && selectedShot"
class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto" class="fixed right-0 top-[113px] bottom-0 w-96 bg-background border-l shadow-lg z-50 hidden lg:block overflow-y-auto"
> >
<ShotDetailPanel <ShotDetailPanel
@@ -277,13 +277,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, shallowRef, markRaw, nextTick } from 'vue' import { ref, computed, watch, shallowRef, markRaw, nextTick, onMounted } from 'vue'
import { import {
Search, Plus, Camera, AlertCircle, RefreshCw, Search, Plus, Camera, AlertCircle, RefreshCw,
Layers, MoreHorizontal, Edit, ListTodo, Trash2 Layers, MoreHorizontal, Edit, ListTodo, Trash2
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
import { import {
Dialog, Dialog,
@@ -378,6 +379,7 @@ const validationError = ref<ValidationErrorDisplay | null>(null)
// TanStack Table state // TanStack Table state
const sorting = ref<SortingState>([]) const sorting = ref<SortingState>([])
const columnVisibilityStore = useColumnVisibilityStore()
const columnVisibility = ref<VisibilityState>({}) const columnVisibility = ref<VisibilityState>({})
const rowSelection = ref<Record<string, boolean>>({}) const rowSelection = ref<Record<string, boolean>>({})
@@ -385,16 +387,12 @@ const rowSelection = ref<Record<string, boolean>>({})
const selectedCount = computed(() => { const selectedCount = computed(() => {
return Object.keys(rowSelection.value).length return Object.keys(rowSelection.value).length
}) })
const initializeColumnVisibility = () => { const initializeColumnVisibility = () => {
const stored = sessionStorage.getItem('shotBrowser.columnVisibility') // Initialize the global store
if (stored) { columnVisibilityStore.initialize()
try { // Use store's visibility as the initial value
columnVisibility.value = JSON.parse(stored) columnVisibility.value = columnVisibilityStore.visibility
} catch {
// Fall back to defaults
columnVisibility.value = {}
}
}
} }
initializeColumnVisibility() initializeColumnVisibility()
@@ -538,7 +536,10 @@ let visibilityUpdateTimeout: ReturnType<typeof setTimeout> | null = null
const handleColumnVisibilityChange = (visibility: VisibilityState) => { const handleColumnVisibilityChange = (visibility: VisibilityState) => {
columnVisibility.value = visibility columnVisibility.value = visibility
// Debounce session storage updates // Also update the global store
columnVisibilityStore.setColumnVisibility(visibility)
// Debounce session storage updates (for backwards compatibility)
if (visibilityUpdateTimeout) clearTimeout(visibilityUpdateTimeout) if (visibilityUpdateTimeout) clearTimeout(visibilityUpdateTimeout)
visibilityUpdateTimeout = setTimeout(() => { visibilityUpdateTimeout = setTimeout(() => {
sessionStorage.setItem('shotBrowser.columnVisibility', JSON.stringify(visibility)) sessionStorage.setItem('shotBrowser.columnVisibility', JSON.stringify(visibility))
@@ -588,7 +589,7 @@ const handleTaskAssignmentUpdated = (shotId: number, taskType: string, userId: n
if (shot && shot.task_details) { if (shot && shot.task_details) {
const taskDetail = shot.task_details.find(detail => detail.task_type === taskType) const taskDetail = shot.task_details.find(detail => detail.task_type === taskType)
if (taskDetail) { if (taskDetail) {
taskDetail.assigned_user_id = userId taskDetail.assigned_user_id = userId ?? undefined
} }
} }
@@ -912,4 +913,12 @@ watch(() => showEditDialog.value, (isOpen) => {
// Watch for changes that require column recreation (after all functions are defined) // Watch for changes that require column recreation (after all functions are defined)
watch([() => allTaskTypes.value, () => episodes.value, () => props.projectId], updateColumns, { immediate: true }) watch([() => allTaskTypes.value, () => episodes.value, () => props.projectId], updateColumns, { immediate: true })
// Watch for changes from global column visibility store
watch(() => columnVisibilityStore.visibility, (newVisibility) => {
// Only update if the change didn't come from this component
if (JSON.stringify(newVisibility) !== JSON.stringify(columnVisibility.value)) {
columnVisibility.value = newVisibility
}
}, { deep: true })
</script> </script>
@@ -83,7 +83,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, onMounted, watch } from 'vue'
import { Columns } from 'lucide-vue-next' import { Columns } from 'lucide-vue-next'
import { import {
Select, Select,
@@ -95,9 +95,9 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import type { VisibilityState } from '@tanstack/vue-table' import type { VisibilityState } from '@tanstack/vue-table'
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
interface Props { interface Props {
columnVisibility: VisibilityState
allTaskTypes: string[] allTaskTypes: string[]
} }
@@ -107,6 +107,26 @@ const emit = defineEmits<{
'update:columnVisibility': [visibility: VisibilityState] 'update:columnVisibility': [visibility: VisibilityState]
}>() }>()
// Use global store
const columnVisibilityStore = useColumnVisibilityStore()
// Local reactive state for checkbox visibility
const localVisibility = ref<Record<string, boolean>>({})
// Watch for store changes and update local state
watch(
() => columnVisibilityStore.columnVisibility,
(newVal) => {
localVisibility.value = { ...newVal }
},
{ immediate: true, deep: true }
)
// Initialize store on mount
onMounted(() => {
columnVisibilityStore.initialize()
})
const selectedColumn = ref('toggle') const selectedColumn = ref('toggle')
const handleColumnToggle = (value: string) => { const handleColumnToggle = (value: string) => {
@@ -114,9 +134,9 @@ const handleColumnToggle = (value: string) => {
selectedColumn.value = 'toggle' selectedColumn.value = 'toggle'
} }
const isColumnVisible = (columnId: string) => { const isColumnVisible = (columnId: string): boolean => {
// If not in visibility state, column is visible by default // If not in visibility state, column is visible by default
return props.columnVisibility[columnId] !== false return localVisibility.value[columnId] !== false
} }
const handleCheckboxChange = (column: string, event: Event) => { const handleCheckboxChange = (column: string, event: Event) => {
@@ -129,9 +149,8 @@ const toggleColumn = (column: string) => {
} }
const updateColumn = (column: string, checked: boolean) => { const updateColumn = (column: string, checked: boolean) => {
const newVisibility = { ...props.columnVisibility } // Always use global store
newVisibility[column] = checked columnVisibilityStore.updateColumn(column, checked)
emit('update:columnVisibility', newVisibility)
} }
const formatTaskType = (taskType: string) => { const formatTaskType = (taskType: string) => {
@@ -37,6 +37,7 @@
'selecting': isRangeSelecting 'selecting': isRangeSelecting
}" }"
@click="handleRowClick(row.original, $event, row)" @click="handleRowClick(row.original, $event, row)"
@dblclick="emit('row-dblclick', row.original)"
@mousedown="handleMouseDown" @mousedown="handleMouseDown"
@mouseup="handleMouseUp" @mouseup="handleMouseUp"
> >
@@ -103,6 +104,7 @@ const emit = defineEmits<{
'update:columnVisibility': [visibility: VisibilityState] 'update:columnVisibility': [visibility: VisibilityState]
'update:rowSelection': [selection: Record<string, boolean>] 'update:rowSelection': [selection: Record<string, boolean>]
'row-click': [shot: Shot, event: MouseEvent] 'row-click': [shot: Shot, event: MouseEvent]
'row-dblclick': [shot: Shot]
'selection-cleared': [] 'selection-cleared': []
}>() }>()
@@ -0,0 +1,208 @@
<template>
<SidebarGroup>
<SidebarGroupLabel>View</SidebarGroupLabel>
<SidebarGroup class="py-0" as-child>
<Collapsible v-model:open="isBaseColumnsOpen" class="group/collapsible">
<CollapsibleTrigger as-child>
<SidebarGroupLabel class="px-2 hover:bg-sidebar-accent text-sm hover:text-sidebar-accent-foreground cursor-pointer">
<div class="flex items-center justify-between w-full">
Columns
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="isBaseColumnsOpen ? 'rotate-90' : ''"
/>
</div>
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in baseColumns" :key="column.id">
<CommandItem
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
<SidebarGroup class="py-0">
<Collapsible v-model:open="isTaskColumnsOpen" class="group/collapsible">
<CollapsibleTrigger as-child>
<SidebarGroupLabel class="px-2 hover:bg-sidebar-accent text-sm hover:text-sidebar-accent-foreground cursor-pointer">
<div class="flex items-center justify-between w-full">
Task Columns
<ChevronRight
class="h-4 w-4 transition-transform duration-200"
:class="isTaskColumnsOpen ? 'rotate-90' : ''"
/>
</div>
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarGroupContent>
<Command class="bg-transparent border-0 p-0">
<CommandList>
<CommandGroup>
<template v-for="column in taskColumns" :key="column.id">
<CommandItem
:value="column.id"
@select="toggleColumn(column.id, !isColumnVisible(column.id))"
>
<div
:class="[
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
isColumnVisible(column.id)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
]"
>
<Check class="h-4 w-4" />
</div>
<span class="text-sm text-sidebar-foreground">{{ column.label }}</span>
</CommandItem>
</template>
</CommandGroup>
</CommandList>
</Command>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
</SidebarGroup>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Check, ChevronDown, ChevronRight } from 'lucide-vue-next'
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
} from '@/components/ui/sidebar'
import { useColumnVisibilityStore } from '@/stores/columnVisibility'
import { customTaskTypeService } from '@/services/customTaskType'
const route = useRoute()
const columnVisibilityStore = useColumnVisibilityStore()
// Collapsible state
const isBaseColumnsOpen = ref(true)
const isTaskColumnsOpen = ref(true)
// Task types state
const taskTypes = ref<string[]>([])
// Base columns - synced with Shot Data Table default columns
const baseColumns = [
{ id: 'thumbnail', label: 'Thumbnail' },
{ id: 'name', label: 'Shot Name' },
{ id: 'episode', label: 'Episode' },
{ id: 'frames', label: 'Frames' },
{ id: 'status', label: 'Status' },
]
const taskColumns = computed(() => {
const columns = taskTypes.value.map(taskType => ({
id: taskType,
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
}))
return [...columns]
})
// All columns including task types
const allColumns = computed(() => {
const taskColumns = taskTypes.value.map(taskType => ({
id: taskType,
label: taskType.charAt(0).toUpperCase() + taskType.slice(1),
}))
return [...baseColumns, ...taskColumns]
})
// Local reactive state
const localVisibility = ref<Record<string, boolean>>({})
// Watch for store changes and update local state
watch(
() => columnVisibilityStore.columnVisibility,
(newVal) => {
localVisibility.value = { ...newVal }
},
{ immediate: true, deep: true }
)
const isColumnVisible = (columnId: string): boolean => {
// If not in visibility state, column is visible by default
return localVisibility.value[columnId] !== false
}
const toggleColumn = (columnId: string, value: boolean) => {
columnVisibilityStore.updateColumn(columnId, value)
}
// Fetch task types based on project ID from route
const fetchTaskTypes = async () => {
// Extract projectId from route params
const projectId = route.params.projectId as string | undefined
// Clear task types if no projectId
if (!projectId) {
taskTypes.value = []
return
}
try {
const projectIdNum = parseInt(projectId, 10)
if (!isNaN(projectIdNum)) {
const data = await customTaskTypeService.getAllTaskTypes(projectIdNum)
taskTypes.value = data.shot_task_types || []
} else {
taskTypes.value = []
}
} catch (err) {
console.error('Failed to fetch task types:', err)
taskTypes.value = []
}
}
// Lifecycle - initialize store and fetch task types
onMounted(() => {
columnVisibilityStore.initialize()
fetchTaskTypes()
})
// Watch for route changes to refetch task types
watch(() => route.params.projectId, () => {
fetchTaskTypes()
})
</script>
+6
View File
@@ -60,6 +60,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/project/ProjectShotsView.vue'), component: () => import('@/views/project/ProjectShotsView.vue'),
meta: { tab: 'shots', tabLabel: 'Shots', hasEpisode: true } meta: { tab: 'shots', tabLabel: 'Shots', hasEpisode: true }
}, },
{
path: 'shots/:shotId',
name: 'ShotDetail',
component: () => import('@/views/project/ShotDetailView.vue'),
meta: { requiresAuth: true }
},
{ {
path: 'assets', path: 'assets',
name: 'ProjectAssets', name: 'ProjectAssets',
+111
View File
@@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { VisibilityState } from '@tanstack/vue-table'
// Default column visibility - basic columns visible, task type columns hidden by default
const DEFAULT_VISIBILITY: VisibilityState = {
thumbnail: true,
name: true,
episode: true,
frames: true,
status: true,
}
const STORAGE_KEY = 'shot-column-visibility'
export const useColumnVisibilityStore = defineStore('columnVisibility', () => {
// State
const columnVisibility = ref<VisibilityState>({})
let isInitialized = false
// Getters
const visibility = computed(() => {
// Auto-initialize if empty
if (Object.keys(columnVisibility.value).length === 0) {
initialize()
}
return columnVisibility.value
})
const isColumnVisible = (columnId: string): boolean => {
// Auto-initialize if empty
if (Object.keys(columnVisibility.value).length === 0) {
initialize()
}
// If not in visibility state, column is visible by default
return columnVisibility.value[columnId] !== false
}
const visibleColumns = computed(() => {
return Object.entries(columnVisibility.value)
.filter(([, visible]) => visible !== false)
.map(([column]) => column)
})
// Actions
const initialize = () => {
if (isInitialized) return
// Get saved visibility from localStorage
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
try {
const parsed = JSON.parse(saved)
// Merge with defaults to ensure all columns have a state
columnVisibility.value = { ...DEFAULT_VISIBILITY, ...parsed }
} catch {
// If parsing fails, use defaults
columnVisibility.value = { ...DEFAULT_VISIBILITY }
}
} else {
// No saved state, use defaults
columnVisibility.value = { ...DEFAULT_VISIBILITY }
}
isInitialized = true
}
const toggleColumn = (columnId: string) => {
const newVisibility = !isColumnVisible(columnId)
updateColumn(columnId, newVisibility)
}
const updateColumn = (columnId: string, visible: boolean) => {
columnVisibility.value = {
...columnVisibility.value,
[columnId]: visible,
}
saveToStorage()
}
const setColumnVisibility = (visibility: VisibilityState) => {
columnVisibility.value = { ...DEFAULT_VISIBILITY, ...visibility }
saveToStorage()
}
const resetToDefaults = () => {
columnVisibility.value = { ...DEFAULT_VISIBILITY }
saveToStorage()
}
const saveToStorage = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnVisibility.value))
}
return {
// State
columnVisibility,
// Getters
visibility,
isColumnVisible,
visibleColumns,
// Actions
initialize,
toggleColumn,
updateColumn,
setColumnVisibility,
resetToDefaults,
}
})
+22
View File
@@ -108,4 +108,26 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
}
@layer utilities {
.scrollbar::-webkit-scrollbar {
width: 20px;
height: 20px;
}
.scrollbar::-webkit-scrollbar-track {
border-radius: 100vh;
background: #f7f4ed;
}
.scrollbar::-webkit-scrollbar-thumb {
background: #e0cbcb;
border-radius: 100vh;
border: 3px solid #f6f7ed;
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background: #c0a0b9;
}
} }
+1
View File
@@ -20,6 +20,7 @@ export interface UserInfo {
first_name: string first_name: string
last_name: string last_name: string
email: string email: string
avatar_url: string
} }
export interface Activity { export interface Activity {
+7 -3
View File
@@ -1,6 +1,10 @@
// src/vue-shims.d.ts // src/vue-shims.d.ts
import type { DefineComponent } from 'vue'
// Declare default export for all .vue files
// This is needed for TypeScript to understand Vue 3 <script setup> components
declare module "*.vue" { declare module "*.vue" {
import Vue from "vue"; const component: DefineComponent<{}, {}, any>
export default Vue; export default component
} }
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-05
@@ -0,0 +1,92 @@
## Context
The VFX Studio application currently has a ShotColumnVisibilityControl component in the Shot page toolbar that allows users to toggle which columns to display in the Shot data table. However, this control is only accessible when users are on the Shot page. Users have requested the ability to quickly toggle Shot table columns from the Sidebar so they can adjust column visibility without navigating to the Shot page first.
This change will add a column display switch to the application Sidebar, making column visibility controls accessible from anywhere in the application.
## Goals / Non-Goals
**Goals:**
- Add a column visibility control to the AppSidebar component
- Create a global column visibility state accessible throughout the application
- Ensure column preferences persist across user sessions using localStorage
- Maintain consistency with the existing ShotColumnVisibilityControl component
**Non-Goals:**
- Backend API changes (not required)
- Database schema changes (not required)
- Modifying the existing Shot page column visibility functionality
- Adding column visibility controls for other data tables (e.g., Tasks, Assets)
## Decisions
### 1. Global State Management Approach
**Decision:** Use a Pinia store for global column visibility state with localStorage persistence.
**Rationale:**
- Pinia is already the state management solution used in the project
- localStorage provides simple persistence without backend changes
- This approach aligns with existing patterns in the codebase
**Alternative Considered:** Use Vue's provide/inject pattern
- Rejected because it doesn't provide automatic persistence and is less scalable
### 2. Sidebar Integration Location
**Decision:** Add the column display switch as a new SidebarGroup in the AppSidebar component, positioned in the SidebarContent area.
**Rationale:**
- Keeps the control accessible but not obtrusive
- Follows the existing Sidebar organization pattern (Navigation, Administration, Developer Tools)
- Allows for future expansion to other column visibility controls
**Alternative Considered:** Add to SidebarFooter
- Rejected because Footer is already used for UserMenu and would become cluttered
### 3. Reuse Existing Component vs. New Implementation
**Decision:** Create a new Sidebar-specific component that adapts the existing ShotColumnVisibilityControl logic.
**Rationale:**
- The existing ShotColumnVisibilityControl is tightly coupled to the Shot page context
- A sidebar-specific component can be simpler and more focused
- Allows for different UI presentation in sidebarmore compact)
** context (Alternative Considered:** Reuse ShotColumnVisibilityControl directly
- Rejected because it has Shot-specific UI elements (toolbar styling, select dropdown)
### 4. Default Column Visibility
**Decision:** Default visibility: Thumbnail, Shot Name, Episode, Status visible; all task type columns hidden.
**Rationale:**
- Matches typical VFX production workflow priorities
- Reduces visual clutter for new users
- Aligns with the current Shot page defaults
## Risks / Trade-offs
**[Risk]** State synchronization between Sidebar control and Shot page control
**Mitigation:** Both controls will read from and write to the same Pinia store, ensuring consistency
**[Risk]** Performance impact from reactive column visibility state
**Mitigation:** Column visibility is a simple object; Vue's reactivity system handles this efficiently
**[Risk]** User confusion if Sidebar is collapsed
**Mitigation:** The column control will be hidden when sidebar is collapsed (consistent with other sidebar content)
## Migration Plan
1. Create new Pinia store for column visibility (e.g., `useColumnVisibilityStore`)
2. Create new SidebarColumnSwitch component
3. Add SidebarColumnSwitch to AppSidebar.vue
4. Update ShotColumnVisibilityControl to use the new store
5. Test state synchronization between Sidebar and Shot page
6. No rollback needed - this is a purely additive feature
## Open Questions
1. Should the column visibility settings be per-project or global across all projects?
- Current design assumes global (simpler implementation)
- Could be enhanced later if users request per-project settings
2. Should we show all task type columns or only a subset in the Sidebar?
- Current design shows all available task types
- Could be limited to top 5 most-used types if the list becomes too long
@@ -0,0 +1,27 @@
## Why
The Shot page already has a column visibility control component (`ShotColumnVisibilityControl.vue`) that allows users to toggle which columns to display in the Shot data table. However, users need to navigate to the Shot page to access this functionality. Adding a column display switch directly to the Sidebar will improve user experience by providing quick access to toggle Shot table columns from anywhere in the application, without needing to first navigate to the Shot page.
## What Changes
- Add a new Sidebar menu item or control section in the Sidebar for Shot column visibility
- Create a global column visibility state that can be accessed from the Sidebar
- Integrate the existing column toggle logic into the Sidebar component
- Persist user column preferences globally (so they apply across all views)
- Add appropriate UI components (switch/checkbox) in the Sidebar for quick column toggling
## Capabilities
### New Capabilities
- `shot-column-display-switch`: Add a Sidebar control for toggling Shot data table column visibility globally
### Modified Capabilities
- None. This is a new feature that doesn't change existing requirements.
## Impact
- Frontend: New Sidebar component or modification to AppSidebar.vue
- State Management: May need to add a Pinia store or extend existing store for global column visibility state
- No API changes required
- Existing ShotColumnVisibilityControl component can be reused or adapted
- Minimal impact on existing functionality
@@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Sidebar Shot Column Display Switch
The system SHALL provide a column visibility control in the Sidebar that allows users to toggle which columns are displayed in the Shot data table from any page in the application.
#### Scenario: User toggles column from Sidebar
- **WHEN** user interacts with the column display switch in the Sidebar
- **THEN** the Shot data table column visibility is updated globally across all views
#### Scenario: User views available column options
- **WHEN** user expands the column display control in the Sidebar
- **THEN** the system displays all available Shot table columns with their current visibility state
#### Scenario: Column preferences persist across sessions
- **WHEN** user toggles a column visibility setting
- **THEN** the preference SHALL be saved and restored when the user returns to the application
### Requirement: Global Column Visibility State
The system SHALL maintain a global column visibility state that is accessible from both the Sidebar and the Shot page column visibility control.
#### Scenario: Sidebar control reflects current visibility state
- **WHEN** user opens the Sidebar column control
- **THEN** the checkboxes SHALL reflect the current column visibility state from the global state
#### Scenario: Changes in Sidebar reflect in Shot page
- **WHEN** user toggles a column from the Sidebar
- **THEN** the Shot page column visibility control SHALL show the updated state
### Requirement: Default Column Visibility
The system SHALL provide sensible default column visibility settings that apply when no user preference has been saved.
#### Scenario: New user views Shot table
- **WHEN** a new user (no saved preferences) views the Shot data table
- **THEN** default columns (Thumbnail, Shot Name, Episode, Status) SHALL be visible
- **AND** task type columns SHALL be hidden by default
### Requirement: Column Types Supported
The system SHALL support toggling visibility for the following column types:
- Thumbnail
- Shot Name
- Episode
- Status
- Task type columns (dynamic based on project task types)
#### Scenario: User toggles thumbnail column
- **WHEN** user toggles the Thumbnail column visibility
- **THEN** the thumbnail column SHALL show/hide in the Shot data table
#### Scenario: User toggles task type column
- **WHEN** user toggles a task type column (e.g., "Animation", "Compositing")
- **THEN** that task status column SHALL show/hide in the Shot data table
@@ -0,0 +1,41 @@
## 1. Create Column Visibility Store
- [x] 1.1 Create Pinia store `useColumnVisibilityStore` in `frontend/src/stores/`
- [x] 1.2 Add localStorage persistence for column visibility state
- [x] 1.3 Define default column visibility (Thumbnail, Shot Name, Episode, Status visible; task types hidden)
- [x] 1.4 Add getter methods for checking column visibility
- [x] 1.5 Add action methods for toggling column visibility
## 2. Create Sidebar Column Switch Component
- [x] 2.1 Create new component `SidebarColumnSwitch.vue` in `frontend/src/components/ui/sidebar/`
- [x] 2.2 Implement column visibility toggle UI (checkboxes or switches)
- [x] 2.3 Integrate with `useColumnVisibilityStore`
- [x] 2.4 Style component for sidebar context (compact, collapsible)
## 3. Integrate Component into Sidebar
- [x] 3.1 Import `SidebarColumnSwitch` into `AppSidebar.vue`
- [x] 3.2 Add new `SidebarGroup` for "View Settings" in `AppSidebar.vue`
- [x] 3.3 Place `SidebarColumnSwitch` inside the new group
- [ ] 3.4 Test visibility when sidebar is collapsed
## 4. Update Existing Shot Column Control
- [x] 4.1 Modify `ShotColumnVisibilityControl.vue` to use `useColumnVisibilityStore`
- [x] 4.2 Remove local state management from Shot page column control
- [x] 4.3 Ensure changes in Shot page reflect in Sidebar and vice versa
## 5. Testing and Verification
- [ ] 5.1 Test column toggle from Sidebar updates Shot table
- [ ] 5.2 Test column toggle from Shot page updates Sidebar control
- [ ] 5.3 Test persistence - preferences survive page refresh
- [ ] 5.4 Test default visibility for new users
- [ ] 5.5 Test behavior when sidebar is collapsed/expanded
## 6. Edge Cases
- [ ] 6.1 Handle case when no project is selected
- [ ] 6.2 Handle case when task types list is empty
- [ ] 6.3 Test responsiveness on mobile (sidebar behavior)