Skip to content

Commit

Permalink
Merge pull request #98 from BumgeunSong/feat/editor-image
Browse files Browse the repository at this point in the history
글 에디터에서 이미지를 업로드할 수 있게 된다
  • Loading branch information
BumgeunSong authored Dec 27, 2024
2 parents 396d4d4 + c535ab2 commit d3dab74
Show file tree
Hide file tree
Showing 9 changed files with 332 additions and 38 deletions.
77 changes: 77 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 17 additions & 8 deletions src/components/pages/post/PostCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,20 +7,21 @@ 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;
onClick: () => void;
}

const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
const sanitizedContent = DOMPurify.sanitize(post.content);
const contentPreview = getContentPreview(post.content);

const { data: authorData, error } = useQuery<Author | null>(
['authorData', post.authorId],
() => fetchUserData(post.authorId),
{
staleTime: 60 * 1000, // 1 minute
staleTime: 60 * 1000,
}
);

Expand All @@ -30,7 +30,7 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
}

return (
<Card className='mb-4'>
<Card>
<CardHeader>
<div className='flex items-center gap-2'>
{post.weekDaysFromFirstDay !== undefined && (
Expand Down Expand Up @@ -61,12 +61,21 @@ const PostCard: React.FC<PostCardProps> = ({ post, onClick }) => {
<Link to={`/board/${post.boardId}/post/${post.id}`} onClick={onClick}>
<CardContent className='cursor-pointer p-6 transition-colors duration-200 hover:bg-muted'>
<div
className='line-clamp-5'
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
></div>
className='prose prose-lg prose-slate dark:prose-invert line-clamp-3'
dangerouslySetInnerHTML={{ __html: contentPreview }}
/>
{post.thumbnailImageURL && (
<div className='aspect-video w-full overflow-hidden rounded-lg bg-muted'>
<img
src={post.thumbnailImageURL}
alt="게시글 썸네일"
className='h-full w-full object-cover transition-transform duration-300 hover:scale-105'
/>
</div>
)}
</CardContent>
</Link>
<CardFooter>
<CardFooter className='pt-2'>
<div className='flex items-center'>
<MessageCircle className='mr-1 size-4' />
<p className='text-sm'>{post.countOfComments + post.countOfReplies}</p>
Expand Down
177 changes: 151 additions & 26 deletions src/components/pages/post/PostTextEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<any>(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;
Expand All @@ -150,16 +264,27 @@ export function PostTextEditor({ value, onChange, placeholder = '내용을 입
}, []);

return (
<div className='rounded-lg border border-border bg-background'>
<ReactQuill
value={value}
onChange={onChange}
placeholder={placeholder}
theme="snow"
modules={modules}
formats={formats}
className="prose prose-lg prose-slate dark:prose-invert prose-h1:text-3xl prose-h1:font-semibold prose-h2:text-2xl prose-h2:font-semibold"
/>
<div className='space-y-2'>
{isUploading && (
<div className='relative w-full'>
<Progress value={uploadProgress} className="h-1" />
<p className='text-sm text-muted-foreground mt-1 text-center'>
이미지 업로드 중... {uploadProgress}%
</p>
</div>
)}
<div className='rounded-lg border border-border bg-background'>
<ReactQuill
ref={quillRef}
value={value}
onChange={onChange}
placeholder={placeholder}
theme="snow"
modules={modules}
formats={formats}
className="prose prose-lg prose-slate dark:prose-invert prose-h1:text-3xl prose-h1:font-semibold prose-h2:text-2xl prose-h2:font-semibold"
/>
</div>
</div>
);
}
Expand Down
26 changes: 26 additions & 0 deletions src/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }
1 change: 1 addition & 0 deletions src/types/Posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface Post {
boardId: string;
title: string;
content: string;
thumbnailImageURL?: string;
authorId: string;
authorName: string;
createdAt?: Date;
Expand Down
Loading

0 comments on commit d3dab74

Please sign in to comment.