From 7566df183b5951ce15d5c98476ba82c282f05ed5 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Fri, 13 Mar 2026 19:51:49 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(zpl-viewer):=20ZPL=20=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20=EC=9B=B9=20=EC=95=B1=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - React, Vite, TanStack Query, TypeScript 기본 구조 - ESLint, Prettier 설정 - vite-env.d.ts 타입 선언 --- apps/zpl-viewer/.gitignore | 24 ++++++++++++++++++++++ apps/zpl-viewer/.prettierrc | 7 +++++++ apps/zpl-viewer/eslint.config.js | 14 +++++++++++++ apps/zpl-viewer/index.html | 12 +++++++++++ apps/zpl-viewer/package.json | 33 ++++++++++++++++++++++++++++++ apps/zpl-viewer/src/App.tsx | 5 +++++ apps/zpl-viewer/src/index.css | 28 +++++++++++++++++++++++++ apps/zpl-viewer/src/main.tsx | 15 ++++++++++++++ apps/zpl-viewer/src/vite-env.d.ts | 1 + apps/zpl-viewer/tsconfig.json | 25 ++++++++++++++++++++++ apps/zpl-viewer/tsconfig.node.json | 11 ++++++++++ apps/zpl-viewer/vite.config.ts | 20 ++++++++++++++++++ 12 files changed, 195 insertions(+) create mode 100644 apps/zpl-viewer/.gitignore create mode 100644 apps/zpl-viewer/.prettierrc create mode 100644 apps/zpl-viewer/eslint.config.js create mode 100644 apps/zpl-viewer/index.html create mode 100644 apps/zpl-viewer/package.json create mode 100644 apps/zpl-viewer/src/App.tsx create mode 100644 apps/zpl-viewer/src/index.css create mode 100644 apps/zpl-viewer/src/main.tsx create mode 100644 apps/zpl-viewer/src/vite-env.d.ts create mode 100644 apps/zpl-viewer/tsconfig.json create mode 100644 apps/zpl-viewer/tsconfig.node.json create mode 100644 apps/zpl-viewer/vite.config.ts diff --git a/apps/zpl-viewer/.gitignore b/apps/zpl-viewer/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/apps/zpl-viewer/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/zpl-viewer/.prettierrc b/apps/zpl-viewer/.prettierrc new file mode 100644 index 0000000..1f4c4bb --- /dev/null +++ b/apps/zpl-viewer/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/apps/zpl-viewer/eslint.config.js b/apps/zpl-viewer/eslint.config.js new file mode 100644 index 0000000..a34e06e --- /dev/null +++ b/apps/zpl-viewer/eslint.config.js @@ -0,0 +1,14 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + } +); diff --git a/apps/zpl-viewer/index.html b/apps/zpl-viewer/index.html new file mode 100644 index 0000000..6c46ee0 --- /dev/null +++ b/apps/zpl-viewer/index.html @@ -0,0 +1,12 @@ + + + + + + ZPL Viewer + + +
+ + + diff --git a/apps/zpl-viewer/package.json b/apps/zpl-viewer/package.json new file mode 100644 index 0000000..8c87c9d --- /dev/null +++ b/apps/zpl-viewer/package.json @@ -0,0 +1,33 @@ +{ + "name": "@zpl-kit/zpl-viewer", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tailwind-merge": "^2.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.0.0", + "prettier": "^3.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^5.4.0" + } +} \ No newline at end of file diff --git a/apps/zpl-viewer/src/App.tsx b/apps/zpl-viewer/src/App.tsx new file mode 100644 index 0000000..544badc --- /dev/null +++ b/apps/zpl-viewer/src/App.tsx @@ -0,0 +1,5 @@ +function App() { + return
App
; +} + +export default App; diff --git a/apps/zpl-viewer/src/index.css b/apps/zpl-viewer/src/index.css new file mode 100644 index 0000000..dd38dec --- /dev/null +++ b/apps/zpl-viewer/src/index.css @@ -0,0 +1,28 @@ +@import "tailwindcss"; + +@theme { + --color-background: oklch(1 0 0); + --color-foreground: oklch(0.145 0 0); + --color-muted: oklch(0.97 0 0); + --color-muted-foreground: oklch(0.556 0 0); + --color-border: oklch(0.922 0 0); + --color-input: oklch(0.922 0 0); + --color-primary: oklch(0.205 0 0); + --color-primary-foreground: oklch(0.985 0 0); + --color-destructive: oklch(0.577 0.245 27.325); + --color-destructive-foreground: oklch(0.985 0 0); + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: var(--color-background); + color: var(--color-foreground); +} diff --git a/apps/zpl-viewer/src/main.tsx b/apps/zpl-viewer/src/main.tsx new file mode 100644 index 0000000..9948045 --- /dev/null +++ b/apps/zpl-viewer/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './index.css'; + +const queryClient = new QueryClient(); + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/apps/zpl-viewer/src/vite-env.d.ts b/apps/zpl-viewer/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/zpl-viewer/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/zpl-viewer/tsconfig.json b/apps/zpl-viewer/tsconfig.json new file mode 100644 index 0000000..c20738e --- /dev/null +++ b/apps/zpl-viewer/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/zpl-viewer/tsconfig.node.json b/apps/zpl-viewer/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/apps/zpl-viewer/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/zpl-viewer/vite.config.ts b/apps/zpl-viewer/vite.config.ts new file mode 100644 index 0000000..53f5aba --- /dev/null +++ b/apps/zpl-viewer/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +export default defineConfig({ + plugins: [tailwindcss(), react()], + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + port: 3001, + open: true, + }, +}); From 76c8d1869a148580088cc10377d003d44608e605 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Fri, 13 Mar 2026 23:27:06 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(zpl-viewer):=20Labelary=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=B7=B0=EC=96=B4=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchLabelaryPng, useZplPreview 훅 추가 - ZplInput, ZplPreview, OptionsPanel 컴포넌트 분리 - examples.json 예제 데이터 및 zpl-preview 타입 정의 --- apps/zpl-viewer/src/App.tsx | 128 ++- apps/zpl-viewer/src/api/labelary.ts | 104 +++ .../src/components/OptionsPanel/index.tsx | 69 ++ .../src/components/ZplInput/index.tsx | 17 + .../src/components/ZplPreview/index.tsx | 81 ++ apps/zpl-viewer/src/data/examples.json | 23 + apps/zpl-viewer/src/hooks/use-zpl-preview.ts | 103 +++ apps/zpl-viewer/src/lib/utils.ts | 6 + apps/zpl-viewer/src/types/zpl-preview.ts | 20 + pnpm-lock.yaml | 839 +++++++++++++++++- 10 files changed, 1363 insertions(+), 27 deletions(-) create mode 100644 apps/zpl-viewer/src/api/labelary.ts create mode 100644 apps/zpl-viewer/src/components/OptionsPanel/index.tsx create mode 100644 apps/zpl-viewer/src/components/ZplInput/index.tsx create mode 100644 apps/zpl-viewer/src/components/ZplPreview/index.tsx create mode 100644 apps/zpl-viewer/src/data/examples.json create mode 100644 apps/zpl-viewer/src/hooks/use-zpl-preview.ts create mode 100644 apps/zpl-viewer/src/lib/utils.ts create mode 100644 apps/zpl-viewer/src/types/zpl-preview.ts diff --git a/apps/zpl-viewer/src/App.tsx b/apps/zpl-viewer/src/App.tsx index 544badc..40b25c9 100644 --- a/apps/zpl-viewer/src/App.tsx +++ b/apps/zpl-viewer/src/App.tsx @@ -1,5 +1,125 @@ -function App() { - return
App
; -} +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ZplInput } from '@/components/ZplInput'; +import { OptionsPanel } from '@/components/OptionsPanel'; +import { ZplPreview } from '@/components/ZplPreview'; +import { useZplPreview } from '@/hooks/use-zpl-preview'; +import type { Example, LabelaryDpmm } from '@/types/zpl-preview'; +import examples from '@/data/examples.json'; + +const EXAMPLES = examples as Example[]; +const THROTTLE_MS = 400; + +export default function App() { + const [zpl, setZpl] = useState(''); + const [widthMm, setWidthMm] = useState(75); + const [heightMm, setHeightMm] = useState(50); + const [dpmm, setDpmm] = useState(8); + + const lastTriggerRef = useRef(0); + + const { trigger, clearPreview, imageUrl, isPending, error, retryCountdown } = useZplPreview(); + + const handleExampleSelect = (example: Example) => { + setZpl(example.zpl); + setWidthMm(example.widthMm); + setHeightMm(example.heightMm); + clearPreview(); + }; + + const handleZplChange = (value: string) => { + setZpl(value); + clearPreview(); + }; + + const handlePreview = useCallback(() => { + const now = Date.now(); + if (now - lastTriggerRef.current < THROTTLE_MS) return; + lastTriggerRef.current = now; + trigger({ zpl, widthMm, heightMm, dpmm }); + }, [trigger, zpl, widthMm, heightMm, dpmm]); + + // Cmd/Ctrl+Enter + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + handlePreview(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handlePreview]); -export default App; + const isPreviewDisabled = isPending || retryCountdown > 0 || !zpl.trim(); + + return ( +
+
+

zpl-viewer

+
+ +
+
+ {/* Examples sidebar */} + + +
+ {/* Center: Input */} +
+ + + + + + +

+ ZPL 데이터는 미리보기 생성을 위해 Labelary(labelary.com)로 전송되며 최대 60일간 + 보존될 수 있습니다. +

+
+ + {/* Right: Preview */} + +
+
+
+
+ ); +} diff --git a/apps/zpl-viewer/src/api/labelary.ts b/apps/zpl-viewer/src/api/labelary.ts new file mode 100644 index 0000000..780f29a --- /dev/null +++ b/apps/zpl-viewer/src/api/labelary.ts @@ -0,0 +1,104 @@ +import type { FetchLabelaryPngParams, LabelaryDpmm, ZplPreviewError } from '@/types/zpl-preview'; + +const LABEL_MAX_MM = 381; +const LABELARY_BASE = 'https://api.labelary.com/v1/printers'; + +function validateLabelDimensions( + w: unknown, + h: unknown +): { widthMm: number; heightMm: number } | null { + if (!Number.isFinite(w) || !Number.isFinite(h)) return null; + const widthMm = Number(w); + const heightMm = Number(h); + if (widthMm < 1 || widthMm > LABEL_MAX_MM || heightMm < 1 || heightMm > LABEL_MAX_MM) + return null; + return { widthMm, heightMm }; +} + +function isValidDpmm(value: unknown): value is LabelaryDpmm { + return value === 6 || value === 8 || value === 12 || value === 24; +} + +export function parseRetryAfterSeconds(header: string | null): number { + const raw = header ? parseInt(header, 10) : NaN; + return Math.min(300, Math.max(2, isNaN(raw) || raw < 0 ? 2 : raw)); +} + +export async function fetchLabelaryPng( + params: FetchLabelaryPngParams, + signal?: AbortSignal +): Promise { + const { zpl, widthMm, heightMm, dpmm } = params; + + if (!zpl.trim()) { + const err: ZplPreviewError = { code: 'EMPTY_ZPL', message: 'ZPL을 입력해 주세요' }; + throw err; + } + + const byteLength = new TextEncoder().encode(zpl).length; + if (byteLength > 1024 * 1024) { + const err: ZplPreviewError = { code: 'BAD_REQUEST', message: 'ZPL 크기가 1MB를 초과합니다' }; + throw err; + } + + const dims = validateLabelDimensions(widthMm, heightMm); + if (!dims) { + const err: ZplPreviewError = { + code: 'BAD_REQUEST', + message: '라벨 크기가 유효하지 않습니다 (1~381mm)', + }; + throw err; + } + + if (!isValidDpmm(dpmm)) { + const err: ZplPreviewError = { code: 'BAD_REQUEST', message: '잘못된 dpmm 값입니다' }; + throw err; + } + + const widthInch = dims.widthMm / 25.4; + const heightInch = dims.heightMm / 25.4; + const url = `${LABELARY_BASE}/${dpmm}dpmm/labels/${widthInch}x${heightInch}/0/`; + + const timeoutSignal = AbortSignal.timeout(5000); + const effectiveSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: zpl, + signal: effectiveSignal, + }); + } catch (e) { + if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError')) { + throw e; + } + const err: ZplPreviewError = { code: 'NETWORK', message: '네트워크 오류가 발생했습니다' }; + throw err; + } + + if (res.status === 429) { + const retryAfterSeconds = parseRetryAfterSeconds(res.headers.get('Retry-After')); + const err: ZplPreviewError = { + code: 'RATE_LIMIT', + message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요', + retryAfterSeconds, + }; + throw err; + } + + if (!res.ok) { + let message = `오류가 발생했습니다 (HTTP ${res.status})`; + try { + const text = await res.text(); + if (text) message = text.substring(0, 200); + } catch { + // ignore + } + const err: ZplPreviewError = { code: 'BAD_REQUEST', message }; + throw err; + } + + return res.blob(); +} diff --git a/apps/zpl-viewer/src/components/OptionsPanel/index.tsx b/apps/zpl-viewer/src/components/OptionsPanel/index.tsx new file mode 100644 index 0000000..1c9d445 --- /dev/null +++ b/apps/zpl-viewer/src/components/OptionsPanel/index.tsx @@ -0,0 +1,69 @@ +import type { LabelaryDpmm } from '@/types/zpl-preview'; + +const DPMM_OPTIONS: LabelaryDpmm[] = [6, 8, 12, 24]; + +interface OptionsPanelProps { + widthMm: number; + heightMm: number; + dpmm: LabelaryDpmm; + onWidthChange: (v: number) => void; + onHeightChange: (v: number) => void; + onDpmmChange: (v: LabelaryDpmm) => void; +} + +export function OptionsPanel({ + widthMm, + heightMm, + dpmm, + onWidthChange, + onHeightChange, + onDpmmChange, +}: OptionsPanelProps) { + return ( +
+ + + + + +
+ ); +} diff --git a/apps/zpl-viewer/src/components/ZplInput/index.tsx b/apps/zpl-viewer/src/components/ZplInput/index.tsx new file mode 100644 index 0000000..e3420b9 --- /dev/null +++ b/apps/zpl-viewer/src/components/ZplInput/index.tsx @@ -0,0 +1,17 @@ +interface ZplInputProps { + zpl: string; + onZplChange: (zpl: string) => void; +} + +export function ZplInput({ zpl, onZplChange }: ZplInputProps) { + return ( +