/* global React */ /* ============================================================ lib.jsx — shared utilities - i18n strings (zh / en) - theme (light / dark) with localStorage - history (recent conversions) with localStorage - hash router hook - dropzone hook - icons - file helpers ============================================================ */ const { useState, useEffect, useCallback, useRef, useMemo, createContext, useContext } = React; /* ---------------------------- TOOLS METADATA ---------------------------- */ const TOOLS = [ { id: 'cli-to-markdown', hue: 'cli', initials: 'CLI', nameKey: 'tool_cli_md', descKey: 'tool_cli_md_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['paste'], accepts: '', }, { id: 'chat-to-markdown', hue: 'chat', initials: 'CHAT', nameKey: 'tool_chat_md', descKey: 'tool_chat_md_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['paste'], accepts: '', }, { id: 'ai-to-markdown', hue: 'ai-md', initials: 'AI', nameKey: 'tool_ai_md', descKey: 'tool_ai_md_desc', badge: 'beta', badgeKey: 'badge_beta', inputs: ['paste', 'file'], accepts: '.txt,.md,.markdown,.log,text/plain', }, { id: 'pdf-to-markdown', hue: 'pdf-md', initials: 'PDF', nameKey: 'tool_pdf_md', descKey: 'tool_pdf_md_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['file'], accepts: 'application/pdf,.pdf', }, { id: 'pdf-to-excel', hue: 'pdf-xlsx', initials: 'P→X', nameKey: 'tool_pdf_xlsx', descKey: 'tool_pdf_xlsx_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['file'], accepts: 'application/pdf,.pdf', }, { id: 'docx-to-markdown', hue: 'docx', initials: 'DOC', nameKey: 'tool_docx_md', descKey: 'tool_docx_md_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['file'], accepts: '.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document', }, { id: 'excel-to-pdf', hue: 'xlsx-pdf', initials: 'X→P', nameKey: 'tool_xlsx_pdf', descKey: 'tool_xlsx_pdf_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['file'], accepts: '.xlsx,.xls,.csv', }, { id: 'markdown-to-html', hue: 'md-html', initials: 'MD', nameKey: 'tool_md_html', descKey: 'tool_md_html_desc', badge: 'live', badgeKey: 'badge_live', inputs: ['paste', 'file'], accepts: '.md,.markdown,text/markdown,text/plain', }, { id: 'image-to-text', hue: 'ocr', initials: 'OCR', nameKey: 'tool_ocr', descKey: 'tool_ocr_desc', badge: 'beta', badgeKey: 'badge_beta', inputs: ['file'], accepts: 'image/*', }, ]; const TOOL_BY_ID = Object.fromEntries(TOOLS.map(t => [t.id, t])); /* ---------------------------- i18n ---------------------------- */ const STRINGS = { zh: { brand: 'Convertly', brand_sub: 'DOC SUITE', nav_hub: '工具集', crumb_hub: '工具集', hub_eyebrow: '本地浏览器内运行 · 无需上传', hub_title_pre: '文档转换', hub_title_em: '工具套件', hub_sub: '把 Claude CLI 输出、PDF、Word、Excel、Markdown、图片之间互相转换。文件不离开你的设备 —— 一切在浏览器里完成。', hub_search_placeholder: '搜索工具…', section_tools: '所有工具', section_tools_meta: 'TOOLS', section_history: '最近转换', section_history_meta: 'HISTORY', history_empty: '还没有转换记录。从上方选一个工具开始吧。', history_clear: '清空', crumb_sep: '/', common_paste_input: '粘贴输入', common_input: '输入', common_output: '输出', common_preview: '预览', common_markdown: 'Markdown', common_html: 'HTML', common_copy: '复制', common_copied: '✓ 已复制', common_clear: '清空', common_download: '下载', common_load_sample: '加载示例', common_convert: '开始转换', common_options: '选项', common_ready: '就绪', common_busy: '处理中', common_error: '错误', common_chars: '字符', common_lines: '行', common_tables: '表格', common_pages: '页', common_files: '个文件', common_size: '大小', common_select_file: '选择文件', common_or_drop: '或拖拽到这里', common_drop_here: '松开以上传', common_browse: '浏览…', common_remove: '移除', common_run_again: '重新转换', common_rendered_preview: '渲染预览', common_source: '源码', common_in_notebook: '粘贴到笔记本中的效果', common_text: '文本', common_view_table: '表格', common_view_csv: 'CSV', // Tools — names & descriptions tool_cli_md: 'CLI → Markdown', tool_cli_md_desc: '把 Claude CLI 输出 (含表格、代码、emoji 标题) 转为干净 Markdown', tool_chat_md: 'Chat → Markdown', tool_chat_md_desc: '把 Claude/ChatGPT 网页版 chat 里的自然语言段落转为结构化 Markdown(识别标题、key:value、占位符)', opt_chat_h1: '首行 → H1', opt_chat_h2: '裸行 → H2', opt_chat_inline_code: 'key:value 自动 backtick', opt_chat_fence: '<placeholder> 自动 fence', chat_input_placeholder: '粘贴 chat 段落,例如带"Volterra"标题 + bullet list + <placeholder> 描述的文字…', // AI → Markdown tool_ai_md: 'AI → Markdown', tool_ai_md_desc: '让大模型(Ollama / OpenAI / Claude)理解你的文本,输出结构化 Markdown —— 突破固定规则', ai_input_placeholder: '粘贴任意文本,或拖入 .txt / .md / .log 文件,点「开始转换」让模型理解并补上 Markdown 结构…', ai_convert: '开始转换', ai_stop: '停止', ai_streaming: '生成中…', ai_provider: '供应商', ai_extra: '额外指令', ai_extra_ph: '可选,如「用中文标题」「保留 emoji」', ai_continue: '继续指示(基于上次结果精修)', ai_continue_ph: '如「把第二节改成表格」', ai_continue_btn: '继续精修', ai_settings: 'AI 设置', ai_tidy: 'AI 整理', ai_open_settings: '打开 AI 设置', ai_provider_ollama: 'Ollama 本地', ai_provider_openai: 'OpenAI 兼容', ai_provider_anthropic: 'Anthropic Claude', ai_set_title: 'AI 模型设置', ai_set_security: 'API Key 仅保存在你的浏览器本地(localStorage),不会发往 Convertly 服务器。托管使用时请视为已暴露。', ai_set_active: '当前使用', ai_set_transport: '连接方式', ai_set_transport_direct: '直连', ai_set_transport_proxy: '经服务器代理', ai_set_transport_proxy_na: '(代理仅在托管部署可用;Ollama 在本机,不适用)', ai_set_baseurl: 'Base URL', ai_set_apikey: 'API Key', ai_set_model: '模型', ai_set_loading_models: '加载模型列表…', ai_set_refresh_models: '刷新模型列表', ai_set_test: '测试连接', ai_set_testing: '测试中…', ai_set_test_ok: '✓ 连接成功', ai_set_test_fail: '✗ 失败', ai_set_save: '保存', ai_set_close: '关闭', ai_usage_tokens: 'tokens', ai_err_aborted: '已停止,保留已生成的部分。', ai_err_auth: 'API Key 无效或被拒绝(401/403),请检查 Key。', ai_err_http: '请求失败:', ai_err_cors: 'CORS / 连接失败。可尝试:换用中转站、本地运行 Convertly、或在「AI 设置」里改用「经服务器代理」。', ai_err_ollama: '连不上 Ollama。请确认 ollama serve 已运行;从线上 HTTPS 站访问需设 OLLAMA_ORIGINS=<本站域名>,且 Safari 不支持 localhost。', ai_err_network: '网络错误:', ai_err_nokey: '请先在「AI 设置」里填入该供应商的 API Key。', tool_pdf_md: 'PDF → Markdown', tool_pdf_md_desc: '提取文章型 PDF 的正文,保留标题层级和段落', tool_pdf_xlsx: 'PDF → Excel', tool_pdf_xlsx_desc: '识别 PDF 中的表格,导出为 Excel / CSV', tool_docx_md: 'Word → Markdown', tool_docx_md_desc: '把 DOCX 文档转换为 Markdown,保留标题、列表、表格', tool_xlsx_pdf: 'Excel → PDF', tool_xlsx_pdf_desc: 'Excel/CSV 渲染为可打印的 PDF', tool_md_html: 'Markdown → HTML/DOCX', tool_md_html_desc: '把 Markdown 渲染为 HTML 或导出为 DOCX', tool_ocr: '图片 → 文字 (OCR)', tool_ocr_desc: '从图片提取文字,支持中英文', badge_live: '可用', badge_beta: '测试', badge_soon: '即将', // CLI specific cli_input_placeholder: '粘贴 Claude CLI 输出…\n\n表格、emoji 标题、工具调用会自动识别为 Markdown。', opt_headers: '标题风格', opt_headers_auto: '自动 (## / ###)', opt_headers_bold: '仅加粗', opt_headers_none: '纯文本', opt_fence_code: '工具调用包代码块', opt_fence_code_hint: 'mcp__, npm, $ …', opt_tight_lists: '紧凑列表', opt_strip_emojis: '去除 emoji', // PDF pdf_drop_title: '拖入 PDF 文件', pdf_drop_sub: '在浏览器中提取文字 (不上传到任何服务器)', pdf_processing: '正在解析 PDF…', pdf_pages: '页', pdf_md_opt_pagebreaks: '保留分页', pdf_md_opt_pagebreaks_hint: '页之间插入 ---', pdf_md_opt_detect_headings: '智能识别标题', pdf_xlsx_opt_join_lines: '合并相邻行表格', // DOCX docx_drop_title: '拖入 Word 文档 (.docx)', docx_drop_sub: '保留标题、列表、表格和加粗格式', docx_processing: '正在解析 Word…', // Excel xlsx_drop_title: '拖入 Excel / CSV 文件', xlsx_drop_sub: '支持 .xlsx, .xls, .csv', xlsx_processing: '正在解析…', xlsx_sheets: '工作表', xlsx_rows: '行', xlsx_cols: '列', xlsx_pdf_print: '打印 / 保存为 PDF', // MD md_input_placeholder: '在此输入 Markdown…\n\n或者拖入 .md 文件。', md_export_html: '下载 HTML', md_export_docx: '下载 DOCX', // OCR ocr_drop_title: '拖入图片', ocr_drop_sub: '支持 PNG / JPG / WEBP,识别中英文', ocr_lang: '识别语言', ocr_lang_eng: '英文', ocr_lang_chi: '简体中文', ocr_lang_both: '中英混合', ocr_loading_engine: '正在加载 OCR 引擎 (首次较慢)…', ocr_processing: '正在识别…', ocr_progress: '进度', // Tweaks tweak_card_style: '工具卡片风格', tweak_card_style_soft: '柔和 (默认)', tweak_card_style_flat: '极简', tweak_card_style_tinted: '色调', tweak_card_style_bold: '醒目', tweak_layout: '转换页布局', tweak_layout_horiz: '左右分屏', tweak_layout_vert: '上下分屏', tweak_md_style: 'Markdown 风格', tweak_md_style_gfm: 'GitHub (GFM)', tweak_md_style_plain: '朴素', tweak_md_style_emoji: '保留 emoji', tweak_dropzone: '拖拽区视觉', tweak_dropzone_default: '默认', tweak_dropzone_minimal: '极简', tweak_dropzone_grid: '网格纸', tweak_dropzone_blueprint: '蓝图', tweak_density: '密度', tweak_density_comfy: '舒适', tweak_density_compact: '紧凑', untitled: '未命名', just_now: '刚刚', minutes_ago: '分钟前', hours_ago: '小时前', days_ago: '天前', placeholder_input_first: '从左侧粘贴或上传后,输出会出现在这里。', placeholder_paste_first: '粘贴内容后,转换结果会出现在这里。', placeholder_no_content: '尚无内容。', download_filename: '转换结果', error_unsupported: '不支持的文件类型', error_parse: '解析失败', }, en: { brand: 'Convertly', brand_sub: 'DOC SUITE', nav_hub: 'Tools', crumb_hub: 'Tools', hub_eyebrow: 'Runs entirely in your browser · no uploads', hub_title_pre: 'Document conversion', hub_title_em: 'toolkit', hub_sub: 'Convert between Claude CLI output, PDFs, Word, Excel, Markdown and images — all locally in the browser. Files never leave your device.', hub_search_placeholder: 'Search tools…', section_tools: 'All tools', section_tools_meta: 'TOOLS', section_history: 'Recent conversions', section_history_meta: 'HISTORY', history_empty: 'No conversions yet. Pick a tool above to start.', history_clear: 'Clear', crumb_sep: '/', common_paste_input: 'Paste input', common_input: 'Input', common_output: 'Output', common_preview: 'Preview', common_markdown: 'Markdown', common_html: 'HTML', common_copy: 'Copy', common_copied: '✓ Copied', common_clear: 'Clear', common_download: 'Download', common_load_sample: 'Load sample', common_convert: 'Convert', common_options: 'Options', common_ready: 'Ready', common_busy: 'Working', common_error: 'Error', common_chars: 'chars', common_lines: 'lines', common_tables: 'tables', common_pages: 'pages', common_files: 'files', common_size: 'size', common_select_file: 'Select file', common_or_drop: 'or drop here', common_drop_here: 'Release to upload', common_browse: 'Browse…', common_remove: 'Remove', common_run_again: 'Convert again', common_rendered_preview: 'Rendered preview', common_source: 'Source', common_in_notebook: 'How it looks in your notebook', common_text: 'Text', common_view_table: 'Table', common_view_csv: 'CSV', tool_cli_md: 'CLI → Markdown', tool_cli_md_desc: 'Convert Claude CLI output (boxes, emoji headers, tool calls) into clean Markdown', tool_chat_md: 'Chat → Markdown', tool_chat_md_desc: 'Convert prose from Claude/ChatGPT web chat into structured Markdown — detects headings, key:value patterns, <placeholder> segments', opt_chat_h1: 'First line → H1', opt_chat_h2: 'Bare lines → H2', opt_chat_inline_code: 'Auto-backtick key:value', opt_chat_fence: 'Auto-fence <placeholder>', chat_input_placeholder: 'Paste chat-style text — e.g. with "Volterra" headings + bullets + <placeholder> segments…', // AI → Markdown tool_ai_md: 'AI → Markdown', tool_ai_md_desc: 'Let an LLM (Ollama / OpenAI / Claude) understand your text and output structured Markdown — beyond fixed rules', ai_input_placeholder: 'Paste any text, or drop a .txt / .md / .log file, then hit Convert to let the model understand it and add Markdown structure…', ai_convert: 'Convert', ai_stop: 'Stop', ai_streaming: 'Generating…', ai_provider: 'Provider', ai_extra: 'Extra instructions', ai_extra_ph: 'Optional, e.g. "use Chinese headings", "keep emojis"', ai_continue: 'Refine (based on last result)', ai_continue_ph: 'e.g. "turn section 2 into a table"', ai_continue_btn: 'Refine', ai_settings: 'AI settings', ai_tidy: 'AI tidy', ai_open_settings: 'Open AI settings', ai_provider_ollama: 'Ollama (local)', ai_provider_openai: 'OpenAI-compatible', ai_provider_anthropic: 'Anthropic Claude', ai_set_title: 'AI model settings', ai_set_security: 'Your API key is stored only in this browser (localStorage) and is never sent to Convertly\'s server. Treat it as exposed when using the hosted site.', ai_set_active: 'Active', ai_set_transport: 'Connection', ai_set_transport_direct: 'Direct', ai_set_transport_proxy: 'Via server proxy', ai_set_transport_proxy_na: '(Proxy only on hosted deploy; N/A for Ollama on your machine)', ai_set_baseurl: 'Base URL', ai_set_apikey: 'API Key', ai_set_model: 'Model', ai_set_loading_models: 'loading models…', ai_set_refresh_models: 'Refresh model list', ai_set_test: 'Test connection', ai_set_testing: 'Testing…', ai_set_test_ok: '✓ Connected', ai_set_test_fail: '✗ Failed', ai_set_save: 'Save', ai_set_close: 'Close', ai_usage_tokens: 'tokens', ai_err_aborted: 'Stopped. Kept the partial output.', ai_err_auth: 'API key invalid or rejected (401/403). Check your key.', ai_err_http: 'Request failed: ', ai_err_cors: 'CORS / connection failed. Try: a relay endpoint, running Convertly locally, or switch to "Via server proxy" in AI settings.', ai_err_ollama: 'Cannot reach Ollama. Make sure `ollama serve` is running; from a hosted HTTPS site set OLLAMA_ORIGINS=, and note Safari blocks localhost.', ai_err_network: 'Network error: ', ai_err_nokey: 'Set this provider\'s API key in AI settings first.', tool_pdf_md: 'PDF → Markdown', tool_pdf_md_desc: 'Extract body text from article-style PDFs with heading detection', tool_pdf_xlsx: 'PDF → Excel', tool_pdf_xlsx_desc: 'Detect tables in PDFs and export to Excel / CSV', tool_docx_md: 'Word → Markdown', tool_docx_md_desc: 'Convert DOCX documents to Markdown — preserves headings, lists, tables', tool_xlsx_pdf: 'Excel → PDF', tool_xlsx_pdf_desc: 'Render Excel/CSV files as printable PDFs', tool_md_html: 'Markdown → HTML/DOCX', tool_md_html_desc: 'Render Markdown to HTML or export as DOCX', tool_ocr: 'Image → Text (OCR)', tool_ocr_desc: 'Extract text from images, supports English and Chinese', badge_live: 'Live', badge_beta: 'Beta', badge_soon: 'Soon', cli_input_placeholder: 'Paste your Claude CLI output here…\n\nBox-drawing tables, emoji headers, and tool calls will be converted automatically.', opt_headers: 'Headers', opt_headers_auto: 'Auto (## / ###)', opt_headers_bold: 'Bold only', opt_headers_none: 'Plain text', opt_fence_code: 'Fence code calls', opt_fence_code_hint: 'mcp__, npm, $ …', opt_tight_lists: 'Tight lists', opt_strip_emojis: 'Strip emojis', pdf_drop_title: 'Drop a PDF here', pdf_drop_sub: 'Text is extracted locally — never uploaded.', pdf_processing: 'Parsing PDF…', pdf_pages: 'pages', pdf_md_opt_pagebreaks: 'Page breaks', pdf_md_opt_pagebreaks_hint: 'Insert --- between pages', pdf_md_opt_detect_headings: 'Detect headings', pdf_xlsx_opt_join_lines: 'Merge wrapped rows', docx_drop_title: 'Drop a Word document (.docx)', docx_drop_sub: 'Preserves headings, lists, tables and bold formatting.', docx_processing: 'Parsing Word…', xlsx_drop_title: 'Drop an Excel / CSV file', xlsx_drop_sub: 'Supports .xlsx, .xls, .csv', xlsx_processing: 'Parsing…', xlsx_sheets: 'sheets', xlsx_rows: 'rows', xlsx_cols: 'cols', xlsx_pdf_print: 'Print / Save as PDF', md_input_placeholder: 'Type or paste Markdown here…\n\nOr drop a .md file.', md_export_html: 'Download HTML', md_export_docx: 'Download DOCX', ocr_drop_title: 'Drop an image', ocr_drop_sub: 'PNG / JPG / WEBP — supports English & Chinese', ocr_lang: 'Language', ocr_lang_eng: 'English', ocr_lang_chi: 'Chinese', ocr_lang_both: 'English + Chinese', ocr_loading_engine: 'Loading OCR engine (slow first time)…', ocr_processing: 'Recognizing…', ocr_progress: 'Progress', tweak_card_style: 'Tool card style', tweak_card_style_soft: 'Soft (default)', tweak_card_style_flat: 'Flat', tweak_card_style_tinted: 'Tinted', tweak_card_style_bold: 'Bold', tweak_layout: 'Tool page layout', tweak_layout_horiz: 'Side-by-side', tweak_layout_vert: 'Stacked', tweak_md_style: 'Markdown style', tweak_md_style_gfm: 'GitHub (GFM)', tweak_md_style_plain: 'Plain', tweak_md_style_emoji: 'Keep emojis', tweak_dropzone: 'Dropzone visual', tweak_dropzone_default: 'Default', tweak_dropzone_minimal: 'Minimal', tweak_dropzone_grid: 'Graph paper', tweak_dropzone_blueprint: 'Blueprint', tweak_density: 'Density', tweak_density_comfy: 'Comfy', tweak_density_compact: 'Compact', untitled: 'Untitled', just_now: 'just now', minutes_ago: 'm ago', hours_ago: 'h ago', days_ago: 'd ago', placeholder_input_first: 'Paste or upload on the left to see output here.', placeholder_paste_first: 'Output appears here after you paste content.', placeholder_no_content: 'No content yet.', download_filename: 'converted', error_unsupported: 'Unsupported file type', error_parse: 'Parse failed', }, }; const I18nContext = createContext({ lang: 'zh', t: (k) => k, setLang: () => {} }); function I18nProvider({ children }) { const [lang, setLang] = useState(() => { return localStorage.getItem('cv_lang') || (navigator.language?.startsWith('zh') ? 'zh' : 'en'); }); useEffect(() => { localStorage.setItem('cv_lang', lang); document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh-CN' : 'en'); }, [lang]); const t = useCallback((key) => { return STRINGS[lang]?.[key] ?? STRINGS.en[key] ?? key; }, [lang]); const value = useMemo(() => ({ lang, setLang, t }), [lang, t]); return {children}; } function useI18n() { return useContext(I18nContext); } /* ---------------------------- Theme ---------------------------- */ const ThemeContext = createContext({ theme: 'light', toggle: () => {} }); function ThemeProvider({ children }) { const [theme, setTheme] = useState(() => { return localStorage.getItem('cv_theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); }); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('cv_theme', theme); }, [theme]); const toggle = useCallback(() => setTheme(t => t === 'light' ? 'dark' : 'light'), []); const value = useMemo(() => ({ theme, toggle, setTheme }), [theme, toggle]); return {children}; } function useTheme() { return useContext(ThemeContext); } /* ---------------------------- History ---------------------------- */ const HISTORY_KEY = 'cv_history_v1'; const HISTORY_MAX = 50; // Skip storing a restore payload bigger than ~250KB (keeps the entry, drops the heavy body). const HISTORY_PAYLOAD_MAX = 250000; function getHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function getHistoryEntry(id) { if (!id) return null; return getHistory().find(e => e.id === id) || null; } // Strip the payload field from one entry (used to shed weight under quota pressure). // NOTE: avoid object rest ({...rest}) here — @babel/standalone emits a top-level // `_excluded` helper that would collide across the shared-scope script files. function dropPayload(entry) { const copy = Object.assign({}, entry); delete copy.payload; return copy; } function saveHistory(items) { let list = items.slice(0, HISTORY_MAX); // Retry under quota: first shed payloads from the oldest entries, then drop oldest entries. for (let attempt = 0; attempt < 12; attempt++) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); return; } catch (e) { let trimmed = false; for (let i = list.length - 1; i >= 0; i--) { if (list[i].payload) { list = list.map((it, j) => (j === i ? dropPayload(it) : it)); trimmed = true; break; } } if (!trimmed) { list = list.slice(0, -1); if (!list.length) return; } } } } function useHistory() { const [items, setItems] = useState(getHistory); const reload = useCallback(() => setItems(getHistory()), []); useEffect(() => { const onStorage = () => reload(); window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, [reload]); return { items, reload }; } function addToHistory(entry) { const e = { ...entry, ts: Date.now(), id: Math.random().toString(36).slice(2, 9) }; // Drop oversized payloads so a single huge conversion can't blow the quota. if (e.payload) { try { if (JSON.stringify(e.payload).length > HISTORY_PAYLOAD_MAX) delete e.payload; } catch (_) { delete e.payload; } } saveHistory([e, ...getHistory()]); // notify same-window listeners window.dispatchEvent(new Event('storage')); } function clearHistory() { localStorage.removeItem(HISTORY_KEY); window.dispatchEvent(new Event('storage')); } function formatRelativeTime(ts, t) { const diff = Date.now() - ts; const m = Math.floor(diff / 60000); const h = Math.floor(diff / 3600000); const d = Math.floor(diff / 86400000); if (m < 1) return t('just_now'); if (m < 60) return `${m}${t('minutes_ago')}`; if (h < 24) return `${h}${t('hours_ago')}`; return `${d}${t('days_ago')}`; } /* ---------------------------- Hash router ---------------------------- */ function useHashRoute() { const [route, setRoute] = useState(() => parseHash(window.location.hash)); useEffect(() => { const onHash = () => setRoute(parseHash(window.location.hash)); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); return route; } function parseHash(hash) { if (!hash || hash === '#' || hash === '#/') return { name: 'hub' }; let path = hash.replace(/^#\/?/, ''); let restoreId = null; const q = path.indexOf('?'); if (q !== -1) { try { restoreId = new URLSearchParams(path.slice(q + 1)).get('h'); } catch (_) { /* ignore */ } path = path.slice(0, q); } if (TOOL_BY_ID[path]) return { name: 'tool', toolId: path, restoreId }; return { name: 'hub' }; } function navigate(path) { window.location.hash = path ? `#/${path}` : '#/'; } /* ---------------------------- Dropzone ---------------------------- */ function useDropzone({ accept, multiple, onFiles }) { const [dragover, setDragover] = useState(false); const inputRef = useRef(null); const dragDepth = useRef(0); const onDragEnter = useCallback((e) => { e.preventDefault(); dragDepth.current++; if (dragDepth.current === 1) setDragover(true); }, []); const onDragLeave = useCallback((e) => { e.preventDefault(); dragDepth.current--; if (dragDepth.current <= 0) { dragDepth.current = 0; setDragover(false); } }, []); const onDragOver = useCallback((e) => { e.preventDefault(); }, []); const onDrop = useCallback((e) => { e.preventDefault(); dragDepth.current = 0; setDragover(false); const files = Array.from(e.dataTransfer.files); if (files.length) onFiles(multiple ? files : [files[0]]); }, [onFiles, multiple]); const onClick = useCallback(() => inputRef.current?.click(), []); const onChange = useCallback((e) => { const files = Array.from(e.target.files || []); if (files.length) onFiles(multiple ? files : [files[0]]); e.target.value = ''; }, [onFiles, multiple]); const bind = { onClick, onDragEnter, onDragLeave, onDragOver, onDrop, }; const inputProps = { type: 'file', accept: accept || undefined, multiple: !!multiple, onChange, style: { display: 'none' }, ref: inputRef, }; return { dragover, bind, inputProps }; } /* ---------------------------- Icons ---------------------------- */ const Icon = { Sun: () => ( ), Moon: () => ( ), Search: () => ( ), Home: () => ( ), Upload: () => ( ), X: () => ( ), Sliders: () => ( ), }; /* ---------------------------- File helpers ---------------------------- */ function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 200); } function downloadText(text, filename, mime = 'text/plain') { downloadBlob(new Blob([text], { type: mime + ';charset=utf-8' }), filename); } function readFileAsText(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsText(file); }); } function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsArrayBuffer(file); }); } function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsDataURL(file); }); } function fmtBytes(n) { if (n < 1024) return `${n}B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`; return `${(n / 1024 / 1024).toFixed(2)}MB`; } /* ---------------------------- Async script loader ---------------------------- */ const _scriptCache = {}; function loadScript(src) { if (_scriptCache[src]) return _scriptCache[src]; _scriptCache[src] = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.async = true; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); return _scriptCache[src]; } /* ---------------------------- Export to window for cross-script use ---------------------------- */ Object.assign(window, { // Data TOOLS, TOOL_BY_ID, // Context I18nProvider, ThemeProvider, useI18n, useTheme, // History useHistory, addToHistory, clearHistory, getHistoryEntry, formatRelativeTime, // Router useHashRoute, navigate, // Hooks useDropzone, // Icons Icon, // File utils downloadBlob, downloadText, readFileAsText, readFileAsArrayBuffer, readFileAsDataURL, fmtBytes, loadScript, });