mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 12:25:14 +09:00
Compare commits
1 Commits
1e2a61c518
...
ab2277d67b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2277d67b |
47
.github/workflows/pages.yml
vendored
47
.github/workflows/pages.yml
vendored
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -4,9 +4,6 @@ on:
|
|||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-release:
|
build-and-release:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
@@ -24,7 +21,7 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build:cli && npm run build:lib && npm run build:app -- --publish never
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -142,7 +142,6 @@ out
|
|||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
dist-web
|
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
macOS에서 한글 파일명이 NFD(자모 분해)로 저장되는 문제를 실시간으로 해결하는 트레이 앱 + CLI + Node.js 라이브러리 통합 패키지입니다.
|
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 <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
|
> **Breaking Change (2.0.0)**: 단일 패키지 구조로 통합됐습니다. `MACOS-APP/`, `nfd2nfc/` 서브 패키지 구조는 제거됐습니다. CLI 명령이 `nfd2nfc file <path>` / `nfd2nfc dir <path>` 형태로 변경됐습니다.
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
@@ -39,21 +37,14 @@ npm install @pieroot/nfd2nfc
|
|||||||
|
|
||||||
### macOS 애플리케이션
|
### macOS 애플리케이션
|
||||||
|
|
||||||
앱 실행 후 메뉴바 아이콘을 좌클릭하면 팝오버가 표시됩니다. 감시 중인 디렉토리 목록과 최근 활동, Undo 버튼을 한눈에 확인할 수 있습니다.
|
<!-- TODO: 새 트레이 UI 스크린샷 교체 -->
|
||||||
|

|
||||||
|
|
||||||

|
앱 실행 후 메뉴바 아이콘을 클릭하면 팝오버가 표시됩니다. 팝오버에서 디렉토리를 추가하거나 설정창을 열 수 있습니다.
|
||||||
|
|
||||||
메뉴바 아이콘을 우클릭하면 감시 일시 정지, 설정창 열기, 종료 메뉴가 나옵니다.
|

|
||||||
|
|
||||||

|
감시할 디렉토리를 선택합니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
|
||||||
|
|
||||||
설정창의 **디렉토리** 탭에서 감시할 디렉토리를 추가하고, 디렉토리별로 감시 활성화 여부 / Auto·Manual 모드 / 하위 폴더 포함 / 추가 필터 범위를 개별 설정할 수 있습니다. 추가된 디렉토리는 앱 재시작 시에도 유지됩니다.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**일반** 탭에서는 기본 변환 모드, macOS 알림 활성화, 알림 인터벌(초)을 설정합니다.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
BIN
assets/select-directory.png
Normal file
BIN
assets/select-directory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
BIN
assets/start-app.png
Normal file
BIN
assets/start-app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,16 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@pieroot/nfd2nfc",
|
"name": "@pieroot/nfd2nfc",
|
||||||
"version": "2.0.1",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@pieroot/nfd2nfc",
|
"name": "@pieroot/nfd2nfc",
|
||||||
"version": "2.0.1",
|
"version": "2.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"fflate": "^0.8.2",
|
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4504,12 +4503,6 @@
|
|||||||
"pend": "~1.2.0"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pieroot/nfd2nfc",
|
"name": "@pieroot/nfd2nfc",
|
||||||
"version": "2.0.1",
|
"version": "2.0.0",
|
||||||
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
|
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
|
||||||
"main": "out/lib/index.js",
|
"main": "out/lib/index.js",
|
||||||
"types": "out/lib/index.d.ts",
|
"types": "out/lib/index.d.ts",
|
||||||
@@ -29,9 +29,6 @@
|
|||||||
"build": "npm run build:cli && npm run build:lib && npm run build:app",
|
"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",
|
"prepublishOnly": "npm run build:cli && npm run build:lib && chmod +x out/cli/index.js",
|
||||||
"preview": "electron-vite preview",
|
"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": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
@@ -39,7 +36,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"fflate": "^0.8.2",
|
|
||||||
"yargs": "^17.7.2"
|
"yargs": "^17.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -100,10 +96,7 @@
|
|||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
"arch": [
|
"arch": ["arm64", "x64"]
|
||||||
"arm64",
|
|
||||||
"x64"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -111,6 +104,6 @@
|
|||||||
"buildResources": "resources",
|
"buildResources": "resources",
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
"publish": null
|
"publish": "never"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export async function startDir(dir: WatchedDir): Promise<void> {
|
|||||||
ignoreInitial: false,
|
ignoreInitial: false,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
depth: dir.recursive ? undefined : 0,
|
depth: dir.recursive ? undefined : 0,
|
||||||
|
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
||||||
ignored: /(^|[/\\])\../,
|
ignored: /(^|[/\\])\../,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"baseUrl": "."
|
"baseUrl": "."
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "out", "dist", "dist-web", "src/renderer/**", "web/**"]
|
"exclude": ["node_modules", "out", "dist", "src/renderer/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="macOS에서 NFD로 깨진 한글 파일명을 NFC로 변환하는 웹 도구. 설치 없이 브라우저에서 바로 사용."
|
|
||||||
/>
|
|
||||||
<title>NFD → NFC 파일명 변환기</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
349
web/src/App.tsx
349
web/src/App.tsx
@@ -1,349 +0,0 @@
|
|||||||
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<HTMLLabelElement>) => {
|
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
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<HTMLLabelElement>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
if (state.kind === 'idle') dispatch({ type: 'drag', active: true });
|
|
||||||
};
|
|
||||||
const onDragLeave = (ev: DragEvent<HTMLLabelElement>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
if (state.kind === 'idle') dispatch({ type: 'drag', active: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<header>
|
|
||||||
<h1>NFD → NFC 파일명 변환기</h1>
|
|
||||||
<p>
|
|
||||||
macOS 에서 만들어진 ZIP 이나 폴더의 한글 파일명이 Windows / Linux 에서 깨져 보이는 문제를
|
|
||||||
해결합니다. 파일이나 폴더를 아래 영역에 끌어다 놓으면 NFC 로 정규화된 ZIP 을 다운로드할 수
|
|
||||||
있습니다. 업로드되는 파일은 서버로 전송되지 않고 브라우저 안에서만 처리됩니다.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{state.kind === 'idle' && (
|
|
||||||
<IdleView
|
|
||||||
dragActive={state.dragActive}
|
|
||||||
error={state.error}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onFileInput={handleFileInput}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{state.kind === 'reading' && <ReadingView />}
|
|
||||||
{state.kind === 'processing' && (
|
|
||||||
<ProcessingView collected={state.collected} progress={state.progress} />
|
|
||||||
)}
|
|
||||||
{state.kind === 'ready' && (
|
|
||||||
<ReadyView
|
|
||||||
collected={state.collected}
|
|
||||||
blob={state.blob}
|
|
||||||
filename={state.filename}
|
|
||||||
onReset={() => dispatch({ type: 'reset' })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p>
|
|
||||||
모든 변환은 브라우저에서 로컬로 수행됩니다. 큰 폴더(약 2GB 이상)는 메모리 한계로 실패할
|
|
||||||
수 있습니다.
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IdleView(props: {
|
|
||||||
dragActive: boolean;
|
|
||||||
error?: string;
|
|
||||||
onDrop: (ev: DragEvent<HTMLLabelElement>) => void;
|
|
||||||
onDragOver: (ev: DragEvent<HTMLLabelElement>) => void;
|
|
||||||
onDragLeave: (ev: DragEvent<HTMLLabelElement>) => void;
|
|
||||||
onFileInput: (ev: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<label
|
|
||||||
className={'dropzone' + (props.dragActive ? ' active' : '')}
|
|
||||||
onDrop={props.onDrop}
|
|
||||||
onDragOver={props.onDragOver}
|
|
||||||
onDragLeave={props.onDragLeave}
|
|
||||||
>
|
|
||||||
<h2>여기에 파일이나 폴더를 끌어다 놓으세요</h2>
|
|
||||||
<p>
|
|
||||||
또는 클릭하여 폴더를 선택할 수 있습니다.
|
|
||||||
<br />
|
|
||||||
드롭한 파일은 NFC 이름으로 정규화되어 하나의 ZIP 으로 묶입니다.
|
|
||||||
</p>
|
|
||||||
<p className="hint">한글이 없는 파일은 이름을 그대로 유지합니다.</p>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
/* @ts-expect-error — webkitdirectory 는 React 타입 정의가 누락된 비표준 속성. */
|
|
||||||
webkitdirectory=""
|
|
||||||
directory=""
|
|
||||||
multiple
|
|
||||||
onChange={props.onFileInput}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{props.error && <div className="error">{props.error}</div>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReadingView() {
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<h2>파일 목록을 읽는 중…</h2>
|
|
||||||
<p className="progress-label">큰 폴더는 시간이 걸릴 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProcessingView(props: {
|
|
||||||
collected: CollectResult;
|
|
||||||
progress: BuildProgress;
|
|
||||||
}) {
|
|
||||||
const pct =
|
|
||||||
props.progress.total === 0 ? 0 : Math.round((props.progress.done / props.progress.total) * 100);
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<h2>ZIP 으로 묶는 중…</h2>
|
|
||||||
<div className="progress-track">
|
|
||||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="progress-label">
|
|
||||||
{props.progress.done.toLocaleString()} / {props.progress.total.toLocaleString()} 파일
|
|
||||||
{' · '}
|
|
||||||
{pct}%
|
|
||||||
</div>
|
|
||||||
<div className="stats">
|
|
||||||
<span>
|
|
||||||
<strong>{props.collected.normalizedCount.toLocaleString()}</strong>변환 대상
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{props.collected.passthroughCount.toLocaleString()}</strong>그대로 유지
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="status">
|
|
||||||
<h2>
|
|
||||||
{hasNormalizable ? '완료되었습니다.' : '변환할 파일이 없습니다.'}
|
|
||||||
</h2>
|
|
||||||
<div className="stats">
|
|
||||||
<span>
|
|
||||||
<strong>{collected.normalizedCount.toLocaleString()}</strong>이름 변경
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{collected.passthroughCount.toLocaleString()}</strong>그대로 유지
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasFiles && (
|
|
||||||
<div className="warn">
|
|
||||||
드롭한 항목에서 처리할 파일을 찾지 못했습니다. 폴더가 비어있거나 형식이 지원되지 않을 수
|
|
||||||
있습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasFiles && !hasNormalizable && (
|
|
||||||
<div className="success">
|
|
||||||
모든 파일명이 이미 NFC 입니다. 별도의 변환이 필요하지 않습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collected.collisions.length > 0 && (
|
|
||||||
<div className="warn">
|
|
||||||
정규화 후 같은 이름이 된 파일 {collected.collisions.length} 개를 자동으로 <code>-2</code>,
|
|
||||||
<code>-3</code> ... 형태로 이름을 변경했습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="actions">
|
|
||||||
{blob && hasFiles && (
|
|
||||||
<button className="primary" onClick={() => downloadBlob(blob, filename)}>
|
|
||||||
ZIP 다운로드 ({formatSize(blob.size)})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className="link" onClick={onReset}>
|
|
||||||
다시 시작
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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<Blob> {
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
|
|
||||||
const zipDone = new Promise<void>((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<ArrayBufferLike> 의 TypeScript 5.7+ 변경 때문에 BlobPart 직접 매칭이 실패할 수 있어 캐스팅.
|
|
||||||
return new Blob(chunks as BlobPart[], { type: 'application/zip' });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pipeFileToEntry(file: File, entry: ZipPassThrough): Promise<void> {
|
|
||||||
// 일부 브라우저(특히 구버전) 에서 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);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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<CollectResult> {
|
|
||||||
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 type="file" webkitdirectory>` 또는 일반 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<void> {
|
|
||||||
if (entry.isFile) {
|
|
||||||
const file = await new Promise<File>((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<FileSystemEntry[]>((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<string>();
|
|
||||||
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>): 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}`;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// 브라우저 환경 전용 정규화 헬퍼.
|
|
||||||
// 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');
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { App } from './App';
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
: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;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user