-
Notifications
You must be signed in to change notification settings - Fork 2
Api(extension): 익스텐션 북마크 아티클 저장 API 연결 #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eefe0e2
a4c9009
9a4a63a
a4af97d
1dde65c
fc77268
a36784a
9b2e1b9
145f99d
4d61ccb
1a5cb72
d587259
0cebb18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import apiRequest from './axiosInstance'; | ||
|
|
||
| export const getCategoriesDash = async () => { | ||
| const { data } = await apiRequest.get('/api/v1/categories/dashboard', {}); | ||
| return data; | ||
| }; | ||
|
|
||
| export const postArticles = async (payload: { | ||
| url: string; | ||
| categoryId: number | null; | ||
| memo: string; | ||
| remindTime: string | null; | ||
| }) => { | ||
| const response = await apiRequest.post('/api/v1/articles', payload); | ||
| return response.data; | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,23 +15,20 @@ const fetchToken = async (email?: string) => { | |||||||||||||
| } | ||||||||||||||
| ); | ||||||||||||||
| const newToken = response.data.token; | ||||||||||||||
| localStorage.setItem('jwtToken', newToken); | ||||||||||||||
| return newToken; | ||||||||||||||
| }; | ||||||||||||||
| const getChromeToken = async (): Promise<string | null> => { | ||||||||||||||
| return new Promise((resolve) => { | ||||||||||||||
| chrome.storage.local.get(['jwtToken'], (result) => { | ||||||||||||||
| resolve(result.jwtToken ?? null); | ||||||||||||||
| }); | ||||||||||||||
| chrome.storage.local.set({ jwtToken: newToken }, () => { | ||||||||||||||
| console.log('Token saved to chrome storage'); | ||||||||||||||
| }); | ||||||||||||||
| return newToken; | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| apiRequest.interceptors.request.use(async (config) => { | ||||||||||||||
| const noAuthNeeded = ['/api/v1/auth/token', '/api/v1/auth/signin']; | ||||||||||||||
| const noAuthNeeded = ['/api/v1/auth/token', '/api/v1/auth/signup']; | ||||||||||||||
| const isNoAuth = noAuthNeeded.some((url) => config.url?.includes(url)); | ||||||||||||||
|
|
||||||||||||||
| if (!isNoAuth) { | ||||||||||||||
| let token = await getChromeToken(); | ||||||||||||||
| let token = | ||||||||||||||
| 'eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwaW5iYWNrIiwiaWQiOiJhOTA1NGFjOS03MTg0LTQ3NjktYWY4Mi1jNGViYTg0YzYxYTIiLCJzdWIiOiJBY2Nlc3NUb2tlbiIsImV4cCI6MTc1MjgwMDY1Nn0.hXti-Jlnhg8mRoPl5nB8Vi8UV6HPdZYAtgtpTuqtH39lQWle8T5GlX0ug0nNVUqu5B_Pyzafck7lhfXN6ArHOA'; | ||||||||||||||
|
|
||||||||||||||
|
Comment on lines
+29
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 하드코딩된 JWT 토큰 보안 문제 코드에 하드코딩된 JWT 토큰이 포함되어 있습니다. PR 목표에서 언급한 임시 토큰이라고 하더라도 보안상 위험합니다. 다음과 같은 개선이 필요합니다:
- let token =
- 'eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJwaW5iYWNrIiwiaWQiOiJhOTA1NGFjOS03MTg0LTQ3NjktYWY4Mi1jNGViYTg0YzYxYTIiLCJzdWIiOiJBY2Nlc3NUb2tlbiIsImV4cCI6MTc1MjgwMDY1Nn0.hXti-Jlnhg8mRoPl5nB8Vi8UV6HPdZYAtgtpTuqtH39lQWle8T5GlX0ug0nNVUqu5B_Pyzafck7lhfXN6ArHOA';
+ const result = await new Promise<{ jwtToken?: string }>((resolve) => {
+ chrome.storage.local.get(['jwtToken'], resolve);
+ });
+ let token = result.jwtToken || '';📝 Committable suggestion
Suggested change
🧰 Tools🪛 Gitleaks (8.27.2)30-30: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data. (jwt) 🤖 Prompt for AI Agents |
||||||||||||||
| if (!token || token === 'undefined') { | ||||||||||||||
| token = await fetchToken('test@gmail.com'); | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -44,7 +41,7 @@ apiRequest.interceptors.response.use( | |||||||||||||
| (response) => response, | ||||||||||||||
| async (error) => { | ||||||||||||||
| const originalRequest = error.config; | ||||||||||||||
| const noAuthNeeded = ['/api/v1/auth/token', '/api/v1/auth/signin']; | ||||||||||||||
| const noAuthNeeded = ['/api/v1/auth/token', '/api/v1/auth/signup']; | ||||||||||||||
| const isNoAuth = noAuthNeeded.some((url) => | ||||||||||||||
| originalRequest.url?.includes(url) | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as apiRequest } from './axiosInstance'; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,9 @@ | ||||||
| import apiRequest from './axiosInstance'; | ||||||
|
|
||||||
| export const getRemindTime = async (time: String) => { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 원시 타입 'string' 사용을 권장합니다. 타입 일관성을 위해 대문자 -export const getRemindTime = async (time: String) => {
+export const getRemindTime = async (time: string) => {📝 Committable suggestion
Suggested change
🧰 Tools🪛 Biome (1.9.4)[error] 3-3: Don't use 'String' as a type. Use lowercase primitives for consistency. (lint/complexity/noBannedTypes) 🤖 Prompt for AI Agents |
||||||
| const { data } = await apiRequest.get( | ||||||
| `/api/v1/users/remind-time?now=${time}`, | ||||||
| {} | ||||||
| ); | ||||||
| return data; | ||||||
| }; | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,9 @@ | ||||||||||||||||||||
| import { useQuery } from '@tanstack/react-query'; | ||||||||||||||||||||
| import { getRemindTime } from './modalAxios'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export const useGetRemindTime = (time: string) => { | ||||||||||||||||||||
| return useQuery({ | ||||||||||||||||||||
| queryKey: ['remindTime'], | ||||||||||||||||||||
| queryFn: () => getRemindTime(time), | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
Comment on lines
+5
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 쿼리 키에 시간 매개변수를 포함하세요. 현재 쿼리 키가 return useQuery({
- queryKey: ['remindTime'],
+ queryKey: ['remindTime', time],
queryFn: () => getRemindTime(time),
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }; | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { getCategoriesDash, postArticles } from './axios'; | ||
| import { useQuery, useMutation } from '@tanstack/react-query'; | ||
| export const useGetCategoriesDash = () => { | ||
| return useQuery({ | ||
| queryKey: ['categoriesDash'], | ||
| queryFn: getCategoriesDash, | ||
| }); | ||
| }; | ||
|
|
||
| export const usePostArticles = () => { | ||
| return useMutation({ | ||
| mutationFn: postArticles, | ||
| }); | ||
| }; | ||
|
Comment on lines
+1
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion TypeScript 타입 정의 추가를 권장합니다. API 응답과 요청에 대한 TypeScript 타입을 정의하면 타입 안정성을 높일 수 있습니다. 다음과 같이 타입을 정의하는 것을 제안합니다: interface Category {
id: string;
name: string;
// 기타 카테고리 속성들
}
interface PostArticleRequest {
url: string;
categoryId: string;
memo?: string;
remindTime?: string;
}
export const useGetCategoriesDash = (): UseQueryResult<Category[]> => {
return useQuery({
queryKey: ['categoriesDash'],
queryFn: getCategoriesDash,
});
};
export const usePostArticles = (): UseMutationResult<any, Error, PostArticleRequest> => {
return useMutation({
mutationFn: postArticles,
});
};🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,7 +9,6 @@ chrome.runtime.onInstalled.addListener((details) => { | |||||
| if (details.reason === 'install') { | ||||||
| chrome.identity.getProfileUserInfo(function (info) { | ||||||
| console.log('google email:', info.email); | ||||||
|
|
||||||
| setTimeout(() => { | ||||||
| chrome.tabs.create({ | ||||||
| url: `http://localhost:5180/onboarding?email=${info.email}`, | ||||||
|
|
@@ -18,6 +17,7 @@ chrome.runtime.onInstalled.addListener((details) => { | |||||
| }); | ||||||
| } | ||||||
| }); | ||||||
|
|
||||||
| chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { | ||||||
| if (message.type === 'SAVE_BOOKMARK') { | ||||||
| const { url, title } = message.payload; | ||||||
|
|
@@ -28,7 +28,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { | |||||
|
|
||||||
| chrome.bookmarks.create( | ||||||
| { | ||||||
| title: localStorage.getItem('titleSave') ?? '핀백 저장소', | ||||||
| title: '핀백 저장소', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 하드코딩된 북마크 제목 사용을 재검토해주세요. 북마크 제목이 고정된 문자열 '핀백 저장소'로 하드코딩되어 있습니다. 이는 사용자 경험을 해칠 수 있으며, 실제 페이지 제목이나 사용자가 설정한 제목을 사용하는 것이 더 적절할 것 같습니다. 메시지 페이로드에서 제목을 받아서 사용하는 것을 제안합니다: - title: '핀백 저장소',
+ title: title || '핀백 저장소',또는 Chrome 저장소에서 저장된 제목을 가져와서 사용하는 방법도 고려해볼 수 있습니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| url: url, | ||||||
| parentId: '1', | ||||||
| }, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||
| import { POP_TEXTAREA_MAX_LENGTH } from '@constants/index'; | ||||||
| import { useState, useEffect } from 'react'; | ||||||
| import { | ||||||
| CategoryDropDown, | ||||||
| CommonBtn, | ||||||
|
|
@@ -8,31 +8,72 @@ import { | |||||
| TimePicker, | ||||||
| ToggleButton, | ||||||
| } from '@pinback/design-system/ui'; | ||||||
| import { useState } from 'react'; | ||||||
| import ModalHeader from './ModalHeader'; | ||||||
| import { POP_TEXTAREA_MAX_LENGTH } from '@constants/index'; | ||||||
| import { useGetCategoriesDash, usePostArticles } from '@api/queries'; | ||||||
| import { useGetRemindTime } from '../../api/modalQueries'; | ||||||
| import { fomatToday } from '@pinback/design-system/utils'; | ||||||
|
|
||||||
| interface ModalPopProps { | ||||||
| urlInfo: string; | ||||||
| imgInfo?: string; | ||||||
| titleInfo?: string; | ||||||
| desInfo?: string; | ||||||
| } | ||||||
|
|
||||||
| interface Category { | ||||||
| categoryId: number; | ||||||
| categoryName: string; | ||||||
| unreadCount: number; | ||||||
| } | ||||||
| const ModalPop = ({ urlInfo, imgInfo, titleInfo, desInfo }: ModalPopProps) => { | ||||||
| const [formState, setFormState] = useState({ | ||||||
| date: '', | ||||||
| dateError: '', | ||||||
| time: '', | ||||||
| timeError: '', | ||||||
| }); | ||||||
| const [categories, setCategories] = useState(['']); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. categories 상태 초기값 수정 필요 빈 문자열이 포함된 배열 - const [categories, setCategories] = useState(['']);
+ const [categories, setCategories] = useState<string[]>([]);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| const [memo, setMemo] = useState(''); | ||||||
| const { mutate: postArticle } = usePostArticles(); | ||||||
|
|
||||||
| const { | ||||||
| data: remindTime, | ||||||
| isLoading, | ||||||
| error, | ||||||
| } = useGetRemindTime(fomatToday(new Date())); | ||||||
|
|
||||||
| if (error) { | ||||||
| console.error('Error fetching remind time:', error); | ||||||
| return <div>Error loading remind time</div>; | ||||||
| } | ||||||
| const { data: categoriesData } = useGetCategoriesDash(); | ||||||
| useEffect(() => { | ||||||
| if (categoriesData) { | ||||||
| const categoryNames: string[] = categoriesData.data.categories.map( | ||||||
| (item: Category) => item.categoryName | ||||||
| ); | ||||||
| setCategories(categoryNames); | ||||||
| } | ||||||
| }, [categoriesData]); | ||||||
| const [categoryPopupMode, setCategoryPopupMode] = useState< | ||||||
| 'edit' | 'add' | '' | ||||||
| >(''); | ||||||
| const [selectedCategory, setSelectedCategory] = useState(''); | ||||||
| const [categories, setCategories] = useState(['기획', '취미', '요리']); | ||||||
| const [matchId, setMatchedId] = useState<number | null>(null); | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| if (categoriesData?.data?.categories && selectedCategory) { | ||||||
| const matched = categoriesData.data.categories.find( | ||||||
| (item: Category) => item.categoryName === selectedCategory | ||||||
| ); | ||||||
| setMatchedId(matched?.categoryId ?? null); | ||||||
| } | ||||||
| }, [selectedCategory, categoriesData]); | ||||||
|
|
||||||
| const handleSave = () => { | ||||||
| chrome.storage.local.set({ savedTitle: titleInfo }, () => { | ||||||
| console.log('📦 storage 저장 완료:', titleInfo); | ||||||
| }); | ||||||
| chrome.runtime.sendMessage( | ||||||
| { | ||||||
| type: 'SAVE_BOOKMARK', | ||||||
|
|
@@ -45,7 +86,24 @@ const ModalPop = ({ urlInfo, imgInfo, titleInfo, desInfo }: ModalPopProps) => { | |||||
| console.log('✅ 응답 받음:', response); | ||||||
| } | ||||||
| ); | ||||||
| window.close(); | ||||||
| postArticle( | ||||||
| { | ||||||
| url: urlInfo, | ||||||
| categoryId: matchId, | ||||||
| memo: memo, | ||||||
| remindTime: fomatToday(new Date()), | ||||||
| }, | ||||||
| { | ||||||
| onSuccess: (data) => { | ||||||
| console.log('✅ 저장 성공:', data); | ||||||
| // TODO : 저장 시. 창 원래 닫아야하나 우선 개발 중이라 열어두었습니당 | ||||||
| // window.close(); | ||||||
| }, | ||||||
| onError: (error) => { | ||||||
| console.error('❌ 저장 실패:', error); | ||||||
| }, | ||||||
| } | ||||||
| ); | ||||||
| }; | ||||||
|
|
||||||
| const handleFieldChange = ( | ||||||
|
|
@@ -67,6 +125,7 @@ const ModalPop = ({ urlInfo, imgInfo, titleInfo, desInfo }: ModalPopProps) => { | |||||
| setCategoryPopupMode('add'); | ||||||
| } else { | ||||||
| setSelectedCategory(value); | ||||||
| console.log(value); | ||||||
| setCategoryPopupMode(''); | ||||||
| } | ||||||
| }; | ||||||
|
|
@@ -93,7 +152,7 @@ const ModalPop = ({ urlInfo, imgInfo, titleInfo, desInfo }: ModalPopProps) => { | |||||
| text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; | ||||||
|
|
||||||
| return ( | ||||||
| <div className="flex h-[54.7rem] w-[32rem] flex-col items-center justify-between rounded-[1rem] bg-white px-[2rem] py-[2rem]"> | ||||||
| <div className="relative flex h-[54.7rem] w-[32rem] flex-col items-center justify-between rounded-[1rem] bg-white px-[2rem] py-[2rem]"> | ||||||
| <div> | ||||||
| <ModalHeader onClick={() => window.close()} /> | ||||||
| <div className="px-[1rem] pt-[1.9rem]"> | ||||||
|
|
@@ -118,6 +177,8 @@ const ModalPop = ({ urlInfo, imgInfo, titleInfo, desInfo }: ModalPopProps) => { | |||||
| size="medium" | ||||||
| maxLength={POP_TEXTAREA_MAX_LENGTH} | ||||||
| placeholder="메모를 입력하고 도토리를 받아보세요!" | ||||||
| value={memo} | ||||||
| onChange={(e) => setMemo(e.target.value)} | ||||||
| /> | ||||||
| </section> | ||||||
| <section> | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,10 +3,21 @@ import * as React from 'react'; | |||||||||||||||||||||
| import { createRoot } from 'react-dom/client'; | ||||||||||||||||||||||
| import App from './App'; | ||||||||||||||||||||||
| import './App.css'; | ||||||||||||||||||||||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const queryClient = new QueryClient(); | ||||||||||||||||||||||
| const rootEl = document.getElementById('root'); | ||||||||||||||||||||||
| if (rootEl) { | ||||||||||||||||||||||
| createRoot(rootEl).render(<App />); | ||||||||||||||||||||||
| createRoot(rootEl).render( | ||||||||||||||||||||||
| <QueryClientProvider client={queryClient}> | ||||||||||||||||||||||
| <App /> | ||||||||||||||||||||||
| </QueryClientProvider> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| createRoot(rootEl).render( | ||||||||||||||||||||||
| <QueryClientProvider client={queryClient}> | ||||||||||||||||||||||
| <App /> | ||||||||||||||||||||||
| </QueryClientProvider> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
Comment on lines
+16
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 중복된 렌더링 호출을 제거하세요. 동일한 루트 엘리먼트에 대해 다음과 같이 중복된 렌더링 호출을 제거하세요: createRoot(rootEl).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
- createRoot(rootEl).render(
- <QueryClientProvider client={queryClient}>
- <App />
- </QueryClientProvider>
- );📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| console.error('❌ root element not found!'); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { queryClient } from '@utils/queryClient'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { QueryClient } from '@tanstack/react-query'; | ||
|
|
||
| export const queryClient = new QueryClient({ | ||
| defaultOptions: { | ||
| queries: { | ||
| retry: false, | ||
| refetchOnWindowFocus: false, | ||
| throwOnError: true, | ||
| }, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| function formatToday(date: Date): string { | ||
| const year = date.getFullYear(); | ||
| const month = `${date.getMonth() + 1}`.padStart(2, '0'); | ||
| const day = `${date.getDate()}`.padStart(2, '0'); | ||
| const hours = `${date.getHours()}`.padStart(2, '0'); | ||
| const minutes = `${date.getMinutes()}`.padStart(2, '0'); | ||
| const seconds = `${date.getSeconds()}`.padStart(2, '0'); | ||
|
|
||
| return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; | ||
| } | ||
|
|
||
| export default formatToday; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1 +1,2 @@ | ||||||
| export { default as fomatToday } from './fomatToday'; | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 함수명 오타를 수정하세요.
다음과 같이 수정하세요: -export { default as fomatToday } from './fomatToday';
+export { default as formatToday } from './fomatToday';또는 모듈 파일명도 함께 수정하는 것을 고려하세요: -export { default as fomatToday } from './fomatToday';
+export { default as formatToday } from './formatToday';📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| export * from './utils'; | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
title 상태 업데이트 타이밍 문제를 확인해주세요.
chrome.storage.local.set에서title상태 변수를 저장하고 있지만, 이 시점에서title상태가 아직 업데이트되지 않았을 수 있습니다.setTitle이 38번째 줄에서 호출되고 바로 41번째 줄에서title상태를 사용하고 있는데, React 상태 업데이트는 비동기적으로 처리됩니다.다음과 같이 수정하는 것을 제안합니다:
setTitle(imageUrl?.title ?? ''); setDescription(imageUrl?.description ?? ''); setImgUrl(imageUrl?.image ?? ''); - chrome.storage.local.set({ titleSave: title }, () => { + chrome.storage.local.set({ titleSave: imageUrl?.title ?? '' }, () => { console.log('Title saved to chrome storage'); });또는 별도의 useEffect를 사용하여 title이 변경될 때 저장하는 방법도 고려해볼 수 있습니다.
📝 Committable suggestion
🤖 Prompt for AI Agents