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..b8d3488 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,259 @@ +# 아키텍처 + +이 문서는 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 내부에서 +일어납니다. + +## Backend 의존성 + +LinKU는 frontend-only extension이 아닙니다. auth, template sync, posted +template, icons, alerts 같은 backend 연동 기능은 `VITE_API_BASE_URL`이 +올바르게 설정되어야 동작합니다. + +로컬 개발 환경에서 `VITE_API_BASE_URL`이 없거나 placeholder 값으로 남아 +있다면, 다음 기능은 정상적으로 동작하지 않을 수 있습니다. + +- Google OAuth login +- template sync와 posted-template API +- icons API +- alerts subscription API +- `src/apis/` 아래 LinKU backend를 호출하는 기타 기능 + +## 빌드 모델 + +`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..fb90604 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,284 @@ +# 기여 가이드 + +이 문서는 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입니다. + +`VITE_API_BASE_URL`이 없거나 placeholder 값으로 남아 있으면, local build를 +해도 Google OAuth login과 `src/apis/` 기반 backend API 검증은 정상적으로 +진행되지 않습니다. auth, templates, icons, alerts 같은 기능을 확인하려면 +실제 backend URL이 필요합니다. + +실제 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으로 보고, 가볍게 확장하지 마세요. + +## 로깅 규칙 + +runtime log는 필요한 최소한만 남기고, 정책은 `src/utils/logger.ts`를 기준으로 +일관되게 적용합니다. + +- `console.log`, `console.warn`, `console.error`, `console.info`를 직접 호출하지 + 말고 공통 logger를 사용하세요. +- `debug`/`info` 수준 로그는 개발 환경에서만 출력되도록 유지하세요. +- 운영 환경에는 장애 원인 파악에 필요한 `warn`/`error`만 남기고, 동일한 실패를 + 여러 계층에서 중복 기록하지 마세요. +- access token, refresh token, auth code, secret, authorization header, + private user data 같은 민감정보를 로그에 남기지 마세요. +- 외부 응답 본문이나 큰 object 전체 dump 대신, 상태 코드와 비민감 핵심 필드만 + 선택적으로 기록하세요. + +## 외부 사이트 연동 + +`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도 갱신하세요. diff --git a/eslint.config.js b/eslint.config.js index 092408a..1bc3482 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,10 +19,29 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, + 'no-console': 'error', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, + { + files: ['src/utils/logger.ts'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['vite.config.ts'], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['src/components/ui/button.tsx', 'src/components/ui/badge.tsx'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, ) diff --git a/package.json b/package.json index c67aebc..20171b5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -28,9 +27,6 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", - "@tailwindcss/vite": "^4.2.2", - "@types/chrome": "^0.1.38", - "@types/qrcode": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -43,23 +39,23 @@ "react-error-boundary": "^6.1.1", "react-router-dom": "^7.13.2", "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "tailwindcss-animate": "^1.0.7" + "tailwind-merge": "^3.5.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.2.2", + "@tailwindcss/vite": "^4.2.2", + "@types/chrome": "^0.1.38", "@types/node": "^25.5.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react-swc": "^4.3.0", "autoprefixer": "^10.4.27", "eslint": "^10.1.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", - "install": "^0.13.0", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "~6.0.2", @@ -72,4 +68,4 @@ "rollup": "^4.59.0" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbff154..92b0987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.4) @@ -41,15 +38,6 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)) - '@types/chrome': - specifier: ^0.1.38 - version: 0.1.38 - '@types/qrcode': - specifier: ^1.5.6 - version: 1.5.6 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -89,9 +77,6 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@4.2.2) devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -99,18 +84,24 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.2 version: 4.2.2 + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)) + '@types/chrome': + specifier: ^0.1.38 + version: 0.1.38 '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.14 version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) - '@types/react-router-dom': - specifier: ^5.3.3 - version: 5.3.3 '@vitejs/plugin-react-swc': specifier: ^4.3.0 version: 4.3.0(vite@8.0.3(@types/node@25.5.0)(jiti@2.6.1)) @@ -129,9 +120,6 @@ importers: globals: specifier: ^17.4.0 version: 17.4.0 - install: - specifier: ^0.13.0 - version: 0.13.0 postcss: specifier: ^8.5.8 version: 8.5.8 @@ -235,12 +223,6 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - '@dnd-kit/utilities@3.2.2': resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} peerDependencies: @@ -756,42 +738,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -922,42 +898,36 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.21': resolution: {integrity: sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.21': resolution: {integrity: sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] - libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.21': resolution: {integrity: sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==} engines: {node: '>=10'} cpu: [s390x] os: [linux] - libc: [glibc] '@swc/core-linux-x64-gnu@1.15.21': resolution: {integrity: sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.21': resolution: {integrity: sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.21': resolution: {integrity: sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==} @@ -1030,28 +1000,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1110,9 +1076,6 @@ packages: '@types/har-format@1.2.16': resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} - '@types/history@4.7.11': - resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1127,12 +1090,6 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react-router-dom@5.3.3': - resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} - - '@types/react-router@5.1.20': - resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} - '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -1533,10 +1490,6 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} - install@0.13.0: - resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==} - engines: {node: '>= 0.10'} - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1630,28 +1583,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1920,11 +1869,6 @@ packages: tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} @@ -2206,13 +2150,6 @@ snapshots: react-dom: 19.2.4(react@19.2.4) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.4)': dependencies: react: 19.2.4 @@ -2971,8 +2908,6 @@ snapshots: '@types/har-format@1.2.16': {} - '@types/history@4.7.11': {} - '@types/json-schema@7.0.15': {} '@types/node@25.5.0': @@ -2987,17 +2922,6 @@ snapshots: dependencies: '@types/react': 19.2.14 - '@types/react-router-dom@5.3.3': - dependencies: - '@types/history': 4.7.11 - '@types/react': 19.2.14 - '@types/react-router': 5.1.20 - - '@types/react-router@5.1.20': - dependencies: - '@types/history': 4.7.11 - '@types/react': 19.2.14 - '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -3414,8 +3338,6 @@ snapshots: imurmurhash@0.1.4: {} - install@0.13.0: {} - is-arrayish@0.2.1: {} is-extglob@2.1.1: {} @@ -3738,10 +3660,6 @@ snapshots: tailwind-merge@3.5.0: {} - tailwindcss-animate@1.0.7(tailwindcss@4.2.2): - dependencies: - tailwindcss: 4.2.2 - tailwindcss@4.2.2: {} tapable@2.3.2: {} diff --git a/src/App.tsx b/src/App.tsx index 53883ca..4da3796 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,17 +9,17 @@ import { ErrorBoundary } from "react-error-boundary"; import { Toaster } from "./components/ui/sonner"; import { PostedTemplatesProvider } from "./contexts/PostedTemplatesContext"; import { sendPageView } from "./utils/analytics"; +import { debugLog } from "@/utils/logger"; import "./App.css"; function App() { - console.log( - "%c여길 열어보시다니...\n이 참에 직접 코드 기여도 해주시는 건 어떤가요?", - "font-family: Nanum Gothic; color: darkgreen; padding: 6px; border-radius: 4px; font-size:14px" - ); - console.log("https://github.com/Turtle-Hwan/LinKU"); - // Google Analytics: Extension 열릴 때 페이지뷰 전송 useEffect(() => { + debugLog( + "%c여길 열어보시다니...\n이 참에 직접 코드 기여도 해주시는 건 어떤가요?", + "font-family: Nanum Gothic; color: darkgreen; padding: 6px; border-radius: 4px; font-size:14px", + ); + debugLog("https://github.com/Turtle-Hwan/LinKU"); sendPageView("LinKU Extension - Popup", "chrome-extension://linku/popup"); }, []); diff --git a/src/apis/alerts.ts b/src/apis/alerts.ts index dadfa7f..d46a510 100644 --- a/src/apis/alerts.ts +++ b/src/apis/alerts.ts @@ -13,6 +13,7 @@ import type { } from '../types/api'; import { getAlertsFromRSS } from './external/rss-parser'; import { getCareerAlertsFromHTML } from './external/html-parser'; +import { errorLog } from '@/utils/logger'; /** * Get filtered alerts by category @@ -46,11 +47,7 @@ export async function getAlerts( status: 200, }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error( - `[Alerts] Failed to fetch alerts from external sources`, - `\n Error: ${errorMessage}` - ); + errorLog("[Alerts] Failed to fetch alerts from external sources", error); return { success: false, error: { diff --git a/src/apis/client.ts b/src/apis/client.ts index 773b404..4b9e038 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -6,6 +6,7 @@ import type { ApiResponse, RequestConfig } from "../types/api"; import { BackgroundMessageType } from "../background/types"; import type { SilentReauthResponse } from "../background/types"; +import { debugLog, errorLog, getErrorLogDetails, warnLog } from "@/utils/logger"; /** * Token expired error code from backend @@ -100,11 +101,11 @@ async function clearAccessToken(): Promise { async function handleTokenExpired(): Promise { // If already reauthenticating, wait for the existing promise if (isReauthenticating && reauthPromise) { - console.log("[API Client] Reauth already in progress, waiting..."); + debugLog("[API Client] Reauth already in progress, waiting..."); return reauthPromise; } - console.log("[API Client] Token expired (5004), attempting silent reauth..."); + debugLog("[API Client] Token expired (5004), attempting silent reauth..."); isReauthenticating = true; reauthPromise = (async () => { @@ -117,14 +118,16 @@ async function handleTokenExpired(): Promise { }); if (response?.success) { - console.log("[API Client] Silent reauth succeeded"); + debugLog("[API Client] Silent reauth succeeded"); return true; } else { - console.warn("[API Client] Silent reauth failed:", response?.error); + warnLog("[API Client] Silent reauth failed", { + error: response?.error, + }); return false; } } catch (error) { - console.warn("[API Client] Silent reauth error:", error); + warnLog("[API Client] Silent reauth error", getErrorLogDetails(error)); return false; } finally { isReauthenticating = false; @@ -250,7 +253,11 @@ async function request( data = (await response.text()) as T; } } catch (parseError) { - console.error("Response parsing error:", parseError); + errorLog("[API Client] Response parsing error", { + ...getErrorLogDetails(parseError), + status: response.status, + url: fullUrl, + }); // If parsing fails, return error response return { success: false, @@ -270,7 +277,7 @@ async function request( "code" in data && (data as Record).code === TOKEN_EXPIRED_CODE ) { - console.log( + debugLog( "[API Client] Detected 5004 token expired error, attempting reauth...", ); @@ -278,11 +285,11 @@ async function request( if (reauthSuccess) { // Retry the original request with new token - console.log("[API Client] Retrying request after successful reauth"); + debugLog("[API Client] Retrying request after successful reauth"); return request(url, method, body, config, true); } else { // Reauth failed, clear tokens and notify - console.warn("[API Client] Reauth failed, clearing tokens"); + warnLog("[API Client] Reauth failed, clearing tokens"); await clearAccessToken(); window.dispatchEvent(new CustomEvent("auth:unauthorized")); @@ -330,7 +337,7 @@ async function request( status: response.status, }); } catch (error) { - console.warn("API Request Error:", error); + warnLog("[API Client] Request error", getErrorLogDetails(error)); return { success: false, error: { @@ -422,6 +429,7 @@ export async function publicRequest( return { success: true, status: response.status, data: resultData as T }; } catch (error) { + warnLog("[API Client] Public request error", getErrorLogDetails(error)); return { success: false, error: { code: "NETWORK_ERROR", message: String(error) }, diff --git a/src/apis/external/ecampus.ts b/src/apis/external/ecampus.ts index d832bc5..8fb8196 100644 --- a/src/apis/external/ecampus.ts +++ b/src/apis/external/ecampus.ts @@ -4,6 +4,7 @@ */ import { ECampusTodoItem } from '@/types/todo'; +import { errorLog } from '@/utils/logger'; export interface ECampusLoginResponse { success: boolean; @@ -75,7 +76,7 @@ export async function eCampusLoginAPI( data, }; } catch (error) { - console.error('Login error:', error); + errorLog('Login error:', error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -158,7 +159,7 @@ export async function eCampusTodoListAPI(): Promise { }, }; } catch (error) { - console.error('Failed to fetch todo list:', error); + errorLog('Failed to fetch todo list:', error); return { success: false, needLogin: true, error }; } } @@ -184,7 +185,7 @@ export async function eCampusGoLectureAPI( message: lectureUrl, }; } catch (error) { - console.error('Failed to access lecture:', error); + errorLog('Failed to access lecture:', error); return { success: false, error: error instanceof Error ? error.message : String(error), diff --git a/src/apis/external/html-parser.ts b/src/apis/external/html-parser.ts index 7506390..8f0b07e 100644 --- a/src/apis/external/html-parser.ts +++ b/src/apis/external/html-parser.ts @@ -1,4 +1,5 @@ import type { GeneralAlert } from "../../types/api"; +import { debugLog, warnLog, errorLog } from '@/utils/logger'; const CAREER_URL = "https://www.konkuk.ac.kr/combBbs/konkuk/2/list.do"; @@ -51,7 +52,7 @@ const parseHTMLToAlerts = ( // Debug logging if (!url) { - console.warn("Failed to parse URL from href:", hrefAttr, "Title:", title); + warnLog("Failed to parse URL from href:", hrefAttr, "Title:", title); } // Convert date to ISO string (format: YYYY.MM.DD) @@ -87,7 +88,7 @@ export const getCareerAlertsFromHTML = async ( startId: number = 3001 ): Promise => { try { - console.log("Fetching career alerts from:", CAREER_URL); + debugLog("Fetching career alerts from:", CAREER_URL); const response = await fetch(CAREER_URL); if (!response.ok) { @@ -96,10 +97,10 @@ export const getCareerAlertsFromHTML = async ( const htmlText = await response.text(); const alerts = parseHTMLToAlerts(htmlText, startId); - console.log(`Parsed ${alerts.length} career alerts, sample:`, alerts[0]); + debugLog(`Parsed ${alerts.length} career alerts, sample:`, alerts[0]); return alerts; } catch (error) { - console.error("Error fetching career HTML:", error); + errorLog("Error fetching career HTML:", error); return []; // Return empty array on error } }; diff --git a/src/apis/external/library.ts b/src/apis/external/library.ts index 110f5b9..eb7e030 100644 --- a/src/apis/external/library.ts +++ b/src/apis/external/library.ts @@ -3,12 +3,13 @@ * External service integration for Konkuk University Library seat reservation */ -import { +import type { LibraryApiResponse, LibraryLoginData, LibraryLoginRequest, LibrarySeatRoomsData, } from "@/types/api"; +import { errorLog } from "@/utils/logger"; const LIBRARY_BASE_URL = "https://library.konkuk.ac.kr"; const LIBRARY_API_URL = `${LIBRARY_BASE_URL}/pyxis-api`; @@ -106,7 +107,7 @@ export async function libraryLoginAPI( }; } } catch (error) { - console.error("[Library] Login error:", error); + errorLog("[Library] Login error:", error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -160,7 +161,7 @@ export async function getLibrarySeatRoomsAPI( data: result.data, }; } catch (error) { - console.error("[Library] Get seat rooms error:", error); + errorLog("[Library] Get seat rooms error:", error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -193,7 +194,7 @@ export async function setLibraryToken( return true; } catch (error) { - console.error("[Library] Failed to set token:", error); + errorLog("[Library] Failed to set token:", error); return false; } } @@ -233,7 +234,7 @@ export async function getLibraryTokenFromStorage(): Promise { return data.accessToken; } catch (error) { - console.error("[Library] Failed to get token from storage:", error); + errorLog("[Library] Failed to get token from storage:", error); return null; } } diff --git a/src/apis/external/rss-parser.ts b/src/apis/external/rss-parser.ts index c1ce6dc..d2aaca5 100644 --- a/src/apis/external/rss-parser.ts +++ b/src/apis/external/rss-parser.ts @@ -1,4 +1,5 @@ import type { GeneralAlert, RSSAlertCategory } from "../../types/api"; +import { errorLog } from '@/utils/logger'; /** * RSS URL configuration for each category @@ -84,7 +85,7 @@ const fetchRSSByCategory = async ( const xmlText = await response.text(); return parseRSSToAlerts(xmlText, category, startId); } catch (error) { - console.error(`Error fetching RSS for ${category}:`, error); + errorLog(`Error fetching RSS for ${category}:`, error); return []; // Return empty array on error } }; @@ -113,7 +114,7 @@ export const getAlertsFromRSS = async (): Promise => { // Combine all results return results.flat(); } catch (error) { - console.error("Error fetching RSS feeds:", error); + errorLog("Error fetching RSS feeds:", error); throw error; } }; diff --git a/src/background/handlers/oauth.ts b/src/background/handlers/oauth.ts index 86af7e2..48e0bbc 100644 --- a/src/background/handlers/oauth.ts +++ b/src/background/handlers/oauth.ts @@ -17,6 +17,13 @@ */ import type { GoogleLoginResponse } from "../types"; +import { + debugLog, + errorLog, + getErrorLogDetails, + getHttpErrorLogDetails, + warnLog, +} from "@/utils/logger"; // Backend URL from environment const BACKEND_URL = (() => { @@ -60,16 +67,12 @@ async function saveTokens( */ export async function handleGoogleLogin(): Promise { try { - console.log("[Background] Starting Google OAuth flow"); + debugLog("[Background] Starting Google OAuth flow"); // 1. Get extension ID and construct redirect URI const extensionId = chrome.runtime.id; const redirectUri = `https://${extensionId}.chromiumapp.org/`; - console.log("[Background] Extension ID:", extensionId); - console.log("[Background] Redirect URI:", redirectUri); - console.log("[Background] Backend URL:", BACKEND_URL); - if (!BACKEND_URL) { return { success: false, @@ -81,16 +84,12 @@ export async function handleGoogleLogin(): Promise { const authUrl = new URL(`${BACKEND_URL}/api/oauth2/google`); authUrl.searchParams.set("redirectUri", redirectUri); - console.log("[Background] Auth URL:", authUrl.toString()); - // 3. Launch OAuth flow using chrome.identity API const responseUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true, }); - console.log("[Background] Response URL:", responseUrl); - if (!responseUrl) { return { success: false, error: "인증이 취소되었습니다." }; } @@ -100,10 +99,10 @@ export async function handleGoogleLogin(): Promise { const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); - console.log("[Background] Extracted code:", code ? "있음" : "없음"); + debugLog("[Background] Extracted code:", code ? "있음" : "없음"); if (error) { - console.error("[Background] OAuth error:", error); + warnLog("[Background] OAuth error returned from provider", { error }); return { success: false, error: `OAuth 오류: ${error}`, @@ -118,14 +117,12 @@ export async function handleGoogleLogin(): Promise { } // 5. Exchange code for token via backend (새 API 스펙) - console.log("[Background] Exchanging code for token..."); + debugLog("[Background] Exchanging code for token..."); const tokenUrl = new URL(`${BACKEND_URL}/api/oauth2/google/login`); tokenUrl.searchParams.set("redirectUri", redirectUri); tokenUrl.searchParams.set("code", code); - console.log("[Background] Token URL:", tokenUrl.toString()); - const tokenResponse = await fetch(tokenUrl.toString(), { method: "GET", headers: { @@ -133,11 +130,18 @@ export async function handleGoogleLogin(): Promise { }, }); - console.log("[Background] Token Response Status:", tokenResponse.status); + debugLog("[Background] Token Response Status:", tokenResponse.status); if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - console.error("[Background] Token exchange failed:", errorText); + const errorBody = await tokenResponse.text(); + errorLog( + "[Background] Token exchange failed", + getHttpErrorLogDetails( + tokenResponse.status, + tokenResponse.statusText, + errorBody, + ), + ); return { success: false, error: `토큰 교환 실패: ${tokenResponse.status} ${tokenResponse.statusText}`, @@ -145,11 +149,15 @@ export async function handleGoogleLogin(): Promise { } const tokenData = await tokenResponse.json(); - console.log("[Background] Token Data:", JSON.stringify(tokenData, null, 2)); // 6. Parse backend response // 응답 형식: { code: 1000, message: "SUCCESS", result: { accessToken, refreshToken } } if (tokenData.code !== 1000) { + warnLog("[Background] Backend rejected token exchange", { + status: tokenResponse.status, + code: tokenData.code, + message: tokenData.message, + }); return { success: false, error: tokenData.message || "토큰 교환에 실패했습니다.", @@ -159,7 +167,10 @@ export async function handleGoogleLogin(): Promise { const { accessToken, refreshToken } = tokenData.result || {}; if (!accessToken) { - console.error("[Background] No accessToken in response:", tokenData); + errorLog("[Background] No accessToken in OAuth response", { + status: tokenResponse.status, + code: tokenData.code, + }); return { success: false, error: "백엔드 응답에서 토큰을 찾을 수 없습니다.", @@ -168,7 +179,7 @@ export async function handleGoogleLogin(): Promise { // 7. Save tokens await saveTokens(accessToken, refreshToken); - console.log("[Background] Tokens saved successfully"); + debugLog("[Background] Tokens saved successfully"); // 8. Return success response // refreshToken이 없으면 게스트(신규 회원) @@ -197,9 +208,11 @@ export async function handleGoogleLogin(): Promise { error.message.includes("interrupted")); if (isUserCancellation) { - console.warn("[Background] OAuth cancelled by user:", (error as Error).message); + debugLog("[Background] OAuth cancelled by user", { + message: error instanceof Error ? error.message : String(error), + }); } else { - console.error("[Background] OAuth error:", error); + errorLog("[Background] OAuth error", getErrorLogDetails(error)); } // User closed the popup or cancelled diff --git a/src/background/index.ts b/src/background/index.ts index 72b669b..7f09dd4 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -13,6 +13,7 @@ import { isGoogleLoginMessage, isSilentReauthMessage, } from "./types"; +import { debugLog, getErrorLogDetails, warnLog } from "@/utils/logger"; import type { BackgroundMessage, GoogleLoginResponse, @@ -20,7 +21,7 @@ import type { } from "./types"; import { handleGoogleLogin } from "./handlers/oauth"; -console.log("[Background] Service worker initialized"); +debugLog("[Background] Service worker initialized"); /** * Message handler for popup -> background communication @@ -43,20 +44,23 @@ chrome.runtime.onMessage.addListener( // At this point, message is an object with a type property // Cast to BackgroundMessage for type-safe handling const typedMessage = message as BackgroundMessage; - console.log("[Background] Message received:", typedMessage.type); + debugLog("[Background] Message received:", typedMessage.type); // Handle Google Login if (isGoogleLoginMessage(typedMessage)) { - console.log("[Background] Handling Google login request"); + debugLog("[Background] Handling Google login request"); // Handle async OAuth flow handleGoogleLogin() .then((response: GoogleLoginResponse) => { - console.log("[Background] Sending OAuth response to popup"); + debugLog("[Background] Sending OAuth response to popup"); sendResponse(response); }) .catch((error: unknown) => { - console.warn("[Background] OAuth handler error:", error); + warnLog( + "[Background] OAuth handler error", + getErrorLogDetails(error), + ); sendResponse({ success: false, error: @@ -72,14 +76,14 @@ chrome.runtime.onMessage.addListener( // Handle Silent Reauth (when token expires - 5004 error) if (isSilentReauthMessage(typedMessage)) { - console.log( + debugLog( "[Background] Handling silent reauth request (token expired)", ); // Reuse Google OAuth flow for silent reauth handleGoogleLogin() .then((response: GoogleLoginResponse) => { - console.log( + debugLog( "[Background] Silent reauth completed:", response.success, ); @@ -92,7 +96,10 @@ chrome.runtime.onMessage.addListener( sendResponse(reauthResponse); }) .catch((error: unknown) => { - console.warn("[Background] Silent reauth error:", error); + warnLog( + "[Background] Silent reauth error", + getErrorLogDetails(error), + ); sendResponse({ success: false, error: @@ -105,7 +112,7 @@ chrome.runtime.onMessage.addListener( // Unknown message type const unknownType = (message as { type: string }).type; - console.warn("[Background] Unknown message type:", unknownType); + warnLog("[Background] Unknown message type", { type: unknownType }); sendResponse({ success: false, error: `Unknown message type: ${unknownType}`, @@ -119,12 +126,12 @@ chrome.runtime.onMessage.addListener( * Extension install/update handler */ chrome.runtime.onInstalled.addListener((details) => { - console.log("[Background] Extension installed/updated:", details.reason); + debugLog("[Background] Extension installed/updated:", details.reason); if (details.reason === "install") { - console.log("[Background] First install - welcome!"); + debugLog("[Background] First install - welcome!"); } else if (details.reason === "update") { - console.log("[Background] Extension updated"); + debugLog("[Background] Extension updated"); } }); @@ -132,7 +139,7 @@ chrome.runtime.onInstalled.addListener((details) => { * Keep service worker alive (optional, for debugging) */ chrome.runtime.onStartup.addListener(() => { - console.log("[Background] Browser started, service worker activated"); + debugLog("[Background] Browser started, service worker activated"); }); /** diff --git a/src/components/Editor/EditorCanvas/DraggableItem.tsx b/src/components/Editor/EditorCanvas/DraggableItem.tsx index 5973529..31e7f64 100644 --- a/src/components/Editor/EditorCanvas/DraggableItem.tsx +++ b/src/components/Editor/EditorCanvas/DraggableItem.tsx @@ -8,7 +8,7 @@ import { useDraggable } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import { useState, useRef, useEffect } from 'react'; import type { TemplateItem } from '@/types/api'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { cn } from '@/lib/utils'; import { gridToPixelPosition, gridToPixelSize, pixelToGridSize, clampToGridBounds, GRID_CONFIG } from '@/utils/template'; import { Maximize2, Trash2 } from 'lucide-react'; diff --git a/src/components/Editor/EditorCanvas/EditorCanvas.tsx b/src/components/Editor/EditorCanvas/EditorCanvas.tsx index bf5c917..ce1445c 100644 --- a/src/components/Editor/EditorCanvas/EditorCanvas.tsx +++ b/src/components/Editor/EditorCanvas/EditorCanvas.tsx @@ -5,7 +5,7 @@ */ import { useDroppable } from '@dnd-kit/core'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { DraggableItem } from './DraggableItem'; import { CanvasGrid } from './CanvasGrid'; diff --git a/src/components/Editor/EditorHeader/BackButton.tsx b/src/components/Editor/EditorHeader/BackButton.tsx index 9064452..8a551b8 100644 --- a/src/components/Editor/EditorHeader/BackButton.tsx +++ b/src/components/Editor/EditorHeader/BackButton.tsx @@ -3,7 +3,7 @@ */ import { useNavigate } from 'react-router-dom'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { Button } from '@/components/ui/button'; import { ArrowLeft } from 'lucide-react'; diff --git a/src/components/Editor/EditorHeader/EditorHeader.tsx b/src/components/Editor/EditorHeader/EditorHeader.tsx index e558b34..15d07eb 100644 --- a/src/components/Editor/EditorHeader/EditorHeader.tsx +++ b/src/components/Editor/EditorHeader/EditorHeader.tsx @@ -2,7 +2,7 @@ * Editor Header - Top bar with template name, save, and publish controls */ -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { Input } from '@/components/ui/input'; import { SaveButton } from './SaveButton'; import { SyncButton } from './SyncButton'; @@ -18,6 +18,7 @@ import { } from '@/utils/templateStorage'; import { getTemplate } from '@/apis/templates'; import { areTemplatesEqual } from '@/utils/templateUtils'; +import { debugLog, errorLog } from '@/utils/logger'; export const EditorHeader = () => { const { state, dispatch } = useEditorContext(); @@ -43,12 +44,12 @@ export const EditorHeader = () => { } } } catch (error) { - console.error('[EditorHeader] Failed to fetch original template:', error); + errorLog('[EditorHeader] Failed to fetch original template:', error); // 원본 확인 실패 시 저장 진행 (fail-safe) } } - console.log('[EditorHeader] handleSave started:', { + debugLog('[EditorHeader] handleSave started:', { currentTemplateId: state.template.templateId, mode: state.mode, templateName: state.template.name, @@ -74,17 +75,17 @@ export const EditorHeader = () => { templateId: newId, updatedAt: new Date().toISOString(), }; - console.log('[EditorHeader] Generated new ID for draft template:', newId); + debugLog('[EditorHeader] Generated new ID for draft template:', newId); } else { // Existing template - keep ID savedTemplate = { ...state.template, updatedAt: new Date().toISOString(), }; - console.log('[EditorHeader] Using existing ID for saved template:', savedTemplate.templateId); + debugLog('[EditorHeader] Using existing ID for saved template:', savedTemplate.templateId); } - console.log('[EditorHeader] Saving template:', { + debugLog('[EditorHeader] Saving template:', { templateId: savedTemplate.templateId, name: savedTemplate.name, itemCount: savedTemplate.items.length, @@ -103,7 +104,7 @@ export const EditorHeader = () => { description: '템플릿이 로컬에 저장되었습니다.', }); } catch (error) { - console.error('[EditorHeader] Save failed:', error); + errorLog('[EditorHeader] Save failed:', error); dispatch({ type: 'SAVE_FAILED', payload: @@ -134,7 +135,7 @@ export const EditorHeader = () => { } } } catch (error) { - console.error('[EditorHeader] Failed to fetch original template:', error); + errorLog('[EditorHeader] Failed to fetch original template:', error); // 원본 확인 실패 시 동기화 진행 (fail-safe) } } diff --git a/src/components/Editor/EditorSidebar/EditorSidebar.tsx b/src/components/Editor/EditorSidebar/EditorSidebar.tsx index c51674b..cf7b0c4 100644 --- a/src/components/Editor/EditorSidebar/EditorSidebar.tsx +++ b/src/components/Editor/EditorSidebar/EditorSidebar.tsx @@ -3,7 +3,7 @@ */ import { useState } from 'react'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { GRID_CONFIG } from '@/utils/template'; import { Button } from '@/components/ui/button'; import { Zap, Upload } from 'lucide-react'; diff --git a/src/components/Editor/EditorSidebar/IconUploadDialog.tsx b/src/components/Editor/EditorSidebar/IconUploadDialog.tsx index 92151b8..7ac5f1f 100644 --- a/src/components/Editor/EditorSidebar/IconUploadDialog.tsx +++ b/src/components/Editor/EditorSidebar/IconUploadDialog.tsx @@ -18,6 +18,7 @@ import { Upload, ImageIcon, X } from 'lucide-react'; import { createIcon } from '@/apis/icons'; import { toast } from 'sonner'; import type { Icon } from '@/types/api'; +import { errorLog } from '@/utils/logger'; interface IconUploadDialogProps { open: boolean; @@ -144,7 +145,7 @@ export const IconUploadDialog = ({ }); } } catch (error) { - console.error('Icon upload error:', error); + errorLog('Icon upload error:', error); toast.error('오류', { description: '아이콘 업로드 중 오류가 발생했습니다.', }); diff --git a/src/components/Editor/EditorSidebar/QuickAddDialog.tsx b/src/components/Editor/EditorSidebar/QuickAddDialog.tsx index 3e71404..031894d 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, @@ -17,11 +17,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; 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/EditorSidebar/StagingArea.tsx b/src/components/Editor/EditorSidebar/StagingArea.tsx index 48c1923..9d94e21 100644 --- a/src/components/Editor/EditorSidebar/StagingArea.tsx +++ b/src/components/Editor/EditorSidebar/StagingArea.tsx @@ -3,7 +3,7 @@ * Items can be dragged to canvas or deleted permanently */ -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { StagingItem } from './StagingItem'; import { useDroppable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; diff --git a/src/components/Editor/EditorSidebar/StagingItem.tsx b/src/components/Editor/EditorSidebar/StagingItem.tsx index 01fc54d..339227b 100644 --- a/src/components/Editor/EditorSidebar/StagingItem.tsx +++ b/src/components/Editor/EditorSidebar/StagingItem.tsx @@ -6,7 +6,7 @@ import { useDraggable } from '@dnd-kit/core'; import { CSS } from '@dnd-kit/utilities'; import type { TemplateItem } from '@/types/api'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { cn } from '@/lib/utils'; import { Trash2 } from 'lucide-react'; diff --git a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx index 9ad150d..794ebcb 100644 --- a/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx +++ b/src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx @@ -3,8 +3,8 @@ * Allows editing name, URL, icon, size, and position */ -import { useState, useEffect } from 'react'; -import { useEditorContext } from '@/contexts/EditorContext'; +import { useEffect, useState } from 'react'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -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,55 @@ 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']; +} + +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()); + + useEffect(() => { + 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]); + + const handleSave = () => { // Validate form using centralized validation const validation = validateLinkForm(name, url, selectedIconId, 15); if (!validation.valid) { @@ -76,7 +100,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 +142,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 +154,11 @@ export const ItemPropertiesPanel = () => { }; const handleMoveToCanvas = () => { - if (!selectedItem || !isFromStaging) return; + if (!isFromStaging) return; dispatch({ type: 'MOVE_TO_CANVAS', payload: selectedItem.templateItemId }); toast.success('아이템이 캔버스에 추가되었습니다.'); }; - return (