Skip to content

Commit e7e4e83

Browse files
authored
Feat(extension): 익스텐션 페이지 레이아웃 및 북마크 저장 (#64)
* feat: 익스텐션 화면 초기 세팅 * feat: 익스텐션 팝업 레이아웃 * feat: 컴포넌트 조합 시작 * chore: 빌드에러 사항 수정 * feat: 익스텐션 화면 레이아웃 * feat: 데이터 fetch 구간 * feat: 도메인 썸네일 및 정보 불러오기 * feat: 북마크 저장 로직 * feat: 리마인드 시간 유효성 검사 * feat: 팝업 화면 연동로직 * feat: 드롭다운 연결 및 리팩토링 * chore: 빌드에러 수정 * chore: 주석제거 * chore: 주석 제거 * feat: 코드리뷰 반영 * feat: 아이콘 빌드 * feat: 아이콘 svg 연결 * feat: 유효성 검사 공통 유틸 ds 이동 * fix: 가독성 측면 줄바꿈 * feat: 팝업 오픈 핸들링 수정 * feat: 카테고리 추가 에러 prop 추가 * feat: 카테고리 추가 에러 연동 * chore: 콘솔 제거 * feat: 코드리뷰 수정 반영 * feat: 스토리북 수정
1 parent 6149315 commit e7e4e83

31 files changed

+499
-97
lines changed

apps/extension/manifest.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
"version": "0.1.0",
55
"action": { "default_popup": "popup.html" },
66
"icons": {
7-
"128": "icon128.png"
7+
"128": "icon.png"
88
},
9+
"permissions": ["activeTab", "tabs", "storage", "scripting", "bookmarks"],
910
"background": {
10-
"service_worker": "src/background.ts",
11+
"service_worker": "src/background.js",
1112
"type": "module"
1213
},
1314
"content_scripts": [
1415
{
1516
"matches": ["<all_urls>"],
16-
"js": ["src/content.ts"]
17+
"js": ["src/content.js"]
1718
}
1819
],
1920
"host_permissions": ["<all_urls>"]

apps/extension/popup.html

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
<!doctype html>
2-
<html>
2+
<html lang="en">
33
<head>
4-
<meta charset="utf-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1" />
4+
<meta charset="UTF-8" />
5+
<title>PinBack Extension</title>
66
</head>
77
<body>
88
<div id="root"></div>
9-
<!-- src 경로는 프로젝트 루트 기준으로 모듈 import -->
109
<script type="module" src="/src/popup.tsx"></script>
1110
</body>
1211
</html>

apps/extension/public/icon.png

460 KB
Loading

apps/extension/public/icon128.png

-3.86 KB
Binary file not shown.

apps/extension/src/App.tsx

Lines changed: 170 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,177 @@
11
import './App.css';
2-
2+
import {
3+
InfoBox,
4+
Button,
5+
Textarea,
6+
DateTime,
7+
Switch,
8+
PopupContainer,
9+
Dropdown,
10+
validateDate,
11+
validateTime
12+
} from '@pinback/design-system/ui';
13+
import { useState } from 'react';
14+
import { usePageMeta } from './hooks/usePageMeta';
15+
import { useSaveBookmark } from './hooks/useSaveBookmarks';
16+
import { Icon } from '@pinback/design-system/icons';
317
const App = () => {
18+
const [isRemindOn, setIsRemindOn] = useState(false);
19+
const [memo, setMemo] = useState('');
20+
const [isPopupOpen, setIsPopupOpen] = useState(false);
21+
22+
const [selected, setSelected] = useState<string | null>(null);
23+
// 시간,날짜 검사 구간!
24+
const [date, setDate] = useState('2025.10.10');
25+
const [time, setTime] = useState('19:00');
26+
const [dateError, setDateError] = useState('');
27+
const [timeError, setTimeError] = useState('');
28+
29+
const handleDateChange = (value: string) => {
30+
setDate(value);
31+
setDateError(validateDate(value));
32+
};
33+
34+
const handleTimeChange = (value: string) => {
35+
setTime(value);
36+
setTimeError(validateTime(value));
37+
};
38+
39+
// 스위치
40+
const handleSwitchChange = (checked: boolean) => {
41+
setIsRemindOn(checked);
42+
};
43+
44+
const { url, title, description, imgUrl } = usePageMeta();
45+
const { save } = useSaveBookmark();
46+
47+
const handleSave = async () => {
48+
save({
49+
url,
50+
title,
51+
description,
52+
imgUrl,
53+
memo,
54+
isRemindOn,
55+
selectedCategory: selected,
56+
date: isRemindOn ? date : null,
57+
time: isRemindOn ? time : null,
58+
});
59+
const saveData = {
60+
url,
61+
title,
62+
description,
63+
imgUrl,
64+
memo,
65+
isRemindOn,
66+
selectedCategory: selected,
67+
date: isRemindOn ? date : null,
68+
time: isRemindOn ? time : null,
69+
createdAt: new Date().toISOString(),
70+
};
71+
console.log('저장된 데이터:', saveData);
72+
};
73+
const [categoryTitle, setCategoryTitle] = useState('');
74+
const [isPopError, setIsPopError] = useState(false);
75+
const [errorTxt, setErrorTxt] = useState('');
76+
const saveCategory = () => {
77+
if (categoryTitle.length >20){
78+
setIsPopError(true);
79+
setErrorTxt('20자 이내로 작성해주세요');
80+
} else{
81+
setIsPopupOpen(false);
82+
}
83+
}
84+
85+
486
return (
587
<div className="App">
6-
<div className="flex h-[50rem] w-[26rem] items-center justify-center bg-blue-500 text-2xl text-white">
7-
자 핀백 앱잼 시작~오늘은 7월 7일임~
88+
<div className="relative flex h-[56.8rem] w-[31.2rem] items-center justify-center">
89+
{isPopupOpen && (
90+
<PopupContainer
91+
isOpen={isPopupOpen}
92+
onClose={() => setIsPopupOpen(false)}
93+
type="input"
94+
title="카테고리 추가하기"
95+
left="취소"
96+
right="확인"
97+
inputValue={categoryTitle}
98+
isError={isPopError}
99+
errortext={errorTxt}
100+
onInputChange={setCategoryTitle}
101+
placeholder="카테고리 제목을 입력해주세요"
102+
onLeftClick={() => setIsPopupOpen(false)}
103+
onRightClick={saveCategory}
104+
/>
105+
)}
106+
<div className="flex flex-col justify-between gap-[1.6rem] rounded-[12px] bg-white px-[3.2rem] py-[2.4rem] text-black">
107+
<div className="mr-auto">
108+
<Icon name="main_logo" width={72} height={20} />
109+
</div>
110+
111+
<InfoBox
112+
title={title || '제목 없음'}
113+
source={description || '웹페이지'}
114+
imgUrl={imgUrl}
115+
/>
116+
117+
<div>
118+
<p className="caption1-sb mb-[0.4rem]">카테고리</p>
119+
<Dropdown
120+
options={['옵션1', '옵션2']}
121+
selectedValue={selected}
122+
onChange={(value) => setSelected(value)}
123+
placeholder="선택해주세요"
124+
onAddItem={() => setIsPopupOpen(true)}
125+
addItemLabel="추가하기"
126+
/>
127+
</div>
128+
129+
<div>
130+
<p className="caption1-sb mb-[0.4rem]">메모</p>
131+
<Textarea
132+
maxLength={100}
133+
placeholder="나중에 내가 꺼내줄 수 있게 살짝 적어줘!"
134+
onChange={(e) => setMemo(e.target.value)}
135+
/>
136+
</div>
137+
138+
<div>
139+
<div className="mb-[0.4rem] flex items-center justify-between">
140+
<p className="caption1-sb">리마인드</p>
141+
<Switch
142+
onCheckedChange={handleSwitchChange}
143+
checked={isRemindOn}
144+
/>
145+
</div>
146+
147+
<div className="mb-[0.4rem] flex items-center justify-between gap-[0.8rem]">
148+
<DateTime
149+
type="date"
150+
state={
151+
dateError ? 'error' : isRemindOn ? 'default' : 'disabled'
152+
}
153+
value={date}
154+
onChange={handleDateChange}
155+
/>
156+
<DateTime
157+
type="time"
158+
state={
159+
timeError ? 'error' : isRemindOn ? 'default' : 'disabled'
160+
}
161+
value={time}
162+
onChange={handleTimeChange}
163+
/>
164+
</div>
165+
166+
{/* 에러 메시지 출력 */}
167+
{dateError && <p className="body3-r text-error">{dateError}</p>}
168+
{timeError && <p className="body3-r text-error">{timeError}</p>}
169+
</div>
170+
171+
<Button size="medium" onClick={handleSave}>
172+
저장
173+
</Button>
174+
</div>
8175
</div>
9176
</div>
10177
);

apps/extension/src/assets/logo.svg

Lines changed: 3 additions & 0 deletions
Loading

apps/extension/src/background.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,39 @@
1-
console.log("백그라운드 기능")
1+
console.log('백그라운드 기능');
2+
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
3+
if (message.type === 'FETCH_OG_META') {
4+
fetch(message.url)
5+
.then((res) => res.text())
6+
.then((html) => {
7+
const parser = new DOMParser();
8+
const doc = parser.parseFromString(html, 'text/html');
9+
10+
const getMeta = (prop) =>
11+
doc
12+
.querySelector(`meta[property="${prop}"]`)
13+
?.getAttribute('content') || '';
14+
15+
const makeAbsoluteUrl = (base, img) => {
16+
try {
17+
return img ? new URL(img, base).href : '';
18+
} catch {
19+
return img;
20+
}
21+
};
22+
23+
const image = getMeta('og:image');
24+
25+
sendResponse({
26+
title: getMeta('og:title'),
27+
description: getMeta('og:description'),
28+
siteName: getMeta('og:site_name'),
29+
image: makeAbsoluteUrl(message.url, image),
30+
url: getMeta('og:url') || message.url,
31+
});
32+
})
33+
.catch((err) => {
34+
console.error('OG fetch 실패:', err);
35+
sendResponse(null);
36+
});
37+
return true; // async 응답
38+
}
39+
});

apps/extension/src/content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
console.log("컨텐츠 스크립트")
1+
console.log('컨텐츠 스크립트 로드됨');
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect, useState } from 'react';
2+
import { OgImageFetcher } from '@utils/OGFetch';
3+
4+
export interface PageMeta {
5+
url: string;
6+
title: string;
7+
description: string;
8+
imgUrl: string;
9+
}
10+
const getOgMeta = async (url: string) => {
11+
const imageUrl = await OgImageFetcher({ url });
12+
return {
13+
url,
14+
title: imageUrl?.title ?? '',
15+
description: imageUrl?.description ?? '',
16+
imgUrl: imageUrl?.image ?? '',
17+
};
18+
};
19+
export const usePageMeta = () => {
20+
const [meta, setMeta] = useState<PageMeta>({
21+
url: '',
22+
title: '',
23+
description: '',
24+
imgUrl: '',
25+
});
26+
27+
useEffect(() => {
28+
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
29+
const activeTab = tabs[0];
30+
if (!activeTab?.url) return;
31+
32+
const currentUrl = activeTab.url;
33+
34+
chrome.storage.local.set({ bookmarkedUrl: currentUrl });
35+
const newMeta = await getOgMeta(activeTab.url);
36+
// 개발중에는 잠시 주석처리
37+
// const isInternalChromePage =
38+
// /^chrome:\/\//.test(currentUrl) ||
39+
// /^edge:\/\//.test(currentUrl) ||
40+
// /^about:/.test(currentUrl);
41+
// // chrome-extension:// 은 내부 페이지로 취급하지 않음
42+
43+
// if (isInternalChromePage || !imageUrl?.title) {
44+
// window.close();
45+
// return;
46+
// }
47+
48+
49+
50+
setMeta(newMeta);
51+
52+
chrome.storage.local.set({ titleSave: newMeta.title });
53+
});
54+
}, []);
55+
56+
return meta;
57+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
interface SaveBookmarkParams {
2+
url: string;
3+
title: string;
4+
description: string;
5+
imgUrl: string;
6+
memo: string;
7+
isRemindOn: boolean;
8+
selectedCategory: string | null;
9+
date: string | null;
10+
time: string | null;
11+
}
12+
13+
export const useSaveBookmark = () => {
14+
const save = async (params: SaveBookmarkParams) => {
15+
try {
16+
const saveData = {
17+
...params,
18+
createdAt: new Date().toISOString(),
19+
};
20+
21+
const result = await new Promise<{ bookmarks?: any[] }>((resolve) => {
22+
chrome.storage.local.get(['bookmarks'], (items) => resolve(items));
23+
});
24+
25+
const bookmarks = result.bookmarks || [];
26+
bookmarks.push(saveData);
27+
28+
await new Promise<void>((resolve) => {
29+
chrome.storage.local.set({ bookmarks }, resolve);
30+
});
31+
32+
chrome.bookmarks.create(
33+
{
34+
parentId: '1',
35+
title: params.title || params.url,
36+
url: params.url,
37+
},
38+
(newBookmark) => {
39+
console.log('크롬 북마크바에 저장 완료:', newBookmark);
40+
}
41+
);
42+
43+
// window.close();
44+
} catch (error) {
45+
console.error('저장 중 오류:', error);
46+
}
47+
};
48+
49+
return { save };
50+
};

0 commit comments

Comments
 (0)