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..9ddb99f --- /dev/null +++ b/apps/zpl-viewer/package.json @@ -0,0 +1,34 @@ +{ + "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", + "axios": "^1.13.6", + "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..95d2e74 --- /dev/null +++ b/apps/zpl-viewer/src/App.tsx @@ -0,0 +1,187 @@ +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 { fetchLabelaryPng } from '@/api/labelary'; +import type { + Example, + FetchLabelaryPngParams, + LabelaryAccept, + LabelaryDpmm, +} from '@/types/zpl-preview'; +import examples from '@/data/examples.json'; + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +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 [isDownloading, setIsDownloading] = useState(false); + + 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]); + + const isPreviewDisabled = isPending || retryCountdown > 0 || !zpl.trim(); + const canDownload = !!zpl.trim() && !isPending && !isDownloading; + + const handleDownloadZpl = () => { + const blob = new Blob([zpl], { type: 'text/plain' }); + downloadBlob(blob, 'label.zpl'); + }; + + const handleDownloadLabelary = async (accept: LabelaryAccept, filename: string) => { + setIsDownloading(true); + try { + const params: FetchLabelaryPngParams = { zpl, widthMm, heightMm, dpmm }; + const blob = await fetchLabelaryPng(params, undefined, accept); + downloadBlob(blob, filename); + } catch { + // silent — preview error UI handles feedback + } finally { + setIsDownloading(false); + } + }; + + 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..742902d --- /dev/null +++ b/apps/zpl-viewer/src/api/labelary.ts @@ -0,0 +1,117 @@ +import axios, { isAxiosError, isCancel } from 'axios'; + +import { mmToInch } from '@/lib/units'; +import type { + FetchLabelaryPngParams, + LabelaryAccept, + LabelaryDpmm, + ZplPreviewError, +} from '@/types/zpl-preview'; + +const LABEL_MAX_MM = 381; + +const labelaryClient = axios.create({ + baseURL: 'https://api.labelary.com/v1/printers', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + responseType: 'blob', + timeout: 5000, +}); + +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, + accept: LabelaryAccept = 'image/png' +): 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 = mmToInch(dims.widthMm); + const heightInch = mmToInch(dims.heightMm); + const path = `/${dpmm}dpmm/labels/${widthInch}x${heightInch}/0/`; + + try { + const res = await labelaryClient.post(path, zpl, { + headers: { Accept: accept }, + signal, + }); + + return res.data; + } catch (e) { + if (isCancel(e) || (e instanceof Error && e.name === 'AbortError')) throw e; + + if (isAxiosError(e)) { + if (e.code === 'ECONNABORTED') throw e; + + const status = e.response?.status; + + if (status === 429) { + const retryAfterSeconds = parseRetryAfterSeconds( + e.response?.headers['retry-after'] ?? null + ); + const err: ZplPreviewError = { + code: 'RATE_LIMIT', + message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요', + retryAfterSeconds, + }; + throw err; + } + + let message = `오류가 발생했습니다 (HTTP ${status})`; + try { + const text = await (e.response?.data as Blob).text(); + if (text) message = text.substring(0, 200); + } catch { + // ignore + } + const err: ZplPreviewError = { code: 'BAD_REQUEST', message }; + throw err; + } + + const err: ZplPreviewError = { code: 'NETWORK', message: '네트워크 오류가 발생했습니다' }; + throw err; + } +} 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 ( +