diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index ae77623..1999f07 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -31,5 +31,11 @@ jobs: VITE_VOC_ENDPOINT: ${{ vars.VITE_VOC_ENDPOINT }} run: pnpm run build:local + - name: Test stateless template sharing + run: pnpm run test:template-share + + - name: Build GitHub Pages share viewer + run: pnpm run build:gh-pages + - name: Build success run: echo "✅ Build completed successfully!" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9769f88..3feefbf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,6 +39,27 @@ extension build는 다음 entry를 `dist/`에 생성합니다. ## 주요 데이터 흐름 +### 개인 템플릿과 공유 + +개인 템플릿 CRUD는 LinKU backend와 분리되어 있습니다. popup과 editor는 +`src/utils/templateStorage.ts`의 저장소 경계만 사용하고, 실제 템플릿·draft는 +`linku` IndexedDB에 저장합니다. 사용자가 올린 아이콘도 256px 이하 WebP로 +정규화한 뒤 같은 DB의 별도 store에 저장합니다. + +기존 `localStorage` 템플릿과 draft는 popup이 처음 저장소를 열 때 한 번 +IndexedDB로 복사합니다. 이전 값은 한 릴리즈 동안 rollback 원본으로 남기므로 +마이그레이션 실패가 기존 데이터 삭제로 이어지지 않습니다. + +작은 템플릿 공유 링크는 압축한 payload를 GitHub Pages URL의 fragment(`#`)에 +담습니다. fragment는 HTTP 요청에 포함되지 않으며 Pages의 `/share/` 화면에서만 +검증·해제됩니다. URL 제한을 넘는 템플릿은 서버에 자동 업로드하지 않고 +`.linku.json` 파일로 내보냅니다. Pages에서 확장 프로그램으로 가져오는 외부 +메시지는 manifest와 background 양쪽에서 LinKU share 경로로 제한합니다. + +계정 로그인, 여러 기기 동기화, 충돌 처리와 cloud share는 이 로컬 저장소 위에 +별도 계층으로 추가하며, 로컬 저장 성공 여부와 분리해야 합니다. 상세 경계는 +`docs/LOCAL_FIRST.md`를 참고합니다. + ### Backend와 인증 `src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 backend 요청, bearer token, @@ -80,7 +101,9 @@ popup이 닫힌 동안 background polling은 실행하지 않습니다. - `chrome.storage.local`: auth, 설정, todo, badge, 공지 캐시, 시간표 metadata와 snapshot/override. -- `localStorage`: template draft와 local template. +- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob. +- `localStorage`: non-extension 시간표 fallback과 이전 template/draft의 1회 + 마이그레이션 원본. 새 template 데이터는 쓰지 않습니다. - IndexedDB: 사용자가 직접 올린 시간표 이미지 blob. 시간표 metadata의 read-modify-write는 Web Locks로 popup과 background 사이에서 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index cabe367..fdd7439 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -56,6 +56,7 @@ TypeScript, React hook, shared utility를 수정했다면 lint를 실행합니 ```bash pnpm run lint pnpm run test:timetable +pnpm run test:template-share ``` 변경 유형별 추가 확인: @@ -65,6 +66,8 @@ pnpm run test:timetable - 외부 사이트 parser: 실제 페이지·응답과 fallback. 로그인 정보나 원문 응답을 로그 또는 fixture에 남기지 않습니다. - Permission: 추가된 API/domain이 최소 범위인지 확인합니다. +- Template share: codec test와 `pnpm run build:gh-pages`를 함께 실행하고, + fragment가 네트워크 요청에 포함되지 않는지 확인합니다. - 배너 운영 기간: `startAt`/`endAt`에 timezone이 포함된 ISO 8601 값을 사용하고, 즉시 내려야 하는 배너는 이전 확장도 고려해 목록에서 제거합니다. diff --git a/docs/LOCAL_FIRST.md b/docs/LOCAL_FIRST.md new file mode 100644 index 0000000..68e3cc1 --- /dev/null +++ b/docs/LOCAL_FIRST.md @@ -0,0 +1,56 @@ +# Local-first 경계 + +LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기능은 그 위에 선택적으로 +붙이는 구조를 사용합니다. 이 문서는 stateless 기반과 후속 stateful 계층 사이의 +계약을 설명합니다. + +## Stateless 기반 + +현재 기반에서 서버 없이 완결되는 기능은 다음과 같습니다. + +| 데이터/기능 | 저장 또는 전달 위치 | 서버 장애 시 동작 | +| --- | --- | --- | +| 개인 템플릿 | Chrome IndexedDB `linku/templates` | 생성·조회·수정·삭제 가능 | +| 편집 draft | Chrome IndexedDB `linku/drafts` | 편집 복구 가능 | +| 사용자 아이콘 | Chrome IndexedDB `linku/assets` | 업로드·이름 변경·삭제 가능 | +| 적용 중인 템플릿 ID | `chrome.storage.local` | popup 재실행 후 유지 | +| 작은 템플릿 공유 | GitHub Pages URL fragment | 서버 저장 없이 미리보기·가져오기 가능 | +| 큰 템플릿 공유 | `.linku.json` 파일 | 파일 전달로 내보내기·가져오기 가능 | + +`templateStorage.ts`의 저장 함수 이름은 기존 호출부와 migration 의미를 드러내기 +위해 유지하지만 모든 읽기와 쓰기는 비동기 IndexedDB 작업입니다. 과거 +`localStorage` 값은 +`local-storage-templates-v1` migration이 완료되기 전에 복사하며, migration 완료 +기록과 데이터 저장을 같은 transaction에서 처리합니다. rollback을 위해 원본 값은 +남겨 두되, 사용자가 IndexedDB에서 템플릿을 삭제하면 같은 legacy 항목도 함께 +삭제하여 다음 migration에서 되살아나지 않게 합니다. + +## 공유 보안 경계 + +- URL payload는 gzip 후 base64url로 인코딩하며 `#v1.` 뒤에 둡니다. +- payload는 template 1개, item 최대 36개, 6×6 grid, HTTP(S) 링크만 허용합니다. +- 압축 해제 결과와 파일은 256KB 이하만 처리합니다. +- 실행 가능한 SVG data URL은 받지 않고 PNG, JPEG, WebP base64만 허용합니다. +- 외부 URL 아이콘은 내보낼 때 기본 링크 아이콘으로 바꾸고, 가져올 때는 거부해 + 미리보기만으로 제3자 서버에 요청하지 않게 합니다. +- Pages의 외부 extension message는 + `https://turtle-hwan.github.io/LinKU/share/`에서만 받습니다. +- Pages에서 보낸 가져오기 요청은 service worker가 `chrome.storage.local` queue에 + 보관하고, popup이 열릴 때 검증 후 IndexedDB에 저장합니다. + +## 후속 stateful 계층의 계약 + +계정 동기화 PR은 다음 원칙을 지켜 이 기반 위에 추가합니다. + +1. IndexedDB 저장은 항상 먼저 완료하고 성공 UI를 반환합니다. +2. 동기화는 durable outbox로 별도 수행하며 네트워크 실패가 로컬 저장을 rollback하지 + 않습니다. +3. DB schema를 확장할 때 version을 올리고 기존 `templates`, `drafts`, `assets`, + `migrations` store를 그대로 보존합니다. +4. Google 로그인은 동기화와 여러 기기 사용을 위한 선택 기능입니다. 개인 템플릿 + 편집 자체의 선행 조건이 아닙니다. +5. Worker는 인증, 사용자별 object namespace, optimistic concurrency와 공유 수명만 + 담당합니다. 템플릿 편집·검증·압축·미리보기는 프론트에 둡니다. + +stateful 계층이 추가되기 전에는 로그인, 여러 기기 동기화, cloud share, 커뮤니티 +게시를 제공한다고 표시하지 않습니다. diff --git a/package.json b/package.json index f8b0d80..450253e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:timetable": "node --experimental-strip-types --test tests/timetable/*.test.ts", "test:feedback": "node --experimental-strip-types --test tests/feedback/*.test.ts", "test:forms": "node --experimental-strip-types --test tests/forms/*.test.ts", + "test:template-share": "node --experimental-strip-types --test tests/templates/*.test.ts", "test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts", "preview": "vite preview", "deploy": "git subtree push --prefix gh-pages origin gh-pages" @@ -38,6 +39,7 @@ "cmdk": "^1.1.1", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", + "idb": "^8.0.3", "lucide-react": "^1.28.0", "qrcode": "^1.5.4", "react": "^19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 923b74d..6a237fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.8) + idb: + specifier: ^8.0.3 + version: 8.0.3 lucide-react: specifier: ^1.28.0 version: 1.28.0(react@19.2.8) @@ -1504,6 +1507,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + idb@8.0.3: + resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3422,6 +3428,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + idb@8.0.3: {} + ignore@5.3.2: {} ignore@7.0.5: {} diff --git a/public/manifest.json b/public/manifest.json index 280dfd2..e35ad46 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -41,6 +41,11 @@ "https://www.google-analytics.com/*", "https://ku-linku.store/*" ], + "externally_connectable": { + "matches": [ + "https://turtle-hwan.github.io/LinKU/*" + ] + }, "commands": { "_execute_action": { "suggested_key": { @@ -50,4 +55,4 @@ "description": "Open LinKU extension" } } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index e471019..d9234eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,10 @@ import { ErrorBoundary } from "react-error-boundary"; import { Toaster } from "./components/ui/sonner"; import { PostedTemplatesProvider } from "./contexts/PostedTemplatesContext"; import { sendExtensionOpen, sendPageView, sendError } from "./utils/analytics"; -import { debugLog } from "@/utils/logger"; +import { debugLog, errorLog } from "@/utils/logger"; +import { consumePendingTemplateImports } from "@/utils/pendingTemplateImports"; +import { portablePayloadToTemplate } from "@/utils/templateShare"; +import { importSharedTemplate } from "@/utils/templateStorage"; import "./App.css"; function App() { @@ -24,6 +27,20 @@ function App() { sendPageView("LinKU Extension - Popup"); }, []); + useEffect(() => { + void consumePendingTemplateImports(async (payload) => { + await importSharedTemplate(portablePayloadToTemplate(payload)); + }) + .then((importedCount) => { + if (importedCount > 0) { + window.dispatchEvent(new Event("linku:templates-changed")); + } + }) + .catch((error: unknown) => { + errorLog("Failed to process pending template imports", error); + }); + }, []); + return ( { diff --git a/src/apis/icons.ts b/src/apis/icons.ts index b05c038..68b641e 100644 --- a/src/apis/icons.ts +++ b/src/apis/icons.ts @@ -1,54 +1,109 @@ /** - * Icons API - * Icon asset management + * Frontend-owned icon repository. + * Bundled icons and custom IndexedDB assets keep the editor usable offline. */ -import { get, post, put, del, publicRequest, ENDPOINTS } from './client'; -import type { ApiResponse, Icon, CreateIconResponse, DeleteResponse } from '../types/api'; +import { getBundledTemplateIcons } from "@/constants/templateIcons"; +import { + deleteAsset, + listAssets, + renameAsset, + saveAsset, +} from "@/storage/assetRepository"; +import type { + ApiResponse, + CreateIconResponse, + DeleteResponse, + Icon, +} from "@/types/api"; -/** - * Upload and create a new icon - */ export async function createIcon( iconName: string, - iconFile: File | Blob + iconFile: File | Blob, ): Promise> { - const formData = new FormData(); - formData.append('name', iconName); - formData.append('file', iconFile); - - return post(ENDPOINTS.ICONS.BASE, formData); + try { + const asset = await saveAsset(iconName, iconFile); + return { + success: true, + data: { id: asset.numericId, name: asset.name, imageUrl: asset.dataUrl }, + }; + } catch (error) { + return { + success: false, + error: { + code: "ICON_STORAGE_ERROR", + message: + error instanceof Error + ? error.message + : "아이콘을 저장하지 못했습니다.", + }, + }; + } } -/** - * Get list of default system icons (public endpoint, no auth required) - */ export async function getDefaultIcons(): Promise> { - return publicRequest(ENDPOINTS.ICONS.DEFAULT, "GET"); + return { success: true, data: getBundledTemplateIcons() }; } -/** - * Get list of user's custom icons - */ export async function getMyIcons(): Promise> { - return get(ENDPOINTS.ICONS.MY); + const assets = await listAssets(); + return { + success: true, + data: assets.map((asset) => ({ + id: asset.numericId, + name: asset.name, + imageUrl: asset.dataUrl, + isDefault: false, + createdAt: new Date(asset.createdAt).toISOString(), + })), + }; } -/** - * Rename an existing icon - */ export async function renameIcon( iconId: number, - newName: string + newName: string, ): Promise> { - return put(ENDPOINTS.ICONS.RENAME(iconId), { name: newName }); + try { + const assets = await listAssets(); + const asset = assets.find((candidate) => candidate.numericId === iconId); + if (!asset) { + return { + success: false, + error: { code: "ICON_NOT_FOUND", message: "아이콘을 찾을 수 없습니다." }, + }; + } + const renamed = await renameAsset(asset.id, newName); + return { + success: true, + data: { + id: renamed.numericId, + name: renamed.name, + imageUrl: renamed.dataUrl, + isDefault: false, + }, + }; + } catch (error) { + return { + success: false, + error: { + code: "ICON_STORAGE_ERROR", + message: error instanceof Error ? error.message : "아이콘 이름을 바꾸지 못했습니다.", + }, + }; + } } -/** - * Delete a custom icon - */ export async function deleteIcon( - iconId: number + iconId: number, ): Promise> { - return del(ENDPOINTS.ICONS.DELETE(iconId)); + const assets = await listAssets(); + const asset = assets.find((candidate) => candidate.numericId === iconId); + if (!asset) { + return { + success: false, + error: { code: "ICON_NOT_FOUND", message: "아이콘을 찾을 수 없습니다." }, + }; + } + await deleteAsset(asset.id); + return { success: true, data: { message: "아이콘을 삭제했습니다." } }; } diff --git a/src/background/index.ts b/src/background/index.ts index 28ccd43..963af80 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -32,6 +32,9 @@ import { handlePendingImportTabUpdated, handleTimetableImport, } from "./handlers/timetable"; +import type { TemplateSharePayloadV1 } from "@/types/templateShare"; +import { enqueuePendingTemplateImport } from "@/utils/pendingTemplateImports"; +import { validateTemplateSharePayload } from "@/utils/templateShareCodec"; debugLog("[Background] Service worker initialized"); @@ -172,6 +175,52 @@ chrome.runtime.onMessage.addListener( }, ); +chrome.runtime.onMessageExternal.addListener( + ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: (response: { success: boolean; error?: string }) => void, + ) => { + if ( + sender.origin !== "https://turtle-hwan.github.io" || + !sender.url?.startsWith("https://turtle-hwan.github.io/LinKU/share/") || + !message || + typeof message !== "object" || + (message as { type?: unknown }).type !== "IMPORT_SHARED_TEMPLATE" + ) { + sendResponse({ success: false, error: "허용되지 않은 가져오기 요청입니다." }); + return false; + } + + const payload = ( + message as { data?: { payload?: TemplateSharePayloadV1 } } + ).data?.payload; + + try { + validateTemplateSharePayload(payload); + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : "공유 데이터가 올바르지 않습니다.", + }); + return false; + } + + void enqueuePendingTemplateImport(payload) + .then(() => sendResponse({ success: true })) + .catch((error: unknown) => + sendResponse({ + success: false, + error: + error instanceof Error + ? error.message + : "템플릿을 가져오지 못했습니다.", + }), + ); + return true; + }, +); + /** * Extension install/update handler */ diff --git a/src/components/Editor/EditorHeader/EditorHeader.tsx b/src/components/Editor/EditorHeader/EditorHeader.tsx index 1a259ea..41e165c 100644 --- a/src/components/Editor/EditorHeader/EditorHeader.tsx +++ b/src/components/Editor/EditorHeader/EditorHeader.tsx @@ -1,187 +1,68 @@ -/** - * Editor Header - Top bar with template name, save, and publish controls - */ +/** Editor header for local-first template editing. */ -import { useEditorContext } from '@/hooks/useEditorContext'; import { Input } from '@/components/ui/input'; -import { SaveButton } from './SaveButton'; -import { SyncButton } from './SyncButton'; -import { PublishButton } from './PublishButton'; import { BackButton } from './BackButton'; -import { useTemplateSync } from '@/hooks/useTemplateSync'; -import { useTemplatePublish } from '@/hooks/useTemplatePublish'; -import { usePostedTemplates } from '@/hooks/usePostedTemplates'; -import { toast } from 'sonner'; +import { SaveButton } from './SaveButton'; +import { useEditorContext } from '@/hooks/useEditorContext'; import { + checkTemplateStorageAvailability, saveTemplateToLocalStorage, - checkLocalStorageSpace, } from '@/utils/templateStorage'; -import { getTemplate } from '@/apis/templates'; -import { areTemplatesEqual } from '@/utils/templateUtils'; -import { debugLog, errorLog } from '@/utils/logger'; +import { toast } from 'sonner'; +import { errorLog } from '@/utils/logger'; import { - sendTemplateSaveSuccess, sendTemplateSaveFail, - sendTemplateSyncSuccess, - sendTemplateSyncFail, - sendTemplatePublishSuccess, - sendTemplatePublishFail, + sendTemplateSaveSuccess, } from '@/utils/analytics'; export const EditorHeader = () => { const { state, dispatch } = useEditorContext(); - const { syncToServer } = useTemplateSync(); - const { publishTemplate } = useTemplatePublish(); - const { loadPostedTemplates } = usePostedTemplates(); - const handleNameChange = (e: React.ChangeEvent) => { - dispatch({ type: 'UPDATE_TEMPLATE_NAME', payload: e.target.value }); + + const handleNameChange = (event: React.ChangeEvent) => { + dispatch({ type: 'UPDATE_TEMPLATE_NAME', payload: event.target.value }); }; const handleSave = async () => { if (!state.template) return; - // 복제된 템플릿인 경우: 서버 원본과 비교하여 변경 여부 확인 - if (state.template.cloned) { - try { - const serverResult = await getTemplate(state.template.templateId); - if (serverResult.success && serverResult.data) { - if (areTemplatesEqual(serverResult.data, state.template)) { - toast.info('수정된 내용이 없습니다.'); - return; - } - } - } catch (error) { - errorLog('[EditorHeader] Failed to fetch original template:', error); - // 원본 확인 실패 시 저장 진행 (fail-safe) - } - } - - debugLog('[EditorHeader] handleSave started:', { - currentTemplateId: state.template.templateId, - mode: state.mode, - templateName: state.template.name, - }); - dispatch({ type: 'START_SAVING' }); - try { - // Check localStorage space - const spaceCheck = checkLocalStorageSpace(); - if (!spaceCheck.available) { - throw new Error(spaceCheck.error); - } - - // Generate new templateId if creating new template - // Check templateId directly (0 = draft/new template) instead of relying on mode - let savedTemplate = state.template; - if (state.template.templateId === 0) { - // Draft template - generate new ID - const newId = Date.now(); - savedTemplate = { - ...state.template, - templateId: newId, - updatedAt: new Date().toISOString(), - }; - debugLog('[EditorHeader] Generated new ID for draft template:', newId); - } else { - // Existing template - keep ID - savedTemplate = { - ...state.template, - updatedAt: new Date().toISOString(), - }; - debugLog('[EditorHeader] Using existing ID for saved template:', savedTemplate.templateId); - } - - debugLog('[EditorHeader] Saving template:', { - templateId: savedTemplate.templateId, - name: savedTemplate.name, - itemCount: savedTemplate.items.length, - }); + const storageCheck = checkTemplateStorageAvailability(); + if (!storageCheck.available) throw new Error(storageCheck.error); + + const now = new Date().toISOString(); + const savedTemplate = { + ...state.template, + templateId: + state.template.templateId === 0 + ? Date.now() + : state.template.templateId, + syncStatus: 'local' as const, + updatedAt: now, + }; - // Save to localStorage await saveTemplateToLocalStorage( savedTemplate, state.stagingItems, - false // Not synced with server ); - dispatch({ type: 'SAVE_SUCCESS', payload: savedTemplate }); const origin = savedTemplate.cloned ? 'cloned' : 'owned'; - sendTemplateSaveSuccess(savedTemplate.templateId, origin, savedTemplate.items.length); - + sendTemplateSaveSuccess( + savedTemplate.templateId, + origin, + savedTemplate.items.length, + ); toast.success('저장 완료', { - description: '템플릿이 로컬에 저장되었습니다.', + description: '이 기기의 IndexedDB에 저장했습니다.', }); } catch (error) { errorLog('[EditorHeader] Save failed:', error); - const errMsg = error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.'; - dispatch({ type: 'SAVE_FAILED', payload: errMsg }); - sendTemplateSaveFail(state.template.templateId, 'save_error', errMsg); - toast.error('저장 실패', { description: errMsg }); - } - }; - - const handleSyncToServer = async () => { - if (!state.template) return; - - // 복제된 템플릿인 경우: 서버 원본과 비교하여 변경 여부 확인 - if (state.template.cloned) { - try { - const serverResult = await getTemplate(state.template.templateId); - if (serverResult.success && serverResult.data) { - if (areTemplatesEqual(serverResult.data, state.template)) { - toast.info('수정된 내용이 없습니다.'); - return; - } - } - } catch (error) { - errorLog('[EditorHeader] Failed to fetch original template:', error); - // 원본 확인 실패 시 동기화 진행 (fail-safe) - } - } - - dispatch({ type: 'START_SYNCING' }); - const result = await syncToServer(state.template, state.stagingItems); - - if (result.success && result.data) { - dispatch({ type: 'SYNC_SUCCESS', payload: result.data }); - sendTemplateSyncSuccess( - state.template.templateId, - result.data.items?.length ?? state.template.items.length - ); - toast.success('동기화 완료', { - description: '템플릿이 서버에 동기화되었습니다.', - }); - } else { - const errorMsg = result.error || '동기화에 실패했습니다.'; - dispatch({ type: 'SYNC_FAILED', payload: errorMsg }); - sendTemplateSyncFail(state.template.templateId, 'sync_failed', errorMsg); - toast.error('동기화 실패', { description: errorMsg }); - } - }; - - const handlePublish = async () => { - if (!state.template || state.mode === 'create') { - toast.error('알림', { - description: '먼저 템플릿을 저장해주세요.', - }); - return; - } - - const currentItems = state.template.items || []; - const result = await publishTemplate(state.template.templateId, currentItems); - - if (result.success) { - await loadPostedTemplates(); - sendTemplatePublishSuccess(state.template.templateId, currentItems.length); - toast.success('게시 완료', { - description: '템플릿이 공개 갤러리에 게시되었습니다.', - }); - } else { - const errMsg = result.error || '게시에 실패했습니다.'; - sendTemplatePublishFail(state.template.templateId, 'publish_failed', errMsg); - toast.error('게시 실패', { description: errMsg }); + const message = + error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.'; + dispatch({ type: 'SAVE_FAILED', payload: message }); + sendTemplateSaveFail(state.template.templateId, 'save_error', message); + toast.error('저장 실패', { description: message }); } }; @@ -200,21 +81,11 @@ export const EditorHeader = () => { ) : state.isDirty ? ( • 저장되지 않음 ) : ( - • 저장 완료됨 + • 이 기기에 저장됨 )} -
- - - -
+ ); }; diff --git a/src/components/Editor/EditorSidebar/IconUploadDialog.tsx b/src/components/Editor/EditorSidebar/IconUploadDialog.tsx index 7ac5f1f..3e1ae81 100644 --- a/src/components/Editor/EditorSidebar/IconUploadDialog.tsx +++ b/src/components/Editor/EditorSidebar/IconUploadDialog.tsx @@ -26,8 +26,8 @@ interface IconUploadDialogProps { onIconUploaded?: (icon: Icon) => void; } -const VALID_TYPES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/jpg']; -const MAX_SIZE = 20 * 1024 * 1024; // 20MB +const VALID_TYPES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/jpg', 'image/webp']; +const MAX_SIZE = 5 * 1024 * 1024; export const IconUploadDialog = ({ open, @@ -54,10 +54,10 @@ export const IconUploadDialog = ({ const validateFile = (file: File): string | null => { if (!VALID_TYPES.includes(file.type)) { - return 'SVG, PNG, JPG 파일만 업로드 가능합니다.'; + return 'SVG, PNG, JPG, WebP 파일만 사용할 수 있습니다.'; } if (file.size > MAX_SIZE) { - return '파일 크기는 20MB 이하이어야 합니다.'; + return '파일 크기는 5MB 이하이어야 합니다.'; } return null; }; @@ -160,7 +160,7 @@ export const IconUploadDialog = ({ 아이콘 업로드 - 아이콘 이미지를 업로드합니다. (SVG, PNG, JPG / 최대 20MB) + 아이콘은 이 기기에 WebP로 변환해 저장합니다. (최대 5MB) @@ -215,7 +215,7 @@ export const IconUploadDialog = ({ diff --git a/src/components/Editor/TemplatePreview/TemplateCard.tsx b/src/components/Editor/TemplatePreview/TemplateCard.tsx index 672a86a..d66c947 100644 --- a/src/components/Editor/TemplatePreview/TemplateCard.tsx +++ b/src/components/Editor/TemplatePreview/TemplateCard.tsx @@ -1,23 +1,18 @@ -/** - * Template Card - Preview card for template in lists - */ - import type { TemplateSummary } from '@/types/api'; import { cn } from '@/lib/utils'; import { TemplatePreviewCanvas } from './TemplatePreviewCanvas'; -import { Check, CloudUpload, Cloud, Trash2, Share2 } from 'lucide-react'; +import { Check, HardDrive, Loader2, Share2, Trash2 } from 'lucide-react'; interface TemplateCardProps { template: TemplateSummary; onClick?: () => void; className?: string; isSelected?: boolean; - onApply?: (e: React.MouseEvent) => void; - onDelete?: (e: React.MouseEvent) => void; - onSync?: (e: React.MouseEvent) => void; - onPublish?: (e: React.MouseEvent) => void; + onApply?: (event: React.MouseEvent) => void; + onDelete?: (event: React.MouseEvent) => void; + onShare?: (event: React.MouseEvent) => void; showDelete?: boolean; - needsSync?: boolean; // 로컬과 서버 데이터가 다를 때 true + isActionLoading?: boolean; } export const TemplateCard = ({ @@ -27,107 +22,86 @@ export const TemplateCard = ({ isSelected, onApply, onDelete, - onSync, - onPublish, + onShare, showDelete = false, - needsSync = false, -}: TemplateCardProps) => { - const canPublish = template.syncStatus === 'synced' && !needsSync; + isActionLoading = false, +}: TemplateCardProps) => ( +
+ {template.items && template.items.length > 0 && ( + + )} + +
+

{template.name}

+
+ {template.itemCount || 0} items + {template.height}행 +
+
- return (
- {/* Preview Canvas - no padding */} - {template.items && template.items.length > 0 && ( - + {isActionLoading && ( +
+ +
)} - - {/* Template Info - with padding */} -
-

{template.name}

-
- {template.itemCount || 0} items - {template.height}행 + {onShare && !isActionLoading && ( + + )} + {template.templateId !== 0 && ( +
+
-
- - {/* Action buttons */} -
- {/* Publish button - always show, disabled when not synced */} - {template.templateId !== 0 && onPublish && ( - - )} - {/* Sync button - show for local-only OR needsSync (local changes pending) */} - {template.templateId !== 0 && (template.syncStatus === 'local' || needsSync) && onSync && ( - - )} - {/* Synced badge - only show when fully synced (no pending changes) */} - {template.templateId !== 0 && template.syncStatus === 'synced' && !needsSync && ( -
- + )} + {onApply && + (isSelected ? ( +
+
- )} - - {/* Apply button */} - {onApply && ( - !isSelected ? ( - - ) : ( -
- -
- ) - )} - - {/* Delete button (owned only) - hide for default template */} - {showDelete && template.templateId !== 0 && onDelete && ( + ) : ( - )} -
+ ))} + {showDelete && template.templateId !== 0 && onDelete && ( + + )}
- ); -}; +
+); diff --git a/src/constants/templateIcons.ts b/src/constants/templateIcons.ts new file mode 100644 index 0000000..d3fa2e7 --- /dev/null +++ b/src/constants/templateIcons.ts @@ -0,0 +1,28 @@ +import type { Icon } from "@/types/api"; +import { LinkList, type LinkListElement } from "@/constants/LinkList"; +import { convertLucideIconToDataUri } from "@/utils/template"; + +let bundledIcons: Icon[] | undefined; + +function createIcons(links: LinkListElement[]): Icon[] { + return links.map((link, index) => ({ + id: index + 1, + name: + typeof link.icon === "string" + ? link.label + : link.icon.displayName || link.icon.name || link.label, + imageUrl: + typeof link.icon === "string" + ? link.icon + : convertLucideIconToDataUri(link.icon), + isDefault: true, + })); +} + +export function getBundledTemplateIcons( + links: LinkListElement[] = LinkList, +): Icon[] { + if (links !== LinkList) return createIcons(links); + bundledIcons ??= createIcons(LinkList); + return bundledIcons; +} diff --git a/src/contexts/EditorContext.tsx b/src/contexts/EditorContext.tsx index a31ebdf..ff9ba07 100644 --- a/src/contexts/EditorContext.tsx +++ b/src/contexts/EditorContext.tsx @@ -5,14 +5,13 @@ import { useReducer, useEffect, ReactNode } from 'react'; import type { Template, TemplateItem, Icon } from '@/types/api'; -import { getTemplate } from '@/apis/templates'; import { getDefaultIcons, getMyIcons } from '@/apis/icons'; import { resolveLatestBulletin } from '@/apis/external/bulletin'; import { createDefaultLinkList } from '@/constants/LinkList'; import { BULLETIN_FALLBACK } from '@/constants/bulletin'; +import { getBundledTemplateIcons } from '@/constants/templateIcons'; import { convertLinkListToTemplateItems, calculateTemplateHeight } from '@/utils/template'; import { loadTemplateFromLocalStorage } from '@/utils/templateStorage'; -import { toast } from 'sonner'; import { debugLog, errorLog } from '@/utils/logger'; import { EditorContext } from './EditorContextObject'; @@ -436,8 +435,8 @@ export const EditorProvider = ({ children, templateId, startFrom }: EditorProvid dispatch({ type: 'LOAD_USER_ICONS', payload: userIcons }); } - // Try loading from localStorage first - const localData = loadTemplateFromLocalStorage(id); + // Try loading from IndexedDB first + const localData = await loadTemplateFromLocalStorage(id); if (localData) { dispatch({ type: 'LOAD_TEMPLATE', payload: localData.template }); @@ -446,20 +445,14 @@ export const EditorProvider = ({ children, templateId, startFrom }: EditorProvid dispatch({ type: 'ADD_TO_STAGING', payload: item }); }); - debugLog('[EditorContext] Loaded template from localStorage', id); + debugLog('[EditorContext] Loaded template from IndexedDB', id); return; } - // Fallback: Load from server - const result = await getTemplate(id); - if (result.success && result.data) { - dispatch({ type: 'LOAD_TEMPLATE', payload: result.data }); - } else { - dispatch({ - type: 'SET_ERROR', - payload: result.error?.message || '템플릿을 불러올 수 없습니다.', - }); - } + dispatch({ + type: 'SET_ERROR', + payload: '이 기기에서 템플릿을 찾을 수 없습니다.', + }); } catch (error) { dispatch({ type: 'SET_ERROR', @@ -477,7 +470,7 @@ export const EditorProvider = ({ children, templateId, startFrom }: EditorProvid dispatch({ type: 'SET_LOADING', payload: true }); try { - // Fetch editor icons and resolve the bulletin only for default templates. + // Read bundled/user icons and resolve the public bulletin when needed. const [iconsResult, userIconsResult, bulletinResult] = await Promise.allSettled([ getDefaultIcons(), getMyIcons(), @@ -506,7 +499,7 @@ export const EditorProvider = ({ children, templateId, startFrom }: EditorProvid debugLog('[EditorContext] Final defaultIcons count:', defaultIcons.length); - // Load server icons for icon picker (even if empty) + // Load bundled icons for the icon picker. dispatch({ type: 'LOAD_DEFAULT_ICONS', payload: defaultIcons }); // Load user icons for icon picker @@ -532,46 +525,29 @@ export const EditorProvider = ({ children, templateId, startFrom }: EditorProvid }; dispatch({ type: 'LOAD_TEMPLATE', payload: emptyTemplate }); } else { - // Default template - convert LinkList using server icons - // If no icons available, show warning and create empty template (still saveable locally) - if (defaultIcons.length === 0) { - toast.warning('서버 아이콘을 불러올 수 없어 빈 템플릿으로 시작합니다.'); - const emptyTemplate: Template = { - templateId: 0, - name: '새 템플릿', - height: 6, - cloned: false, - items: [], - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - dispatch({ type: 'LOAD_TEMPLATE', payload: emptyTemplate }); - } else { - const defaultLinks = createDefaultLinkList( - bulletinResult.status === 'fulfilled' - ? bulletinResult.value - : undefined, - ); - const templateItems = convertLinkListToTemplateItems( - defaultIcons, - defaultLinks, - ); - const templateHeight = calculateTemplateHeight(); - - const newTemplate: Template = { - templateId: 0, - name: '새 템플릿', - height: templateHeight, - cloned: false, - items: templateItems, - id: crypto.randomUUID(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - dispatch({ type: 'LOAD_TEMPLATE', payload: newTemplate }); - } + const defaultLinks = createDefaultLinkList( + bulletinResult.status === 'fulfilled' + ? bulletinResult.value + : undefined, + ); + const templateItems = convertLinkListToTemplateItems( + getBundledTemplateIcons(defaultLinks), + defaultLinks, + ); + const templateHeight = calculateTemplateHeight(); + + const newTemplate: Template = { + templateId: 0, + name: '새 템플릿', + height: templateHeight, + cloned: false, + items: templateItems, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + dispatch({ type: 'LOAD_TEMPLATE', payload: newTemplate }); } } catch (error) { errorLog('[EditorContext] Failed to initialize template:', error); diff --git a/src/hooks/useSelectedTemplate.ts b/src/hooks/useSelectedTemplate.ts index 7a2edc9..03be87f 100644 --- a/src/hooks/useSelectedTemplate.ts +++ b/src/hooks/useSelectedTemplate.ts @@ -5,7 +5,6 @@ */ import { useEffect, useRef, useState } from "react"; -import { getTemplate } from "@/apis/templates"; import { resolveLatestBulletin, subscribeLatestBulletin, @@ -215,15 +214,15 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { const isStaleRequest = () => requestId !== loadRequestIdRef.current; try { - // Try loading from localStorage first (for local-only templates) - const localData = loadTemplateFromLocalStorage(templateId); + // Try loading from IndexedDB first (for local-only templates) + const localData = await loadTemplateFromLocalStorage(templateId); if (localData) { if (isStaleRequest()) { return; } debugLog( - "[useSelectedTemplate] Loaded template from localStorage:", + "[useSelectedTemplate] Loaded template from IndexedDB:", templateId, ); setTemplateData(localData.template); @@ -232,28 +231,10 @@ export function useSelectedTemplate(): UseSelectedTemplateResult { return; } - // Fallback: Load from server - const result = await getTemplate(templateId); - - if (isStaleRequest()) { - return; - } - - if (result.success && result.data) { - debugLog( - "[useSelectedTemplate] Loaded template from server:", - templateId, - ); - setTemplateData(result.data); - setLinkItems(convertTemplateToLinkList(result.data)); - } else { - // Failed to load template - fallback to default - errorLog("Failed to load template:", result.error); - setError(result.error?.message || "템플릿을 불러올 수 없습니다."); - setTemplateData(null); - setSelectedTemplateId(null); - setLinkItems(defaultLinkItemsRef.current); - } + setError("이 기기에서 템플릿을 찾을 수 없어 기본 템플릿을 표시합니다."); + setTemplateData(null); + setSelectedTemplateId(null); + setLinkItems(defaultLinkItemsRef.current); } catch (err) { if (isStaleRequest()) { return; diff --git a/src/hooks/useTemplateSync.ts b/src/hooks/useTemplateSync.ts deleted file mode 100644 index 93f268c..0000000 --- a/src/hooks/useTemplateSync.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * useTemplateSync hook - * Syncs templates to server (Option A - callback style) - * Returns result only, caller handles toast/state updates - */ - -import { useState } from 'react'; -import { syncTemplateToServer } from '@/apis/templates'; -import { - saveTemplateToLocalStorage, - deleteTemplateFromLocalStorage, -} from '@/utils/templateStorage'; -import { getErrorMessage } from '@/utils/apiErrorHandler'; -import type { Template, TemplateItem } from '@/types/api'; - -interface SyncResult { - success: boolean; - data?: Template; - error?: string; -} - -export function useTemplateSync() { - const [isSyncing, setIsSyncing] = useState(false); - - const syncToServer = async ( - template: Template, - stagingItems: TemplateItem[] = [] - ): Promise => { - setIsSyncing(true); - - try { - const result = await syncTemplateToServer(template); - - if (result.success && result.data) { - const oldTemplateId = template.templateId; - const newTemplateId = result.data.templateId; - - // 이전 ID와 새 ID가 다르면 이전 localStorage 삭제 - if (oldTemplateId !== newTemplateId) { - deleteTemplateFromLocalStorage(oldTemplateId); - } - - // 새 ID로 저장 (동기화 상태 포함) - await saveTemplateToLocalStorage( - { ...result.data, syncStatus: 'synced' }, - stagingItems, - true // synced with server - ); - - return { success: true, data: result.data }; - } else { - const errorMsg = getErrorMessage(result, '동기화 실패'); - return { success: false, error: errorMsg }; - } - } catch { - return { success: false, error: '서버와 연결할 수 없습니다.' }; - } finally { - setIsSyncing(false); - } - }; - - return { syncToServer, isSyncing }; -} diff --git a/src/pages/GalleryPage.tsx b/src/pages/GalleryPage.tsx index eea4487..629920b 100644 --- a/src/pages/GalleryPage.tsx +++ b/src/pages/GalleryPage.tsx @@ -1,428 +1,79 @@ -/** - * Gallery Page - Public template gallery with infinite scroll - * Browse, search, and clone posted templates - */ - -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { getPublicPostedTemplates, getPostedTemplateDetail, clonePostedTemplate, likePostedTemplate } from '@/apis/posted-templates'; -import { getClonedTemplates, getTemplate } from '@/apis/templates'; -import type { PostedTemplateSummary, PostedTemplateListParams } from '@/types/api'; -import { areItemsEqual } from '@/utils/templateUtils'; -import { PostedTemplateCard } from '@/components/Editor/TemplatePreview/PostedTemplateCard'; -import { Input } from '@/components/ui/input'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { ArrowLeft, Search, Loader2, ChevronDown } from 'lucide-react'; +import { ArrowLeft, Download, Sparkles } from 'lucide-react'; +import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard'; import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/use-toast'; -import { isLoggedIn } from '@/utils/oauth'; -import { errorLog } from '@/utils/logger'; +import { createBundledDefaultTemplate } from '@/utils/defaultTemplate'; +import { importSharedTemplate } from '@/utils/templateStorage'; import { - sendTemplateGalleryView, - sendTemplateGallerySearch, - sendTemplateCloneSuccess, - sendTemplateCloneFail, - sendTemplateLikeToggle, -} from '@/utils/analytics'; - -type SortOption = 'newest' | 'oldest' | 'most-liked' | 'most-used'; - -const SORT_OPTIONS: { value: SortOption; label: string }[] = [ - { value: 'newest', label: '최신순' }, - { value: 'most-liked', label: '좋아요순' }, - { value: 'most-used', label: '복제순' }, - { value: 'oldest', label: '오래된순' }, -]; - -const PAGE_SIZE = 12; + resolveLatestBulletin, + subscribeLatestBulletin, +} from '@/apis/external/bulletin'; +import { errorLog } from '@/utils/logger'; export const GalleryPage = () => { const navigate = useNavigate(); const { toast } = useToast(); + const [importing, setImporting] = useState(false); + const [template, setTemplate] = useState(createBundledDefaultTemplate); - // State - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [page, setPage] = useState(1); - const [sort, setSort] = useState('newest'); - const [searchQuery, setSearchQuery] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); - const [userLoggedIn, setUserLoggedIn] = useState(false); - const [actionLoading, setActionLoading] = useState(null); - - // Refs - const observerRef = useRef(null); - const loadMoreRef = useRef(null); - const searchTimeoutRef = useRef(null); - const cloneInProgressRef = useRef(null); - - // GA4: 갤러리 진입 이벤트 (mount 1회) - useEffect(() => { - sendTemplateGalleryView('popup'); - }, []); - - // Check login status useEffect(() => { - const checkAuth = async () => { - const loggedIn = await isLoggedIn(); - setUserLoggedIn(loggedIn); - }; - checkAuth(); - - // Listen for auth changes - const handleAuthChange = async () => { - const loggedIn = await isLoggedIn(); - setUserLoggedIn(loggedIn); + const applyBulletin = (bulletin: Parameters[0]) => { + setTemplate(createBundledDefaultTemplate(bulletin)); }; - - window.addEventListener('auth:logout', handleAuthChange); - return () => window.removeEventListener('auth:logout', handleAuthChange); + const unsubscribe = subscribeLatestBulletin(applyBulletin); + void resolveLatestBulletin().then(applyBulletin); + return unsubscribe; }, []); - // Debounce search query - useEffect(() => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - - searchTimeoutRef.current = setTimeout(() => { - setDebouncedQuery(searchQuery); - sendTemplateGallerySearch(searchQuery.trim().length, sort); - }, 300); - - return () => { - if (searchTimeoutRef.current) { - clearTimeout(searchTimeoutRef.current); - } - }; - }, [searchQuery, sort]); - - // Load templates - const loadTemplates = useCallback(async (pageNum: number, reset: boolean = false) => { - if (reset) { - setLoading(true); - } else { - setLoadingMore(true); - } - - try { - const params: PostedTemplateListParams = { - sort, - page: pageNum, - limit: PAGE_SIZE, - }; - - if (debouncedQuery.trim()) { - params.query = debouncedQuery.trim(); - } - - const result = await getPublicPostedTemplates(params); - - if (result.success && result.data) { - const newTemplates = result.data; - - // 각 템플릿의 상세 items 로드 (미리보기용) - const templatesWithItems = await Promise.all( - newTemplates.map(async (template) => { - const detailResult = await getPostedTemplateDetail(template.postedTemplateId); - return { - ...template, - detailItems: detailResult.success ? detailResult.data?.items : undefined, - }; - }) - ); - - if (reset) { - setTemplates(templatesWithItems); - } else { - setTemplates(prev => [...prev, ...templatesWithItems]); - } - - // Check if there are more pages - setHasMore(newTemplates.length === PAGE_SIZE); - setPage(pageNum); - } else { - errorLog('Failed to load templates:', result.error); - if (reset) { - setTemplates([]); - } - setHasMore(false); - } - } catch (error) { - errorLog('Failed to load templates:', error); - toast({ - title: '로드 실패', - description: '템플릿을 불러오는데 실패했습니다.', - variant: 'destructive', - }); - } finally { - setLoading(false); - setLoadingMore(false); - } - }, [sort, debouncedQuery, toast]); - - // Initial load and when filters change - useEffect(() => { - setPage(1); - loadTemplates(1, true); - }, [sort, debouncedQuery]); // eslint-disable-line react-hooks/exhaustive-deps - - // Infinite scroll observer - useEffect(() => { - if (loading) return; - - if (observerRef.current) { - observerRef.current.disconnect(); - } - - observerRef.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMore && !loadingMore) { - loadTemplates(page + 1, false); - } - }, - { threshold: 0.1 } - ); - - if (loadMoreRef.current) { - observerRef.current.observe(loadMoreRef.current); - } - - return () => { - if (observerRef.current) { - observerRef.current.disconnect(); - } - }; - }, [loading, hasMore, loadingMore, page, loadTemplates]); - - // Handle clone - const handleClone = async (template: PostedTemplateSummary) => { - const id = template.postedTemplateId; - - if (!userLoggedIn) { - toast({ - title: '로그인 필요', - description: '템플릿을 복제하려면 로그인이 필요합니다.', - variant: 'destructive', - }); - return; - } - - // 1. 진행 중인 복제가 있으면 무시 (레이스 컨디션 방지) - if (cloneInProgressRef.current === id) { - return; - } - - cloneInProgressRef.current = id; - setActionLoading(id); - + const handleImport = async () => { + setImporting(true); try { - // 2. 이미 복제한 템플릿인지 확인 - const clonedResult = await getClonedTemplates(); - if (clonedResult.success && clonedResult.data) { - // posted template의 items (이미 detailItems로 로드됨) - const postedItems = template.detailItems || []; - - // 각 cloned template의 items와 비교 - for (const cloned of clonedResult.data) { - const detailResult = await getTemplate(cloned.templateId); - if (detailResult.success && detailResult.data) { - if (areItemsEqual(postedItems, detailResult.data.items)) { - toast({ - title: '이미 복제됨', - description: '동일한 내용의 템플릿이 이미 존재합니다.', - }); - return; // 복제 취소 - } - } - } - } - - // 3. 복제 진행 - const result = await clonePostedTemplate(id); - - if (result.success) { - // Update clone count locally - setTemplates(prev => - prev.map(t => - t.postedTemplateId === id - ? { ...t, usageCount: t.usageCount + 1 } - : t - ) - ); - sendTemplateCloneSuccess(id, Boolean(template.ownerId)); - toast({ - title: '복제 완료', - description: `"${template.name}" 템플릿이 내 템플릿에 추가되었습니다.`, - }); - } else { - throw new Error(result.error?.message || '복제에 실패했습니다.'); - } - } catch (error) { - errorLog('Failed to clone template:', error); - sendTemplateCloneFail(id, 'clone_failed', error instanceof Error ? error.message : undefined); + const stored = await importSharedTemplate(template); toast({ - title: '복제 실패', - description: error instanceof Error ? error.message : '템플릿 복제에 실패했습니다.', - variant: 'destructive', + title: '템플릿 추가 완료', + description: '서버 없이 이 기기의 IndexedDB에 저장했습니다.', }); - } finally { - cloneInProgressRef.current = null; - setActionLoading(null); - } - }; - - // Handle like - const handleLike = async (template: PostedTemplateSummary) => { - if (!userLoggedIn) { - toast({ - title: '로그인 필요', - description: '좋아요를 하려면 로그인이 필요합니다.', - variant: 'destructive', - }); - return; - } - - setActionLoading(template.postedTemplateId); - - try { - const result = await likePostedTemplate(template.postedTemplateId); - - if (result.success && result.data) { - // Update like state locally - setTemplates(prev => - prev.map(t => - t.postedTemplateId === template.postedTemplateId - ? { ...t, isLiked: result.data!.isLiked, likesCount: result.data!.likeCount } - : t - ) - ); - sendTemplateLikeToggle(template.postedTemplateId, result.data.isLiked); - } else { - throw new Error(result.error?.message || '좋아요 처리에 실패했습니다.'); - } + navigate(`/editor/${stored.template.templateId}`); } catch (error) { - errorLog('Failed to like template:', error); + errorLog('Failed to import bundled template', error); toast({ - title: '좋아요 실패', - description: error instanceof Error ? error.message : '좋아요 처리에 실패했습니다.', + title: '가져오기 실패', + description: '브라우저 저장소에 템플릿을 추가하지 못했습니다.', variant: 'destructive', }); } finally { - setActionLoading(null); + setImporting(false); } }; return ( -
- {/* Header */} -
-
-

템플릿 갤러리

-

- 다른 사용자들이 공유한 템플릿을 찾아보세요 +

+ +

템플릿 둘러보기

+
+

+ 커뮤니티가 열리기 전에는 검증된 템플릿을 확장 프로그램에 함께 제공합니다.

- {/* Filters */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- - {/* Sort */} - - - - - - {SORT_OPTIONS.map((option) => ( - { - setSort(option.value); - }} - className={sort === option.value ? 'bg-accent' : ''} - > - {option.label} - - ))} - - +
+ +
- - {/* Template Grid */} - {loading ? ( -
- -
- ) : templates.length === 0 ? ( -
-

- {debouncedQuery ? '검색 결과가 없습니다.' : '아직 공유된 템플릿이 없습니다.'} -

- {debouncedQuery && ( - - )} -
- ) : ( -
- {templates.map((template) => ( - handleClone(template)} - onLike={() => handleLike(template)} - /> - ))} -
- )} - - {/* Load More Trigger */} - {!loading && hasMore && ( -
- {loadingMore && } -
- )} - - {/* End of List */} - {!loading && !hasMore && templates.length > 0 && ( -
-

모든 템플릿을 불러왔습니다.

-
- )}
); }; diff --git a/src/pages/TemplateListPage.tsx b/src/pages/TemplateListPage.tsx index 048ddc0..df37367 100644 --- a/src/pages/TemplateListPage.tsx +++ b/src/pages/TemplateListPage.tsx @@ -1,70 +1,54 @@ -/** - * Template List Page - * Displays user's owned and cloned templates - */ - -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; -import { getOwnedTemplates, getClonedTemplates, deleteTemplate, getTemplate } from '@/apis/templates'; -import type { TemplateSummary, PostedTemplateSummary } from '@/types/api'; -import type { BulletinInfo } from '@/constants/bulletin'; - -// Extended type with needsSync flag -interface TemplateSummaryWithSync extends TemplateSummary { - needsSync?: boolean; // 로컬과 서버 데이터가 다를 때 true -} +import { FileText, FileUp, LayoutTemplate, Plus, Sparkles } from 'lucide-react'; import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard'; -import { PostedTemplateCard } from '@/components/Editor/TemplatePreview/PostedTemplateCard'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, FileText, LayoutTemplate, Share2 } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useToast } from '@/components/ui/use-toast'; import { useSelectedTemplate } from '@/hooks/useSelectedTemplate'; -import { usePostedTemplates } from '@/hooks/usePostedTemplates'; -import { useTemplateSync } from '@/hooks/useTemplateSync'; -import { useTemplatePublish } from '@/hooks/useTemplatePublish'; +import type { Template, TemplateSummary } from '@/types/api'; +import { + deleteTemplateFromLocalStorage, + getTemplatesIndex, + importSharedTemplate, + loadTemplateFromLocalStorage, +} from '@/utils/templateStorage'; +import { + createTemplateShareUrl, + downloadTemplatePayload, + MAX_SHARE_FILE_BYTES, + portablePayloadToTemplate, + validateTemplateSharePayload, +} from '@/utils/templateShare'; +import { createBundledDefaultTemplate } from '@/utils/defaultTemplate'; import { resolveLatestBulletin, subscribeLatestBulletin, } from '@/apis/external/bulletin'; -import { getTemplatesIndex, loadTemplateFromLocalStorage, deleteTemplateFromLocalStorage } from '@/utils/templateStorage'; -import { getErrorMessage } from '@/utils/apiErrorHandler'; -import { convertLinkListToTemplateItems, convertLucideIconToDataUri } from '@/utils/template'; -import { areItemsEqual } from '@/utils/templateUtils'; -import { createDefaultLinkList } from '@/constants/LinkList'; -import { isLoggedIn } from '@/utils/oauth'; -import { warnLog, errorLog } from '@/utils/logger'; -import { sendTemplateApply, sendTemplateCreateStart, sendTemplateDelete } from '@/utils/analytics'; - -function createDefaultTemplate(bulletin: BulletinInfo): TemplateSummary { - const defaultLinks = createDefaultLinkList(bulletin); - const defaultIcons = defaultLinks.map((link, index) => ({ - id: index, - name: link.label, - imageUrl: - typeof link.icon === 'string' - ? link.icon - : convertLucideIconToDataUri(link.icon), - })); - const items = convertLinkListToTemplateItems(defaultIcons, defaultLinks); - const timestamp = new Date().toISOString(); +import { errorLog } from '@/utils/logger'; +import { + sendTemplateApply, + sendTemplateCreateStart, + sendTemplateDelete, +} from '@/utils/analytics'; +function toSummary(template: Template): TemplateSummary { return { - templateId: 0, - name: 'LinKU 기본 템플릿', - height: 6, - cloned: false, - createdAt: timestamp, - updatedAt: timestamp, - itemCount: items.length, - syncStatus: 'synced', - items, + templateId: template.templateId, + name: template.name, + height: template.height, + cloned: template.cloned, + createdAt: template.createdAt, + updatedAt: template.updatedAt, + itemCount: template.items.length, + syncStatus: 'local', + items: template.items, }; } @@ -72,238 +56,70 @@ export const TemplateListPage = () => { const navigate = useNavigate(); const { toast } = useToast(); const { selectedTemplateId, selectTemplate } = useSelectedTemplate(); - const toastRef = useRef(toast); - - const [ownedTemplates, setOwnedTemplates] = useState([]); - const [clonedTemplates, setClonedTemplates] = useState([]); + const [defaultTemplate, setDefaultTemplate] = useState( + createBundledDefaultTemplate, + ); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'owned' | 'cloned' | 'posted'>('owned'); - const [userLoggedIn, setUserLoggedIn] = useState(false); + const [activeTab, setActiveTab] = useState<'owned' | 'cloned'>('owned'); const [actionLoading, setActionLoading] = useState(null); - - // Posted templates from global context - const { - postedTemplates, - loadPostedTemplates, - unpostTemplate, - likeTemplate, - } = usePostedTemplates(); - - // Template sync and publish hooks (Option A style) - const { syncToServer } = useTemplateSync(); - const { publishTemplate } = useTemplatePublish(); + const importInputRef = useRef(null); useEffect(() => { - toastRef.current = toast; - }, [toast]); - - useEffect( - () => - subscribeLatestBulletin((bulletin) => { - setOwnedTemplates((templates) => - templates[0]?.templateId === 0 - ? [createDefaultTemplate(bulletin), ...templates.slice(1)] - : templates, - ); - }), - [], - ); + const applyBulletin = (bulletin: Parameters[0]) => { + setDefaultTemplate(createBundledDefaultTemplate(bulletin)); + }; + const unsubscribe = subscribeLatestBulletin(applyBulletin); + void resolveLatestBulletin().then(applyBulletin); + return unsubscribe; + }, []); const loadTemplates = useCallback(async () => { setLoading(true); try { - // 1. 로그인 상태 확인 - 비로그인 시 서버 API 호출 스킵 - const loggedIn = await isLoggedIn(); - setUserLoggedIn(loggedIn); - - // 로그인 시 posted 템플릿도 즉시 로드 (탭 개수 표시용) - if (loggedIn) { - loadPostedTemplates(); // await 없이 병렬 실행 - } - - let ownedResult: { success: boolean; data?: TemplateSummary[]; error?: { message: string } } = { success: false }; - let clonedResult: { success: boolean; data?: TemplateSummary[]; error?: { message: string } } = { success: false }; - - // 2. 로그인된 경우에만 서버에서 템플릿 목록 가져오기 - if (loggedIn) { - const [ownedRes, clonedRes] = await Promise.allSettled([ - getOwnedTemplates(), - getClonedTemplates(), - ]); - - // Extract values from settled promises - ownedResult = ownedRes.status === 'fulfilled' ? ownedRes.value : { success: false, error: { message: 'Failed to load owned templates' } }; - clonedResult = clonedRes.status === 'fulfilled' ? clonedRes.value : { success: false, error: { message: 'Failed to load cloned templates' } }; - } - - // 3. localStorage에서 템플릿 인덱스 가져오기 (항상 로드) - const localIndex = getTemplatesIndex(); - - // 4. 서버 템플릿과 localStorage 템플릿 병합 (로컬 우선) - let mergedOwned: TemplateSummaryWithSync[] = []; - if (ownedResult.success && ownedResult.data) { - // 서버 템플릿 처리 - 로컬 데이터가 있으면 로컬 우선 사용 - const detailedTemplates = await Promise.all( - ownedResult.data.map(async (serverTemplate) => { - try { - // 1. 먼저 localStorage에서 데이터 확인 - const localStored = loadTemplateFromLocalStorage(serverTemplate.templateId); - - // 2. 서버에서 상세 정보 로드 - const detailResult = await getTemplate(serverTemplate.templateId); - - if (localStored && localStored.template.items) { - // 로컬 데이터가 있으면 로컬 items 사용 - const serverItems = detailResult.success && detailResult.data - ? detailResult.data.items - : []; - const needsSync = !areItemsEqual(localStored.template.items, serverItems); - - return { - ...serverTemplate, - syncStatus: 'synced' as const, - items: localStored.template.items, // 로컬 데이터 우선 - needsSync, // 로컬과 서버가 다르면 true - }; - } else if (detailResult.success && detailResult.data) { - // 로컬 데이터 없으면 서버 데이터 사용 - return { - ...serverTemplate, - syncStatus: 'synced' as const, - items: detailResult.data.items, - needsSync: false, - }; - } - } catch (error) { - errorLog(`Failed to load template ${serverTemplate.templateId}:`, error); - } - // Fallback without items - return { - ...serverTemplate, - syncStatus: 'synced' as const, - needsSync: false, - }; - }) - ); - mergedOwned = detailedTemplates; - } - - // 4. localStorage에만 있는 템플릿 추가 - localIndex - .filter(localTemplate => localTemplate.templateId !== 0) // Skip draft templates (templateId: 0) - .forEach(localTemplate => { - // 서버 목록에 없는 템플릿만 추가 - const existsInServer = mergedOwned.some( - t => t.templateId === localTemplate.templateId - ); - - if (!existsInServer) { - // localStorage에서 전체 템플릿 데이터 로드 - const stored = loadTemplateFromLocalStorage(localTemplate.templateId); - if (stored) { - mergedOwned.push({ - templateId: stored.template.templateId, - name: stored.template.name, - height: stored.template.height, - cloned: stored.template.cloned, - createdAt: stored.template.createdAt, - updatedAt: stored.template.updatedAt, - itemCount: stored.template.items.length, - syncStatus: stored.metadata.syncedWithServer ? 'synced' : 'local', - items: stored.template.items, // Add items for preview - }); - } - } - }); - - // 5. 중복 검사 및 경고 (Deduplication check) - const seenIds = new Set(); - const duplicateIds: number[] = []; - for (const template of mergedOwned) { - if (seenIds.has(template.templateId)) { - duplicateIds.push(template.templateId); - } - seenIds.add(template.templateId); - } - if (duplicateIds.length > 0) { - warnLog('[TemplateListPage] Duplicate template IDs detected:', duplicateIds); - // Remove duplicates - keep first occurrence only - mergedOwned = mergedOwned.filter((template, index, self) => - index === self.findIndex(t => t.templateId === template.templateId) - ); - } - - // 6. 기본 템플릿 추가 (항상 맨 위에 표시) - const latestBulletin = await resolveLatestBulletin(); - const defaultTemplate = createDefaultTemplate(latestBulletin); - - // 7. 최종 정렬 (updatedAt 기준 내림차순) - mergedOwned.sort((a, b) => { - const aTime = new Date(a.updatedAt).getTime(); - const bTime = new Date(b.updatedAt).getTime(); - return bTime - aTime; - }); - - // 기본 템플릿을 맨 앞에 추가 - setOwnedTemplates([defaultTemplate, ...mergedOwned]); - - // 복제 템플릿도 동일하게 처리 (서버만, 보통 복제는 로컬에 없음) - if (clonedResult.success && clonedResult.data) { - const detailedCloned = await Promise.all( - clonedResult.data.map(async (clonedTemplate) => { - try { - const detailResult = await getTemplate(clonedTemplate.templateId); - if (detailResult.success && detailResult.data) { - return { - ...clonedTemplate, - syncStatus: 'synced' as const, - items: detailResult.data.items, - }; - } - } catch (error) { - errorLog(`Failed to load cloned template ${clonedTemplate.templateId}:`, error); - } - return { - ...clonedTemplate, - syncStatus: 'synced' as const, - }; - }) - ); - setClonedTemplates(detailedCloned); - } - - // 에러 처리 - 에러가 발생해도 localStorage 템플릿은 표시되도록 조용히 처리 - if (!ownedResult.success) { - warnLog('Failed to load owned templates from server:', ownedResult.error); - // Don't show error toast - user can still see local templates - } - - if (!clonedResult.success) { - warnLog('Failed to load cloned templates from server:', clonedResult.error); - // Don't show error toast - this is not critical for normal usage - } + const index = await getTemplatesIndex(); + const storedTemplates = await Promise.all( + index.map((entry) => loadTemplateFromLocalStorage(entry.templateId)), + ); + setTemplates( + storedTemplates + .filter((stored) => stored !== null) + .map((stored) => toSummary(stored.template)), + ); } catch (error) { - errorLog('Failed to load templates:', error); - toastRef.current({ - title: '네트워크 오류', - description: '서버와 연결할 수 없습니다. 네트워크 연결을 확인해주세요.', + errorLog('Failed to load IndexedDB templates', error); + setTemplates([]); + toast({ + title: '로컬 저장소 오류', + description: '기본 템플릿은 계속 사용할 수 있습니다.', variant: 'destructive', }); } finally { setLoading(false); } - }, [loadPostedTemplates]); + }, [toast]); useEffect(() => { - loadTemplates(); + void loadTemplates(); }, [loadTemplates]); - // Load posted templates when tab changes to 'posted' useEffect(() => { - if (activeTab === 'posted' && postedTemplates.length === 0 && userLoggedIn) { - loadPostedTemplates(); - } - }, [activeTab, userLoggedIn, postedTemplates.length, loadPostedTemplates]); + const handleTemplatesChanged = () => void loadTemplates(); + window.addEventListener('linku:templates-changed', handleTemplatesChanged); + return () => { + window.removeEventListener('linku:templates-changed', handleTemplatesChanged); + }; + }, [loadTemplates]); + + const ownedTemplates = useMemo( + () => [toSummary(defaultTemplate), ...templates.filter((template) => !template.cloned)], + [defaultTemplate, templates], + ); + const clonedTemplates = useMemo( + () => templates.filter((template) => template.cloned), + [templates], + ); + const visibleTemplates = activeTab === 'cloned' ? clonedTemplates : ownedTemplates; const handleCreateFromDefault = () => { sendTemplateCreateStart('default'); @@ -315,341 +131,164 @@ export const TemplateListPage = () => { navigate('/editor?from=empty'); }; - const handleEditTemplate = (templateId: number) => { - navigate(`/editor/${templateId}`); + const handleApplyTemplate = async (template: TemplateSummary) => { + const targetId = template.templateId === 0 ? null : template.templateId; + await selectTemplate(targetId); + sendTemplateApply( + template.templateId, + template.templateId === 0 + ? 'default' + : template.cloned + ? 'cloned' + : 'owned', + template.templateId === 0, + ); + toast({ + title: '템플릿 적용 완료', + description: + template.templateId === 0 + ? '기본 템플릿이 적용되었습니다.' + : `“${template.name}” 템플릿이 적용되었습니다.`, + }); }; - const handleApplyTemplate = async (templateId: number, templateName: string) => { + const handleDeleteTemplate = async (template: TemplateSummary) => { + if (!confirm(`“${template.name}” 템플릿을 삭제하시겠습니까?`)) return; try { - // 기본 템플릿(templateId === 0)인 경우 null로 처리 - const targetId = templateId === 0 ? null : templateId; - await selectTemplate(targetId); - - const isDefault = templateId === 0; - const isCloned = clonedTemplates.some((t) => t.templateId === templateId); - const origin = isDefault ? 'default' : isCloned ? 'cloned' : 'owned'; - sendTemplateApply(templateId, origin, isDefault); - - const message = isDefault - ? '기본 템플릿이 적용되었습니다.' - : `"${templateName}" 템플릿이 메인 화면에 적용되었습니다.`; - + await deleteTemplateFromLocalStorage(template.templateId); + setTemplates((current) => + current.filter((item) => item.templateId !== template.templateId), + ); + if (selectedTemplateId === template.templateId) await selectTemplate(null); + sendTemplateDelete( + template.templateId, + template.cloned ? 'cloned' : 'owned', + 'local', + ); toast({ - title: '템플릿 적용 완료', - description: message, + title: '삭제 완료', + description: '이 기기의 저장소에서 삭제했습니다.', }); } catch (error) { - errorLog('Failed to apply template:', error); + errorLog('Failed to delete local template', error); toast({ - title: '적용 실패', - description: '템플릿 적용에 실패했습니다.', + title: '삭제 실패', + description: '이 기기의 저장소에서 템플릿을 삭제하지 못했습니다.', variant: 'destructive', }); } }; - const handleDeleteTemplate = async ( - templateId: number, - templateName: string, - syncStatus?: 'local' | 'synced' - ) => { - if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) { - return; - } - + const handleShareTemplate = async (templateId: number) => { + setActionLoading(templateId); try { - // Local-only template: Delete from localStorage only - if (syncStatus === 'local') { - deleteTemplateFromLocalStorage(templateId); - - const origin = clonedTemplates.some(t => t.templateId === templateId) ? 'cloned' : 'owned'; - sendTemplateDelete(templateId, origin, 'local'); - - toast({ - title: '삭제 완료', - description: '로컬 템플릿이 삭제되었습니다.', - }); - - // 목록 갱신 - setOwnedTemplates(prev => prev.filter(t => t.templateId !== templateId)); - setClonedTemplates(prev => prev.filter(t => t.templateId !== templateId)); - - // 삭제된 템플릿이 현재 적용 중이라면 선택 해제 - if (selectedTemplateId === templateId) { - await selectTemplate(null); - } - return; - } - - // Synced template: Delete from server first - const result = await deleteTemplate(templateId); - - if (result.success) { - // Also delete from localStorage if exists - deleteTemplateFromLocalStorage(templateId); - - const origin = clonedTemplates.some(t => t.templateId === templateId) ? 'cloned' : 'owned'; - sendTemplateDelete(templateId, origin, 'synced'); - + const stored = + templateId === 0 + ? { template: defaultTemplate } + : await loadTemplateFromLocalStorage(templateId); + if (!stored) throw new Error('이 기기에서 템플릿을 찾을 수 없습니다.'); + + const share = await createTemplateShareUrl(stored.template); + if (share.mode === 'url') { + await navigator.clipboard.writeText(share.url); toast({ - title: '삭제 완료', - description: '템플릿이 서버와 로컬에서 삭제되었습니다.', + title: '공유 링크 복사 완료', + description: '템플릿 데이터는 링크의 fragment에만 들어 있습니다.', }); - - // 목록 갱신 - setOwnedTemplates(prev => prev.filter(t => t.templateId !== templateId)); - setClonedTemplates(prev => prev.filter(t => t.templateId !== templateId)); - - // 삭제된 템플릿이 현재 적용 중이라면 선택 해제 - if (selectedTemplateId === templateId) { - await selectTemplate(null); - } } else { - const errorMsg = getErrorMessage(result, '템플릿 삭제에 실패했습니다.'); + downloadTemplatePayload(share.payload); toast({ - title: '삭제 실패', - description: errorMsg, - variant: 'destructive', + title: '공유 파일 저장 완료', + description: '링크에 담기 큰 템플릿이라 파일로 저장했습니다.', }); } } catch (error) { - errorLog('Failed to delete template:', error); + errorLog('Failed to share template', error); toast({ - title: '네트워크 오류', - description: '서버와 연결할 수 없습니다. 네트워크 연결을 확인해주세요.', + title: '공유 실패', + description: + error instanceof Error ? error.message : '공유 데이터를 만들지 못했습니다.', variant: 'destructive', }); + } finally { + setActionLoading(null); } }; - const handleSyncTemplate = async (templateId: number, templateName: string, e: React.MouseEvent) => { - e.stopPropagation(); - - // Load full template data from localStorage - const stored = loadTemplateFromLocalStorage(templateId); - if (!stored) { - toast({ - title: '동기화 실패', - description: '로컬 템플릿을 찾을 수 없습니다.', - variant: 'destructive', - }); - return; - } - - const result = await syncToServer(stored.template, stored.stagingItems); - - if (result.success && result.data) { - // Update local state - replace old templateId with new server ID - setOwnedTemplates(prev => - prev.map(t => - t.templateId === templateId - ? { - ...t, - templateId: result.data!.templateId, - syncStatus: 'synced' as const, - needsSync: false, - } - : t - ) + const handleImportFile = async (file: File | undefined) => { + if (!file) return; + try { + if (file.size > MAX_SHARE_FILE_BYTES) { + throw new Error('템플릿 가져오기 파일은 256KB 이하여야 합니다.'); + } + const value: unknown = JSON.parse(await file.text()); + validateTemplateSharePayload(value); + const imported = await importSharedTemplate( + portablePayloadToTemplate(value), ); - - toast({ - title: '동기화 완료', - description: `"${templateName}" 템플릿이 서버에 동기화되었습니다.`, - }); - } else { - toast({ - title: '동기화 실패', - description: result.error || '동기화에 실패했습니다.', - variant: 'destructive', - }); - } - }; - - // Handle unpost (게시 취소) - const handleUnpostTemplate = async (template: PostedTemplateSummary) => { - if (!confirm(`"${template.name}" 템플릿의 게시를 취소하시겠습니까?`)) { - return; - } - - setActionLoading(template.postedTemplateId); - - const result = await unpostTemplate(template.postedTemplateId); - - if (result.success) { - toast({ - title: '게시 취소 완료', - description: `"${template.name}" 템플릿이 갤러리에서 제거되었습니다.`, - }); - } else { - toast({ - title: '게시 취소 실패', - description: result.error || '게시 취소에 실패했습니다.', - variant: 'destructive', - }); - } - - setActionLoading(null); - }; - - // Handle like for posted templates - const handleLikePostedTemplate = async (template: PostedTemplateSummary) => { - setActionLoading(template.postedTemplateId); - - const result = await likeTemplate(template.postedTemplateId); - - if (!result.success) { - toast({ - title: '좋아요 실패', - description: result.error || '좋아요 처리에 실패했습니다.', - variant: 'destructive', - }); - } - - setActionLoading(null); - }; - - // Handle publish template to gallery - const handlePublishTemplate = async (templateId: number, templateName: string, e: React.MouseEvent) => { - e.stopPropagation(); - - // 현재 템플릿의 items 찾기 - const currentTemplate = [...ownedTemplates, ...clonedTemplates].find(t => t.templateId === templateId); - const currentItems = currentTemplate?.items || []; - - const result = await publishTemplate(templateId, currentItems); - - if (result.success) { - await loadPostedTemplates(); + await loadTemplates(); + setActiveTab('cloned'); toast({ - title: '게시 완료', - description: `"${templateName}" 템플릿이 갤러리에 게시되었습니다.`, + title: '템플릿 가져오기 완료', + description: `“${imported.template.name}”을 이 기기에 저장했습니다.`, }); - } else { + } catch (error) { toast({ - title: '게시 실패', - description: result.error || '게시에 실패했습니다.', + title: '가져오기 실패', + description: + error instanceof Error ? error.message : '템플릿 파일을 읽지 못했습니다.', variant: 'destructive', }); + } finally { + if (importInputRef.current) importInputRef.current.value = ''; } }; - const renderTemplateList = (templates: TemplateSummaryWithSync[]) => { - if (loading) { - return ( -
-

로딩 중...

-
- ); - } - - if (templates.length === 0) { - return ( -
-

템플릿이 없습니다.

- {activeTab === 'owned' && ( - - - - - - - - 기본 템플릿에서 시작하기 - - - - 빈 템플릿에서 시작하기 - - - - )} -
- ); - } - - return ( -
- {templates.map((template) => { - // Ensure only ONE template is selected at a time - // - If selectedTemplateId is null → default template (0) is selected - // - If selectedTemplateId has a value → that specific template is selected - const isSelected = selectedTemplateId === null - ? template.templateId === 0 - : selectedTemplateId === template.templateId; - - return ( - handleEditTemplate(template.templateId)} - isSelected={isSelected} - onApply={(e) => { - e.stopPropagation(); - handleApplyTemplate(template.templateId, template.name); - }} - onDelete={(e) => { - e.stopPropagation(); - handleDeleteTemplate(template.templateId, template.name, template.syncStatus); - }} - onSync={(e) => handleSyncTemplate(template.templateId, template.name, e)} - onPublish={(e) => handlePublishTemplate(template.templateId, template.name, e)} - showDelete={activeTab === 'owned' || activeTab === 'cloned'} - needsSync={template.needsSync} - /> - ); - })} -
- ); - }; - - const renderPostedTemplateList = () => { + const renderList = () => { if (loading) { - return ( -
-

로딩 중...

-
- ); - } - - if (!userLoggedIn) { - return ( -
-

로그인이 필요합니다.

-

- 게시한 템플릿을 보려면 로그인해주세요. -

-
- ); + return

불러오는 중...

; } - - if (postedTemplates.length === 0) { + if (visibleTemplates.length === 0) { return ( -
-

게시한 템플릿이 없습니다.

-

- 템플릿 편집기에서 템플릿을 게시해보세요. -

+
+

가져온 템플릿이 없습니다.

+
); } return ( -
- {postedTemplates.map((template) => ( - + {visibleTemplates.map((template) => ( + handleLikePostedTemplate(template)} - onUnpost={() => handleUnpostTemplate(template)} + onClick={ + template.templateId === 0 + ? undefined + : () => navigate(`/editor/${template.templateId}`) + } + isSelected={ + selectedTemplateId === null + ? template.templateId === 0 + : selectedTemplateId === template.templateId + } + onApply={(event) => { + event.stopPropagation(); + void handleApplyTemplate(template); + }} + onDelete={(event) => { + event.stopPropagation(); + void handleDeleteTemplate(template); + }} + onShare={(event) => { + event.stopPropagation(); + void handleShareTemplate(template.templateId); + }} + showDelete={template.templateId !== 0} + isActionLoading={actionLoading === template.templateId} /> ))}
@@ -657,67 +296,54 @@ export const TemplateListPage = () => { }; return ( -
- {/* Header */} -
+
+

내 템플릿

-

- 저장된 템플릿을 관리하고 편집하세요 +

+ 로그인이나 서버 연결 없이 이 기기에 바로 저장합니다.

-
- + - - 기본 템플릿에서 시작하기 + 기본 템플릿에서 시작 - - 빈 템플릿에서 시작하기 + 빈 템플릿에서 시작 + + importInputRef.current?.click()}> + 파일에서 가져오기 + void handleImportFile(event.target.files?.[0])} + />
- {/* Tabs */} - setActiveTab(v as 'owned' | 'cloned' | 'posted')}> + setActiveTab(value as 'owned' | 'cloned')} + > - - 내가 만든 템플릿 ({ownedTemplates.length}) - - - 복제한 템플릿 ({clonedTemplates.length}) - - - 게시한 템플릿 ({postedTemplates.length}) - + 내가 만든 템플릿 ({ownedTemplates.length}) + 가져온 템플릿 ({clonedTemplates.length}) - - - {renderTemplateList(ownedTemplates)} - - - - {renderTemplateList(clonedTemplates)} - - - - {renderPostedTemplateList()} - + {renderList()} + {renderList()}
); diff --git a/src/storage/assetRepository.ts b/src/storage/assetRepository.ts new file mode 100644 index 0000000..a192e67 --- /dev/null +++ b/src/storage/assetRepository.ts @@ -0,0 +1,116 @@ +import { getLinkuDb, type StoredAsset } from "@/storage/linkuDb"; + +const MAX_ICON_BYTES = 5 * 1024 * 1024; +const MAX_ICON_DIMENSION = 256; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error("파일을 읽지 못했습니다.")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }); +} + +async function canvasToWebp(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error("아이콘 이미지를 변환하지 못했습니다.")); + }, + "image/webp", + 0.9, + ); + }); +} + +export async function normalizeIconBlob(source: Blob): Promise { + if (source.size > MAX_ICON_BYTES) { + throw new Error("아이콘 원본은 5MB 이하여야 합니다."); + } + + const objectUrl = URL.createObjectURL(source); + try { + const image = new Image(); + image.src = objectUrl; + await image.decode(); + + const scale = Math.min( + 1, + MAX_ICON_DIMENSION / Math.max(image.naturalWidth, image.naturalHeight), + ); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("이미지 변환 기능을 사용할 수 없습니다."); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + return await canvasToWebp(canvas); + } finally { + URL.revokeObjectURL(objectUrl); + } +} + +export async function saveAsset(name: string, source: Blob): Promise { + const blob = await normalizeIconBlob(source); + const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()); + const id = bytesToHex(new Uint8Array(digest)); + const dataUrl = await blobToDataUrl(blob); + const createdAt = Date.now(); + const database = await getLinkuDb(); + const transaction = database.transaction("assets", "readwrite"); + const store = transaction.objectStore("assets"); + const existing = await store.get(id); + if (existing) { + await transaction.done; + return existing; + } + + let numericId = Date.now(); + while (await store.index("by-numeric-id").get(numericId)) { + numericId += 1; + } + + const asset: StoredAsset = { + id, + numericId, + name, + blob, + dataUrl, + createdAt, + }; + await store.put(asset); + await transaction.done; + return asset; +} + +export async function listAssets(): Promise { + const database = await getLinkuDb(); + const assets = await database.getAll("assets"); + return assets.sort((left, right) => right.createdAt - left.createdAt); +} + +export async function renameAsset(id: string, name: string): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction("assets", "readwrite"); + const store = transaction.objectStore("assets"); + const asset = await store.get(id); + if (!asset) { + transaction.abort(); + throw new Error("아이콘을 찾을 수 없습니다."); + } + const renamed = { ...asset, name }; + await store.put(renamed); + await transaction.done; + return renamed; +} + +export async function deleteAsset(id: string): Promise { + const database = await getLinkuDb(); + await database.delete("assets", id); +} diff --git a/src/storage/linkuDb.ts b/src/storage/linkuDb.ts new file mode 100644 index 0000000..0ec7833 --- /dev/null +++ b/src/storage/linkuDb.ts @@ -0,0 +1,74 @@ +import { openDB, type DBSchema, type IDBPDatabase } from "idb"; +import type { Template, TemplateItem } from "@/types/api"; + +export interface StoredTemplate { + template: Template; + stagingItems: TemplateItem[]; + metadata: { + lastSaved: number; + savedLocally: true; + }; +} + +export interface StoredAsset { + id: string; + numericId: number; + name: string; + blob: Blob; + dataUrl: string; + createdAt: number; +} + +interface LinkuDatabase extends DBSchema { + templates: { + key: number; + value: StoredTemplate; + indexes: { "by-last-saved": number }; + }; + drafts: { + key: "current"; + value: StoredTemplate; + }; + assets: { + key: string; + value: StoredAsset; + indexes: { "by-numeric-id": number }; + }; + migrations: { + key: string; + value: { completedAt: number }; + }; +} + +const DATABASE_NAME = "linku"; +const DATABASE_VERSION = 1; + +let databasePromise: Promise> | undefined; + +export function getLinkuDb(): Promise> { + if (!databasePromise) { + databasePromise = openDB(DATABASE_NAME, DATABASE_VERSION, { + upgrade(database) { + const templates = database.createObjectStore("templates"); + templates.createIndex("by-last-saved", "metadata.lastSaved"); + database.createObjectStore("drafts"); + + const assets = database.createObjectStore("assets", { keyPath: "id" }); + assets.createIndex("by-numeric-id", "numericId", { unique: true }); + + database.createObjectStore("migrations"); + }, + blocked() { + databasePromise = undefined; + }, + terminated() { + databasePromise = undefined; + }, + }); + void databasePromise.catch(() => { + databasePromise = undefined; + }); + } + + return databasePromise; +} diff --git a/src/types/templateShare.ts b/src/types/templateShare.ts new file mode 100644 index 0000000..ec04d1f --- /dev/null +++ b/src/types/templateShare.ts @@ -0,0 +1,29 @@ +export interface PortableIconBuiltin { + kind: "builtin"; + key: string; +} + +export interface PortableIconData { + kind: "data"; + name: string; + dataUrl: string; +} + +export type PortableIcon = PortableIconBuiltin | PortableIconData; + +export interface PortableTemplateItem { + name: string; + siteUrl: string; + position: { x: number; y: number }; + size: { width: number; height: number }; + icon: PortableIcon; +} + +export interface TemplateSharePayloadV1 { + version: 1; + template: { + name: string; + height: number; + items: PortableTemplateItem[]; + }; +} diff --git a/src/utils/defaultTemplate.ts b/src/utils/defaultTemplate.ts new file mode 100644 index 0000000..e8b79c4 --- /dev/null +++ b/src/utils/defaultTemplate.ts @@ -0,0 +1,29 @@ +import { createDefaultLinkList } from "@/constants/LinkList"; +import type { BulletinInfo } from "@/constants/bulletin"; +import { getBundledTemplateIcons } from "@/constants/templateIcons"; +import type { Template } from "@/types/api"; +import { + calculateTemplateHeight, + convertLinkListToTemplateItems, +} from "@/utils/template"; + +export function createBundledDefaultTemplate( + bulletin?: BulletinInfo, +): Template { + const links = createDefaultLinkList(bulletin); + const timestamp = "2026-01-01T00:00:00.000Z"; + return { + id: "builtin:linku-default@1", + templateId: 0, + name: "LinKU 기본 템플릿", + height: calculateTemplateHeight(), + cloned: false, + items: convertLinkListToTemplateItems( + getBundledTemplateIcons(links), + links, + ), + syncStatus: "local", + createdAt: timestamp, + updatedAt: timestamp, + }; +} diff --git a/src/utils/pendingTemplateImports.ts b/src/utils/pendingTemplateImports.ts new file mode 100644 index 0000000..9b12b4b --- /dev/null +++ b/src/utils/pendingTemplateImports.ts @@ -0,0 +1,84 @@ +import type { TemplateSharePayloadV1 } from "@/types/templateShare"; +import { validateTemplateSharePayload } from "@/utils/templateShareCodec"; +import { errorLog } from "@/utils/logger"; + +const STORAGE_KEY = "pendingTemplateImports"; +const MAX_PENDING_IMPORTS = 5; + +let mutationQueue: Promise = Promise.resolve(); + +function withMutationQueue(operation: () => Promise): Promise { + const result = mutationQueue.then(operation, operation); + mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +function getStorage(): chrome.storage.StorageArea { + if (!globalThis.chrome?.storage?.local) { + throw new Error("확장 프로그램 저장소를 사용할 수 없습니다."); + } + return chrome.storage.local; +} + +function readValidQueue(value: unknown): TemplateSharePayloadV1[] { + if (!Array.isArray(value)) return []; + return value.filter((candidate): candidate is TemplateSharePayloadV1 => { + try { + validateTemplateSharePayload(candidate); + return true; + } catch (error) { + errorLog("Discarding invalid pending template import", error); + return false; + } + }); +} + +export function enqueuePendingTemplateImport( + payload: TemplateSharePayloadV1, +): Promise { + validateTemplateSharePayload(payload); + return withMutationQueue(async () => { + const storage = getStorage(); + const stored = await storage.get(STORAGE_KEY); + const queue = readValidQueue(stored[STORAGE_KEY]); + await storage.set({ + [STORAGE_KEY]: [...queue, payload].slice(-MAX_PENDING_IMPORTS), + }); + }); +} + +export function consumePendingTemplateImports( + importer: (payload: TemplateSharePayloadV1) => Promise, +): Promise { + return withMutationQueue(async () => { + const storage = getStorage(); + const stored = await storage.get(STORAGE_KEY); + const queue = readValidQueue(stored[STORAGE_KEY]); + if (queue.length === 0) { + await storage.remove(STORAGE_KEY); + return 0; + } + + const failed: TemplateSharePayloadV1[] = []; + let importedCount = 0; + for (const payload of queue) { + try { + await importer(payload); + importedCount += 1; + } catch (error) { + errorLog("Failed to consume pending template import", error); + failed.push(payload); + } + } + + if (failed.length > 0) { + await storage.set({ [STORAGE_KEY]: failed }); + } else { + await storage.remove(STORAGE_KEY); + } + return importedCount; + }); +} diff --git a/src/utils/templateShare.ts b/src/utils/templateShare.ts new file mode 100644 index 0000000..f2891f1 --- /dev/null +++ b/src/utils/templateShare.ts @@ -0,0 +1,147 @@ +import { getBundledTemplateIcons } from "@/constants/templateIcons"; +import type { Template, TemplateIcon, TemplateItem } from "@/types/api"; +import type { + PortableIcon, + TemplateSharePayloadV1, +} from "@/types/templateShare"; +import { + encodeTemplateSharePayload, + MAX_SHARE_FILE_BYTES, + validateTemplateSharePayload, +} from "@/utils/templateShareCodec"; + +export { + decodeTemplateSharePayload, + encodeTemplateSharePayload, + MAX_SHARE_FILE_BYTES, + validateTemplateSharePayload, +} from "@/utils/templateShareCodec"; + +export const SHARE_URL_LIMIT = 1_800; +export const SHARE_PAGE_URL = "https://turtle-hwan.github.io/LinKU/share/"; +const GENERIC_LINK_ICON_KEY = "linku:generic-link"; +const PORTABLE_DATA_ICON_PATTERN = /^data:image\/(?:png|jpeg|webp);base64,/u; +const GENERIC_LINK_ICON_URL = `data:image/svg+xml,${encodeURIComponent( + '', +)}`; + +function genericLinkIcon(name = "링크"): TemplateIcon { + return { + iconId: -1, + iconName: name, + iconUrl: GENERIC_LINK_ICON_URL, + }; +} + +function toPortableIcon(icon: TemplateIcon): PortableIcon { + const builtin = getBundledTemplateIcons().find( + (candidate) => + candidate.name.toLowerCase() === icon.iconName.toLowerCase(), + ); + if (builtin) return { kind: "builtin", key: builtin.name }; + + if (PORTABLE_DATA_ICON_PATTERN.test(icon.iconUrl)) { + return { kind: "data", name: icon.iconName, dataUrl: icon.iconUrl }; + } + + return { kind: "builtin", key: GENERIC_LINK_ICON_KEY }; +} + +function fromPortableIcon(icon: PortableIcon): TemplateIcon { + if (icon.kind === "builtin") { + if (icon.key === GENERIC_LINK_ICON_KEY) return genericLinkIcon(); + const bundled = getBundledTemplateIcons().find( + (candidate) => candidate.name.toLowerCase() === icon.key.toLowerCase(), + ); + if (!bundled) return genericLinkIcon(icon.key); + return { + iconId: bundled.id, + iconName: bundled.name, + iconUrl: bundled.imageUrl, + }; + } + + return { + iconId: -Math.floor(Math.random() * Number.MAX_SAFE_INTEGER), + iconName: icon.name, + iconUrl: icon.dataUrl, + }; +} + +export function createTemplateSharePayload( + template: Template, +): TemplateSharePayloadV1 { + return { + version: 1, + template: { + name: template.name.slice(0, 80), + height: template.height, + items: template.items.map((item) => ({ + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: toPortableIcon(item.icon), + })), + }, + }; +} + +export function portablePayloadToTemplate( + payload: TemplateSharePayloadV1, +): Template { + validateTemplateSharePayload(payload); + const now = new Date().toISOString(); + const items: TemplateItem[] = payload.template.items.map((item, index) => ({ + templateItemId: -(index + 1), + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: fromPortableIcon(item.icon), + })); + return { + id: crypto.randomUUID(), + templateId: Date.now(), + name: payload.template.name, + height: payload.template.height, + cloned: true, + items, + syncStatus: "local", + createdAt: now, + updatedAt: now, + }; +} + +export async function createTemplateShareUrl( + template: Template, +): Promise< + | { mode: "url"; url: string; payload: TemplateSharePayloadV1 } + | { mode: "file-required"; payload: TemplateSharePayloadV1 } +> { + const payload = createTemplateSharePayload(template); + const fragment = await encodeTemplateSharePayload(payload); + const url = `${SHARE_PAGE_URL}#${fragment}`; + return url.length <= SHARE_URL_LIMIT + ? { mode: "url", url, payload } + : { mode: "file-required", payload }; +} + +export function downloadTemplatePayload( + payload: TemplateSharePayloadV1, + fileName = "linku-template.linku.json", +): void { + validateTemplateSharePayload(payload); + const json = JSON.stringify(payload, null, 2); + if (new TextEncoder().encode(json).byteLength > MAX_SHARE_FILE_BYTES) { + throw new Error("공유 데이터가 허용된 크기를 초과합니다."); + } + const url = URL.createObjectURL( + new Blob([json], { type: "application/json" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/src/utils/templateShareCodec.ts b/src/utils/templateShareCodec.ts new file mode 100644 index 0000000..e045385 --- /dev/null +++ b/src/utils/templateShareCodec.ts @@ -0,0 +1,193 @@ +import type { + PortableTemplateItem, + TemplateSharePayloadV1, +} from "../types/templateShare.ts"; + +export const SHARE_FRAGMENT_PREFIX = "v1."; +export const MAX_SHARE_FILE_BYTES = 256 * 1024; + +const MAX_FRAGMENT_CHARACTERS = 4_096; +const DATA_ICON_PATTERN = /^data:image\/(?:png|jpeg|webp);base64,/u; + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary) + .replace(/\+/gu, "-") + .replace(/\//gu, "_") + .replace(/=+$/u, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/u.test(value)) { + throw new Error("공유 링크의 인코딩이 올바르지 않습니다."); + } + const base64 = value.replace(/-/gu, "+").replace(/_/gu, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function compress(value: string): Promise { + const stream = new Blob([value]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +async function decompress(value: Uint8Array): Promise { + const stream = new Blob([Uint8Array.from(value).buffer]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + totalBytes += chunk.byteLength; + if (totalBytes > MAX_SHARE_FILE_BYTES) { + await reader.cancel(); + throw new Error("공유 데이터가 허용된 크기를 초과합니다."); + } + chunks.push(chunk); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + +function assertHttpUrl(value: string): void { + if (value.length > 2_048) throw new Error("공유 링크 주소가 너무 깁니다."); + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("공유 템플릿에는 HTTP 또는 HTTPS 링크만 사용할 수 있습니다."); + } +} + +function validatePortableItem( + value: unknown, + index: number, +): asserts value is PortableTemplateItem { + if (!value || typeof value !== "object") { + throw new Error(`${index + 1}번째 항목이 올바르지 않습니다.`); + } + const item = value as Record; + if ( + typeof item.name !== "string" || + item.name.trim().length === 0 || + item.name.length > 80 || + typeof item.siteUrl !== "string" + ) { + throw new Error(`${index + 1}번째 항목의 이름이나 주소가 올바르지 않습니다.`); + } + assertHttpUrl(item.siteUrl); + + const position = item.position as Record | undefined; + const size = item.size as Record | undefined; + if ( + !position || + !size || + !Number.isInteger(position.x) || + !Number.isInteger(position.y) || + !Number.isInteger(size.width) || + !Number.isInteger(size.height) || + Number(position.x) < 0 || + Number(position.y) < 0 || + Number(size.width) < 1 || + Number(size.height) < 1 || + Number(position.x) + Number(size.width) > 6 || + Number(position.y) + Number(size.height) > 6 + ) { + throw new Error(`${index + 1}번째 항목이 템플릿 영역을 벗어납니다.`); + } + + const icon = item.icon as Record | undefined; + if (!icon || !["builtin", "data"].includes(String(icon.kind))) { + throw new Error(`${index + 1}번째 항목의 아이콘이 올바르지 않습니다.`); + } + if ( + icon.kind === "builtin" && + (typeof icon.key !== "string" || icon.key.length === 0 || icon.key.length > 80) + ) { + throw new Error(`${index + 1}번째 기본 아이콘이 올바르지 않습니다.`); + } + if (icon.kind === "data") { + if ( + typeof icon.name !== "string" || + icon.name.length === 0 || + icon.name.length > 80 || + typeof icon.dataUrl !== "string" || + icon.dataUrl.length > MAX_SHARE_FILE_BYTES || + !DATA_ICON_PATTERN.test(icon.dataUrl) + ) { + throw new Error(`${index + 1}번째 이미지 아이콘이 올바르지 않습니다.`); + } + } +} + +export function validateTemplateSharePayload( + value: unknown, +): asserts value is TemplateSharePayloadV1 { + if (!value || typeof value !== "object") throw new Error("공유 데이터가 없습니다."); + const payload = value as Record; + const template = payload.template as Record | undefined; + if ( + payload.version !== 1 || + !template || + typeof template.name !== "string" || + template.name.trim().length === 0 || + template.name.length > 80 || + !Number.isInteger(template.height) || + Number(template.height) < 1 || + Number(template.height) > 6 || + !Array.isArray(template.items) || + template.items.length > 36 + ) { + throw new Error("지원하지 않는 템플릿 공유 형식입니다."); + } + template.items.forEach(validatePortableItem); +} + +export async function encodeTemplateSharePayload( + payload: TemplateSharePayloadV1, +): Promise { + validateTemplateSharePayload(payload); + const json = JSON.stringify(payload); + if (new TextEncoder().encode(json).byteLength > MAX_SHARE_FILE_BYTES) { + throw new Error("공유 데이터가 허용된 크기를 초과합니다."); + } + const compressed = await compress(json); + return `${SHARE_FRAGMENT_PREFIX}${bytesToBase64Url(compressed)}`; +} + +export async function decodeTemplateSharePayload( + fragment: string, +): Promise { + const normalized = fragment.startsWith("#") ? fragment.slice(1) : fragment; + if ( + !normalized.startsWith(SHARE_FRAGMENT_PREFIX) || + normalized.length > MAX_FRAGMENT_CHARACTERS + ) { + throw new Error("지원하지 않는 공유 링크입니다."); + } + try { + const json = await decompress( + base64UrlToBytes(normalized.slice(SHARE_FRAGMENT_PREFIX.length)), + ); + const payload: unknown = JSON.parse(json); + validateTemplateSharePayload(payload); + return payload; + } catch { + throw new Error("공유 링크 데이터가 손상되었거나 지원되지 않습니다."); + } +} diff --git a/src/utils/templateStorage.ts b/src/utils/templateStorage.ts index fba8e2d..1335525 100644 --- a/src/utils/templateStorage.ts +++ b/src/utils/templateStorage.ts @@ -1,222 +1,295 @@ /** - * Template LocalStorage Management - * Handles saving and loading templates to/from browser's localStorage + * IndexedDB-backed template persistence. + * + * Existing function names remain stable while legacy localStorage data is + * imported once. Legacy values are retained as a rollback source. */ -import type { Template, TemplateItem } from '@/types/api'; -import { errorLog } from '@/utils/logger'; - -export interface StoredTemplate { - template: Template; - stagingItems: TemplateItem[]; - metadata: { - lastSaved: number; - savedLocally: boolean; - syncedWithServer: boolean; - serverSyncedAt?: number; - }; -} +import { getLinkuDb, type StoredTemplate } from "@/storage/linkuDb"; +import type { Template, TemplateItem } from "@/types/api"; +import { errorLog } from "@/utils/logger"; + +export type { StoredTemplate } from "@/storage/linkuDb"; export interface TemplateIndexEntry { templateId: number; name: string; lastSaved: number; - syncedWithServer: boolean; } -const STORAGE_PREFIX = 'linku_template_'; -const INDEX_KEY = 'linku_templates_index'; -const DRAFT_KEY = 'linku_template_draft'; - -/** - * Save template to localStorage - */ -export async function saveTemplateToLocalStorage( - template: Template, - stagingItems: TemplateItem[] = [], - syncedWithServer = false -): Promise { - try { - const stored: StoredTemplate = { - template, - stagingItems, - metadata: { - lastSaved: Date.now(), - savedLocally: true, - syncedWithServer, - serverSyncedAt: syncedWithServer ? Date.now() : undefined, - }, - }; - - // Save template data - const key = - template.templateId === 0 - ? DRAFT_KEY - : `${STORAGE_PREFIX}${template.templateId}`; - - localStorage.setItem(key, JSON.stringify(stored)); - - // Update index - await updateTemplateIndex(template, syncedWithServer); - } catch (error) { - errorLog('Failed to save template to localStorage:', error); - throw Object.assign(new Error('LocalStorage 저장 실패'), { cause: error }); - } -} +const STORAGE_PREFIX = "linku_template_"; +const INDEX_KEY = "linku_templates_index"; +const DRAFT_KEY = "linku_template_draft"; +const MIGRATION_KEY = "local-storage-templates-v1"; -/** - * Load template from localStorage - */ -export function loadTemplateFromLocalStorage( - templateId: number -): StoredTemplate | null { - try { - const key = - templateId === 0 ? DRAFT_KEY : `${STORAGE_PREFIX}${templateId}`; +let migrationPromise: Promise | undefined; - const data = localStorage.getItem(key); - if (!data) return null; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} - return JSON.parse(data) as StoredTemplate; - } catch (error) { - errorLog('Failed to load template from localStorage:', error); +function normalizeStoredTemplate(value: unknown): StoredTemplate | null { + if (!isRecord(value) || !isRecord(value.template)) return null; + const templateValue = value.template; + if ( + !Number.isSafeInteger(templateValue.templateId) || + Number(templateValue.templateId) < 0 || + typeof templateValue.name !== "string" || + !Number.isInteger(templateValue.height) || + Number(templateValue.height) < 1 || + Number(templateValue.height) > 6 || + !Array.isArray(templateValue.items) + ) { return null; } + + const metadata = isRecord(value.metadata) ? value.metadata : {}; + const now = new Date().toISOString(); + const template = templateValue as unknown as Template; + return { + template: { + ...template, + id: + typeof templateValue.id === "string" && templateValue.id.length > 0 + ? templateValue.id + : crypto.randomUUID(), + cloned: Boolean(templateValue.cloned), + createdAt: + typeof templateValue.createdAt === "string" + ? templateValue.createdAt + : now, + updatedAt: + typeof templateValue.updatedAt === "string" + ? templateValue.updatedAt + : now, + syncStatus: "local", + }, + stagingItems: Array.isArray(value.stagingItems) + ? (value.stagingItems as TemplateItem[]) + : [], + metadata: { + lastSaved: + typeof metadata.lastSaved === "number" + ? metadata.lastSaved + : Date.now(), + savedLocally: true, + }, + }; } -/** - * Update template index - */ -async function updateTemplateIndex( - template: Template, - syncedWithServer: boolean -): Promise { +async function migrateLegacyLocalStorage(): Promise { + const database = await getLinkuDb(); + if (await database.get("migrations", MIGRATION_KEY)) return; + + if (typeof localStorage === "undefined") return; + + const transaction = database.transaction( + ["templates", "drafts", "migrations"], + "readwrite", + ); + try { - const indexData = localStorage.getItem(INDEX_KEY); - const index: TemplateIndexEntry[] = indexData ? JSON.parse(indexData) : []; + const keys = new Set(); + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key?.startsWith(STORAGE_PREFIX)) keys.add(key); + } - // Skip draft templates (templateId === 0) - they should not be in the index - // Draft templates are stored separately under DRAFT_KEY - // Also remove any existing draft entries from the index - if (template.templateId === 0) { - // Remove any existing entries with templateId === 0 from index - const filteredIndex = index.filter((t) => t.templateId !== 0); - if (filteredIndex.length !== index.length) { - localStorage.setItem(INDEX_KEY, JSON.stringify(filteredIndex)); + const legacyIndex = localStorage.getItem(INDEX_KEY); + if (legacyIndex) { + try { + const entries = JSON.parse(legacyIndex) as Array<{ templateId?: number }>; + for (const entry of entries) { + if (typeof entry.templateId === "number" && entry.templateId !== 0) { + keys.add(`${STORAGE_PREFIX}${entry.templateId}`); + } + } + } catch (error) { + errorLog("Failed to read legacy template index", error); } - return; } - // Find existing entry or create new - const existingIndex = index.findIndex( - (t) => t.templateId === template.templateId - ); - - const entry: TemplateIndexEntry = { - templateId: template.templateId, - name: template.name, - lastSaved: Date.now(), - syncedWithServer, - }; + for (const key of keys) { + const raw = localStorage.getItem(key); + if (!raw) continue; + try { + const stored = normalizeStoredTemplate(JSON.parse(raw) as unknown); + if (stored && stored.template.templateId !== 0) { + await transaction + .objectStore("templates") + .put(stored, stored.template.templateId); + } + } catch (error) { + errorLog(`Failed to migrate legacy template ${key}`, error); + } + } - if (existingIndex >= 0) { - index[existingIndex] = entry; - } else { - index.push(entry); + const draft = localStorage.getItem(DRAFT_KEY); + if (draft) { + try { + const storedDraft = normalizeStoredTemplate(JSON.parse(draft) as unknown); + if (storedDraft) { + await transaction.objectStore("drafts").put(storedDraft, "current"); + } + } catch (error) { + errorLog("Failed to migrate legacy template draft", error); + } } - localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + await transaction + .objectStore("migrations") + .put({ completedAt: Date.now() }, MIGRATION_KEY); + await transaction.done; } catch (error) { - errorLog('Failed to update template index:', error); + try { + transaction.abort(); + } catch { + // The transaction may already be aborted by IndexedDB. + } + errorLog("Failed to migrate template localStorage to IndexedDB", error); + throw error; } } -/** - * Get all templates index - */ -export function getTemplatesIndex(): TemplateIndexEntry[] { - try { - const data = localStorage.getItem(INDEX_KEY); - return data ? JSON.parse(data) : []; - } catch (error) { - errorLog('Failed to load templates index:', error); - return []; +async function ensureMigration(): Promise { + if (!migrationPromise) { + migrationPromise = migrateLegacyLocalStorage().catch((error) => { + errorLog( + "Legacy template migration failed; continuing with IndexedDB", + error, + ); + }); } + await migrationPromise; } -/** - * Delete template from localStorage - */ -export function deleteTemplateFromLocalStorage(templateId: number): void { +export async function saveTemplateToLocalStorage( + template: Template, + stagingItems: TemplateItem[] = [], +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const now = Date.now(); + const stored: StoredTemplate = { + template: { + ...template, + id: template.id || crypto.randomUUID(), + syncStatus: "local", + }, + stagingItems, + metadata: { + lastSaved: now, + savedLocally: true, + }, + }; + try { - const key = `${STORAGE_PREFIX}${templateId}`; - localStorage.removeItem(key); - - // Update index - const indexData = localStorage.getItem(INDEX_KEY); - if (indexData) { - const index: TemplateIndexEntry[] = JSON.parse(indexData); - const filtered = index.filter((t) => t.templateId !== templateId); - localStorage.setItem(INDEX_KEY, JSON.stringify(filtered)); + if (template.templateId === 0) { + await database.put("drafts", stored, "current"); + } else { + await database.put("templates", stored, template.templateId); } } catch (error) { - errorLog('Failed to delete template from localStorage:', error); + errorLog("Failed to save template to IndexedDB", error); + throw Object.assign(new Error("브라우저 저장소에 저장하지 못했습니다."), { + cause: error, + }); } } -/** - * Check if localStorage has available space - */ -export function checkLocalStorageSpace(): { - available: boolean; - error?: string; -} { - try { - // Test write with 1MB test data - const testKey = '__storage_test__'; - const testData = 'x'.repeat(1024 * 1024); // 1MB - localStorage.setItem(testKey, testData); - localStorage.removeItem(testKey); - return { available: true }; - } catch { - return { - available: false, - error: 'LocalStorage 공간이 부족합니다.', - }; - } +export async function loadTemplateFromLocalStorage( + templateId: number, +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const value = + templateId === 0 + ? await database.get("drafts", "current") + : await database.get("templates", templateId); + return value ?? null; } -/** - * Update sync status for a template - */ -export function updateTemplateSyncStatus( +export async function getTemplatesIndex(): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const templates = await database.getAll("templates"); + return templates + .map((stored) => ({ + templateId: stored.template.templateId, + name: stored.template.name, + lastSaved: stored.metadata.lastSaved, + })) + .sort((left, right) => right.lastSaved - left.lastSaved); +} + +export async function deleteTemplateFromLocalStorage( templateId: number, - syncedWithServer: boolean -): void { - try { - // Update stored template - const key = `${STORAGE_PREFIX}${templateId}`; - const data = localStorage.getItem(key); - if (data) { - const stored: StoredTemplate = JSON.parse(data); - stored.metadata.syncedWithServer = syncedWithServer; - stored.metadata.serverSyncedAt = syncedWithServer - ? Date.now() - : undefined; - localStorage.setItem(key, JSON.stringify(stored)); - } +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + await database.delete("templates", templateId); - // Update index - const indexData = localStorage.getItem(INDEX_KEY); - if (indexData) { - const index: TemplateIndexEntry[] = JSON.parse(indexData); - const entry = index.find((t) => t.templateId === templateId); - if (entry) { - entry.syncedWithServer = syncedWithServer; - localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + if (typeof localStorage !== "undefined") { + try { + localStorage.removeItem(`${STORAGE_PREFIX}${templateId}`); + const legacyIndex = localStorage.getItem(INDEX_KEY); + if (legacyIndex) { + const entries = JSON.parse(legacyIndex) as Array<{ templateId?: number }>; + localStorage.setItem( + INDEX_KEY, + JSON.stringify( + entries.filter((entry) => entry.templateId !== templateId), + ), + ); } + } catch (error) { + errorLog("Failed to remove deleted template from legacy storage", error); } - } catch (error) { - errorLog('Failed to update sync status:', error); } } + +export function checkTemplateStorageAvailability(): { + available: boolean; + error?: string; +} { + return typeof indexedDB !== "undefined" + ? { available: true } + : { available: false, error: "IndexedDB를 사용할 수 없습니다." }; +} + +export async function importSharedTemplate( + template: Template, + stagingItems: TemplateItem[] = [], +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const transaction = database.transaction("templates", "readwrite"); + const store = transaction.objectStore("templates"); + let templateId = Date.now(); + while (await store.get(templateId)) templateId += 1; + + const now = new Date().toISOString(); + const imported: Template = { + ...template, + id: crypto.randomUUID(), + templateId, + name: template.name.endsWith("(가져옴)") + ? template.name + : `${template.name} (가져옴)`, + cloned: true, + syncStatus: "local", + createdAt: now, + updatedAt: now, + }; + const stored: StoredTemplate = { + template: imported, + stagingItems, + metadata: { + lastSaved: Date.now(), + savedLocally: true, + }, + }; + await store.put(stored, templateId); + await transaction.done; + return stored; +} diff --git a/src/web/home.tsx b/src/web/home.tsx new file mode 100644 index 0000000..65554b3 --- /dev/null +++ b/src/web/home.tsx @@ -0,0 +1,70 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ExternalLink, HardDrive, Link2, WifiOff } from 'lucide-react'; +import '@/App.css'; + +const EXTENSION_URL = + 'https://chromewebstore.google.com/detail/linku/fmfbhmifnohhfiblebbdjlioppfppbgh'; + +const features = [ + { + icon: , + title: '기기 우선 저장', + description: '개인 템플릿과 아이콘을 Chrome IndexedDB에 저장합니다.', + }, + { + icon: , + title: '서버 없이 편집', + description: '백엔드 상태와 무관하게 만들고, 적용하고, 수정할 수 있습니다.', + }, + { + icon: , + title: 'fragment 공유', + description: '작은 템플릿은 서버에 업로드하지 않고 URL 안에 담습니다.', + }, +]; + +export function HomePage() { + return ( +
+
+

+ LOCAL FIRST +

+

+ 학교 생활 링크를
내 방식대로 정리하세요. +

+

+ LinKU의 개인 템플릿은 먼저 내 브라우저에 저장됩니다. 로그인이나 서버 + 연결이 없어도 핵심 기능을 그대로 사용할 수 있습니다. +

+ + Chrome에 추가 + + +
+ {features.map((feature) => ( +
+
{feature.icon}
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+
+ ); +} + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/web/share.tsx b/src/web/share.tsx new file mode 100644 index 0000000..d74d917 --- /dev/null +++ b/src/web/share.tsx @@ -0,0 +1,152 @@ +import { StrictMode, useEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Download, ExternalLink } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { TemplatePreviewCanvas } from '@/components/Editor/TemplatePreview/TemplatePreviewCanvas'; +import type { Template } from '@/types/api'; +import type { TemplateSharePayloadV1 } from '@/types/templateShare'; +import { + decodeTemplateSharePayload, + downloadTemplatePayload, + portablePayloadToTemplate, +} from '@/utils/templateShare'; +import '@/App.css'; + +const EXTENSION_ID = 'fmfbhmifnohhfiblebbdjlioppfppbgh'; +const EXTENSION_URL = `https://chromewebstore.google.com/detail/linku/${EXTENSION_ID}`; + +interface ImportResponse { + success?: boolean; + error?: string; +} + +async function importIntoExtension( + payload: TemplateSharePayloadV1, +): Promise { + const runtime = globalThis.chrome?.runtime; + if (!runtime?.sendMessage) { + throw new Error('LinKU 확장 프로그램을 찾을 수 없습니다.'); + } + const response = (await runtime.sendMessage(EXTENSION_ID, { + type: 'IMPORT_SHARED_TEMPLATE', + data: { payload }, + })) as ImportResponse | undefined; + if (!response?.success) { + throw new Error(response?.error || '확장 프로그램으로 가져오지 못했습니다.'); + } +} + +export function MessagePage({ title, message }: { title: string; message: string }) { + return ( +
+

{title}

+

{message}

+ + LinKU 소개로 돌아가기 + +
+ ); +} + +export function SharedTemplatePage({ + payload, + template, +}: { + payload: TemplateSharePayloadV1; + template: Template; +}) { + const [status, setStatus] = useState(''); + + const handleImport = async () => { + setStatus('가져오는 중...'); + try { + await importIntoExtension(payload); + setStatus('가져오기 요청을 저장했습니다. LinKU를 열면 이 기기에 추가됩니다.'); + } catch (error) { + setStatus(error instanceof Error ? error.message : '가져오지 못했습니다.'); + } + }; + + return ( +
+

공유 템플릿

+

{template.name}

+

+ {template.items.length}개 링크 · {template.height}행 +

+
+ +
+
+ + + +
+ {status &&

{status}

} +

+ 이 페이지는 URL의 # 뒤에 담긴 데이터를 브라우저에서만 읽습니다. 템플릿 + 내용은 GitHub Pages 서버로 전송되지 않습니다. +

+
+ ); +} + +export function ShareApp() { + const [hash, setHash] = useState(() => window.location.hash); + const [sharedTemplate, setSharedTemplate] = useState<{ + payload: TemplateSharePayloadV1; + template: Template; + } | null>(null); + const [error, setError] = useState(''); + + useEffect(() => { + const handleHashChange = () => setHash(window.location.hash); + window.addEventListener('hashchange', handleHashChange); + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + useEffect(() => { + let active = true; + setSharedTemplate(null); + setError(''); + void decodeTemplateSharePayload(hash) + .then((decoded) => { + if (active) { + setSharedTemplate({ + payload: decoded, + template: portablePayloadToTemplate(decoded), + }); + } + }) + .catch((decodeError: unknown) => { + if (active) { + setError( + decodeError instanceof Error + ? decodeError.message + : '공유 링크를 읽지 못했습니다.', + ); + } + }); + return () => { + active = false; + }; + }, [hash]); + + if (error) return ; + if (!sharedTemplate) { + return ; + } + return ; +} + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/tests/templates/templateShareCodec.test.ts b/tests/templates/templateShareCodec.test.ts new file mode 100644 index 0000000..41c0f4a --- /dev/null +++ b/tests/templates/templateShareCodec.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + decodeTemplateSharePayload, + encodeTemplateSharePayload, + validateTemplateSharePayload, +} from "../../src/utils/templateShareCodec.ts"; +import type { TemplateSharePayloadV1 } from "../../src/types/templateShare.ts"; + +const payload: TemplateSharePayloadV1 = { + version: 1, + template: { + name: "테스트 템플릿", + height: 2, + items: [ + { + name: "건국대학교", + siteUrl: "https://www.konkuk.ac.kr/", + position: { x: 0, y: 0 }, + size: { width: 2, height: 1 }, + icon: { kind: "builtin", key: "University" }, + }, + ], + }, +}; + +test("템플릿 공유 payload를 URL fragment로 왕복한다", async () => { + const fragment = await encodeTemplateSharePayload(payload); + assert.match(fragment, /^v1\./u); + assert.deepEqual(await decodeTemplateSharePayload(`#${fragment}`), payload); +}); + +test("위험한 URL scheme과 그리드 이탈을 거부한다", () => { + const unsafeUrl = structuredClone(payload); + unsafeUrl.template.items[0].siteUrl = "javascript:alert(1)"; + assert.throws(() => validateTemplateSharePayload(unsafeUrl), /HTTP/u); + + const outsideGrid = structuredClone(payload); + outsideGrid.template.items[0].position.x = 5; + outsideGrid.template.items[0].size.width = 2; + assert.throws(() => validateTemplateSharePayload(outsideGrid), /영역/u); +}); + +test("실행 가능한 SVG data URL을 거부한다", () => { + const svgIcon = structuredClone(payload); + svgIcon.template.items[0].icon = { + kind: "data", + name: "unsafe", + dataUrl: "data:image/svg+xml,", + }; + assert.throws(() => validateTemplateSharePayload(svgIcon), /이미지 아이콘/u); +}); + +test("외부 추적이 가능한 remote icon 형식을 거부한다", () => { + const remoteIcon = structuredClone(payload) as unknown as { + template: { items: Array<{ icon: unknown }> }; + }; + remoteIcon.template.items[0].icon = { + kind: "remote", + name: "tracker", + url: "https://tracker.example/icon.png", + }; + assert.throws(() => validateTemplateSharePayload(remoteIcon), /아이콘/u); +}); + +test("손상된 압축 fragment를 사용자용 오류로 변환한다", async () => { + await assert.rejects( + decodeTemplateSharePayload("#v1.invalid"), + /손상되었거나 지원되지 않습니다/u, + ); +}); diff --git a/vite.config.ts b/vite.config.ts index 50ef83b..bc56aab 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,22 @@ import fs from "fs"; export default defineConfig(({ mode }) => { // Chrome Extension build configuration const isChromeExtension = mode !== "gh-pages"; + const rollupInput: Record = isChromeExtension + ? { + main: path.resolve(__dirname, "index.html"), + "background/index": path.resolve( + __dirname, + "src/background/index.ts", + ), + "content/everytime-timetable": path.resolve( + __dirname, + "src/content/everytime-timetable.ts", + ), + } + : { + main: path.resolve(__dirname, "web/index.html"), + "share/index": path.resolve(__dirname, "web/share/index.html"), + }; return { plugins: [ @@ -15,35 +31,25 @@ export default defineConfig(({ mode }) => { tailwindcss(), svgr(), mode === "gh-pages" && copyBannersForGhPages(), + mode === "gh-pages" && moveGhPagesWebFilesToRoot(), ], resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, - // base를 상대경로로 설정 - base: "", + publicDir: mode === "gh-pages" ? "web/public" : "public", + base: mode === "gh-pages" ? "/LinKU/" : "", build: { // 빌드 결과물이 dist/ 폴더에 생성되도록 설정 outDir: mode === "gh-pages" ? "gh-pages" : "dist", + emptyOutDir: true, // assets 폴더를 dist에 직접 생성 assetsDir: "dist/assets", // public 폴더의 파일들을 dist로 복사 copyPublicDir: true, rollupOptions: { - input: isChromeExtension - ? { - // Popup entry point - main: path.resolve(__dirname, "index.html"), - // Background service worker entry point - "background/index": path.resolve(__dirname, "src/background/index.ts"), - // Everytime timetable capture content script - "content/everytime-timetable": path.resolve( - __dirname, - "src/content/everytime-timetable.ts", - ), - } - : undefined, + input: rollupInput, output: { assetFileNames: "[name][extname]", chunkFileNames: "[name].js", @@ -71,3 +77,25 @@ function copyBannersForGhPages() { }, }; } + +function moveGhPagesWebFilesToRoot() { + return { + name: "move-gh-pages-web-files-to-root", + closeBundle() { + const outputDir = path.resolve(__dirname, "gh-pages"); + const webDir = path.resolve(outputDir, "web"); + if (!fs.existsSync(webDir)) return; + + const webIndex = path.resolve(webDir, "index.html"); + const rootIndex = path.resolve(outputDir, "index.html"); + if (fs.existsSync(rootIndex)) fs.rmSync(rootIndex); + fs.renameSync(webIndex, rootIndex); + + const webShareIndex = path.resolve(webDir, "share/index.html"); + const shareDir = path.resolve(outputDir, "share"); + fs.mkdirSync(shareDir, { recursive: true }); + fs.renameSync(webShareIndex, path.resolve(shareDir, "index.html")); + fs.rmSync(webDir, { recursive: true }); + }, + }; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8ff30a7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + LinKU · Local First + + +
+ + + diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..cbb3b3a --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://turtle-hwan.github.io/LinKU/sitemap.xml diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml new file mode 100644 index 0000000..a025c9b --- /dev/null +++ b/web/public/sitemap.xml @@ -0,0 +1,6 @@ + + + + https://turtle-hwan.github.io/LinKU/ + + diff --git a/web/share/index.html b/web/share/index.html new file mode 100644 index 0000000..aab0f7c --- /dev/null +++ b/web/share/index.html @@ -0,0 +1,18 @@ + + + + + + + + + 공유 템플릿 · LinKU + + +
+ + +