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 (
+
+
+
+
+
+ {/* 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 (
+