diff --git a/package-lock.json b/package-lock.json index a7ddb4f..8cc3961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", @@ -1685,6 +1686,82 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", + "integrity": "sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-scroll-area": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.1.tgz", diff --git a/package.json b/package.json index 520cf77..076bae7 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-scroll-area": "^1.2.1", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", diff --git a/src/components/pages/post/PostCard.tsx b/src/components/pages/post/PostCard.tsx index da62d3c..9862be3 100644 --- a/src/components/pages/post/PostCard.tsx +++ b/src/components/pages/post/PostCard.tsx @@ -1,4 +1,3 @@ -import DOMPurify from 'dompurify'; import { MessageCircle, User } from 'lucide-react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -8,6 +7,7 @@ import { Post } from '@/types/Posts'; import { User as Author } from '@/types/User'; import { fetchUserData } from '@/utils/userUtils'; import { Badge } from '@/components/ui/badge'; +import { getContentPreview } from '@/utils/contentUtils'; interface PostCardProps { post: Post; @@ -15,13 +15,13 @@ interface PostCardProps { } const PostCard: React.FC = ({ post, onClick }) => { - const sanitizedContent = DOMPurify.sanitize(post.content); + const contentPreview = getContentPreview(post.content); const { data: authorData, error } = useQuery( ['authorData', post.authorId], () => fetchUserData(post.authorId), { - staleTime: 60 * 1000, // 1 minute + staleTime: 60 * 1000, } ); @@ -30,7 +30,7 @@ const PostCard: React.FC = ({ post, onClick }) => { } return ( - +
{post.weekDaysFromFirstDay !== undefined && ( @@ -61,12 +61,21 @@ const PostCard: React.FC = ({ post, onClick }) => {
+ className='prose prose-lg prose-slate dark:prose-invert line-clamp-3' + dangerouslySetInnerHTML={{ __html: contentPreview }} + /> + {post.thumbnailImageURL && ( +
+ 게시글 썸네일 +
+ )}
- +

{post.countOfComments + post.countOfReplies}

diff --git a/src/components/pages/post/PostTextEditor.tsx b/src/components/pages/post/PostTextEditor.tsx index 35115bf..e3e487f 100644 --- a/src/components/pages/post/PostTextEditor.tsx +++ b/src/components/pages/post/PostTextEditor.tsx @@ -1,5 +1,9 @@ -import React, { useEffect } from 'react'; +import { useEffect, useRef, useMemo, useState } from 'react'; import ReactQuill from 'react-quill-new'; +import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'; +import { storage } from '../../../firebase'; +import { useToast } from '@/hooks/use-toast'; +import { Progress } from '@/components/ui/progress'; import 'react-quill-new/dist/quill.snow.css'; interface PostTextEditorProps { @@ -121,24 +125,134 @@ const quillStyles = ` } `; -const modules = { - toolbar: [ - ['bold', 'underline', 'strike'], - ['blockquote'], - [{ 'header': 1 }, { 'header': 2 }], - [{ 'list': 'ordered'}, { 'list': 'bullet' }], - ['link'] - ] +const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + return { + dateFolder: `${year}${month}${day}`, + timePrefix: `${hours}${minutes}${seconds}` + }; }; -const formats = [ - 'bold', 'underline', 'strike', - 'blockquote', 'header', - 'list', - 'link' -]; +export function PostTextEditor({ + value, + onChange, + placeholder = '내용을 입력하세요...', +}: PostTextEditorProps) { + const quillRef = useRef(null); + const { toast } = useToast(); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + + const imageHandler = async () => { + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', 'image/*'); + input.click(); + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + + try { + setIsUploading(true); + setUploadProgress(0); + + // 파일 크기 체크 (5MB) + if (file.size > 5 * 1024 * 1024) { + toast({ + title: "오류", + description: "파일 크기는 5MB를 초과할 수 없습니다.", + variant: "destructive", + }); + return; + } + + // 파일 타입 체크 + if (!file.type.startsWith('image/')) { + toast({ + title: "오류", + description: "이미지 파일만 업로드할 수 있습니다.", + variant: "destructive", + }); + return; + } + + // 업로드 시작 표시 + setUploadProgress(20); + + // 날짜 기반 파일 경로 생성 + const now = new Date(); + const { dateFolder, timePrefix } = formatDate(now); + const fileName = `${timePrefix}_${file.name}`; + const storageRef = ref(storage, `postImages/${dateFolder}/${fileName}`); + + // 파일 업로드 + setUploadProgress(40); + const snapshot = await uploadBytes(storageRef, file); + setUploadProgress(70); + + // URL 가져오기 + const downloadURL = await getDownloadURL(snapshot.ref); + setUploadProgress(90); + + // 에디터에 이미지 삽입 + const editor = quillRef.current?.getEditor(); + const range = editor?.getSelection(true); + editor?.insertEmbed(range?.index, 'image', downloadURL); + + setUploadProgress(100); + toast({ + title: "성공", + description: "이미지가 업로드되었습니다.", + }); + + } catch (error) { + console.error('Image upload error:', error); + toast({ + title: "오류", + description: "이미지 업로드에 실패했습니다.", + variant: "destructive", + }); + } finally { + // 약간의 딜레이 후 로딩 상태 초기화 + setTimeout(() => { + setIsUploading(false); + setUploadProgress(0); + }, 500); + } + }; + }; + + const formats = [ + 'bold', 'underline', 'strike', + 'blockquote', 'header', + 'list', 'link', 'image' + ]; + + const modules = useMemo( + () => ({ + toolbar: { + container: [ + ['bold', 'underline', 'strike'], + ['blockquote'], + [{ 'header': 1 }, { 'header': 2 }], + [{ 'list': 'ordered'}, { 'list': 'bullet' }], + ['link', 'image'], + ], + handlers: { + image: imageHandler, + }, + }, + }), + [toast] + ); -export function PostTextEditor({ value, onChange, placeholder = '내용을 입력하세요...' }: PostTextEditorProps) { useEffect(() => { const styleTag = document.createElement('style'); styleTag.textContent = quillStyles; @@ -150,16 +264,27 @@ export function PostTextEditor({ value, onChange, placeholder = '내용을 입 }, []); return ( -
- +
+ {isUploading && ( +
+ +

+ 이미지 업로드 중... {uploadProgress}% +

+
+ )} +
+ +
); } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..105fb65 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/types/Posts.ts b/src/types/Posts.ts index 0c34213..39c28aa 100644 --- a/src/types/Posts.ts +++ b/src/types/Posts.ts @@ -4,6 +4,7 @@ export interface Post { boardId: string; title: string; content: string; + thumbnailImageURL?: string; authorId: string; authorName: string; createdAt?: Date; diff --git a/src/utils/contentUtils.ts b/src/utils/contentUtils.ts index 27b9899..f251bfe 100644 --- a/src/utils/contentUtils.ts +++ b/src/utils/contentUtils.ts @@ -1,3 +1,5 @@ +import DOMPurify from 'dompurify'; + function convertUrlsToLinks(content: string): string { const urlRegex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|]|\bwww\.[-A-Z0-9+&@#\\/%?=~_|!:,.;]*[-A-Z0-9+&@#\\/%=~_|]|\b[-A-Z0-9+&@#\\/%?=~_|!:,.;]+\.[A-Z]{2,4}\b)/gi; @@ -7,4 +9,40 @@ function convertUrlsToLinks(content: string): string { }); } -export { convertUrlsToLinks }; +const getContentPreview = (content: string) => { + // 1. XSS 방지를 위한 콘텐츠 정제 + const sanitizedContent = DOMPurify.sanitize(content); + + // 2. 이미지와 제목 태그 처리 + const processContent = (html: string) => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // 이미지 태그 제거 + // - 이미지는 별도의 썸네일 영역에 표시되므로 미리보기에서는 제거 + // - 이미지가 포함되면 미리보기의 높이가 불규칙해지는 것을 방지 + const images = tempDiv.getElementsByTagName('img'); + while(images.length > 0) { + images[0].parentNode?.removeChild(images[0]); + } + + // 제목 태그(h1~h6)를 p 태그로 변환 + // - 제목 태그는 큰 여백과 큰 글자 크기를 가져 미리보기의 공간을 비효율적으로 사용 + // - 카드 형태의 미리보기에서는 모든 텍스트가 일관된 크기로 표시되는 것이 더 깔끔 + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach(tag => { + const headings = tempDiv.getElementsByTagName(tag); + while(headings.length > 0) { + const heading = headings[0]; + const p = document.createElement('p'); + p.innerHTML = heading.innerHTML; + heading.parentNode?.replaceChild(p, heading); + } + }); + + return tempDiv.innerHTML; + }; + + return processContent(sanitizedContent); +}; + +export { convertUrlsToLinks, getContentPreview }; \ No newline at end of file diff --git a/src/utils/mapDocToPost.ts b/src/utils/mapDocToPost.ts index 2328967..3ae3578 100644 --- a/src/utils/mapDocToPost.ts +++ b/src/utils/mapDocToPost.ts @@ -2,8 +2,9 @@ import { Post } from "../types/Posts"; import { QueryDocumentSnapshot, DocumentData } from "firebase/firestore"; export async function mapDocToPost(docSnap: QueryDocumentSnapshot): Promise { - const data = docSnap.data(); - return { + const data = docSnap.data() + // convert data into Post type + const post: Post = { id: docSnap.id, boardId: data.boardId, title: data.title, @@ -15,5 +16,7 @@ export async function mapDocToPost(docSnap: QueryDocumentSnapshot) createdAt: data.createdAt?.toDate(), updatedAt: data.updatedAt?.toDate(), weekDaysFromFirstDay: data.weekDaysFromFirstDay, + thumbnailImageURL: data.thumbnailImageURL, }; + return post; } \ No newline at end of file diff --git a/src/utils/postUtils.ts b/src/utils/postUtils.ts index 29e2c80..c1aa919 100644 --- a/src/utils/postUtils.ts +++ b/src/utils/postUtils.ts @@ -42,6 +42,7 @@ export async function createPost(boardId: string, title: string, content: string boardId, title, content, + thumbnailImageURL: extractFirstImageUrl(content), authorId, authorName, countOfComments: 0, @@ -55,6 +56,7 @@ export async function updatePost(boardId: string, postId: string, content: strin const docRef = doc(firestore, `boards/${boardId}/posts`, postId); await updateDoc(docRef, { content, + thumbnailImageURL: extractFirstImageUrl(content), updatedAt: serverTimestamp(), }); } @@ -71,4 +73,16 @@ export const fetchAdjacentPosts = async (boardId: string, currentPostId: string) prevPost: currentIndex < posts.length - 1 ? posts[currentIndex + 1].id : null, nextPost: currentIndex > 0 ? posts[currentIndex - 1].id : null }; -}; \ No newline at end of file +}; + +export const extractFirstImageUrl = (content: string): string | undefined => { + try { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; + const firstImage = tempDiv.querySelector('img'); + return firstImage?.src || undefined; + } catch (error) { + console.error('Error extracting image URL:', error); + return undefined; + } +};