diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a1c832e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,47 @@ +name: Deploy web to Pages + +on: + push: + branches: [main] + paths: + - 'web/**' + - 'src/core/filter.ts' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + - run: npm ci + - run: npm run web:build + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: dist-web + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index b214ce1..d69f5ad 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,7 @@ out # Nuxt.js build / generate output .nuxt dist +dist-web # Gatsby files .cache/ diff --git a/README.md b/README.md index b1a3150..38bdada 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다. +> 🌐 **웹 버전**: Windows/Linux 사용자는 설치 없이 [https://jung-geun.github.io/NFD2NFC/](https://jung-geun.github.io/NFD2NFC/) 에 파일/폴더를 끌어다 놓으면 NFC 이름의 ZIP 으로 받을 수 있습니다. + > **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file ` / `nfd2nfc dir ` 형태로 변경됐습니다. ## 주요 기능 diff --git a/package-lock.json b/package-lock.json index d4b5d45..f5a045d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "chokidar": "^3.6.0", + "fflate": "^0.8.2", "yargs": "^17.7.2" }, "bin": { @@ -4580,6 +4581,12 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index eae898f..a7556ca 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "build": "npm run build:cli && npm run build:lib && npm run build:app", "prepublishOnly": "npm run build:cli && npm run build:lib && chmod +x out/cli/index.js", "preview": "electron-vite preview", + "web:dev": "vite --config web/vite.config.ts", + "web:build": "vite build --config web/vite.config.ts", + "web:preview": "vite preview --config web/vite.config.ts", "test": "vitest run", "test:watch": "vitest", "lint": "eslint src --ext .ts,.tsx", @@ -36,6 +39,7 @@ }, "dependencies": { "chokidar": "^3.6.0", + "fflate": "^0.8.2", "yargs": "^17.7.2" }, "devDependencies": { @@ -96,7 +100,10 @@ "target": [ { "target": "dmg", - "arch": ["arm64", "x64"] + "arch": [ + "arm64", + "x64" + ] } ] }, diff --git a/tsconfig.json b/tsconfig.json index d913e05..7f454bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "forceConsistentCasingInFileNames": true, "baseUrl": "." }, - "exclude": ["node_modules", "out", "dist", "src/renderer/**"] + "exclude": ["node_modules", "out", "dist", "dist-web", "src/renderer/**", "web/**"] } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..0beb21b --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + + NFD → NFC 파일명 변환기 + + +
+ + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..e56d6bc --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,349 @@ +import { useCallback, useReducer, type DragEvent } from 'react'; +import { + collectFromDataTransfer, + collectFromFileList, + supportsDirectoryDrop, + type CollectResult, +} from './lib/collectFiles'; +import { buildZip, downloadBlob, type BuildProgress } from './lib/buildZip'; + +type State = + | { kind: 'idle'; dragActive: boolean; error?: string } + | { kind: 'reading' } + | { + kind: 'processing'; + collected: CollectResult; + progress: BuildProgress; + } + | { + kind: 'ready'; + collected: CollectResult; + blob?: Blob; + filename: string; + }; + +type Action = + | { type: 'drag'; active: boolean } + | { type: 'reading' } + | { type: 'error'; message: string } + | { type: 'processing-start'; collected: CollectResult } + | { type: 'progress'; progress: BuildProgress } + | { type: 'ready'; collected: CollectResult; blob: Blob; filename: string } + | { type: 'ready-empty'; collected: CollectResult; filename: string } + | { type: 'reset' }; + +const initial: State = { kind: 'idle', dragActive: false }; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'drag': + if (state.kind !== 'idle') return state; + return { ...state, dragActive: action.active }; + case 'reading': + return { kind: 'reading' }; + case 'error': + return { kind: 'idle', dragActive: false, error: action.message }; + case 'processing-start': + return { + kind: 'processing', + collected: action.collected, + progress: { done: 0, total: action.collected.files.length }, + }; + case 'progress': + if (state.kind !== 'processing') return state; + return { ...state, progress: action.progress }; + case 'ready': + return { + kind: 'ready', + collected: action.collected, + blob: action.blob, + filename: action.filename, + }; + case 'ready-empty': + return { + kind: 'ready', + collected: action.collected, + filename: action.filename, + }; + case 'reset': + return initial; + } +} + +function timestamp(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad( + d.getMinutes(), + )}${pad(d.getSeconds())}`; +} + +export function App() { + const [state, dispatch] = useReducer(reducer, initial); + + const process = useCallback(async (collected: CollectResult) => { + const filename = `nfc-${timestamp()}.zip`; + if (collected.files.length === 0) { + dispatch({ type: 'ready-empty', collected, filename }); + return; + } + dispatch({ type: 'processing-start', collected }); + try { + const blob = await buildZip(collected.files, (progress) => + dispatch({ type: 'progress', progress }), + ); + dispatch({ type: 'ready', collected, blob, filename }); + } catch (e) { + dispatch({ + type: 'error', + message: e instanceof Error ? e.message : 'ZIP 생성 중 알 수 없는 오류가 발생했습니다.', + }); + } + }, []); + + const handleDrop = useCallback( + async (ev: DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + if (state.kind !== 'idle') return; + + if (!supportsDirectoryDrop(ev.dataTransfer.items)) { + dispatch({ + type: 'error', + message: + '이 브라우저는 폴더 드롭을 완전히 지원하지 않습니다. 최신 Chrome / Edge / Safari 16.4+ 를 사용해주세요.', + }); + return; + } + + dispatch({ type: 'reading' }); + try { + const collected = await collectFromDataTransfer(ev.dataTransfer); + await process(collected); + } catch (e) { + dispatch({ + type: 'error', + message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.', + }); + } + }, + [process, state.kind], + ); + + const handleFileInput = useCallback( + async (ev: React.ChangeEvent) => { + const fl = ev.target.files; + if (!fl || fl.length === 0) return; + dispatch({ type: 'reading' }); + try { + const collected = collectFromFileList(fl); + await process(collected); + } catch (e) { + dispatch({ + type: 'error', + message: e instanceof Error ? e.message : '파일을 읽는 중 오류가 발생했습니다.', + }); + } finally { + // 같은 파일을 다시 선택할 수 있게 input 초기화. + ev.target.value = ''; + } + }, + [process], + ); + + const onDragOver = (ev: DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + if (state.kind === 'idle') dispatch({ type: 'drag', active: true }); + }; + const onDragLeave = (ev: DragEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + if (state.kind === 'idle') dispatch({ type: 'drag', active: false }); + }; + + return ( +
+
+

NFD → NFC 파일명 변환기

+

+ macOS 에서 만들어진 ZIP 이나 폴더의 한글 파일명이 Windows / Linux 에서 깨져 보이는 문제를 + 해결합니다. 파일이나 폴더를 아래 영역에 끌어다 놓으면 NFC 로 정규화된 ZIP 을 다운로드할 수 + 있습니다. 업로드되는 파일은 서버로 전송되지 않고 브라우저 안에서만 처리됩니다. +

+
+ +
+ {state.kind === 'idle' && ( + + )} + {state.kind === 'reading' && } + {state.kind === 'processing' && ( + + )} + {state.kind === 'ready' && ( + dispatch({ type: 'reset' })} + /> + )} +
+ +
+

+ 모든 변환은 브라우저에서 로컬로 수행됩니다. 큰 폴더(약 2GB 이상)는 메모리 한계로 실패할 + 수 있습니다. +

+
+
+ ); +} + +function IdleView(props: { + dragActive: boolean; + error?: string; + onDrop: (ev: DragEvent) => void; + onDragOver: (ev: DragEvent) => void; + onDragLeave: (ev: DragEvent) => void; + onFileInput: (ev: React.ChangeEvent) => void; +}) { + return ( + <> + + {props.error &&
{props.error}
} + + ); +} + +function ReadingView() { + return ( +
+

파일 목록을 읽는 중…

+

큰 폴더는 시간이 걸릴 수 있습니다.

+
+ ); +} + +function ProcessingView(props: { + collected: CollectResult; + progress: BuildProgress; +}) { + const pct = + props.progress.total === 0 ? 0 : Math.round((props.progress.done / props.progress.total) * 100); + return ( +
+

ZIP 으로 묶는 중…

+
+
+
+
+ {props.progress.done.toLocaleString()} / {props.progress.total.toLocaleString()} 파일 + {' · '} + {pct}% +
+
+ + {props.collected.normalizedCount.toLocaleString()}변환 대상 + + + {props.collected.passthroughCount.toLocaleString()}그대로 유지 + +
+
+ ); +} + +function ReadyView(props: { + collected: CollectResult; + blob?: Blob; + filename: string; + onReset: () => void; +}) { + const { collected, blob, filename, onReset } = props; + const hasFiles = collected.files.length > 0; + const hasNormalizable = collected.normalizedCount > 0; + + return ( +
+

+ {hasNormalizable ? '완료되었습니다.' : '변환할 파일이 없습니다.'} +

+
+ + {collected.normalizedCount.toLocaleString()}이름 변경 + + + {collected.passthroughCount.toLocaleString()}그대로 유지 + +
+ + {!hasFiles && ( +
+ 드롭한 항목에서 처리할 파일을 찾지 못했습니다. 폴더가 비어있거나 형식이 지원되지 않을 수 + 있습니다. +
+ )} + + {hasFiles && !hasNormalizable && ( +
+ 모든 파일명이 이미 NFC 입니다. 별도의 변환이 필요하지 않습니다. +
+ )} + + {collected.collisions.length > 0 && ( +
+ 정규화 후 같은 이름이 된 파일 {collected.collisions.length} 개를 자동으로 -2, + -3 ... 형태로 이름을 변경했습니다. +
+ )} + +
+ {blob && hasFiles && ( + + )} + +
+
+ ); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/web/src/lib/buildZip.ts b/web/src/lib/buildZip.ts new file mode 100644 index 0000000..eec228c --- /dev/null +++ b/web/src/lib/buildZip.ts @@ -0,0 +1,94 @@ +import { Zip, ZipPassThrough } from 'fflate'; +import type { CollectedFile } from './collectFiles'; + +export interface BuildProgress { + /** 완료된 파일 수. */ + done: number; + /** 전체 파일 수. */ + total: number; +} + +/** + * NFC 경로로 정규화된 파일 목록을 스트리밍 방식으로 ZIP 으로 묶는다. + * + * - fflate 의 Zip + ZipPassThrough 사용. ZipPassThrough 는 deflate 없이 STORE 모드로 묶기 때문에 + * 사진/영상처럼 이미 압축된 파일이 많은 일반 케이스에서 CPU 를 낭비하지 않는다. + * - 파일명은 JS 문자열로 그대로 넘긴다. fflate 는 non-ASCII 가 포함되면 general purpose bit 11 + * (UTF-8 flag)을 자동으로 켜기 때문에 Windows Explorer 가 CP949 로 오디코딩하지 않는다. + * - 한 번에 하나씩 파이프해서 다중 entry 가 동시에 push 되지 않도록 한다 (Zip 은 순차 처리 필요). + */ +export async function buildZip( + files: CollectedFile[], + onProgress?: (p: BuildProgress) => void, +): Promise { + const chunks: Uint8Array[] = []; + + const zipDone = new Promise((resolve, reject) => { + const zip = new Zip((err, data, final) => { + if (err) { + reject(err); + return; + } + if (data && data.length) chunks.push(data); + if (final) resolve(); + }); + + void (async () => { + try { + let done = 0; + for (const f of files) { + const entry = new ZipPassThrough(f.zipPath); + zip.add(entry); + await pipeFileToEntry(f.file, entry); + done++; + onProgress?.({ done, total: files.length }); + } + zip.end(); + } catch (e) { + reject(e); + } + })(); + }); + + await zipDone; + // Uint8Array 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅. + return new Blob(chunks as BlobPart[], { type: 'application/zip' }); +} + +async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise { + // 일부 브라우저(특히 구버전) 에서 Blob.stream() 이 없을 수 있어 fallback 으로 arrayBuffer 사용. + if (typeof file.stream !== 'function') { + const buf = new Uint8Array(await file.arrayBuffer()); + entry.push(buf, true); + return; + } + + const reader = file.stream().getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + entry.push(new Uint8Array(0), true); + return; + } + if (value && value.length) entry.push(value, false); + } + } finally { + reader.releaseLock(); + } +} + +/** + * 다운로드 트리거. URL.revokeObjectURL 로 메모리 해제까지 처리. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + // 같은 microtask 에서 revoke 하면 일부 브라우저에서 다운로드가 취소될 수 있어 약간 지연. + setTimeout(() => URL.revokeObjectURL(url), 1_000); +} diff --git a/web/src/lib/collectFiles.ts b/web/src/lib/collectFiles.ts new file mode 100644 index 0000000..d45c650 --- /dev/null +++ b/web/src/lib/collectFiles.ts @@ -0,0 +1,164 @@ +import { normalizeToNFC, shouldNormalize } from './normalize'; + +export interface CollectedFile { + /** ZIP 내부에서 사용할 NFC 정규화된 상대 경로 (POSIX, 디렉토리 segment 포함). */ + zipPath: string; + /** 원본 상대 경로 (디버그/통계용). */ + originalPath: string; + /** 실제 파일 핸들. */ + file: File; + /** 경로 segment 중 하나 이상이 정규화되었는지. */ + normalized: boolean; +} + +export interface CollectResult { + files: CollectedFile[]; + normalizedCount: number; + passthroughCount: number; + collisions: string[]; +} + +/** + * webkitGetAsEntry 가 동작하는지 확인. Safari < 16.4 등에서 folder drop 시 null 반환. + */ +export function supportsDirectoryDrop(items: DataTransferItemList): boolean { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind !== 'file') continue; + if (typeof item.webkitGetAsEntry !== 'function') return false; + } + return true; +} + +/** + * DataTransfer 에서 파일과 폴더를 모두 수집한다. + * - 폴더는 webkitGetAsEntry 로 재귀 traversal. + * - DirectoryReader.readEntries 는 Chromium 에서 호출당 최대 100 개만 반환 → 빈 배열 나올 때까지 루프. + * - 각 path segment 를 독립적으로 NFC 정규화. + * - 정규화 후 경로 충돌 시 -2, -3 ... suffix 로 회피. + */ +export async function collectFromDataTransfer(dt: DataTransfer): Promise { + const items = dt.items; + const entries: FileSystemEntry[] = []; + // file objects collected when item.webkitGetAsEntry() returns null (드물게 발생). + const looseFiles: File[] = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind !== 'file') continue; + const entry = + typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null; + if (entry) { + entries.push(entry); + } else { + const f = item.getAsFile(); + if (f) looseFiles.push(f); + } + } + + const flat: { path: string; file: File }[] = []; + + for (const e of entries) { + await walkEntry(e, '', flat); + } + for (const f of looseFiles) { + flat.push({ path: f.name, file: f }); + } + + return finalize(flat); +} + +/** + * `` 또는 일반 input 에서 수집. + */ +export function collectFromFileList(files: FileList): CollectResult { + const flat: { path: string; file: File }[] = []; + for (let i = 0; i < files.length; i++) { + const f = files[i]; + // webkitRelativePath 가 비어있으면 단일 파일 선택. + const rel = (f as File & { webkitRelativePath?: string }).webkitRelativePath || f.name; + flat.push({ path: rel, file: f }); + } + return finalize(flat); +} + +async function walkEntry( + entry: FileSystemEntry, + prefix: string, + out: { path: string; file: File }[], +): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => + (entry as FileSystemFileEntry).file(resolve, reject), + ); + out.push({ path: prefix + entry.name, file }); + return; + } + if (entry.isDirectory) { + const reader = (entry as FileSystemDirectoryEntry).createReader(); + // readEntries 페이지네이션: 빈 배열 반환할 때까지 반복. + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject), + ); + if (batch.length === 0) break; + for (const child of batch) { + await walkEntry(child, prefix + entry.name + '/', out); + } + } + } +} + +function finalize(flat: { path: string; file: File }[]): CollectResult { + const seen = new Set(); + const collisions: string[] = []; + const collected: CollectedFile[] = []; + let normalizedCount = 0; + let passthroughCount = 0; + + for (const { path, file } of flat) { + const segments = path.split('/'); + let normalized = false; + const nfcSegments = segments.map((seg) => { + if (shouldNormalize(seg)) { + normalized = true; + return normalizeToNFC(seg); + } + return seg; + }); + if (normalized) normalizedCount++; + else passthroughCount++; + + let nfcPath = nfcSegments.join('/'); + if (seen.has(nfcPath)) { + collisions.push(nfcPath); + nfcPath = disambiguate(nfcPath, seen); + } + seen.add(nfcPath); + + collected.push({ + zipPath: nfcPath, + originalPath: path, + file, + normalized, + }); + } + + return { + files: collected, + normalizedCount, + passthroughCount, + collisions, + }; +} + +function disambiguate(path: string, seen: Set): string { + const dot = path.lastIndexOf('.'); + const slash = path.lastIndexOf('/'); + const hasExt = dot > slash; // 디렉토리 안의 dot 만 확장자로 인식 + const stem = hasExt ? path.slice(0, dot) : path; + const ext = hasExt ? path.slice(dot) : ''; + let n = 2; + while (seen.has(`${stem}-${n}${ext}`)) n++; + return `${stem}-${n}${ext}`; +} diff --git a/web/src/lib/normalize.ts b/web/src/lib/normalize.ts new file mode 100644 index 0000000..8de5f96 --- /dev/null +++ b/web/src/lib/normalize.ts @@ -0,0 +1,6 @@ +// 브라우저 환경 전용 정규화 헬퍼. +// src/core/filter.ts 는 fs 의존성이 없는 순수 모듈이므로 그대로 import. +// src/lib/index.ts 는 normalizer/scanner(fs 사용)를 re-export 하므로 import 하지 않는다. +export { shouldNormalize } from '@core/core/filter'; + +export const normalizeToNFC = (s: string): string => s.normalize('NFC'); diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..ed0dc63 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..9422f4c --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,257 @@ +:root { + color-scheme: light dark; + --bg: #fafafa; + --fg: #1a1a1a; + --muted: #6b7280; + --border: #e5e7eb; + --accent: #2563eb; + --accent-fg: #ffffff; + --warn-bg: #fef3c7; + --warn-fg: #92400e; + --success-bg: #d1fae5; + --success-fg: #065f46; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1115; + --fg: #f3f4f6; + --muted: #9ca3af; + --border: #2a2f3a; + --accent: #3b82f6; + --accent-fg: #ffffff; + --warn-bg: #422006; + --warn-fg: #fcd34d; + --success-bg: #064e3b; + --success-fg: #a7f3d0; + } +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Segoe UI', Roboto, 'Helvetica Neue', + 'Malgun Gothic', sans-serif; + background: var(--bg); + color: var(--fg); + -webkit-font-smoothing: antialiased; +} + +.app { + min-height: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 24px 32px; +} + +header { + width: 100%; + max-width: 720px; + margin-bottom: 32px; +} + +header h1 { + margin: 0 0 8px; + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; +} + +header p { + margin: 0; + color: var(--muted); + font-size: 15px; + line-height: 1.6; +} + +main { + width: 100%; + max-width: 720px; + flex: 1; +} + +.dropzone { + border: 2px dashed var(--border); + border-radius: 16px; + padding: 64px 24px; + text-align: center; + background: transparent; + transition: + border-color 120ms, + background 120ms; + cursor: pointer; +} + +.dropzone:hover, +.dropzone.active { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, transparent); +} + +.dropzone.busy { + cursor: progress; + opacity: 0.7; + pointer-events: none; +} + +.dropzone h2 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 600; +} + +.dropzone p { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.6; +} + +.dropzone .hint { + margin-top: 16px; + font-size: 12px; + color: var(--muted); +} + +.dropzone input[type='file'] { + display: none; +} + +.status { + margin-top: 24px; + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: color-mix(in srgb, var(--fg) 3%, transparent); +} + +.status h2 { + margin: 0 0 12px; + font-size: 18px; + font-weight: 600; +} + +.status .progress-track { + height: 8px; + border-radius: 999px; + background: var(--border); + overflow: hidden; + margin: 12px 0 8px; +} + +.status .progress-fill { + height: 100%; + background: var(--accent); + transition: width 120ms ease-out; +} + +.status .progress-label { + font-size: 13px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.actions { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + margin-top: 16px; +} + +button.primary { + background: var(--accent); + color: var(--accent-fg); + border: none; + border-radius: 10px; + padding: 12px 20px; + font-size: 15px; + font-weight: 600; + cursor: pointer; +} + +button.primary:hover { + filter: brightness(1.05); +} + +button.link { + background: none; + border: none; + color: var(--accent); + font-size: 14px; + cursor: pointer; + padding: 8px 4px; +} + +button.link:hover { + text-decoration: underline; +} + +.warn, +.success, +.error { + margin-top: 12px; + padding: 12px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.6; +} + +.warn { + background: var(--warn-bg); + color: var(--warn-fg); +} + +.success { + background: var(--success-bg); + color: var(--success-fg); +} + +.error { + background: #fee2e2; + color: #991b1b; +} + +@media (prefers-color-scheme: dark) { + .error { + background: #450a0a; + color: #fca5a5; + } +} + +footer { + margin-top: 48px; + font-size: 12px; + color: var(--muted); + text-align: center; + line-height: 1.6; +} + +footer a { + color: var(--muted); +} + +.stats { + display: flex; + gap: 24px; + margin-top: 8px; + font-size: 14px; + color: var(--muted); +} + +.stats strong { + color: var(--fg); + font-weight: 600; + margin-right: 4px; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a4406f7 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@core/*": ["../src/*"] + }, + "types": ["vite/client"] + }, + "include": ["src/**/*", "../src/core/filter.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..67b93a0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + root: __dirname, + base: '/NFD2NFC/', + plugins: [react()], + build: { + outDir: resolve(__dirname, '../dist-web'), + emptyOutDir: true, + target: 'es2022', + }, + resolve: { + alias: { + '@core': resolve(__dirname, '../src'), + }, + }, +});