-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #360 from ssu-student-union/develop
2차 배포 - 인권신고게시판 & 건의게시판 mockup
- Loading branch information
Showing
57 changed files
with
3,371 additions
and
427 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,7 +28,6 @@ | |
"@radix-ui/react-tooltip": "^1.0.7", | ||
"@tanstack/react-query": "^5.28.9", | ||
"@toast-ui/react-editor": "^3.2.3", | ||
"@types/react-dropzone": "^5.1.0", | ||
"axios": "^1.6.8", | ||
"class-variance-authority": "^0.7.0", | ||
"clsx": "^2.1.0", | ||
|
@@ -58,6 +57,7 @@ | |
"zod": "^3.23.8" | ||
}, | ||
"devDependencies": { | ||
"@toast-ui/editor": "^3.2.2", | ||
"@types/node": "^20.12.2", | ||
"@types/react": "^18.2.66", | ||
"@types/react-dom": "^18.2.22", | ||
|
@@ -78,7 +78,9 @@ | |
"vite": "^5.2.0" | ||
}, | ||
"resolutions": { | ||
"rollup": "4.24.0" | ||
"rollup": "4.24.0", | ||
"react": "^18.3.0", | ||
"@types/react": "^18.3.0" | ||
}, | ||
"packageManager": "[email protected].1" | ||
"packageManager": "[email protected].3" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
import { RefAttributes } from 'react'; | ||
import { cn } from '@/libs/utils.ts'; | ||
import dayjs from 'dayjs'; | ||
import { Skeleton } from '@/components/ui/skeleton.tsx'; | ||
import { Link, LinkProps } from 'react-router-dom'; | ||
|
||
interface PostContentProp<C extends string> extends LinkProps, RefAttributes<HTMLAnchorElement> { | ||
category: { | ||
name: C; | ||
className: string; | ||
}; | ||
date: Date; | ||
title: string; | ||
author?: string; | ||
className?: string; | ||
} | ||
|
||
/** | ||
* # 일반 게시판 목록 게시글 항목 | ||
* | ||
* 일반 형태의 게시판 목록에서 사용할 수 있는 게시글 항목 컴포넌트입니다. | ||
* 일반적으로 `BodyLayout` 아래에 리스트 형태 아이템으로 표시할 수 있습니다. | ||
* C는 literal union이길 권장합니다만, 상황에 따라 다양한 타입을 넣을 수 있습니다. | ||
*/ | ||
export function PostContent<C extends string>({ category, title, author, date, ...props }: PostContentProp<C>) { | ||
const formattedDate = dayjs(date).format('YYYY/MM/DD'); | ||
|
||
return ( | ||
<Link {...props} className={cn('flex gap-5 border-b border-b-gray-400 p-5 font-medium')}> | ||
<div className={cn('text-nowrap', category.className)}>[{category.name}]</div> | ||
{/* 잘못된 tailwind.config.js: `min-`, `max-` prefix로 range가 지원되는데 왜 이렇게 breakpoint를 짰을까요?? | ||
* Reference: https://tailwindcss.com/docs/responsive-design#targeting-mobile-screens | ||
*/} | ||
<div className="flex basis-full justify-between gap-5 xs:flex-col sm:flex-col"> | ||
<div className="max-md:basis-full">{title}</div> | ||
<div className="max-md:basis-full flex justify-between gap-5"> | ||
<span>{author}</span> | ||
<span className="text-gray-500">{formattedDate}</span> | ||
</div> | ||
</div> | ||
</Link> | ||
); | ||
} | ||
|
||
PostContent.Skeleton = () => { | ||
return ( | ||
<div className={cn('flex gap-5 border-b border-b-gray-200 p-5 font-medium')}> | ||
<Skeleton className={cn('h-6 w-[6ch] text-nowrap')} /> | ||
<div className="flex basis-full justify-between gap-5 xs:flex-col sm:flex-col"> | ||
<Skeleton className="max-md:basis-full h-6 w-[20ch]" /> | ||
<div className="max-md:basis-full flex justify-between gap-5"> | ||
<Skeleton className="h-6 w-[4ch]" /> | ||
<Skeleton className="h-6 w-[10ch]" /> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import { RefObject, useRef, useState } from 'react'; | ||
import { EditorType, HookMap, PreviewStyle } from '@toast-ui/editor'; | ||
import { Editor } from '@toast-ui/react-editor'; | ||
import { clientAuth } from '@/apis/client.ts'; | ||
import { PostFileResponse, UploadFilesResponse } from '@/pages/human-rights/hooks/mutations/useUploadFiles.ts'; | ||
import { ApiResponse } from '@/pages/human-rights/hooks/useStuQuery.ts'; | ||
import { FileResponse } from '@/types/apis/get'; | ||
|
||
interface UseContentEditorReturn { | ||
register: { | ||
hooks: HookMap; | ||
ref: RefObject<Editor>; | ||
previewStyle: PreviewStyle; | ||
initialEditType: EditorType; | ||
hideModeSwitch: boolean; | ||
language: string; | ||
autofocus: boolean; | ||
}; | ||
isImageProcessing: boolean; | ||
processImages: (uploadedFiles?: FileResponse[]) => Promise<{ | ||
existedImages: FileResponse[]; | ||
newImages: PostFileResponse[]; | ||
content: string; | ||
}>; | ||
} | ||
|
||
async function postBoardImages(boardCode: string, files: FormData) { | ||
return await clientAuth<ApiResponse<UploadFilesResponse>>({ | ||
url: `/board/${boardCode}/files`, | ||
method: 'post', | ||
data: files, | ||
headers: { | ||
'Content-Type': 'multipart/form-data', | ||
}, | ||
}); | ||
} | ||
|
||
/** | ||
* useContentEditor 훅 | ||
* | ||
* `ContentEditor`의 동작을 정의하는 훅입니다. `Editor` 컴포넌트에 `register` 값을 분해 할당하면 됩니다. | ||
* 첨부 이미지의 처리는 아래의 단계를 따릅니다. | ||
* 1. 이미지 첨부 시 이미지의 `Blob`을 `objectUrl`로 변경하여 표시 | ||
* 2. `processImages` 호출 시 로컬의 이미지를 업로드 시도 | ||
* 3. 업로드된 이미지의 URL으로 기존 objectURL을 대체 | ||
* 4. 업로드된 이미지 정보와 대체된 컨텐츠 문자열 반환 | ||
* | ||
* @param boardCode - 게시판 코드 | ||
* @param ref - `Editor` 컴포넌트의 ref | ||
* @returns | ||
* `register` - `Editor` 컴포넌트에 분해 할당하여 에디터를 훅에 등록합니다. | ||
* `isImageProcessing` - 이미지가 업로드 중이라면 `true`를 반환합니다. | ||
* `processImages` - 이미지를 업로드하고 처리하는 함수입니다. 이미 업로드된 이미지의 `FileResponse` 배열을 인자로 받아, 최종적으로 에디터에 존재하는 파일 목록을 반환합니다. | ||
* 반환 값을 그대로 `postFileList`에 넣을 수 있습니다(첨부파일이 있다면 합쳐야 합니다). | ||
* | ||
* @example | ||
* ```tsx | ||
* const editorRef = useRef<Editor>(null); | ||
* const { register, processImages, isImageProcessing } = useContentEditor('청원게시판', editorRef); | ||
* return (<div> | ||
* <Editor {...register} /> | ||
* <p>{isImageProcessing && '이미지 업로드 중...'}</p> | ||
* </div>); | ||
* ``` | ||
*/ | ||
// TODO: Define boardCode as enum | ||
export function useContentEditor(boardCode: string, ref: RefObject<Editor>): UseContentEditorReturn { | ||
const imageObjectUrlsRef = useRef<[File | Blob, string][]>([]); | ||
const [isImageProcessing, setIsImageProcessing] = useState(false); | ||
|
||
function addImageBlobHook(file: File | Blob, callback: (url: string, text?: string) => void) { | ||
if (file) { | ||
const objectUrl = URL.createObjectURL(file); | ||
imageObjectUrlsRef.current.push([file, objectUrl]); | ||
if ('name' in file) { | ||
callback(objectUrl, file.name); | ||
} else { | ||
callback(objectUrl, 'image'); | ||
} | ||
} | ||
return false; | ||
} | ||
|
||
async function processImages(existedImages: FileResponse[] = []) { | ||
setIsImageProcessing(true); | ||
try { | ||
const markdownContent = ref.current!.getInstance().getMarkdown(); | ||
const imageUploadPromises = imageObjectUrlsRef.current.map(([image, objectUrl]) => { | ||
async function uploadImage() { | ||
const formData = new FormData(); | ||
formData.append('images', image); | ||
const imageResponse = await postBoardImages(boardCode, formData); | ||
return { | ||
objectUrl, | ||
success: imageResponse.data.isSuccess, | ||
...(imageResponse.data.isSuccess ? { files: imageResponse.data.data.postFiles } : {}), | ||
}; | ||
} | ||
|
||
return uploadImage(); | ||
}); | ||
const uploadedImages = await Promise.all(imageUploadPromises); | ||
const processedContent = uploadedImages.reduce((content, { objectUrl, success, files }) => { | ||
if (success && files) { | ||
URL.revokeObjectURL(objectUrl); | ||
return content.replace(objectUrl, files[0].url); | ||
} | ||
return content; | ||
}, markdownContent); | ||
imageObjectUrlsRef.current = []; | ||
setIsImageProcessing(false); | ||
return { | ||
existedImages: existedImages.filter(({ fileUrl }) => processedContent.includes(fileUrl)), | ||
newImages: uploadedImages.filter(({ files }) => files).flatMap(({ files }) => files as PostFileResponse[]), | ||
content: processedContent, | ||
}; | ||
} catch (error) { | ||
setIsImageProcessing(false); | ||
throw error; | ||
} | ||
} | ||
|
||
return { | ||
register: { | ||
hooks: { addImageBlobHook }, | ||
ref: ref, | ||
previewStyle: 'vertical', | ||
initialEditType: 'wysiwyg', | ||
hideModeSwitch: true, | ||
language: 'ko-KR', | ||
autofocus: false, | ||
}, | ||
isImageProcessing, | ||
processImages, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { FileResponse } from '@/types/apis/get'; | ||
import { DownloadSimple } from '@phosphor-icons/react'; | ||
|
||
type AttachmentProps = FileResponse; | ||
|
||
export function Attachment({ fileName, fileUrl }: AttachmentProps) { | ||
return ( | ||
<a | ||
href={fileUrl} | ||
target="_blank" | ||
className="flex items-center gap-4 rounded-xs border border-gray-200 p-5 text-gray-600" | ||
> | ||
<DownloadSimple size="24px" /> | ||
{fileName} | ||
</a> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { Viewer } from '@toast-ui/react-editor'; | ||
import { useRef } from 'react'; | ||
|
||
interface ContentViewerProps { | ||
content?: string; | ||
} | ||
|
||
export function ContentViewer({ content }: ContentViewerProps) { | ||
const viewerRef = useRef<Viewer>(null); | ||
return <Viewer ref={viewerRef} initialValue={content} />; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { ButtonHTMLAttributes, forwardRef, RefObject, useEffect, useRef, useState } from 'react'; | ||
import { cn } from '@/libs/utils.ts'; | ||
|
||
export interface DropdownButtonItem { | ||
id: string; | ||
text: string; | ||
} | ||
|
||
interface DropdownButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { | ||
items: DropdownButtonItem[]; | ||
onItemClick: (id: string) => void; | ||
} | ||
|
||
function useOutsideClick<T extends Node>(ref: RefObject<T>, onOutsideClick: () => void) { | ||
useEffect(() => { | ||
const handler = (evt: MouseEvent) => { | ||
if (ref.current && !ref.current.contains(evt.target as Node)) { | ||
onOutsideClick(); | ||
} | ||
}; | ||
document.addEventListener('mousedown', handler); | ||
return () => { | ||
document.removeEventListener('mousedown', handler); | ||
}; | ||
}, [onOutsideClick, ref]); | ||
} | ||
|
||
const DropdownMenu = forwardRef<HTMLUListElement, Pick<DropdownButtonProps, 'items' | 'onItemClick' | 'className'>>( | ||
({ items, onItemClick, className }, ref) => { | ||
return ( | ||
<ul ref={ref} className={cn('absolute min-w-fit rounded-md bg-white text-[#374151] drop-shadow', className)}> | ||
{items.map((item) => ( | ||
<li | ||
className="cursor-pointer select-none text-nowrap px-8 py-2 text-center text-xs font-medium first:rounded-t-md last:rounded-b-md hover:bg-gray-200 active:bg-gray-300" | ||
key={item.id} | ||
onClick={() => onItemClick(item.id)} | ||
> | ||
{item.text} | ||
</li> | ||
))} | ||
</ul> | ||
); | ||
} | ||
); | ||
|
||
export function DropdownButton({ items, onItemClick, children, className, ...props }: DropdownButtonProps) { | ||
const ref = useRef<HTMLDivElement>(null); | ||
const [opened, setOpened] = useState(false); | ||
|
||
useOutsideClick<HTMLDivElement>(ref, () => opened && setOpened(false)); | ||
return ( | ||
<div ref={ref}> | ||
<button className={className} onClick={() => setOpened(!opened)} {...props}> | ||
{children} | ||
</button> | ||
<div className="relative"> | ||
<DropdownMenu | ||
className={cn( | ||
'right-0 top-0 transition-all', | ||
opened ? 'visible scale-100 opacity-100' : 'invisible scale-90 opacity-0' | ||
)} | ||
items={items} | ||
onItemClick={(id) => { | ||
setOpened(false); | ||
onItemClick(id); | ||
}} | ||
/> | ||
</div> | ||
</div> | ||
); | ||
} |
Oops, something went wrong.