Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/assets/post/close.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/post/plus-disabled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/assets/post/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ interface BookSearchBottomSheetProps {
type TabType = 'saved' | 'group';

// Mock Data
const mockBooks: Book[] = [
const mockSavedBooks: Book[] = [
{
id: 1,
title: '토마토 컵라면',
Expand All @@ -56,51 +56,53 @@ const mockBooks: Book[] = [
author: '작가명',
cover: '/src/assets/books/hormone.svg',
},
];

const mockGroupBooks: Book[] = [
{
id: 4,
title: '토마토 컵라면',
title: '단 한번의 삶',
author: '작가명',
cover: '/src/assets/books/tomato.svg',
cover: '/src/assets/books/life.svg',
},
{
id: 5,
title: '사슴',
author: '작가명',
cover: '/src/assets/books/deer.svg',
},
{
id: 6,
title: '호르몬 체인지',
author: '작가명',
cover: '/src/assets/books/hormone.svg',
},
{
id: 7,
title: '단 한번의 삶',
id: 6,
title: '토마토 컵라면',
author: '작가명',
cover: '/src/assets/books/life.svg',
cover: '/src/assets/books/tomato.svg',
},
];

const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBottomSheetProps) => {
// State
const [searchQuery, setSearchQuery] = useState('');
const [filteredBooks, setFilteredBooks] = useState<Book[]>(mockBooks);
const [filteredBooks, setFilteredBooks] = useState<Book[]>(mockSavedBooks);
const [activeTab, setActiveTab] = useState<TabType>('saved');

// Effects
useEffect(() => {
// 현재 활성화된 탭의 책 목록 가져오기
const currentTabBooks = activeTab === 'saved' ? mockSavedBooks : mockGroupBooks;

if (searchQuery.trim() === '') {
setFilteredBooks(mockBooks);
// 검색어가 없을 때는 선택된 탭의 전체 목록 표시
setFilteredBooks(currentTabBooks);
} else {
const filtered = mockBooks.filter(
// 검색어가 있을 때는 선택된 탭 내에서만 검색
const filtered = currentTabBooks.filter(
book =>
book.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
book.author.toLowerCase().includes(searchQuery.toLowerCase()),
);
setFilteredBooks(filtered);
}
}, [searchQuery]);
}, [searchQuery, activeTab]);

useEffect(() => {
if (isOpen) {
Expand Down Expand Up @@ -145,6 +147,26 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott
setSearchQuery('');
};

const handleTabChange = (tab: TabType) => {
setActiveTab(tab);
// 탭 변경 시 현재 검색어로 새로운 탭에서 다시 검색
const newTabBooks = tab === 'saved' ? mockSavedBooks : mockGroupBooks;

if (searchQuery.trim() === '') {
setFilteredBooks(newTabBooks);
} else {
const filtered = newTabBooks.filter(
book =>
book.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
book.author.toLowerCase().includes(searchQuery.toLowerCase()),
);
setFilteredBooks(filtered);
}
};

// 검색어가 없을 때만 탭 표시
const showTabs = searchQuery.trim() === '';

return (
<Overlay isVisible={isOpen} onClick={handleOverlayClick}>
<BottomSheetContainer isVisible={isOpen}>
Expand All @@ -169,15 +191,17 @@ const BookSearchBottomSheet = ({ isOpen, onClose, onSelectBook }: BookSearchBott
</ButtonGroup>
</SearchContainer>

{/* 탭 영역 */}
<TabContainer>
<Tab active={activeTab === 'saved'} onClick={() => setActiveTab('saved')}>
저장한 책
</Tab>
<Tab active={activeTab === 'group'} onClick={() => setActiveTab('group')}>
모임 책
</Tab>
</TabContainer>
{/* 탭 영역 - 검색어가 없을 때만 표시 */}
{showTabs && (
<TabContainer>
<Tab active={activeTab === 'saved'} onClick={() => handleTabChange('saved')}>
저장한 책
</Tab>
<Tab active={activeTab === 'group'} onClick={() => handleTabChange('group')}>
모임 책
</Tab>
</TabContainer>
)}

{/* 책 목록 영역 */}
<BookListContainer>
Expand Down
85 changes: 85 additions & 0 deletions src/components/createpost/PhotoSection.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import styled from '@emotion/styled';
import { typography, semanticColors, colors } from '../../styles/global/global';

export const PhotoContainer = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;

export const PhotoGrid = styled.div`
display: flex;
gap: 12px;
align-items: center;
`;

export const AddPhotoButton = styled.button`
width: 80px;
height: 80px;
border: 1px solid ${colors.grey[300]};
background-color: ${semanticColors.background.cardDark};
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;

img {
width: 24px;
height: 24px;
}

&:hover:not(:disabled) {
border-color: ${semanticColors.text.primary};
}

&:disabled {
background-color: ${colors.darkgrey.dark};
border: 1px solid ${colors.darkgrey.main};
cursor: not-allowed;
}
`;

export const PhotoItem = styled.div`
position: relative;
width: 80px;
height: 80px;
`;

export const PhotoImage = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
border: 1px solid ${colors.grey[300]};
`;

export const RemoveButton = styled.button`
position: absolute;
top: 0px;
right: 0px;
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid ${colors.grey[300]};

img {
width: 12px;
height: 12px;
}

&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
`;

export const PhotoCount = styled.div`
align-self: flex-end;
color: ${semanticColors.text.point.green};
font-size: ${typography.fontSize.xs};
font-weight: ${typography.fontWeight.regular};
`;
74 changes: 74 additions & 0 deletions src/components/createpost/PhotoSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useRef } from 'react';
import { Section, SectionTitle } from '../../pages/group/CommonSection.styled';
import {
PhotoContainer,
PhotoGrid,
AddPhotoButton,
PhotoImage,
RemoveButton,
PhotoCount,
} from './PhotoSection.styled';
import plusIcon from '../../assets/post/plus.svg';
import plusDisabledIcon from '../../assets/post/plus-disabled.svg';
import closeIcon from '../../assets/post/close.svg';

interface PhotoSectionProps {
photos: File[];
onPhotoAdd: (files: File[]) => void;
onPhotoRemove: (index: number) => void;
}

const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);

const handleFileInputClick = () => {
fileInputRef.current?.click();
};

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
onPhotoAdd(files);
}
// input 값 초기화 (같은 파일을 다시 선택할 수 있도록)
e.target.value = '';
};

const createImageUrl = (file: File) => {
return URL.createObjectURL(file);
};
Comment on lines +37 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

메모리 누수 방지를 위해 URL 해제가 필요합니다.

URL.createObjectURL로 생성된 blob URL은 수동으로 해제해야 메모리 누수를 방지할 수 있습니다.

+import { useRef, useEffect } from 'react';

const PhotoSection = ({ photos, onPhotoAdd, onPhotoRemove }: PhotoSectionProps) => {
  const fileInputRef = useRef<HTMLInputElement>(null);
+ const urlsRef = useRef<string[]>([]);

+ useEffect(() => {
+   // 컴포넌트 언마운트 시 모든 URL 해제
+   return () => {
+     urlsRef.current.forEach(url => URL.revokeObjectURL(url));
+   };
+ }, []);

+ useEffect(() => {
+   // 사진 배열이 변경될 때마다 URL 업데이트
+   urlsRef.current.forEach(url => URL.revokeObjectURL(url));
+   urlsRef.current = photos.map(photo => URL.createObjectURL(photo));
+ }, [photos]);

  const createImageUrl = (file: File) => {
-   return URL.createObjectURL(file);
+   const index = photos.indexOf(file);
+   return urlsRef.current[index] || URL.createObjectURL(file);
  };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/components/createpost/PhotoSection.tsx around lines 37 to 39, the
function createImageUrl uses URL.createObjectURL to generate a blob URL but does
not release it, which can cause memory leaks. Modify the code to call
URL.revokeObjectURL on the created URL when it is no longer needed, such as
during component cleanup or when the image is removed, to properly release the
allocated memory.


const isDisabled = photos.length >= 3;

return (
<Section>
<SectionTitle>사진 추가</SectionTitle>
<PhotoContainer>
<PhotoGrid>
<AddPhotoButton onClick={handleFileInputClick} disabled={isDisabled}>
<img src={isDisabled ? plusDisabledIcon : plusIcon} alt="사진 추가" />
</AddPhotoButton>
{photos.map((photo, index) => (
<div key={index} style={{ position: 'relative', width: '80px', height: '80px' }}>
<PhotoImage src={createImageUrl(photo)} alt={`선택된 사진 ${index + 1}`} />
<RemoveButton onClick={() => onPhotoRemove(index)}>
<img src={closeIcon} alt="삭제" />
</RemoveButton>
</div>
))}
</PhotoGrid>
<PhotoCount>{photos.length}/3개</PhotoCount>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</PhotoContainer>
</Section>
);
};

export default PhotoSection;
33 changes: 33 additions & 0 deletions src/components/createpost/PostContentSection.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import styled from '@emotion/styled';
import { typography, semanticColors } from '../../styles/global/global';

export const TextAreaBox = styled.div`
position: relative;
display: flex;
flex-direction: column;
`;

export const TextArea = styled.textarea`
width: 100%;
min-height: 100px;
background-color: ${semanticColors.background.primary};
color: ${semanticColors.text.secondary};
font-size: ${typography.fontSize.sm};
font-weight: ${typography.fontWeight.regular};
font-family: ${typography.fontFamily.primary};
resize: none;
outline: none;
border: none;

&::placeholder {
color: ${semanticColors.text.ghost};
}
`;

export const CharacterCount = styled.div`
align-self: flex-end;
margin-top: 12px;
color: ${semanticColors.text.point.green};
font-size: ${typography.fontSize.xs};
font-weight: ${typography.fontWeight.regular};
`;
31 changes: 31 additions & 0 deletions src/components/createpost/PostContentSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Section, SectionTitle } from '../../pages/group/CommonSection.styled';
import { TextAreaBox, TextArea, CharacterCount } from './PostContentSection.styled';

interface PostContentSectionProps {
content: string;
onContentChange: (value: string) => void;
}

const PostContentSection = ({ content, onContentChange }: PostContentSectionProps) => {
const maxLength = 2000;

return (
<Section>
<SectionTitle>글 작성</SectionTitle>
<TextAreaBox>
<TextArea
placeholder="...한 생각이 들었어요. 🤔"
value={content}
onChange={e => onContentChange(e.target.value)}
maxLength={maxLength}
rows={4}
/>
<CharacterCount>
{content.length} / {maxLength}
</CharacterCount>
</TextAreaBox>
</Section>
);
};

export default PostContentSection;
Loading