diff --git a/src/apps/work/src/lib/components/ProjectsShowcaseFilter/ProjectsShowcaseFilter.tsx b/src/apps/work/src/lib/components/ProjectsShowcaseFilter/ProjectsShowcaseFilter.tsx index f96532ccd..f83c588a6 100644 --- a/src/apps/work/src/lib/components/ProjectsShowcaseFilter/ProjectsShowcaseFilter.tsx +++ b/src/apps/work/src/lib/components/ProjectsShowcaseFilter/ProjectsShowcaseFilter.tsx @@ -6,12 +6,11 @@ import { IconOutline, } from '~/libs/ui' +import { FormSelectOption } from '../form' + import styles from './ProjectsShowcaseFilter.module.scss' -interface SelectOption { - label: string - value: string -} +type SelectOption = FormSelectOption interface ProjectsShowcaseFilterProps { keywordInput: string diff --git a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx index 9d0af206b..a001ead1b 100644 --- a/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx +++ b/src/apps/work/src/lib/components/form/FormSelectField/FormSelectField.tsx @@ -168,6 +168,20 @@ export const FormSelectField: FC = (props: FormSelectField ? props.fromFieldValue(field.value, options) : defaultFromFieldValue(field.value, options, isMulti) + const selectStyles = useMemo( + () => ({ + menu: (provided: Record) => ({ + ...provided, + zIndex: 9999, + }), + menuPortal: (provided: Record) => ({ + ...provided, + zIndex: 9999, + }), + }), + [], + ) + const handleSelectChange = useCallback( (selectedValue: SelectValue): void => { const normalizedValue = props.toFieldValue @@ -198,6 +212,7 @@ export const FormSelectField: FC = (props: FormSelectField isMulti={isMulti} loadOptions={props.loadOptions} menuPortalTarget={menuPortalTarget} + styles={selectStyles} onBlur={field.onBlur} onChange={handleSelectChange} options={options} diff --git a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts index e39f571b4..b97365abf 100644 --- a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts +++ b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts @@ -10,7 +10,10 @@ export interface ProjectShowcasePostTaxonomyItem { export interface ProjectShowcasePost { id: string title: string + content?: string status: string + projectId?: string + challengeIds?: string[] createdAt: string createdById: number createdByHandle?: string diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index d3f93b87e..e5f1daa7e 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -1,6 +1,8 @@ import { xhrGetAsync, xhrGetPaginatedAsync, + xhrPatchAsync, + xhrPostAsync, } from '~/libs/core' import { @@ -13,6 +15,7 @@ import type { ProjectShowcasePost, ProjectShowcasePostCategory, ProjectShowcasePostIndustry, + ProjectShowcasePostTaxonomyItem, } from '../models' import { fetchMembersByUserIds } from './members.service' @@ -151,6 +154,136 @@ export async function fetchProjectShowcasePosts( } } +function normalizeTaxonomyArray(value: unknown): ProjectShowcasePostTaxonomyItem[] { + if (!Array.isArray(value)) { + return [] + } + + return value.map((item: any) => ({ + id: String(item?.id || ''), + name: String(item?.name || ''), + })) +} + +function normalizeString(value: unknown): string { + return value !== undefined && value !== null + ? String(value) + : '' +} + +function normalizeStringOrUndefined(value: unknown): string | undefined { + return value !== undefined && value !== null + ? String(value) + : undefined +} + +function normalizeProjectShowcasePost(value: unknown): ProjectShowcasePost | undefined { + if (typeof value !== 'object' || value === null) { + return undefined + } + + const post = value as Record + + return { + categories: normalizeTaxonomyArray(post.categories), + challengeIds: Array.isArray(post.challengeIds) + ? post.challengeIds.map((item: any) => String(item)) + : [], + content: normalizeStringOrUndefined(post.content), + createdAt: normalizeString(post.createdAt), + createdByHandle: normalizeStringOrUndefined(post.createdByHandle), + createdById: Number(post.createdById || 0), + id: normalizeString(post.id), + industries: normalizeTaxonomyArray(post.industries), + projectId: normalizeStringOrUndefined(post.projectId), + status: normalizeString(post.status), + title: normalizeString(post.title), + } +} + +export async function fetchProjectShowcasePost( + projectId: string, + postId: string, +): Promise { + try { + const response = await xhrGetAsync( + `${PROJECTS_API_URL}/${encodeURIComponent(projectId)}/posts/${encodeURIComponent(postId)}`, + ) + + const normalized = normalizeProjectShowcasePost(response) + if (!normalized) { + throw new Error('Failed to normalize showcase post') + } + + return normalized + } catch (error) { + throw normalizeError(error, 'Failed to fetch showcase post') + } +} + +export async function createProjectShowcasePost( + projectId: string, + payload: { + title: string + content: string + industryIds: string[] + categoryIds: string[] + challengeIds?: string[] + }, +): Promise { + try { + const response = await xhrPostAsync( + `${PROJECTS_API_URL}/${encodeURIComponent(projectId)}/posts`, + payload, + ) + + const normalized = normalizeProjectShowcasePost(response) + if (!normalized) { + throw new Error('Failed to normalize showcase post') + } + + return normalized + } catch (error) { + throw normalizeError(error, 'Failed to create showcase post') + } +} + +export async function updateProjectShowcasePost( + projectId: string, + postId: string, + payload: { + title?: string + content?: string + industryIds?: string[] + categoryIds?: string[] + challengeIds?: string[] + status?: string + }, +): Promise { + try { + const response = await xhrPatchAsync( + `${PROJECTS_API_URL}/${encodeURIComponent(projectId)}/posts/${encodeURIComponent(postId)}`, + payload, + ) + + const normalized = normalizeProjectShowcasePost(response) + if (!normalized) { + throw new Error('Failed to normalize showcase post') + } + + return normalized + } catch (error) { + throw normalizeError(error, 'Failed to update showcase post') + } +} + +export async function archiveProjectShowcasePost( + projectId: string, + postId: string, +): Promise { + return updateProjectShowcasePost(projectId, postId, { status: 'ARCHIVED' }) +} + function normalizeTaxonomyItem(value: unknown): ProjectShowcasePostCategory | undefined { if (typeof value !== 'object' || value === null) { return undefined @@ -172,6 +305,46 @@ function normalizeTaxonomyItem(value: unknown): ProjectShowcasePostCategory | un return { id, name: trimmedName } } +export async function createProjectShowcasePostIndustry( + name: string, +): Promise { + try { + const response = await xhrPostAsync<{ name: string }, unknown>( + `${PROJECTS_API_URL}/posts/industries`, + { name }, + ) + + const normalized = normalizeTaxonomyItem(response) + if (!normalized) { + throw new Error('Failed to normalize showcase post industry') + } + + return normalized + } catch (error) { + throw normalizeError(error, 'Failed to create showcase post industry') + } +} + +export async function createProjectShowcasePostCategory( + name: string, +): Promise { + try { + const response = await xhrPostAsync<{ name: string }, unknown>( + `${PROJECTS_API_URL}/posts/categories`, + { name }, + ) + + const normalized = normalizeTaxonomyItem(response) + if (!normalized) { + throw new Error('Failed to normalize showcase post category') + } + + return normalized + } catch (error) { + throw normalizeError(error, 'Failed to create showcase post category') + } +} + function sortTaxonomyItems(items: T[]): T[] { return items .slice() diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss index ba695d676..303a47478 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss @@ -44,11 +44,6 @@ width: 100%; } -.actions { - display: flex; - justify-content: flex-start; -} - .errorBanner { max-width: 100%; } @@ -129,6 +124,46 @@ } } +.rowActions { + display: flex; + align-items: center; + gap: 10px; +} + +.actionButton { + color: $link-blue-dark; + font-size: 12px; + font-weight: 700; + background: transparent; + border: 0; + cursor: pointer; + padding: 0; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + outline: none; + } +} + +.actionDelete { + color: #9f1f1f; +} + +.modalBody { + display: flex; + flex-direction: column; + gap: 16px; +} + +.modalFooter { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + .loadingRow, .emptyRow { diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index f22799af1..3bb5e9a0a 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import { ChangeEvent, FC, @@ -9,10 +10,11 @@ import { } from 'react' import { useParams } from 'react-router-dom' import { SingleValue } from 'react-select' +import { FormProvider, useForm, UseFormReturn } from 'react-hook-form' import classNames from 'classnames' import { PageWrapper } from '~/apps/review/src/lib' -import { LoadingSpinner } from '~/libs/ui' +import { BaseModal, Button, LoadingSpinner, useConfirmationModal } from '~/libs/ui' import { ErrorMessage, @@ -20,11 +22,28 @@ import { ProjectListTabs, ProjectsShowcaseFilter, } from '../../../lib/components' +import { + archiveProjectShowcasePost, + createProjectShowcasePost, + createProjectShowcasePostCategory, + createProjectShowcasePostIndustry, + fetchChallenge, + fetchChallenges, + fetchProjectShowcasePost, + updateProjectShowcasePost, +} from '../../../lib/services' import { useFetchProjectShowcasePostCategories, useFetchProjectShowcasePostIndustries, useFetchProjectShowcasePosts, } from '../../../lib/hooks' +import { + FormMarkdownEditor, + FormSelectField, + FormSelectOption, + FormTextField, +} from '../../../lib/components/form' +import { showErrorToast, showSuccessToast } from '../../../lib/utils/toast.utils' import type { FetchProjectShowcasePostsParams, ProjectShowcasePost, @@ -35,10 +54,7 @@ import type { import styles from './ProjectShowcasePage.module.scss' -interface SelectOption { - label: string - value: string -} +type SelectOption = FormSelectOption const DEFAULT_FILTERS: ProjectShowcasePostFilters = { categoryId: undefined, @@ -119,6 +135,74 @@ function normalizeTaxonomyOption(item: ProjectShowcasePostCategory | ProjectShow } } +interface ProjectShowcasePostFormData { + title: string + content: string + industryIds: string[] + categoryIds: string[] + challengeId?: string +} + +function mapPostToFormData(post?: ProjectShowcasePost): ProjectShowcasePostFormData { + return { + categoryIds: post?.categories.map(item => item.id) || [], + challengeId: post?.challengeIds?.[0] || undefined, + content: post?.content || '', + industryIds: post?.industries.map(item => item.id) || [], + title: post?.title || '', + } +} + +async function loadProjectChallenges( + projectId: string, + inputValue: string, +): Promise { + const response = await fetchChallenges( + { name: inputValue, projectId }, + { page: 1, perPage: 20 }, + ) + + return response.data.map(challenge => ({ + label: challenge.name, + value: challenge.id, + })) +} + +async function resolveTaxonomyIds< + T extends ProjectShowcasePostCategory | ProjectShowcasePostIndustry, +>( + selectedIds: string[], + options: SelectOption[], + createEntity: (name: string) => Promise, +): Promise { + const existingIds = new Set(options.map(option => option.value) + .filter(Boolean)) + const createdNames: string[] = [] + const createdNameSet = new Set() + const resolvedIds: string[] = [] + + for (const selectedId of selectedIds) { + const trimmedId = selectedId.trim() + if (trimmedId) { + if (existingIds.has(trimmedId)) { + resolvedIds.push(trimmedId) + } else if (!createdNameSet.has(trimmedId)) { + createdNameSet.add(trimmedId) + createdNames.push(trimmedId) + } + } + } + + const createdItems = await Promise.all( + createdNames.map(name => createEntity(name)), + ) + + return [ + ...resolvedIds, + ...createdItems.map(item => item.id), + ] +} + function createTaxonomyOptions( items: Array, defaultLabel: string, @@ -353,6 +437,178 @@ export const ProjectShowcasePage: FC = () => { const handleSortByIndustry = useCallback(() => handleSort('industry'), [handleSort]) const handleSortByCategory = useCallback(() => handleSort('category'), [handleSort]) + const [isManageModalOpen, setIsManageModalOpen] = useState(false) + const [manageMode, setManageMode] = useState<'create' | 'edit'>('create') + const [selectedPostId, setSelectedPostId] = useState(undefined) + const [isLoadingPostDetails, setIsLoadingPostDetails] = useState(false) + const [selectedChallengeOption, setSelectedChallengeOption] = useState(undefined) + const [isSaving, setIsSaving] = useState(false) + const [formError, setFormError] = useState(undefined) + const [isPublishing, setIsPublishing] = useState(false) + const [isUnpublishing, setIsUnpublishing] = useState(false) + const [isRestoring, setIsRestoring] = useState(false) + const confirmation = useConfirmationModal() + + const handleOpenCreateModal = useCallback(() => { + setManageMode('create') + setIsManageModalOpen(true) + }, []) + + const handleEditPost = useCallback((postId: string) => { + setManageMode('edit') + setSelectedPostId(postId) + setIsManageModalOpen(true) + }, []) + + const handlePublishPost = useCallback(async (post: ProjectShowcasePost) => { + if (!projectId) { + return + } + + const confirmed = await confirmation.confirm({ + content: ( +

+ Are you sure you want to publish the post + {' '} + {post.title} + ? +

+ ), + title: 'Publish Post', + }) + + if (!confirmed) { + return + } + + setIsPublishing(true) + + try { + await updateProjectShowcasePost(projectId, post.id, { + status: 'PUBLISHED', + }) + await postsResult.mutate() + showSuccessToast('Post published successfully') + } catch (err) { + showErrorToast(err instanceof Error ? err.message : 'Unable to publish post.') + } finally { + setIsPublishing(false) + } + }, [confirmation, postsResult, projectId]) + + const handleUnpublishPost = useCallback(async (post: ProjectShowcasePost) => { + if (!projectId) { + return + } + + const confirmed = await confirmation.confirm({ + content: ( +

+ Are you sure you want to unpublish the post + {' '} + {post.title} + ? +

+ ), + title: 'Unpublish Post', + }) + + if (!confirmed) { + return + } + + setIsUnpublishing(true) + + try { + await updateProjectShowcasePost(projectId, post.id, { + status: 'DRAFT', + }) + await postsResult.mutate() + showSuccessToast('Post unpublished successfully') + } catch (err) { + showErrorToast(err instanceof Error ? err.message : 'Unable to unpublish post.') + } finally { + setIsUnpublishing(false) + } + }, [confirmation, postsResult, projectId]) + + const handleRestorePost = useCallback(async (post: ProjectShowcasePost) => { + if (!projectId) { + return + } + + const confirmed = await confirmation.confirm({ + content: ( +

+ Are you sure you want to restore the archived post + {' '} + {post.title} + ? +

+ ), + title: 'Restore Post', + }) + + if (!confirmed) { + return + } + + setIsRestoring(true) + + try { + await updateProjectShowcasePost(projectId, post.id, { + status: 'DRAFT', + }) + await postsResult.mutate() + showSuccessToast('Post restored successfully') + } catch (err) { + showErrorToast(err instanceof Error ? err.message : 'Unable to restore post.') + } finally { + setIsRestoring(false) + } + }, [confirmation, postsResult, projectId]) + + const handleArchivePost = useCallback(async (post: ProjectShowcasePost) => { + if (!projectId) { + return + } + + const confirmed = await confirmation.confirm({ + content: ( +

+ Are you sure you want to archive the post + {' '} + {post.title} + ? +

+ ), + title: 'Archive Post', + }) + + if (!confirmed) { + return + } + + try { + await archiveProjectShowcasePost(projectId, post.id) + await postsResult.mutate() + showSuccessToast('Post archived successfully') + } catch (err) { + showErrorToast(err instanceof Error ? err.message : 'Unable to archive post.') + } + }, [confirmation, postsResult, projectId]) + + const formMethods = useForm({ + defaultValues: mapPostToFormData(), + mode: 'all', + }) + + const { + handleSubmit, + reset, + setError, + }: UseFormReturn = formMethods + const handleResetFilters = useCallback(() => { setFilters({ categoryId: undefined, @@ -378,16 +634,85 @@ export const ProjectShowcasePage: FC = () => { .catch(() => undefined) }, [categoriesResult, industriesResult, postsResult]) + const formChallengeOptions = useMemo((): FormSelectOption[] => ( + selectedChallengeOption ? [selectedChallengeOption] : [] + ), [selectedChallengeOption]) + + const pageWrapperActions = useMemo(() => ( + + Actions {isLoading && filteredPosts.length === 0 && ( - + @@ -468,7 +794,7 @@ export const ProjectShowcasePage: FC = () => { {!isLoading && filteredPosts.length === 0 && ( - + No showcase posts found. @@ -497,6 +823,55 @@ export const ProjectShowcasePage: FC = () => { .map(item => item.name) .join(', ') || '—'} + + {post.status !== 'ARCHIVED' && ( + + )} + {post.status === 'DRAFT' && ( + + )} + {post.status === 'PUBLISHED' && ( + + )} + {post.status === 'ARCHIVED' ? ( + + ) : ( + + )} + ))} @@ -514,6 +889,193 @@ export const ProjectShowcasePage: FC = () => { /> )} + + + +
{ + if (!projectId) { + return + } + + setFormError(undefined) + setIsSaving(true) + + if (!data.title.trim()) { + setError('title', { message: 'Title is required.', type: 'required' }) + } + + if (!data.content.trim()) { + setError('content', { message: 'Content is required.', type: 'required' }) + } + + if (!data.industryIds.length) { + setError('industryIds', { message: 'Select at least one industry.', type: 'required' }) + } + + if (!data.categoryIds.length) { + setError('categoryIds', { message: 'Select at least one category.', type: 'required' }) + } + + if ( + !data.title.trim() + || !data.content.trim() + || !data.industryIds.length + || !data.categoryIds.length + ) { + setIsSaving(false) + return + } + + try { + const resolvedIndustryIds = await resolveTaxonomyIds( + data.industryIds, + industryOptions, + createProjectShowcasePostIndustry, + ) + const resolvedCategoryIds = await resolveTaxonomyIds( + data.categoryIds, + categoryOptions, + createProjectShowcasePostCategory, + ) + + if (manageMode === 'create') { + await createProjectShowcasePost(projectId, { + categoryIds: resolvedCategoryIds, + challengeIds: data.challengeId ? [data.challengeId] : [], + content: data.content.trim(), + industryIds: resolvedIndustryIds, + title: data.title.trim(), + }) + setIsManageModalOpen(false) + await Promise.all([ + postsResult.mutate(), + industriesResult.mutate(), + categoriesResult.mutate(), + ]) + showSuccessToast('Post created successfully') + } else if (selectedPostId) { + await updateProjectShowcasePost(projectId, selectedPostId, { + categoryIds: resolvedCategoryIds, + challengeIds: data.challengeId ? [data.challengeId] : [], + content: data.content.trim(), + industryIds: resolvedIndustryIds, + title: data.title.trim(), + }) + setIsManageModalOpen(false) + await Promise.all([ + postsResult.mutate(), + industriesResult.mutate(), + categoriesResult.mutate(), + ]) + showSuccessToast('Post updated successfully') + } + } catch (err) { + const message = err instanceof Error + ? err.message + : 'Unable to save post.' + setFormError(message) + } finally { + setIsSaving(false) + } + })} + > +
+ {formError && ( +
+ +
+ )} + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
+
+ + {confirmation.modal} ) }