Skip to content

Commit

Permalink
Merge pull request #360 from ssu-student-union/develop
Browse files Browse the repository at this point in the history
2차 배포 - 인권신고게시판 & 건의게시판 mockup
  • Loading branch information
EATSTEAK authored Dec 8, 2024
2 parents ba623d2 + 285c4e7 commit 21dbc95
Show file tree
Hide file tree
Showing 57 changed files with 3,371 additions and 427 deletions.
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
}
9 changes: 9 additions & 0 deletions src/components/Board/BoardSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,12 @@ export function BoardSelector<T extends string>({
</div>
);
}

BoardSelector.Skeleton = () => {
return (
<div className={cn(`flex flex-wrap gap-2`)}>
<Category.Skeleton />
<Category.Skeleton />
</div>
);
};
11 changes: 11 additions & 0 deletions src/components/Category/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from '@/libs/utils';
import { Skeleton } from '@/components/ui/skeleton.tsx';

interface CategoryProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isActive?: boolean;
Expand All @@ -22,3 +23,13 @@ export function Category({ isActive = false, children, className = '', ...props
</button>
);
}

Category.Skeleton = () => {
return (
<Skeleton
className={cn(
`h-[38px] w-[6ch] rounded-[32px] text-[1.125rem] xs:h-[31px] xs:text-[0.875rem] sm:h-[31px] sm:text-[0.875rem]`
)}
/>
);
};
58 changes: 58 additions & 0 deletions src/components/PostContent/PostContent.tsx
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>
);
};
2 changes: 1 addition & 1 deletion src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const buttonVariants = cva(
Register:
'bg-[#2F4BF7] hover:bg-[#2F4BF7]/90 w-[105px] md:w-full sm:w-full xs:w-full h-10 text-white text-lg xs:text-xs text-center font-semibold rounded-[7px]',
List_Edit:
'pl-2 flex gap-2 pr-6 w-32 h-10 bg-white border border-gray-400 text-lg text-center font-semibold text-gray-700 hover:border-primary hover:bg-white hover:text-primary',
'flex gap-2 w-32 h-10 bg-white border border-gray-400 text-lg text-center font-semibold text-gray-700 hover:border-primary hover:bg-white hover:text-primary',
Write:
'pl-5 flex gap-1 pr-6 w-32 h-10 bg-white border border-gray-400 text-lg text-center font-semibold text-gray-700 hover:border-primary hover:bg-white hover:text-primary',
},
Expand Down
4 changes: 2 additions & 2 deletions src/containers/common/Header/const/pathData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export const menuItems = {
],
소통: [
{ name: '학생청원게시판', path: '/petition-notice' },
// { name: '건의게시판', path: '/sug-notice' },
// { name: '인권신고게시판', path: '/human-notice' },
{ name: '건의게시판', path: '/sug-notice' },
{ name: '인권신고게시판', path: '/human-rights' },
],
};

Expand Down
136 changes: 136 additions & 0 deletions src/hooks/useContentEditor.ts
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,
};
}
3 changes: 1 addition & 2 deletions src/pages/audit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@ import { useCategory } from './hooks/useCategory';

export function AuditPage() {
const boardCode = '감사기구게시판';
const navigate = useNavigate();
const { category } = useCategory();
const { data, totalPages, currentPage, handlePageChange, subcategories, isLoading } = useAuditBoard(
boardCode,
category
);

const navigate = useNavigate();

return (
<>
<HeadLayout
Expand Down
17 changes: 17 additions & 0 deletions src/pages/human-rights/[id]/components/Attachment.tsx
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>
);
}
11 changes: 11 additions & 0 deletions src/pages/human-rights/[id]/components/ContentViewer.tsx
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} />;
}
71 changes: 71 additions & 0 deletions src/pages/human-rights/[id]/components/DropdownButton.tsx
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>
);
}
Loading

0 comments on commit 21dbc95

Please sign in to comment.