first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'
export type ColorPickerContextValue = {
selectedColor?: string
setSelectedColor: (color?: string) => void
showCustomPicker: boolean
setShowCustomPicker: (show: boolean) => void
pickingCustomColor: boolean
setPickingCustomColor: (picking: boolean) => void
}
export const ColorPickerContext = createContext<
ColorPickerContextValue | undefined
>(undefined)
type ColorPickerProviderProps = {
children: ReactNode
}
export function ColorPickerProvider({ children }: ColorPickerProviderProps) {
const [selectedColor, setSelectedColor] = useState<string | undefined>()
const [showCustomPicker, setShowCustomPicker] = useState(false)
const [pickingCustomColor, setPickingCustomColor] = useState(false)
const value = useMemo<ColorPickerContextValue>(
() => ({
pickingCustomColor,
selectedColor,
setPickingCustomColor,
setSelectedColor,
setShowCustomPicker,
showCustomPicker,
}),
[
pickingCustomColor,
selectedColor,
setPickingCustomColor,
setSelectedColor,
setShowCustomPicker,
showCustomPicker,
]
)
return (
<ColorPickerContext.Provider value={value}>
{children}
</ColorPickerContext.Provider>
)
}
export function useColorPickerContext() {
const context = useContext(ColorPickerContext)
if (!context) {
throw new Error(
'ColorPickerContext is only available inside ColorPickerProvider'
)
}
return context
}

View File

@@ -0,0 +1,554 @@
import {
cloneDeep,
concat,
filter as arrayFilter,
find,
flatten,
uniq,
uniqBy,
without,
} from 'lodash'
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import {
ClonedProject,
GetProjectsResponseBody,
Project,
Sort,
} from '../../../../../types/project/dashboard/api'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import getMeta from '../../../utils/meta'
import useAsync from '../../../shared/hooks/use-async'
import { getProjects } from '../util/api'
import sortProjects from '../util/sort-projects'
import {
isArchivedOrTrashed,
isDeletableProject,
isLeavableProject,
} from '../util/project'
import { debugConsole } from '@/utils/debugging'
const MAX_PROJECT_PER_PAGE = 20
export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
type FilterMap = {
[key in Filter]: Partial<Project> | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
}
const filters: FilterMap = {
all: {
archived: false,
trashed: false,
},
owned: {
accessLevel: 'owner',
archived: false,
trashed: false,
},
shared: project => {
return (
project.accessLevel !== 'owner' && !project.archived && !project.trashed
)
},
archived: {
archived: true,
trashed: false,
},
trashed: {
trashed: true,
},
}
export const UNCATEGORIZED_KEY = 'uncategorized'
export type ProjectListContextValue = {
addClonedProjectToViewData: (project: ClonedProject) => void
selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
visibleProjects: Project[]
totalProjectsCount: number
error: Error | null
isLoading: ReturnType<typeof useAsync>['isLoading']
loadProgress: number
sort: Sort
setSort: React.Dispatch<React.SetStateAction<Sort>>
tags: Tag[]
untaggedProjectsCount: number
projectsPerTag: Record<Tag['_id'], Project[]>
filter: Filter
selectFilter: (filter: Filter) => void
selectedTagId?: string | undefined
selectTag: (tagId: string) => void
addTag: (tag: Tag) => void
updateTag: (tagId: string, newTagName: string, newTagColor?: string) => void
deleteTag: (tagId: string) => void
updateProjectViewData: (newProjectData: Project) => void
removeProjectFromView: (project: Project) => void
addProjectToTagInView: (tagId: string, projectId: string) => void
removeProjectFromTagInView: (tagId: string, projectId: string) => void
searchText: string
setSearchText: React.Dispatch<React.SetStateAction<string>>
selectedProjects: Project[]
selectedProjectIds: Set<string>
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
toggleSelectedProject: (projectId: string, selected?: boolean) => void
hiddenProjectsCount: number
loadMoreCount: number
showAllProjects: () => void
loadMoreProjects: () => void
hasLeavableProjectsSelected: boolean
hasDeletableProjectsSelected: boolean
}
export const ProjectListContext = createContext<
ProjectListContextValue | undefined
>(undefined)
type ProjectListProviderProps = {
children: ReactNode
}
export function ProjectListProvider({ children }: ProjectListProviderProps) {
const prefetchedProjectsBlob = getMeta('ol-prefetchedProjectsBlob')
const [loadedProjects, setLoadedProjects] = useState<Project[]>(
prefetchedProjectsBlob?.projects ?? []
)
const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
const [maxVisibleProjects, setMaxVisibleProjects] =
useState(MAX_PROJECT_PER_PAGE)
const [hiddenProjectsCount, setHiddenProjectsCount] = useState(0)
const [loadMoreCount, setLoadMoreCount] = useState(0)
const [loadProgress, setLoadProgress] = useState(
prefetchedProjectsBlob ? 100 : 20
)
const [totalProjectsCount, setTotalProjectsCount] = useState<number>(
prefetchedProjectsBlob?.totalSize ?? 0
)
const [sort, setSort] = useState<Sort>({
by: 'lastUpdated',
order: 'desc',
})
const [filter, setFilter] = usePersistedState<Filter>(
'project-list-filter',
'all'
)
const prevSortRef = useRef<Sort>(sort)
const [selectedTagId, setSelectedTagId] = usePersistedState<
string | undefined
>('project-list-selected-tag-id', undefined)
const [showCustomPicker, setShowCustomPicker] = useState(false)
const olTags = getMeta('ol-tags') || []
const [tags, setTags] = useState<Tag[]>(() =>
// `tag.name` data may be null for some old users
olTags.map(tag => ({ ...tag, name: tag.name ?? '' }))
)
const [searchText, setSearchText] = useState('')
const {
isLoading: loading,
isIdle,
error,
runAsync,
} = useAsync<GetProjectsResponseBody>({
status: prefetchedProjectsBlob ? 'resolved' : 'pending',
data: prefetchedProjectsBlob,
})
const isLoading = isIdle ? true : loading
useEffect(() => {
if (prefetchedProjectsBlob) return
setLoadProgress(40)
runAsync(getProjects({ by: 'lastUpdated', order: 'desc' }))
.then(data => {
setLoadedProjects(data.projects)
setTotalProjectsCount(data.totalSize)
})
.catch(debugConsole.error)
.finally(() => {
setLoadProgress(100)
})
}, [prefetchedProjectsBlob, runAsync])
useEffect(() => {
let filteredProjects = [...loadedProjects]
if (searchText.length) {
filteredProjects = filteredProjects.filter(project =>
project.name.toLowerCase().includes(searchText.toLowerCase())
)
}
if (selectedTagId !== undefined) {
if (selectedTagId === UNCATEGORIZED_KEY) {
const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
filteredProjects = filteredProjects.filter(
project =>
!project.archived &&
!project.trashed &&
!taggedProjectIds.includes(project.id)
)
} else {
const tag = tags.find(tag => tag._id === selectedTagId)
if (tag) {
filteredProjects = filteredProjects.filter(
p => !isArchivedOrTrashed(p) && tag?.project_ids?.includes(p.id)
)
} else {
setSelectedTagId(undefined)
}
}
} else {
filteredProjects = arrayFilter(filteredProjects, filters[filter])
}
if (prevSortRef.current !== sort) {
filteredProjects = sortProjects(filteredProjects, sort)
const loadedProjectsSorted = sortProjects(loadedProjects, sort)
setLoadedProjects(loadedProjectsSorted)
}
if (filteredProjects.length > maxVisibleProjects) {
const visibleFilteredProjects = filteredProjects.slice(
0,
maxVisibleProjects
)
const hiddenFilteredProjectsCount =
filteredProjects.slice(maxVisibleProjects).length
setVisibleProjects(visibleFilteredProjects)
setHiddenProjectsCount(hiddenFilteredProjectsCount)
if (hiddenFilteredProjectsCount > MAX_PROJECT_PER_PAGE) {
setLoadMoreCount(MAX_PROJECT_PER_PAGE)
} else {
setLoadMoreCount(hiddenFilteredProjectsCount)
}
} else {
setVisibleProjects(filteredProjects)
setLoadMoreCount(0)
setHiddenProjectsCount(0)
}
}, [
loadedProjects,
maxVisibleProjects,
tags,
filter,
setFilter,
selectedTagId,
setSelectedTagId,
searchText,
sort,
])
useEffect(() => {
prevSortRef.current = sort
}, [sort])
const showAllProjects = useCallback(() => {
setLoadMoreCount(0)
setHiddenProjectsCount(0)
setMaxVisibleProjects(maxVisibleProjects + hiddenProjectsCount)
}, [hiddenProjectsCount, maxVisibleProjects])
const loadMoreProjects = useCallback(() => {
setMaxVisibleProjects(maxVisibleProjects + loadMoreCount)
}, [maxVisibleProjects, loadMoreCount])
const [selectedProjectIds, setSelectedProjectIds] = useState(
() => new Set<string>()
)
const toggleSelectedProject = useCallback(
(projectId: string, selected?: boolean) => {
setSelectedProjectIds(prevSelectedProjectIds => {
const selectedProjectIds = new Set(prevSelectedProjectIds)
if (selected === true) {
selectedProjectIds.add(projectId)
} else if (selected === false) {
selectedProjectIds.delete(projectId)
} else if (selectedProjectIds.has(projectId)) {
selectedProjectIds.delete(projectId)
} else {
selectedProjectIds.add(projectId)
}
return selectedProjectIds
})
},
[]
)
const selectedProjects = useMemo(() => {
return visibleProjects.filter(project => selectedProjectIds.has(project.id))
}, [selectedProjectIds, visibleProjects])
const selectOrUnselectAllProjects = useCallback(
checked => {
setSelectedProjectIds(prevSelectedProjectIds => {
const selectedProjectIds = new Set(prevSelectedProjectIds)
for (const project of visibleProjects) {
if (checked) {
selectedProjectIds.add(project.id)
} else {
selectedProjectIds.delete(project.id)
}
}
return selectedProjectIds
})
},
[visibleProjects]
)
const untaggedProjectsCount = useMemo(() => {
const taggedProjectIds = uniq(flatten(tags.map(tag => tag.project_ids)))
return loadedProjects.filter(
project =>
!project.archived &&
!project.trashed &&
!taggedProjectIds.includes(project.id)
).length
}, [tags, loadedProjects])
const projectsPerTag = useMemo(() => {
return tags.reduce<Record<Tag['_id'], Project[]>>((prev, curTag) => {
const tagProjects = loadedProjects.filter(p => {
return !isArchivedOrTrashed(p) && curTag.project_ids?.includes(p.id)
})
return { ...prev, [curTag._id]: tagProjects }
}, {})
}, [tags, loadedProjects])
const selectFilter = useCallback(
(filter: Filter) => {
setFilter(filter)
setSelectedTagId(undefined)
const selected = false
selectOrUnselectAllProjects(selected)
},
[selectOrUnselectAllProjects, setFilter, setSelectedTagId]
)
const selectTag = useCallback(
(tagId: string) => {
setFilter('all')
setSelectedTagId(tagId)
},
[setSelectedTagId, setFilter]
)
const addTag = useCallback((tag: Tag) => {
setTags(tags => uniqBy(concat(tags, [tag]), '_id'))
}, [])
const updateTag = useCallback(
(tagId: string, newTagName: string, newTagColor?: string) => {
setTags(tags => {
const newTags = cloneDeep(tags)
const tag = find(newTags, ['_id', tagId])
if (tag) {
tag.name = newTagName
tag.color = newTagColor
}
return newTags
})
},
[]
)
const deleteTag = useCallback(
(tagId: string | null) => {
setTags(tags => tags.filter(tag => tag._id !== tagId))
},
[setTags]
)
const addProjectToTagInView = useCallback(
(tagId: string, projectId: string) => {
setTags(tags => {
const updatedTags = [...tags]
for (const tag of updatedTags) {
if (tag._id === tagId) {
tag.project_ids = uniq([...(tag.project_ids || []), projectId])
}
}
return updatedTags
})
},
[setTags]
)
const removeProjectFromTagInView = useCallback(
(tagId: string, projectId: string) => {
setTags(tags => {
const updatedTags = [...tags]
for (const tag of updatedTags) {
if (tag._id === tagId) {
tag.project_ids = without(tag.project_ids || [], projectId)
}
}
return updatedTags
})
},
[setTags]
)
const addClonedProjectToViewData = useCallback(
(project: ClonedProject) => {
// clone API not using camelCase and does not return all data
const owner = {
id: project.owner?._id,
email: project.owner?.email,
firstName: project.owner?.first_name,
lastName: project.owner?.last_name,
}
const clonedProject: Project = {
...project,
id: project.project_id,
owner,
lastUpdatedBy: owner,
source: 'owner',
trashed: false,
archived: false,
accessLevel: 'owner',
}
setLoadedProjects(loadedProjects => {
return sortProjects([...loadedProjects, clonedProject], sort)
})
},
[sort]
)
const updateProjectViewData = useCallback((newProjectData: Project) => {
setLoadedProjects(loadedProjects => {
return loadedProjects.map(p =>
p.id === newProjectData.id ? { ...newProjectData } : p
)
})
}, [])
const removeProjectFromView = useCallback((project: Project) => {
setLoadedProjects(loadedProjects => {
return loadedProjects.filter(p => p.id !== project.id)
})
}, [])
const hasLeavableProjectsSelected = useMemo(
() => selectedProjects.some(isLeavableProject),
[selectedProjects]
)
const hasDeletableProjectsSelected = useMemo(
() => selectedProjects.some(isDeletableProject),
[selectedProjects]
)
const value = useMemo<ProjectListContextValue>(
() => ({
addTag,
addClonedProjectToViewData,
addProjectToTagInView,
deleteTag,
error,
filter,
hasLeavableProjectsSelected,
hasDeletableProjectsSelected,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromTagInView,
removeProjectFromView,
selectedTagId,
selectFilter,
selectedProjects,
selectedProjectIds,
selectOrUnselectAllProjects,
selectTag,
searchText,
setSearchText,
setSelectedProjectIds,
setShowCustomPicker,
setSort,
showAllProjects,
showCustomPicker,
sort,
tags,
toggleSelectedProject,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
updateTag,
projectsPerTag,
visibleProjects,
}),
[
addTag,
addClonedProjectToViewData,
addProjectToTagInView,
deleteTag,
error,
filter,
hasLeavableProjectsSelected,
hasDeletableProjectsSelected,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromTagInView,
removeProjectFromView,
selectedTagId,
selectFilter,
selectedProjectIds,
selectedProjects,
selectOrUnselectAllProjects,
selectTag,
searchText,
setSearchText,
setSelectedProjectIds,
setShowCustomPicker,
setSort,
showAllProjects,
showCustomPicker,
sort,
tags,
toggleSelectedProject,
totalProjectsCount,
untaggedProjectsCount,
updateProjectViewData,
updateTag,
projectsPerTag,
visibleProjects,
]
)
return (
<ProjectListContext.Provider value={value}>
{children}
</ProjectListContext.Provider>
)
}
export function useProjectListContext() {
const context = useContext(ProjectListContext)
if (!context) {
throw new Error(
'ProjectListContext is only available inside ProjectListProvider'
)
}
return context
}