Skip to content
Open
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
36 changes: 36 additions & 0 deletions src/apis/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { safeFetch } from '@/hooks/util/server/safeFetch';
import { SummaryData, SummaryRes } from '@/types/api/summaryApi';

const API_URL = process.env.NEXT_PUBLIC_BASE_API_URL;
const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;

if (!API_URL) throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL');
if (!API_TOKEN) throw new Error('Missing environment variable: NEXT_PUBLIC_API_TOKEN');
Comment on lines +4 to +8
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

모듈 로드 시점의 환경 변수 검증은 런타임 오류를 유발할 수 있습니다.

모듈이 import되는 시점에 환경 변수가 없으면 즉시 에러가 발생합니다. 테스트 환경이나 특정 빌드 시나리오에서 문제가 될 수 있습니다. 또한 NEXT_PUBLIC_API_TOKEN은 클라이언트에 노출되므로, 민감한 토큰인 경우 서버 사이드에서만 사용하는 것이 좋습니다.

🔎 개선 제안
-const API_URL = process.env.NEXT_PUBLIC_BASE_API_URL;
-const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;
-
-if (!API_URL) throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL');
-if (!API_TOKEN) throw new Error('Missing environment variable: NEXT_PUBLIC_API_TOKEN');
+const getApiConfig = () => {
+  const API_URL = process.env.NEXT_PUBLIC_BASE_API_URL;
+  const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN;
+  
+  if (!API_URL) throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL');
+  if (!API_TOKEN) throw new Error('Missing environment variable: NEXT_PUBLIC_API_TOKEN');
+  
+  return { API_URL, API_TOKEN };
+};

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

🤖 Prompt for AI Agents
In src/apis/summary.ts around lines 4-8 the code performs environment-variable
validation at module load time and throws immediately; change this to perform
checks at runtime (e.g., inside an initialization function or the function that
actually calls the API) instead of during import, and avoid exposing sensitive
tokens to the client by removing NEXT_PUBLIC_API_TOKEN use on client-side
code—read the token from a server-only env var (no NEXT_PUBLIC prefix) and
validate it when the server-side handler starts; if you need lazy validation,
create a getter/init function that reads process.env, throws a descriptive error
if missing, and use that from server-only code so imports never cause immediate
runtime exceptions.


const SUMMARY_ENDPOINT = `${API_URL}/v1/links`;

type Params = {
id: number;
format?: 'CONCISE' | 'DETAILED';
};

export const fetchNewSummary = async (params: Params): Promise<SummaryData> => {
const url = new URL(`${SUMMARY_ENDPOINT}/${params.id}/summary`);
if (params.format) {
url.searchParams.set('format', params.format);
}

const body = await safeFetch<SummaryRes>(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${API_TOKEN}`,
},
timeout: 15_000,
jsonContentTypeCheck: true,
});
if (!body?.data || !body.success) {
throw new Error(body?.message ?? 'Invalid response structure');
}
return body.data;
};
10 changes: 10 additions & 0 deletions src/components/wrappers/ReSummaryModal/NewSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function NewSummary({ content }: { content: string }) {
return (
<div className="flex flex-1 flex-col gap-2">
<span className="font-label-sm">재생성 요약</span>
<div className="rounded-lg bg-white p-2">
<div className="custom-scrollbar font-body-md h-45 overflow-auto pr-1">{content}</div>
</div>
</div>
);
}
47 changes: 24 additions & 23 deletions src/components/wrappers/ReSummaryModal/PostReSummaryButton.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,46 @@
import Button from '@/components/basics/Button/Button';
// import Toast from '@/components/basics/Toast/Toast';
import { useModalStore } from '@/stores/modalStore';
import { showToast } from '@/stores/toastStore';

interface Props {
disabled: boolean;
type: 'prev' | 'new';
isWriting?: boolean;
// content: string;
onClick: () => void;
}

export default function PostReSummaryButton({ type, isWriting }: Props) {
export default function PostReSummaryButton({ type, disabled, onClick }: Props) {
const { close } = useModalStore();
// const [toastVisible, setToastVisible] = useState(false);

const onClick = () => {
close();
// setToastVisible(true);
const handleClick = () => {
onClick();
if (type === 'prev') {
close();
showToast({
id: 'save-prev',
message: '기존 요약을 유지했습니다.',
variant: 'success',
});
} else if (type === 'new') {
close();
showToast({
id: 'save-new',
message: '새로운 요약을 저장했습니다.',
variant: 'success',
});
}
};

// const handleToastClose = () => {
// setToastVisible(false);
// };

return (
<>
<Button
variant={type === 'prev' ? 'secondary' : 'primary'}
label={type === 'prev' ? '기존 요약 유지하기' : ' 요약 적용하기'}
disabled={isWriting}
label={type === 'prev' ? '기존 요약 유지하기' : '재생성된 요약 덮어쓰기'}
disabled={disabled}
className="w-full"
onClick={onClick}
onClick={handleClick}
/>

{/* {toastVisible && (
<Toast
id="summary-toast"
message="요약을 적용했습니다."
variant="success"
duration={2000}
onClose={handleToastClose}
/>
)} */}
</>
);
}
10 changes: 10 additions & 0 deletions src/components/wrappers/ReSummaryModal/PrevSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default function PrevSummary({ content }: { content: string }) {
return (
<div className="flex flex-1 flex-col gap-2">
<span className="font-label-sm">기존 요약</span>
<div className="rounded-lg bg-white p-2">
<div className="custom-scrollbar font-body-md h-45 overflow-auto pr-1">{content}</div>
</div>
</div>
);
}
81 changes: 44 additions & 37 deletions src/components/wrappers/ReSummaryModal/ReSummaryModal.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,75 @@
'use client';

import SVGIcon from '@/components/Icons/SVGIcon';
import Badge from '@/components/basics/Badge/Badge';
import IconButton from '@/components/basics/IconButton/IconButton';
import Modal from '@/components/basics/Modal/Modal';
import ProgressNotification from '@/components/basics/ProgressNotification/ProgressNotification';
import clsx from 'clsx';

import NewSummary from './NewSummary';
import PostReSummaryButton from './PostReSummaryButton';
import PrevSummary from './PrevSummary';
import useReSummary from './hooks/useReSummary';

const DIFF =
'어쩌구 저쩌구 어쩌구 저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구';
const PREV =
'기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. 기존 요약입니다. ';
const NEW =
'새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. 새로운 요약입니다. ';
'어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구 어쩌구저쩌구';

// interface ReSummary {
// id: string;
// interface ReSummaryProps {
// id: number;
// }

export default function ReSummaryModal() {
const { loading, writing, error } = useReSummary();

const prevContent = '이전요약이전요약';
const newContent = '새 요약 새 요약';
// TODO: api 연결(아직 api 작성 안함)

return (
<Modal type="RE_SUMMARY" className="m-10 max-w-240 min-w-150">
<div>
<Modal
type="RE_SUMMARY"
className={clsx('m-10 max-w-240 min-w-150', error && 'border-red500 border')}
>
<div className="p-2">
<span className="font-title-md">요약 비교</span>
{loading && (
<div className="flex gap-2">
<span>SUMMARY COMPARE</span>
<SVGIcon icon="IC_SumGenerate" />
<span>요약 재생성 중입니다...</span>
<div className="text-gray500 flex gap-2">
<ProgressNotification animated={loading} />
</div>
)}
{error && (
<div className="text-red500 mb-64 flex items-center gap-2">
<span className="font-body-md">요약 재생성 중 문제가 발생했습니다.</span>
<IconButton
icon="IC_Regenerate"
size="sm"
variant="tertiary_subtle"
ariaLabel="요약 재생성 재시도"
/>
Comment on lines +43 to +48
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

재시도 버튼에 onClick 핸들러가 없습니다.

IconButtononClick 핸들러가 누락되어 있어 사용자가 재시도 버튼을 클릭해도 아무 동작이 발생하지 않습니다. 재시도 기능을 구현하거나, API 연결 전까지 버튼을 비활성화해야 합니다.

🔎 수정 제안
             <IconButton
               icon="IC_Regenerate"
               size="sm"
               variant="tertiary_subtle"
               ariaLabel="요약 재생성 재시도"
+              onClick={() => {
+                // TODO: 재시도 로직 구현
+              }}
             />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<IconButton
icon="IC_Regenerate"
size="sm"
variant="tertiary_subtle"
ariaLabel="요약 재생성 재시도"
/>
<IconButton
icon="IC_Regenerate"
size="sm"
variant="tertiary_subtle"
ariaLabel="요약 재생성 재시도"
onClick={() => {
// TODO: 재시도 로직 구현
}}
/>

</div>
)}
{error && <div>에러가 발생했습니다.</div>}
{!loading && !error && (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<span className="font-title-md">요약 비교</span>
<span className="relative flex gap-2 rounded-lg bg-white p-2">
<Badge label="WHAT'S CHANGED" className="h-fit" />
<span className="custom-scrollbar font-body-md max-h-20 w-full overflow-auto">
{DIFF}
</span>
<span className="relative flex items-center gap-2 rounded-lg bg-white p-2">
<Badge icon="IC_SumGenerate" label="변화 지점" className="h-fit" />
<span className="font-label-md w-full truncate">{DIFF}</span>
</span>
</div>
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-2">
<span className="font-title-sm">기존 요약</span>
<div className="rounded-lg bg-white p-2">
<div className="custom-scrollbar h-45 overflow-auto pr-1">{PREV}</div>
</div>
</div>
<div className="flex flex-1 flex-col gap-2">
<span className="font-title-sm">재생성 요약</span>
<div className="rounded-lg bg-white p-2">
<div className="custom-scrollbar h-45 overflow-auto pr-1">{NEW}</div>
</div>
</div>
</div>
<div className="flex gap-2">
<PostReSummaryButton type="prev" isWriting={writing} />
<PostReSummaryButton type="new" isWriting={writing} />
<PrevSummary content={prevContent} />
<NewSummary content={newContent} />
</div>
</div>
)}
<div className="flex gap-2">
<PostReSummaryButton type="prev" disabled={writing} onClick={() => console.log('prev')} />
<PostReSummaryButton
type="new"
disabled={!!error || loading || writing}
onClick={() => console.log('new')}
/>
</div>
</div>
</Modal>
);
Expand Down
2 changes: 1 addition & 1 deletion src/stories/ReSummaryModal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useModalStore } from '@/stores/modalStore';
import type { Meta, StoryObj } from '@storybook/nextjs-vite';

const meta = {
title: 'Components/Wrappers/SummaryRegeneratingModal',
title: 'Components/Wrappers/ReSummaryModal',
component: ReSummaryModal,
tags: ['autodocs'],
argTypes: {},
Expand Down
13 changes: 13 additions & 0 deletions src/types/api/summaryApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type SummaryData = {
existingSummary: string;
newSummary: string;
comparison: string;
};

export type SummaryRes = {
success: boolean;
status: string;
message: string;
data: SummaryData;
timestamp: string;
};
Loading