From 2817635ba1eb72a23fd83d15c0b6d0eb9190b3d8 Mon Sep 17 00:00:00 2001 From: fast1597 Date: Fri, 24 Apr 2026 23:56:04 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix(template):=20selected=20template=20st?= =?UTF-8?q?orage=20=EC=A0=91=EA=B7=BC=20=EA=B0=80=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selected template 조회 시 storage 값이 없거나 예상과 다른 경우를 방어합니다. popup 초기 진입이나 데이터 불일치 상황에서 예외가 전파되지 않도록 정리했습니다. template 선택 상태 복원 로직을 안전하게 유지하는 것이 목적입니다. --- src/hooks/useSelectedTemplate.ts | 36 +++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSelectedTemplate.ts b/src/hooks/useSelectedTemplate.ts index 4fd6b26..4adf837 100644 --- a/src/hooks/useSelectedTemplate.ts +++ b/src/hooks/useSelectedTemplate.ts @@ -12,6 +12,10 @@ import { loadTemplateFromLocalStorage } from "@/utils/templateStorage"; const STORAGE_KEY = "selectedTemplateId"; +function getChromeStorage() { + return globalThis.chrome?.storage; +} + /** * Convert Template to LinkListElement[] format */ @@ -51,6 +55,11 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { // Listen for storage changes from other contexts (real-time sync) useEffect(() => { + const storage = getChromeStorage(); + if (!storage?.onChanged) { + return; + } + const listener = ( changes: { [key: string]: chrome.storage.StorageChange }, areaName: string, @@ -69,9 +78,9 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { } }; - chrome.storage.onChanged.addListener(listener); + storage.onChanged.addListener(listener); return () => { - chrome.storage.onChanged.removeListener(listener); + storage.onChanged.removeListener(listener); }; }, []); @@ -90,7 +99,14 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { const loadSelectedTemplate = async () => { setIsLoading(true); try { - const result = await chrome.storage.local.get([STORAGE_KEY]); + const storage = getChromeStorage(); + if (!storage?.local) { + setSelectedTemplateId(null); + setLinkItems(LinkList); + return; + } + + const result = await storage.local.get([STORAGE_KEY]); const templateId = result[STORAGE_KEY]; console.log("[useSelectedTemplate] Loaded from storage:", { @@ -173,15 +189,25 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { const selectTemplate = async (templateId: number | null) => { try { + const storage = getChromeStorage(); + if (!storage?.local) { + setSelectedTemplateId(templateId); + if (templateId === null) { + setTemplateData(null); + setLinkItems(LinkList); + } + return; + } + if (templateId === null) { // Clear selection - await chrome.storage.local.remove(STORAGE_KEY); + await storage.local.remove(STORAGE_KEY); setSelectedTemplateId(null); setTemplateData(null); setLinkItems(LinkList); } else { // Save selection - await chrome.storage.local.set({ [STORAGE_KEY]: templateId }); + await storage.local.set({ [STORAGE_KEY]: templateId }); setSelectedTemplateId(templateId); } } catch (err) { From 2218d76ff65fd04025d4b38a0e7692d4ab316f69 Mon Sep 17 00:00:00 2001 From: fast1597 Date: Fri, 24 Apr 2026 23:58:43 +0900 Subject: [PATCH 02/15] =?UTF-8?q?docs:=20AGENTS.md=20=EC=99=80=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98/=EA=B8=B0=EC=97=AC=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 에이전트와 기여자가 바로 참고할 수 있도록 문서 구조를 보강했습니다. AGENTS.md, docs/ARCHITECTURE.md, docs/CONTRIBUTING.md 를 추가 및 정리했습니다. docs 디렉터리를 추적 대상에 포함하고 개인 작업 문서는 /.docs 사용을 권장합니다. --- .gitignore | 1 - AGENTS.md | 112 ++++++++++++++++++ docs/ARCHITECTURE.md | 245 +++++++++++++++++++++++++++++++++++++++ docs/CONTRIBUTING.md | 264 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTRIBUTING.md diff --git a/.gitignore b/.gitignore index d438d08..3a5aea3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,3 @@ gh-pages *.sw? .claude -docs \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e20c6e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,112 @@ +# AGENTS.md + +이 문서는 LinKU에서 작업하는 코딩 에이전트를 위한 빠른 진입점입니다. +설계, 동작, 배포, 협업 규칙이 관련된 작업이라면 이 문서를 먼저 읽고, +이후 `docs/ARCHITECTURE.md`와 `docs/CONTRIBUTING.md`를 확인하세요. + +## 프로젝트 개요 + +LinKU는 건국대학교 학생을 위한 Manifest V3 Chrome Extension입니다. +팝업 UI에서 학교 및 학생 서비스 링크, 공지, todo, banner, template 편집과 +공유, 도서관 좌석 현황, QR 생성 같은 Labs 기능을 제공합니다. + +이 저장소는 프론트엔드 확장 프로그램 코드만 포함합니다. LinKU backend와의 +통신은 `VITE_API_BASE_URL`을 기준으로 이루어지며, 학교 및 외부 사이트 접근은 +`public/manifest.json`의 `host_permissions`가 제어합니다. + +## 읽는 순서 + +1. `README.md`: 제품 소개와 기본 실행 명령을 확인합니다. +2. `docs/ARCHITECTURE.md`: 런타임 구조, 소스 지도, 데이터 흐름을 파악합니다. +3. `docs/CONTRIBUTING.md`: 브랜치, PR, 검증, 릴리즈 규칙을 확인합니다. +4. 위 문맥을 이해한 뒤 관련 source file을 읽습니다. + +## 런타임 구성 + +- Popup UI: `index.html` -> `src/main.tsx` -> `src/routes.tsx` +- Root app shell: `src/App.tsx` +- Background service worker: `src/background/index.ts` +- OAuth handler: `src/background/handlers/oauth.ts` +- Extension manifest: `public/manifest.json` + +현재 구조에는 content script가 없습니다. `public/manifest.json`이 변경되지 않는 +한 페이지 주입 기능이 존재한다고 가정하지 마세요. + +## 공통 명령 + +```bash +pnpm install +pnpm run dev +pnpm run build:local +pnpm run lint +``` + +Chrome에서 확장 프로그램을 검증하기 전에는 `pnpm run build:local`을 실행하고, +생성된 `dist/` 디렉터리를 `chrome://extensions`에서 Developer Mode로 +로드하세요. + +`pnpm run dev`는 React UI 반복 작업에는 유용하지만, `chrome.identity`, +`chrome.storage`, `chrome.action`, service worker 동작은 빌드된 확장 프로그램 +환경에서 검증해야 합니다. + +## 변경 규칙 + +- `public/manifest.json`의 extension version을 직접 수정하지 마세요. + 운영 workflow가 `scripts/updateVersion.js`를 통해 version을 올립니다. +- Chrome permission을 추가하거나 넓힐 때는 해당 permission이 왜 필요한지 + 문서화하세요. +- `host_permissions`는 보안상 민감한 영역입니다. 가능한 한 넓은 패턴보다 + 구체적인 domain을 사용하세요. +- access token, refresh token, auth code, private user data를 로그로 남기지 + 마세요. +- `README.md`는 제품 소개와 빠른 시작 중심으로 유지하세요. 협업 및 기술 + 세부사항은 `docs/` 아래에 둡니다. +- 이미 working tree에 존재하는 사용자 변경사항을 보존하세요. 편집 전 + `git status`를 확인하세요. + +## 소스 책임 지도 + +- `src/components/Tabs/`: popup tab 기능. +- `src/components/Editor/`: template editor control과 canvas. +- `src/pages/`: route 단위 page. +- `src/layouts/`: route layout wrapper. +- `src/contexts/`: React Context 기반 상태 container. +- `src/hooks/`: feature 단위 hook. +- `src/apis/`: LinKU backend API wrapper. +- `src/apis/external/`: 학교 또는 외부 서비스 연동. +- `src/background/`: Manifest V3 service worker와 message handling. +- `src/utils/`: storage, auth, analytics, template, Chrome helper utility. +- `src/types/`: 공유 TypeScript data contract. +- `src/constants/`: link list 같은 정적 app data. + +## 주의 영역 + +- `public/manifest.json`: permission, entrypoint, Chrome Web Store 심사에 + 직접 영향을 줍니다. +- `scripts/updateVersion.js`: release automation과 version bump에 관여합니다. +- `.github/workflows/`: Chrome Web Store upload, GitHub Pages, release 흐름을 + 제어합니다. +- `src/background/handlers/oauth.ts`: auth flow와 token handling을 담당합니다. +- `src/apis/client.ts`: auth interceptor, backend response parsing, + silent reauth를 담당합니다. +- `src/apis/external/`: third-party 또는 school page markup에 의존하는 parsing + logic이 있습니다. +- `src/utils/templateStorage.ts`: local draft persistence와 migration risk가 + 있습니다. + +## 검증 기준 + +code change라면 최소한 다음 명령을 실행하세요. + +```bash +pnpm run build:local +``` + +TypeScript, React hooks, shared utilities, CI/lint configuration을 수정했다면 +`pnpm run lint`도 실행하세요. 기존 lint issue 때문에 실패한다면 최종 보고에 +명확히 적고, 관련 없는 실패를 숨기지 마세요. + +UI 또는 확장 프로그램 동작을 변경했다면 `dist/`를 Chrome에 직접 로드해 관련 +popup 흐름을 검증하세요. OAuth, storage, badge, service-worker 변경은 Vite +dev mode와 실제 extension runtime이 다르므로 브라우저 검증이 필요합니다. + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..39c9558 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,245 @@ +# 아키텍처 + +이 문서는 LinKU가 런타임에서 어떻게 구성되는지, 그리고 어느 위치를 어떻게 +수정해야 안전한지 설명합니다. + +## 시스템 개요 + +LinKU는 Vite, React, TypeScript, Tailwind CSS로 만든 Manifest V3 Chrome +Extension입니다. 확장 프로그램은 두 개의 런타임 영역을 가집니다. + +- `index.html`에서 렌더링되는 Popup UI. +- `src/background/index.ts`에서 빌드되는 Background service worker. + +현재 프로젝트에는 content script가 없습니다. 확장 프로그램은 임의의 web page에 +UI나 logic을 주입하지 않습니다. 대부분의 user interaction은 popup 내부에서 +일어납니다. + +## 빌드 모델 + +`vite.config.ts`는 extension mode에서 multi-entry build를 설정합니다. + +- `index.html`은 popup entry가 됩니다. +- `src/background/index.ts`는 `background/index.js`가 됩니다. + +`gh-pages` mode에서는 build output이 `gh-pages/`로 바뀌고, static hosting을 +위해 banner asset이 복사됩니다. extension build의 output directory는 +`dist/`입니다. + +중요한 script: + +```bash +pnpm run dev +pnpm run build:local +pnpm run build +pnpm run watch:local +pnpm run build:gh-pages +``` + +`pnpm run build`는 build 전에 manifest patch version을 증가시킵니다. local +validation에서 version bump를 원하지 않는다면 `pnpm run build:local`을 +사용하세요. + +## 런타임 진입점 + +Popup UI 흐름: + +```text +index.html + -> src/main.tsx + -> src/routes.tsx + -> src/App.tsx + -> route pages and layouts +``` + +Background worker 흐름: + +```text +public/manifest.json + -> background.service_worker: background/index.js + -> src/background/index.ts + -> src/background/handlers/oauth.ts +``` + +popup은 React Router의 hash routing을 사용합니다. Chrome extension popup +page는 일반적인 server-backed web route처럼 동작하지 않기 때문에 hash routing을 +사용합니다. + +## 라우트 + +route는 `src/routes.tsx`에 정의되어 있습니다. + +- `/`: `MainLayout` 안의 main popup page. +- `/editor`: 새 template editor. +- `/editor/:templateId`: 기존 template editor. +- `/templates`: owned/local template list. +- `/gallery`: public posted-template gallery. +- `*`: not found page. + +`src/App.tsx`는 root error boundary, global providers, page-view analytics, +toast rendering을 제공합니다. + +## 소스 구조 + +```text +src/ + apis/ LinKU backend API wrapper + apis/external/ 학교 또는 외부 서비스 integration + assets/ local image와 SVG asset + background/ Manifest V3 service worker code + components/ feature component와 UI primitive + constants/ 정적 app data + contexts/ React Context 상태 container + hooks/ reusable React hook + layouts/ route layout wrapper + pages/ route 단위 screen + types/ 공유 TypeScript contract + utils/ storage, auth, analytics, template, Chrome helper +``` + +## 주요 기능 영역 + +기본 popup 기능: + +- Link groups: `src/components/Tabs/LinkGroup.tsx` +- Banners: `src/components/Tabs/ImageCarousel.tsx` +- Todo list: `src/components/Tabs/TodoList/` +- Alerts: `src/components/Tabs/Alerts/` +- Labs: `src/components/Labs/` + +Template system 구성: + +- Editor page: `src/pages/EditorPage.tsx` +- Template list: `src/pages/TemplateListPage.tsx` +- Public gallery: `src/pages/GalleryPage.tsx` +- Editor state: `src/contexts/EditorContext.tsx` +- Editor UI: `src/components/Editor/` +- Local template persistence: `src/utils/templateStorage.ts` +- Template helper: `src/utils/template.ts` + +Auth 및 account 관련 UI: + +- OAuth popup/background bridge: `src/utils/oauth.ts` +- Background OAuth handler: `src/background/handlers/oauth.ts` +- API auth interceptor: `src/apis/client.ts` +- Email verification dialog: `src/components/EmailVerificationDialog.tsx` +- Settings dialog: `src/components/SettingsDialog.tsx` + +## Popup과 Background 통신 + +popup은 `chrome.runtime.sendMessage`를 사용해 background service worker와 +통신합니다. + +message type과 guard는 `src/background/types.ts`에 있습니다. + +background worker가 처리하는 일: + +- Google login request. +- Silent reauth request. +- Extension install/update event. +- Badge count initialization. +- `chrome.storage.local` 변경에 따른 badge count update. + +OAuth는 background worker에 있습니다. `chrome.identity.launchWebAuthFlow`는 +일반 browser page flow가 아니라 extension API로 다뤄야 하기 때문입니다. + +## Backend API 흐름 + +중앙 HTTP client는 `src/apis/client.ts`입니다. + +주요 책임: + +- `VITE_API_BASE_URL`에서 backend URL을 구성합니다. +- `chrome.storage.local`의 bearer token을 request에 붙입니다. +- backend response envelope을 parsing합니다. +- token-expired backend code `5004`를 감지합니다. +- background worker에 silent reauth를 요청합니다. +- silent reauth 성공 후 original request를 한 번 retry합니다. +- hard auth failure에서 `auth:unauthorized`를 dispatch합니다. + +feature-specific API module은 fetch behavior를 중복 구현하지 말고 이 client를 +사용해야 합니다. + +## Storage 모델 + +현재 storage는 두 browser storage system으로 나뉘어 있습니다. + +- `chrome.storage.local`: auth token, user profile state, settings, custom + todo, library token, badge count. +- `localStorage`: `src/utils/templateStorage.ts`를 통한 template draft와 local + template persistence. + +이 분리는 과거 설계의 결과입니다. template local persistence flow를 직접 +수정하는 경우가 아니라면, 새 extension-wide state는 `chrome.storage.local`을 +우선 사용하세요. + +stored data shape를 바꿀 때는 기존 user의 migration behavior를 고려해야 +합니다. LinKU는 실제 user에게 배포되는 extension이므로 가능한 한 backward +compatible해야 합니다. + +## 인증 + +Google OAuth flow는 backend-mediated flow입니다. + +1. popup이 background worker에 login 시작을 요청합니다. +2. background worker가 extension redirect URI를 계산합니다. +3. background worker가 `chrome.identity.launchWebAuthFlow`로 backend Google + OAuth URL을 엽니다. +4. backend가 auth code를 포함해 redirect합니다. +5. background worker가 backend를 통해 code를 token으로 교환합니다. +6. token은 `chrome.storage.local`에 저장됩니다. +7. popup/API state가 auth success 또는 failure에 반응합니다. + +auth code, access token, refresh token, full token response를 로그로 남기지 +마세요. + +## 외부 연동 + +`src/apis/external/`에는 이 repository가 소유하지 않는 integration이 있습니다. +예시는 eCampus, library, banners, RSS, HTML parsing입니다. + +이 module들은 external service의 response shape 또는 DOM structure가 바뀌면 +깨질 수 있습니다. 이 영역의 변경은 PR에 manual verification notes를 포함해야 +합니다. + +## UI 시스템 + +UI는 다음을 사용합니다. + +- Styling에는 Tailwind CSS를 사용합니다. +- `src/components/ui/` 아래에는 shadcn-style Radix wrapper가 있습니다. +- Icon에는 `lucide-react`를 사용합니다. +- Toast notification에는 `sonner`를 사용합니다. +- Editor drag behavior에는 `@dnd-kit/*`를 사용합니다. +- Carousel behavior에는 `embla-carousel`을 사용합니다. + +새 component library를 도입하기보다 existing UI primitive와 local pattern을 +우선 사용하세요. + +## CI와 배포 + +GitHub Actions workflow는 `.github/workflows/`에 있습니다. + +- `pr-build-check.yml`: PR에서 local production-like build check를 실행합니다. +- `upload-chrome-extension-draft.yml`: main에서 Chrome Web Store draft를 + upload합니다. +- `create-release.yml`: GitHub Release를 만들고 built zip을 첨부합니다. +- `deploy-gh-pages.yml`: static assets/pages를 `gh-pages`에 deploy합니다. + +main branch는 release-sensitive합니다. versioning과 deployment behavior는 +의도적으로 변경하고 PR에 문서화해야 합니다. + +## 알려진 기술 부채 + +- lint는 설정되어 있지만 현재 PR CI에서 강제하지 않습니다. +- test framework 또는 automated browser extension test suite가 없습니다. +- `README.md`는 product-focused 문서이며, 깊은 협업 문서는 `docs/` 아래에 + 있습니다. +- 일부 production code에 diagnostic logging이 남아 있습니다. +- `public/manifest.json`에는 broad host permissions가 포함되어 있습니다. +- template persistence는 `localStorage`를 사용하고, 다른 extension state는 + `chrome.storage.local`을 사용합니다. + +이 항목들은 별도 cleanup PR로 다루기 좋습니다. unrelated feature work에 섞지 +마세요. + diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..3f94477 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,264 @@ +# 기여 가이드 + +이 문서는 LinKU의 확장 프로그램 release flow를 깨뜨리지 않고, 이후 유지보수가 +가능한 방식으로 협업하기 위한 기준을 설명합니다. + +## 시작하기 전에 + +LinKU는 Chrome Web Store를 통해 배포되는 실제 Chrome Extension입니다. +변경사항은 review 가능한 크기로 유지하고, 사용자 영향, permission, +authentication, release automation에 미치는 영향을 명확히 드러내야 합니다. + +feature proposal, behavior change, permission change, 큰 refactor는 먼저 +GitHub Issues에서 논의하세요. 의도가 분명한 작은 bug fix와 documentation +improvement는 바로 PR로 진행해도 됩니다. + +## 프로젝트 빠른 이해 + +LinKU는 건국대학교 학생을 위한 Manifest V3 Chrome Extension입니다. 교내 공식 +사이트, 학생 제작 서비스, 공지, todo, template, Labs 기능을 하나의 popup에서 +사용할 수 있게 합니다. + +현재 구조에는 content script가 없습니다. `public/manifest.json`은 popup UI와 +Background service worker를 선언하며, 방문 중인 페이지에 script를 주입하지 +않습니다. popup과 Background service worker 사이의 통신은 +`chrome.runtime.sendMessage`와 `src/background/types.ts`의 type guard를 통해 +이루어집니다. + +기술 스택은 다음을 기준으로 이해하면 됩니다. + +- Build: Vite 8, `@vitejs/plugin-react-swc` +- UI: React 19, TypeScript 6, Tailwind CSS 4, shadcn/ui, Radix UI +- Routing: `react-router-dom` v7, hash routing +- State: 별도 전역 상태 라이브러리 없이 React Context 사용 +- Extension runtime: Manifest V3, Background service worker +- Package manager/runtime: pnpm, Node.js 24 LTS +- Supporting libraries: `@dnd-kit/*`, `embla-carousel`, `cmdk`, `sonner`, + `lucide-react`, `react-error-boundary` + +## 코드베이스 빠른 지도 + +주요 디렉터리 역할은 다음과 같습니다. + +- `src/background/`: MV3 Background service worker, OAuth, badge update. +- `src/pages/`: route 단위 page. Main, Editor, TemplateList, Gallery, + NotFound가 여기에 있습니다. +- `src/components/Tabs/`: main popup tab 기능. link group, todo, alerts, + carousel 등이 여기에 모입니다. +- `src/components/Editor/`: template editor UI, canvas, sidebar, header. +- `src/components/Labs/`: 실험 기능. library seat, QR generator, server clock. +- `src/components/ui/`: shadcn/ui 스타일의 Radix UI wrapper. +- `src/contexts/`: React Context 기반 상태 관리. `EditorContext.tsx`가 + template editor 상태의 중심입니다. +- `src/apis/`: LinKU backend API wrapper와 endpoint 정의. +- `src/apis/external/`: eCampus, library, RSS, HTML parsing, banners 같은 + 외부 또는 학교 사이트 연동. +- `src/utils/`: analytics, chrome, crypto, oauth, template, templateStorage + 등 cross-cutting utility. +- `src/constants/`: `LinkList.ts` 같은 정적 app data. +- `src/types/`: backend response와 domain model의 TypeScript contract. + +더 자세한 runtime 구조와 데이터 흐름은 `docs/ARCHITECTURE.md`를 기준으로 +확인하세요. + +## 로컬 설정 + +```bash +pnpm install +pnpm run dev +``` + +`pnpm run dev`는 React UI를 빠르게 확인할 때 사용합니다. 이 환경은 Chrome +확장 프로그램 runtime을 완전히 재현하지 않습니다. + +확장 프로그램 검증에는 다음 명령을 사용합니다. + +```bash +pnpm run build:local +``` + +이후 `chrome://extensions`를 열고 Developer Mode를 활성화한 뒤, 생성된 +`dist/` 디렉터리를 unpacked extension으로 로드하세요. + +## 환경 변수 + +`.env.development.example`을 복사해 `.env.development`를 만듭니다. + +중요한 변수: + +- `VITE_API_BASE_URL`: LinKU backend API base URL입니다. auth, templates, + icons, alerts, public gallery data 같은 backend-backed feature에 필요합니다. +- `VITE_GA_API_SECRET`: Google Analytics Measurement Protocol secret입니다. + analytics 동작을 검증하지 않는 대부분의 local development에서는 필수는 + 아닙니다. +- `VITE_ENVIRONMENT`: analytics 동작에서 사용하는 environment flag입니다. + +실제 secret을 commit하지 마세요. `.env.*` 파일은 example file을 제외하고 +ignore됩니다. + +## 브랜치 전략 + +이 저장소는 `main`을 통합 branch로 사용합니다. + +권장 branch prefix: + +- `feat/*`: 사용자에게 보이는 feature. +- `fix/*`: bug fix. +- `refactor/*`: 동작을 유지하는 code change. +- `docs/*`: documentation. +- `config/*` 또는 `ci/*`: tooling과 workflow change. + +branch는 하나의 목적에 집중하세요. feature work, formatting churn, 관련 없는 +cleanup을 한 PR에 섞지 않는 것이 좋습니다. + +## 커밋 스타일 + +가능하면 Conventional Commits를 사용합니다. + +- `feat: add template gallery filter` +- `fix(auth): handle expired token response` +- `refactor(editor): split canvas helpers` +- `docs: add architecture guide` +- `ci: add lint check` + +현재 commitlint 또는 Husky enforcement는 없습니다. 따라서 commit style은 +local gate가 아니라 review convention입니다. + +## PR 체크리스트 + +review 요청 전 다음 내용을 포함하세요. + +- behavior 또는 documentation change 요약. +- visible UI change가 있다면 screenshot 또는 GIF. +- extension-only behavior에 대한 manual test notes. +- backend API assumption 또는 response-shape change. +- permission change가 있다면 각 permission이 필요한 이유. +- verification command의 실행 결과 또는 상태. + +권장 local check: + +```bash +pnpm run build:local +pnpm run lint +``` + +현재 CI는 PR에서 `pnpm run build:local`을 실행합니다. shared code, React hooks, +TypeScript utility를 변경했다면 가능한 한 local에서 lint도 실행하세요. + +현재 프로젝트에는 Prettier, Stylelint, Husky, lint-staged, test framework, +pre-commit hook이 없습니다. formatting과 test coverage는 자동으로 보장되지 +않으므로, 변경 범위에 맞는 manual verification을 PR 설명에 남기는 것이 +중요합니다. + +## 수동 테스트 가이드 + +layout과 interaction을 빠르게 확인할 때는 `pnpm run dev`를 사용하고, runtime +behavior는 빌드된 extension으로 검증합니다. + +다음 영역을 수정했다면 unpacked extension으로 검증하세요. + +- `src/background/` +- `src/utils/chrome.ts` +- `src/utils/oauth.ts` +- `src/apis/client.ts` +- `public/manifest.json` +- storage behavior +- OAuth behavior +- badge updates +- host permissions +- external school-site integrations + +UI change는 popup viewport size와 영향을 받는 route를 확인하세요. template +editor change는 관련 범위에 따라 create, edit, save locally, sync, drag, +resize, template list로 돌아가는 navigation을 확인하세요. + +## 아키텍처 경계 + +현재 source boundary를 따르세요. + +- Route-level screen은 `src/pages/`에 둡니다. +- Layout wrapper는 `src/layouts/`에 둡니다. +- Reusable UI primitive는 `src/components/ui/`에 둡니다. +- Main popup feature section은 `src/components/Tabs/`에 둡니다. +- Template editor component는 `src/components/Editor/`에 둡니다. +- Backend API wrapper는 `src/apis/`에 둡니다. +- External school 또는 third-party integration은 `src/apis/external/`에 둡니다. +- Shared type contract는 `src/types/`에 둡니다. +- Cross-cutting browser와 data utility는 `src/utils/`에 둡니다. +- MV3 background code는 `src/background/`에 둡니다. + +feature PR 안에서 새로운 state library, router, styling system, formatter, +test framework를 도입하지 마세요. 그런 변경은 별도 tooling decision PR로 +다루는 것이 좋습니다. + +## Chrome 권한 정책 + +permission change는 추가 검토가 필요합니다. + +`public/manifest.json`을 수정할 때는 다음을 설명하세요. + +- 어떤 feature가 해당 permission을 필요로 하는지. +- 어떤 API 또는 domain이 해당 permission을 요구하는지. +- 더 좁은 permission으로 충분하지 않은 이유. +- Chrome에서 해당 behavior를 어떻게 manual test했는지. + +넓은 access보다 domain-specific `host_permissions`를 선호하세요. ``는 +legacy 또는 temporary permission으로 보고, 가볍게 확장하지 마세요. + +## 외부 사이트 연동 + +`src/apis/external/` 아래 파일은 external markup 또는 API에 의존합니다. 이 +영역을 변경할 때는 무엇을 검증했는지 문서화하세요. + +- Target URL 또는 service. +- Verification date. +- Example response 또는 DOM assumption. +- Parsing 실패 시 fallback behavior. + +이 integration들은 학교 또는 외부 사이트가 구조를 바꾸면 코드 변경 없이도 +깨질 수 있습니다. + +## 첫 기여 흐름 + +처음 기여한다면 다음 순서로 진행하세요. + +1. GitHub Issue에서 변경 의도와 범위를 확인하거나 제안합니다. +2. `feat/*`, `fix/*`, `refactor/*`, `docs/*`, `config/*`, `ci/*` 중 적절한 + branch prefix로 작업 branch를 만듭니다. +3. 관련 source와 `docs/ARCHITECTURE.md`를 읽고 변경 위치를 좁힙니다. +4. 코드를 수정하고 `pnpm run build:local`을 실행합니다. +5. 가능하면 `pnpm run lint`도 실행합니다. +6. `dist/`를 Chrome에 로드해 실제 extension 동작을 확인합니다. +7. PR에 변경 요약, 검증 결과, UI 변경 screenshot/GIF, permission 변경 사유를 + 남깁니다. + +실제 사용자가 있는 확장 프로그램이므로 “브라우저에서 직접 확인했다”는 기록은 +review에서 중요합니다. + +## 릴리즈와 버전 관리 + +일반 PR에서 `public/manifest.json`의 version을 직접 bump하지 마세요. + +main-branch workflow가 다음을 처리합니다. + +- Chrome Web Store draft upload. +- GitHub Release creation. +- GitHub Pages deployment. +- `scripts/updateVersion.js`를 통한 manifest patch version bump. + +release workflow가 `chore: bump version ... [skip ci]` commit을 생성했다면, +release process 자체를 고치는 경우가 아니라면 amend하거나 rebase로 제거하지 +마세요. + +## 문서화 규칙 + +`README.md`는 product overview와 quick start 중심으로 유지합니다. + +용도별 문서: + +- `docs/ARCHITECTURE.md`: runtime design과 technical map. +- `docs/CONTRIBUTING.md`: collaboration rule. +- `AGENTS.md`: AI coding-agent orientation. + +architecture, workflow, permission, environment variable, onboarding step을 +변경했다면 같은 PR에서 documentation도 갱신하세요. From d74b302d1a38776505f002332bb42d0ffa66d86c Mon Sep 17 00:00:00 2001 From: fast1597 Date: Sat, 25 Apr 2026 00:10:26 +0900 Subject: [PATCH 03/15] =?UTF-8?q?fix(lint):=20ESLint=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor, Settings, template 관련 코드에서 ESLint 에러를 해소했습니다. 동작 변경보다는 타입 정리와 unsafe 패턴 제거에 가깝습니다. lint 통과를 위한 구조 정리가 포함되어 있습니다. --- .../Editor/EditorSidebar/QuickAddDialog.tsx | 70 +++++++++----- .../ItemPropertiesPanel.tsx | 94 ++++++++++++------- src/components/SettingsDialog.tsx | 43 +++++---- src/components/ui/label.tsx | 2 +- src/contexts/EditorContext.tsx | 31 +++--- src/utils/crypto.ts | 8 +- src/utils/template.ts | 4 +- src/utils/templateStorage.ts | 4 +- src/utils/todo/customTodo.ts | 7 +- 9 files changed, 158 insertions(+), 105 deletions(-) diff --git a/src/components/Editor/EditorSidebar/QuickAddDialog.tsx b/src/components/Editor/EditorSidebar/QuickAddDialog.tsx index 3e71404..987f662 100644 --- a/src/components/Editor/EditorSidebar/QuickAddDialog.tsx +++ b/src/components/Editor/EditorSidebar/QuickAddDialog.tsx @@ -4,7 +4,7 @@ * Includes both default and user-uploaded icons */ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Dialog, DialogContent, @@ -22,6 +22,7 @@ import { Plus } from 'lucide-react'; import { toast } from 'sonner'; import { validateLinkForm } from '@/utils/formValidation'; import { IconGrid } from '@/components/Editor/shared/IconGrid'; +import type { Icon } from '@/types/api'; interface QuickAddDialogProps { open: boolean; @@ -31,24 +32,45 @@ interface QuickAddDialogProps { export const QuickAddDialog = ({ open, onOpenChange, onAdd }: QuickAddDialogProps) => { const { state } = useEditorContext(); + const firstIconId = getInitialIconId(state.defaultIcons, state.userIcons); + + return ( + + {open && ( + + )} + + ); +}; + +interface QuickAddDialogContentProps { + defaultIcons: Icon[]; + userIcons: Icon[]; + onOpenChange: (open: boolean) => void; + onAdd: (data: { name: string; url: string; iconId: number }) => void; +} + +function getInitialIconId(defaultIcons: Icon[], userIcons: Icon[]): number | null { + return (defaultIcons[0] || userIcons[0])?.id ?? null; +} + +const QuickAddDialogContent = ({ + defaultIcons, + userIcons, + onOpenChange, + onAdd, +}: QuickAddDialogContentProps) => { const [name, setName] = useState(''); const [url, setUrl] = useState(''); - const [selectedIconId, setSelectedIconId] = useState(null); - - // Reset form when dialog opens - useEffect(() => { - if (open) { - setName(''); - setUrl(''); - // Auto-select first available icon - const firstIcon = state.defaultIcons[0] || state.userIcons[0]; - if (firstIcon) { - setSelectedIconId(firstIcon.id); - } else { - setSelectedIconId(null); - } - } - }, [open, state.defaultIcons, state.userIcons]); + const [selectedIconId, setSelectedIconId] = useState(() => + getInitialIconId(defaultIcons, userIcons) + ); const handleAdd = () => { // Validate form using centralized validation @@ -70,8 +92,7 @@ export const QuickAddDialog = ({ open, onOpenChange, onAdd }: QuickAddDialogProp }; return ( - - + 빠른 링크 추가 @@ -117,16 +138,16 @@ export const QuickAddDialog = ({ open, onOpenChange, onAdd }: QuickAddDialogProp - 기본 아이콘 ({state.defaultIcons.length}) + 기본 아이콘 ({defaultIcons.length}) - 내 아이콘 ({state.userIcons.length}) + 내 아이콘 ({userIcons.length}) - - + ); }; diff --git a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx index 9ad150d..73cac87 100644 --- a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx +++ b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx @@ -3,7 +3,7 @@ * Allows editing name, URL, icon, size, and position */ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useEditorContext } from '@/contexts/EditorContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,7 +14,7 @@ import { toast } from 'sonner'; import { GRID_CONFIG } from '@/utils/template'; import { validateLinkForm } from '@/utils/formValidation'; import { IconGrid } from '@/components/Editor/shared/IconGrid'; -import type { TemplateIcon } from '@/types/api'; +import type { TemplateIcon, TemplateItem } from '@/types/api'; import { InputGroup } from '@/components/Editor/shared/InputGroup'; export const ItemPropertiesPanel = () => { @@ -30,28 +30,6 @@ export const ItemPropertiesPanel = () => { const selectedItem = selectedCanvasItem || selectedStagingItem; const isFromStaging = !!selectedStagingItem; - // Local state for form fields - const [name, setName] = useState(''); - const [url, setUrl] = useState(''); - const [selectedIconId, setSelectedIconId] = useState(null); - const [width, setWidth] = useState('2'); - const [height, setHeight] = useState('1'); - const [posX, setPosX] = useState('0'); - const [posY, setPosY] = useState('0'); - - // Update form when selected item changes - useEffect(() => { - if (selectedItem) { - setName(selectedItem.name); - setUrl(selectedItem.siteUrl); - setSelectedIconId(selectedItem.icon.iconId); - setWidth(selectedItem.size.width.toString()); - setHeight(selectedItem.size.height.toString()); - setPosX(selectedItem.position.x.toString()); - setPosY(selectedItem.position.y.toString()); - } - }, [selectedItem]); - // No item selected if (!selectedItem) { return ( @@ -65,9 +43,58 @@ export const ItemPropertiesPanel = () => { ); } - const handleSave = () => { - if (!selectedItem) return; + return ( + + ); +}; +interface ItemPropertiesPanelFormProps { + selectedItem: TemplateItem; + isFromStaging: boolean; + defaultIcons: ReturnType['state']['defaultIcons']; + userIcons: ReturnType['state']['userIcons']; + dispatch: ReturnType['dispatch']; +} + +function getSelectedItemKey(item: TemplateItem): string { + return [ + item.templateItemId, + item.name, + item.siteUrl, + item.icon.iconId, + item.size.width, + item.size.height, + item.position.x, + item.position.y, + ].join(':'); +} + +const ItemPropertiesPanelForm = ({ + selectedItem, + isFromStaging, + defaultIcons, + userIcons, + dispatch, +}: ItemPropertiesPanelFormProps) => { + // Local state for form fields + const [name, setName] = useState(selectedItem.name); + const [url, setUrl] = useState(selectedItem.siteUrl); + const [selectedIconId, setSelectedIconId] = useState( + selectedItem.icon.iconId + ); + const [width, setWidth] = useState(selectedItem.size.width.toString()); + const [height, setHeight] = useState(selectedItem.size.height.toString()); + const [posX, setPosX] = useState(selectedItem.position.x.toString()); + const [posY, setPosY] = useState(selectedItem.position.y.toString()); + + const handleSave = () => { // Validate form using centralized validation const validation = validateLinkForm(name, url, selectedIconId, 15); if (!validation.valid) { @@ -76,7 +103,7 @@ export const ItemPropertiesPanel = () => { } // Find selected icon - const allIcons = [...state.defaultIcons, ...state.userIcons]; + const allIcons = [...defaultIcons, ...userIcons]; const icon = allIcons.find((i) => i.id === selectedIconId); if (!icon) { toast.error('선택한 아이콘을 찾을 수 없습니다.'); @@ -118,8 +145,6 @@ export const ItemPropertiesPanel = () => { }; const handleDelete = () => { - if (!selectedItem) return; - if (isFromStaging) { // Permanently delete from staging dispatch({ type: 'REMOVE_FROM_STAGING', payload: selectedItem.templateItemId }); @@ -132,12 +157,11 @@ export const ItemPropertiesPanel = () => { }; const handleMoveToCanvas = () => { - if (!selectedItem || !isFromStaging) return; + if (!isFromStaging) return; dispatch({ type: 'MOVE_TO_CANVAS', payload: selectedItem.templateItemId }); toast.success('아이템이 캔버스에 추가되었습니다.'); }; - return (