Forráskód Böngészése

Added wip productivity app

Toby Chui 4 napja
szülő
commit
0c38387756

+ 992 - 0
src/web/Productivity/embedded.html

@@ -0,0 +1,992 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Productivity</title>
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <style>
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+        html, body {
+            width: 100%; height: 100%;
+            overflow: hidden;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+            background: #F2F2F7;
+            color: #1C1C1E;
+        }
+
+        /* ── TOOLBAR ─────────────────────────────────────────── */
+        #toolbar {
+            position: fixed;
+            top: 0; left: 0; right: 0;
+            height: 44px;
+            display: flex;
+            align-items: center;
+            padding: 0 8px;
+            gap: 4px;
+            background: rgba(242,242,247,0.94);
+            backdrop-filter: blur(20px) saturate(1.8);
+            -webkit-backdrop-filter: blur(20px) saturate(1.8);
+            border-bottom: 1px solid rgba(0,0,0,0.1);
+            z-index: 500;
+            user-select: none;
+            -webkit-user-select: none;
+        }
+
+        .tb-group { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
+
+        .tb-btn {
+            width: 30px; height: 30px;
+            border: none; background: none;
+            border-radius: 7px;
+            cursor: pointer;
+            display: flex; align-items: center; justify-content: center;
+            color: #3C3C43;
+            transition: background 0.1s;
+            flex-shrink: 0;
+        }
+        .tb-btn:hover  { background: rgba(0,0,0,0.07); }
+        .tb-btn:active { background: rgba(0,0,0,0.13); }
+        .tb-btn.active { background: rgba(0,122,255,0.12); color: #007AFF; }
+        .tb-btn:disabled { opacity: 0.3; cursor: default; }
+        .tb-btn:disabled:hover { background: none; }
+
+        .tb-sep {
+            width: 1px; height: 18px;
+            background: rgba(0,0,0,0.14);
+            flex-shrink: 0; margin: 0 5px;
+        }
+
+        #tbCenter {
+            flex: 1;
+            display: flex; align-items: center; justify-content: center;
+            gap: 7px; min-width: 0;
+        }
+
+        #tbFileIcon { flex-shrink: 0; }
+
+        #tbFileName {
+            font-size: 13px; font-weight: 590;
+            color: #1C1C1E;
+            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        }
+
+        #tbZoomLabel {
+            font-size: 11.5px; font-weight: 500;
+            color: #6C6C70;
+            min-width: 38px; text-align: center;
+            font-variant-numeric: tabular-nums;
+        }
+
+        /* ── CONTENT ─────────────────────────────────────────── */
+        #contentWrap {
+            position: fixed;
+            top: 44px; left: 0; right: 0; bottom: 0;
+        }
+
+        /* Empty state */
+        #emptyState {
+            width: 100%; height: 100%;
+            display: flex; flex-direction: column;
+            align-items: center; justify-content: center;
+            gap: 12px; color: #8E8E93;
+        }
+        #emptyState svg { opacity: 0.45; }
+        #emptyState p  { font-size: 14px; font-weight: 500; }
+
+        /* ── iframe viewer (HTML + PDF) ──────────────────────── */
+        #iframeViewer {
+            display: none;
+            width: 100%; height: 100%;
+            border: none;
+            transform-origin: top left;
+        }
+
+        /* ── Image viewer ────────────────────────────────────── */
+        #imageViewer {
+            display: none;
+            width: 100%; height: 100%;
+            overflow: hidden;
+            background: #1C1C1E;
+            position: relative;
+        }
+
+        #imgStage {
+            position: absolute;
+            top: 0; left: 0;
+            width: 100%; height: 100%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            cursor: grab;
+        }
+        #imgStage.grabbing { cursor: grabbing; }
+
+        #imgEl {
+            display: block;
+            max-width: 100%; max-height: 100%;
+            object-fit: contain;
+            transform-origin: center center;
+            pointer-events: none;
+            user-select: none;
+            -webkit-user-drag: none;
+            box-shadow: 0 12px 40px rgba(0,0,0,0.55);
+        }
+
+        #imgHint {
+            position: absolute;
+            bottom: 14px; left: 50%;
+            transform: translateX(-50%);
+            background: rgba(0,0,0,0.6);
+            color: white; font-size: 12px;
+            padding: 5px 14px; border-radius: 20px;
+            backdrop-filter: blur(8px);
+            pointer-events: none;
+            opacity: 0;
+            transition: opacity 0.35s;
+            white-space: nowrap;
+        }
+
+        /* ── Code/text viewer ────────────────────────────────── */
+        #codeViewer {
+            display: none;
+            width: 100%; height: 100%;
+            overflow: auto;
+            background: #FAFAFA;
+        }
+
+        #codeTable {
+            border-collapse: collapse;
+            min-width: 100%;
+            font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, 'Courier New', monospace;
+            font-size: 12.5px;
+            line-height: 1.65;
+        }
+
+        .cl-num {
+            padding: 0 12px 0 16px;
+            text-align: right;
+            color: #B0B0B0;
+            border-right: 1px solid #E5E5EA;
+            user-select: none;
+            white-space: nowrap;
+            vertical-align: top;
+            background: #F5F5F7;
+            min-width: 52px;
+        }
+
+        .cl-code {
+            padding: 0 20px 0 14px;
+            white-space: pre;
+            vertical-align: top;
+            color: #1C1C1E;
+        }
+
+        #codeTable tr:first-child .cl-num,
+        #codeTable tr:first-child .cl-code { padding-top: 16px; }
+
+        #codeTable tr:last-child .cl-num,
+        #codeTable tr:last-child .cl-code  { padding-bottom: 16px; }
+
+        /* ── Markdown viewer ─────────────────────────────────── */
+        #mdViewer {
+            display: none;
+            width: 100%; height: 100%;
+            overflow: auto;
+            background: white;
+        }
+
+        #mdBody {
+            max-width: 820px;
+            margin: 0 auto;
+            padding: 44px 36px 72px;
+            font-size: 15px;
+            line-height: 1.78;
+            color: #24292E;
+        }
+
+        #mdBody h1,#mdBody h2,#mdBody h3,#mdBody h4,#mdBody h5,#mdBody h6 {
+            margin-top: 1.6em; margin-bottom: 0.55em;
+            line-height: 1.3; font-weight: 600;
+        }
+        #mdBody h1 { font-size: 2em;   padding-bottom: 0.3em;  border-bottom: 1.5px solid #E5E7EB; }
+        #mdBody h2 { font-size: 1.5em; padding-bottom: 0.2em;  border-bottom: 1px solid #E5E7EB; }
+        #mdBody h3 { font-size: 1.25em; }
+        #mdBody h4 { font-size: 1em; }
+        #mdBody p  { margin: 0.75em 0; }
+        #mdBody a  { color: #0366D6; text-decoration: none; }
+        #mdBody a:hover { text-decoration: underline; }
+        #mdBody strong { font-weight: 600; }
+        #mdBody em     { font-style: italic; }
+        #mdBody del    { color: #6A737D; }
+        #mdBody code {
+            font-family: 'SF Mono','Cascadia Code','Fira Code',Consolas,monospace;
+            font-size: 0.875em;
+            background: #F6F8FA; border: 1px solid #E1E4E8;
+            border-radius: 4px; padding: 0.1em 0.4em;
+        }
+        #mdBody pre {
+            background: #F6F8FA; border: 1px solid #E1E4E8;
+            border-radius: 8px; padding: 16px;
+            overflow-x: auto; margin: 1.1em 0;
+        }
+        #mdBody pre code { background: none; border: none; padding: 0; font-size: 0.875em; }
+        #mdBody blockquote {
+            border-left: 4px solid #DFE2E5;
+            padding: 0.35em 0 0.35em 1em;
+            color: #6A737D; margin: 1em 0;
+        }
+        #mdBody ul, #mdBody ol { padding-left: 2em; margin: 0.55em 0; }
+        #mdBody li { margin: 0.25em 0; }
+        #mdBody li > ul, #mdBody li > ol { margin: 0.15em 0; }
+        #mdBody hr { border: none; border-top: 1.5px solid #E5E7EB; margin: 1.6em 0; }
+        #mdBody img { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
+        #mdBody table { border-collapse: collapse; width: 100%; margin: 1.1em 0; }
+        #mdBody th, #mdBody td { border: 1px solid #DFE2E5; padding: 7px 14px; text-align: left; }
+        #mdBody th { background: #F6F8FA; font-weight: 600; }
+        #mdBody tr:nth-child(even) td { background: #FAFBFC; }
+
+        /* ── Info sidebar ────────────────────────────────────── */
+        #infoSidebar {
+            position: fixed;
+            top: 44px; right: 0;
+            width: 260px; height: calc(100% - 44px);
+            background: rgba(242,242,247,0.97);
+            backdrop-filter: blur(20px);
+            -webkit-backdrop-filter: blur(20px);
+            border-left: 1px solid rgba(0,0,0,0.1);
+            transform: translateX(100%);
+            transition: transform 0.22s cubic-bezier(0.25,0.1,0.25,1);
+            overflow-y: auto; padding: 16px; z-index: 400;
+        }
+        #infoSidebar.open { transform: translateX(0); }
+
+        .info-hdr {
+            display: flex; align-items: center;
+            justify-content: space-between;
+            margin-bottom: 18px;
+        }
+        .info-hdr strong { font-size: 14px; font-weight: 600; }
+
+        .info-group { margin-bottom: 18px; }
+        .info-group-title {
+            font-size: 10.5px; font-weight: 700;
+            letter-spacing: 0.7px; text-transform: uppercase;
+            color: #8E8E93; margin-bottom: 8px;
+        }
+        .info-row { margin-bottom: 9px; }
+        .info-key { font-size: 11px; color: #8E8E93; margin-bottom: 2px; }
+        .info-val { font-size: 12.5px; color: #1C1C1E; word-break: break-all; line-height: 1.45; }
+
+        /* ── Dark mode ───────────────────────────────────────── */
+        @media (prefers-color-scheme: dark) {
+            html, body { background: #1C1C1E; color: #F2F2F7; }
+
+            #toolbar { background: rgba(28,28,30,0.94); border-bottom-color: rgba(255,255,255,0.1); }
+            #tbFileName { color: #F2F2F7; }
+            .tb-btn { color: #EBEBF5; }
+            .tb-btn:hover  { background: rgba(255,255,255,0.1); }
+            .tb-btn:active { background: rgba(255,255,255,0.17); }
+            .tb-btn.active { background: rgba(10,132,255,0.2); color: #0A84FF; }
+            .tb-sep { background: rgba(255,255,255,0.14); }
+            #tbZoomLabel { color: #8E8E93; }
+
+            #emptyState { color: #48484A; }
+
+            #codeViewer { background: #1C1C1E; }
+            .cl-num  { background: #2C2C2E; border-right-color: #3A3A3C; color: #48484A; }
+            .cl-code { color: #E5E5EA; }
+
+            #mdViewer { background: #1C1C1E; }
+            #mdBody { color: #E5E5EA; }
+            #mdBody h1,#mdBody h2 { border-bottom-color: #38383A; }
+            #mdBody a { color: #4EA1F3; }
+            #mdBody code { background: #2C2C2E; border-color: #38383A; }
+            #mdBody pre { background: #2C2C2E; border-color: #38383A; }
+            #mdBody blockquote { border-left-color: #38383A; color: #8E8E93; }
+            #mdBody th, #mdBody td { border-color: #38383A; }
+            #mdBody th { background: #2C2C2E; }
+            #mdBody tr:nth-child(even) td { background: #252527; }
+            #mdBody hr { border-top-color: #38383A; }
+
+            #infoSidebar { background: rgba(28,28,30,0.97); border-left-color: rgba(255,255,255,0.1); }
+            .info-val { color: #E5E5EA; }
+        }
+
+        /* ArozOS theme override — applied by ao_module_onThemeChanged via data-theme attribute.
+           These rules win over the media query because the attribute selector adds specificity. */
+        html[data-theme="dark"] body { background: #1C1C1E; color: #F2F2F7; }
+        html[data-theme="dark"] #toolbar { background: rgba(28,28,30,0.94); border-bottom-color: rgba(255,255,255,0.1); }
+        html[data-theme="dark"] #tbFileName { color: #F2F2F7; }
+        html[data-theme="dark"] .tb-btn { color: #EBEBF5; }
+        html[data-theme="dark"] .tb-btn:hover  { background: rgba(255,255,255,0.1); }
+        html[data-theme="dark"] .tb-btn:active { background: rgba(255,255,255,0.17); }
+        html[data-theme="dark"] .tb-btn.active { background: rgba(10,132,255,0.2); color: #0A84FF; }
+        html[data-theme="dark"] .tb-sep { background: rgba(255,255,255,0.14); }
+        html[data-theme="dark"] #tbZoomLabel { color: #8E8E93; }
+        html[data-theme="dark"] #emptyState { color: #48484A; }
+        html[data-theme="dark"] #codeViewer { background: #1C1C1E; }
+        html[data-theme="dark"] .cl-num  { background: #2C2C2E; border-right-color: #3A3A3C; color: #48484A; }
+        html[data-theme="dark"] .cl-code { color: #E5E5EA; }
+        html[data-theme="dark"] #mdViewer { background: #1C1C1E; }
+        html[data-theme="dark"] #mdBody { color: #E5E5EA; }
+        html[data-theme="dark"] #mdBody h1, html[data-theme="dark"] #mdBody h2 { border-bottom-color: #38383A; }
+        html[data-theme="dark"] #mdBody a { color: #4EA1F3; }
+        html[data-theme="dark"] #mdBody code { background: #2C2C2E; border-color: #38383A; }
+        html[data-theme="dark"] #mdBody pre  { background: #2C2C2E; border-color: #38383A; }
+        html[data-theme="dark"] #mdBody blockquote { border-left-color: #38383A; color: #8E8E93; }
+        html[data-theme="dark"] #mdBody th, html[data-theme="dark"] #mdBody td { border-color: #38383A; }
+        html[data-theme="dark"] #mdBody th { background: #2C2C2E; }
+        html[data-theme="dark"] #mdBody tr:nth-child(even) td { background: #252527; }
+        html[data-theme="dark"] #mdBody hr { border-top-color: #38383A; }
+        html[data-theme="dark"] #infoSidebar { background: rgba(28,28,30,0.97); border-left-color: rgba(255,255,255,0.1); }
+        html[data-theme="dark"] .info-val { color: #E5E5EA; }
+
+        /* Force light mode even when the OS prefers dark */
+        html[data-theme="light"] body { background: #F2F2F7; color: #1C1C1E; }
+        html[data-theme="light"] #toolbar { background: rgba(242,242,247,0.94); border-bottom-color: rgba(0,0,0,0.1); }
+        html[data-theme="light"] #tbFileName { color: #1C1C1E; }
+        html[data-theme="light"] .tb-btn { color: #3C3C43; }
+        html[data-theme="light"] .tb-btn:hover  { background: rgba(0,0,0,0.07); }
+        html[data-theme="light"] .tb-btn:active { background: rgba(0,0,0,0.13); }
+        html[data-theme="light"] .tb-btn.active { background: rgba(0,122,255,0.12); color: #007AFF; }
+        html[data-theme="light"] .tb-sep { background: rgba(0,0,0,0.14); }
+        html[data-theme="light"] #tbZoomLabel { color: #6C6C70; }
+        html[data-theme="light"] #emptyState { color: #8E8E93; }
+        html[data-theme="light"] #codeViewer { background: #FFFFFF; }
+        html[data-theme="light"] .cl-num  { background: #F2F2F7; border-right-color: #D1D1D6; color: #8E8E93; }
+        html[data-theme="light"] .cl-code { color: #1C1C1E; }
+        html[data-theme="light"] #mdViewer { background: #FFFFFF; }
+        html[data-theme="light"] #mdBody { color: #1C1C1E; }
+        html[data-theme="light"] #infoSidebar { background: rgba(242,242,247,0.97); border-left-color: rgba(0,0,0,0.1); }
+        html[data-theme="light"] .info-val { color: #1C1C1E; }
+    </style>
+</head>
+<body>
+
+<!-- ═══════════════════════════════════════════════════════════
+     TOOLBAR
+══════════════════════════════════════════════════════════════ -->
+<div id="toolbar">
+
+    <!-- Left: file type badge -->
+    <div class="tb-group">
+        <div id="tbFileIcon" style="width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;letter-spacing:0.3px;color:white;background:#8E8E93;flex-shrink:0;">
+            FILE
+        </div>
+    </div>
+
+    <!-- Center: file name -->
+    <div id="tbCenter">
+        <span id="tbFileName">Preview</span>
+    </div>
+
+    <!-- Right: zoom + info -->
+    <div class="tb-group">
+        <div class="tb-sep"></div>
+
+        <!-- Zoom out -->
+        <button class="tb-btn" id="btnZoomOut" onclick="adjustZoom(-0.15)" title="Zoom Out (Ctrl –)" disabled>
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
+                <circle cx="6.5" cy="6.5" r="4.5"/>
+                <line x1="4" y1="6.5" x2="9" y2="6.5"/>
+                <line x1="10.2" y1="10.2" x2="14" y2="14"/>
+            </svg>
+        </button>
+
+        <span id="tbZoomLabel">—</span>
+
+        <!-- Zoom in -->
+        <button class="tb-btn" id="btnZoomIn" onclick="adjustZoom(0.15)" title="Zoom In (Ctrl +)" disabled>
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
+                <circle cx="6.5" cy="6.5" r="4.5"/>
+                <line x1="4" y1="6.5" x2="9" y2="6.5"/>
+                <line x1="6.5" y1="4" x2="6.5" y2="9"/>
+                <line x1="10.2" y1="10.2" x2="14" y2="14"/>
+            </svg>
+        </button>
+
+        <!-- Reset zoom -->
+        <button class="tb-btn" id="btnZoomReset" onclick="resetZoom()" title="Reset Zoom (Ctrl 0)" disabled>
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M2.5 8 A5.5 5.5 0 1 1 8 13.5"/>
+                <polyline points="2.5,5.5 2.5,8 5,8"/>
+            </svg>
+        </button>
+
+        <div class="tb-sep"></div>
+
+        <!-- Info toggle -->
+        <button class="tb-btn" id="btnInfo" onclick="toggleInfo()" title="File Info (Ctrl I)">
+            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
+                <circle cx="8" cy="8" r="6.5"/>
+                <line x1="8" y1="7.5" x2="8" y2="11.5"/>
+                <circle cx="8" cy="5" r="0.8" fill="currentColor" stroke="none"/>
+            </svg>
+        </button>
+    </div>
+</div>
+
+<!-- ═══════════════════════════════════════════════════════════
+     CONTENT
+══════════════════════════════════════════════════════════════ -->
+<div id="contentWrap">
+
+    <!-- Empty / error state -->
+    <div id="emptyState">
+        <svg width="64" height="64" viewBox="0 0 64 64" fill="none">
+            <rect x="10" y="5" width="36" height="46" rx="5" fill="currentColor" opacity="0.2"/>
+            <path d="M36 5 L46 15 L36 15 Z" fill="currentColor" opacity="0.25"/>
+            <rect x="15" y="24" width="26" height="3" rx="1.5" fill="currentColor" opacity="0.4"/>
+            <rect x="15" y="30" width="20" height="3" rx="1.5" fill="currentColor" opacity="0.3"/>
+            <rect x="15" y="36" width="23" height="3" rx="1.5" fill="currentColor" opacity="0.3"/>
+            <circle cx="44" cy="44" r="14" stroke="currentColor" stroke-width="3" opacity="0.35"/>
+            <line x1="53.5" y1="53.5" x2="61" y2="61" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.35"/>
+        </svg>
+        <p id="emptyMsg">No file to preview</p>
+    </div>
+
+    <!-- iframe viewer: HTML and PDF -->
+    <iframe id="iframeViewer" title="File Preview" allowfullscreen></iframe>
+
+    <!-- Image viewer -->
+    <div id="imageViewer">
+        <div id="imgStage">
+            <img id="imgEl" alt="Preview image">
+        </div>
+        <div id="imgHint"></div>
+    </div>
+
+    <!-- Code / text viewer -->
+    <div id="codeViewer">
+        <table id="codeTable"><tbody id="codeTbody"></tbody></table>
+    </div>
+
+    <!-- Markdown viewer -->
+    <div id="mdViewer">
+        <div id="mdBody"></div>
+    </div>
+
+</div>
+
+<!-- ═══════════════════════════════════════════════════════════
+     INFO SIDEBAR
+══════════════════════════════════════════════════════════════ -->
+<div id="infoSidebar">
+    <div class="info-hdr">
+        <strong>File Info</strong>
+        <button class="tb-btn" onclick="toggleInfo()" style="color:#8E8E93">
+            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
+                <line x1="1" y1="1" x2="13" y2="13"/>
+                <line x1="13" y1="1" x2="1" y2="13"/>
+            </svg>
+        </button>
+    </div>
+    <div class="info-group">
+        <div class="info-group-title">General</div>
+        <div class="info-row"><div class="info-key">Name</div><div class="info-val" id="infoName">—</div></div>
+        <div class="info-row"><div class="info-key">Type</div><div class="info-val" id="infoType">—</div></div>
+        <div class="info-row"><div class="info-key">Extension</div><div class="info-val" id="infoExt">—</div></div>
+    </div>
+    <div class="info-group">
+        <div class="info-group-title">Location</div>
+        <div class="info-row"><div class="info-key">Path</div><div class="info-val" id="infoPath">—</div></div>
+    </div>
+</div>
+
+<script>
+/* ═══════════════════════════════════════════════════════════════
+   FILE TYPE REGISTRY
+═══════════════════════════════════════════════════════════════ */
+var TYPE_MAP = {
+    html: ['html','htm'],
+    pdf:  ['pdf'],
+    image:['jpg','jpeg','png','gif','webp','svg','bmp','ico'],
+    markdown: ['md','markdown'],
+    code: [
+        'js','mjs','cjs','jsx','ts','tsx',
+        'py','rb','pl','r','go','rs','swift','kt','scala',
+        'java','c','h','cpp','hpp','cs','php',
+        'sh','bash','zsh','bat','cmd','ps1',
+        'json','yaml','yml','xml','css','scss','less',
+        'sql','lua','agi','dockerfile','makefile',
+        'gitignore','editorconfig','env',
+        'toml','ini','conf','cfg','log','txt'
+    ]
+};
+
+var TYPE_LABELS = {
+    html:'HTML', pdf:'PDF', image:'IMG',
+    markdown:'MD', code:'CODE'
+};
+
+var TYPE_COLORS = {
+    html: '#E44D26',
+    pdf:  '#FF3B30',
+    image:'#30B06E',
+    markdown: '#8B5CF6',
+    code: '#007AFF'
+};
+
+var TYPE_NAMES = {
+    html:'HTML Document', pdf:'PDF Document', image:'Image',
+    markdown:'Markdown Document', code:'Source File'
+};
+
+function getFileType(ext) {
+    ext = ext.toLowerCase().replace(/^\./, '');
+    for (var t in TYPE_MAP) {
+        if (TYPE_MAP[t].indexOf(ext) !== -1) return t;
+    }
+    return 'code';
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   STATE
+═══════════════════════════════════════════════════════════════ */
+var currentFile = null;
+var currentType = null;
+var zoom = 1.0;
+
+// Image pan state
+var imgPan = { dragging: false, startX: 0, startY: 0, tx: 0, ty: 0, baseTx: 0, baseTy: 0 };
+
+/* ═══════════════════════════════════════════════════════════════
+   HELPERS
+═══════════════════════════════════════════════════════════════ */
+function escHtml(s) {
+    return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+function showHint(msg) {
+    var h = document.getElementById('imgHint');
+    h.textContent = msg;
+    h.style.opacity = '1';
+    clearTimeout(h._tid);
+    h._tid = setTimeout(function(){ h.style.opacity = '0'; }, 1600);
+}
+
+function setZoomEnabled(on) {
+    document.getElementById('btnZoomOut').disabled   = !on;
+    document.getElementById('btnZoomIn').disabled    = !on;
+    document.getElementById('btnZoomReset').disabled = !on;
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   VIEWER SWITCHING
+═══════════════════════════════════════════════════════════════ */
+function showViewer(type) {
+    document.getElementById('emptyState').style.display    = 'none';
+    document.getElementById('iframeViewer').style.display  = 'none';
+    document.getElementById('imageViewer').style.display   = 'none';
+    document.getElementById('codeViewer').style.display    = 'none';
+    document.getElementById('mdViewer').style.display      = 'none';
+
+    if      (type === 'html' || type === 'pdf') document.getElementById('iframeViewer').style.display = 'block';
+    else if (type === 'image')                  document.getElementById('imageViewer').style.display  = 'block';
+    else if (type === 'code')                   document.getElementById('codeViewer').style.display   = 'block';
+    else if (type === 'markdown')               document.getElementById('mdViewer').style.display     = 'block';
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   ZOOM
+═══════════════════════════════════════════════════════════════ */
+function adjustZoom(delta) {
+    if (!currentType) return;
+    zoom = Math.round(Math.max(0.1, Math.min(8.0, zoom + delta)) * 100) / 100;
+    applyZoom();
+    updateZoomLabel();
+}
+
+function resetZoom() {
+    zoom = 1.0;
+    if (currentType === 'image') {
+        imgPan.tx = 0; imgPan.ty = 0;
+        imgPan.baseTx = 0; imgPan.baseTy = 0;
+    }
+    applyZoom();
+    updateZoomLabel();
+}
+
+function applyZoom() {
+    if (currentType === 'image') {
+        document.getElementById('imgEl').style.transform =
+            'translate(' + imgPan.tx + 'px,' + imgPan.ty + 'px) scale(' + zoom + ')';
+    } else if (currentType === 'code') {
+        document.getElementById('codeTable').style.fontSize = (12.5 * zoom) + 'px';
+    } else if (currentType === 'markdown') {
+        document.getElementById('mdBody').style.fontSize = (15 * zoom) + 'px';
+    } else if (currentType === 'html') {
+        /* CSS zoom works in Chromium/WebKit; harmless elsewhere */
+        document.getElementById('iframeViewer').style.zoom = zoom;
+    }
+}
+
+function updateZoomLabel() {
+    document.getElementById('tbZoomLabel').textContent = Math.round(zoom * 100) + '%';
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   IMAGE PAN
+═══════════════════════════════════════════════════════════════ */
+function initImagePan() {
+    var stage = document.getElementById('imgStage');
+
+    stage.addEventListener('mousedown', function(e) {
+        if (zoom <= 1.0) return;
+        e.preventDefault();
+        imgPan.dragging = true;
+        imgPan.startX = e.clientX;
+        imgPan.startY = e.clientY;
+        imgPan.baseTx = imgPan.tx;
+        imgPan.baseTy = imgPan.ty;
+        stage.classList.add('grabbing');
+    });
+
+    window.addEventListener('mousemove', function(e) {
+        if (!imgPan.dragging) return;
+        imgPan.tx = imgPan.baseTx + (e.clientX - imgPan.startX);
+        imgPan.ty = imgPan.baseTy + (e.clientY - imgPan.startY);
+        applyZoom();
+    });
+
+    window.addEventListener('mouseup', function() {
+        if (!imgPan.dragging) return;
+        imgPan.dragging = false;
+        stage.classList.remove('grabbing');
+    });
+
+    /* touch pan */
+    stage.addEventListener('touchstart', function(e) {
+        if (e.touches.length === 1 && zoom > 1.0) {
+            imgPan.dragging = true;
+            imgPan.startX = e.touches[0].clientX;
+            imgPan.startY = e.touches[0].clientY;
+            imgPan.baseTx = imgPan.tx;
+            imgPan.baseTy = imgPan.ty;
+        }
+    }, {passive: true});
+
+    stage.addEventListener('touchmove', function(e) {
+        if (!imgPan.dragging || e.touches.length !== 1) return;
+        imgPan.tx = imgPan.baseTx + (e.touches[0].clientX - imgPan.startX);
+        imgPan.ty = imgPan.baseTy + (e.touches[0].clientY - imgPan.startY);
+        applyZoom();
+    }, {passive: true});
+
+    stage.addEventListener('touchend', function() { imgPan.dragging = false; });
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   MARKDOWN RENDERER
+═══════════════════════════════════════════════════════════════ */
+function renderMarkdown(raw) {
+    var codeBlocks = [];
+    var inlineCodes = [];
+
+    /* 1. Extract fenced code blocks */
+    raw = raw.replace(/```([^\n]*)\n([\s\S]*?)```/gm, function(_, lang, code) {
+        var i = codeBlocks.length;
+        codeBlocks.push('<pre><code>' + escHtml(code.replace(/\n$/, '')) + '</code></pre>');
+        return '\x01CB' + i + '\x01';
+    });
+
+    /* 2. Extract inline code */
+    raw = raw.replace(/`([^`]+)`/g, function(_, code) {
+        var i = inlineCodes.length;
+        inlineCodes.push('<code>' + escHtml(code) + '</code>');
+        return '\x01IC' + i + '\x01';
+    });
+
+    /* 3. Escape remaining HTML */
+    raw = raw.replace(/&(?![a-zA-Z#]\w*;)/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+    /* 4. Block-level elements */
+    var lines = raw.split('\n');
+    var out = [];
+    var i = 0;
+
+    while (i < lines.length) {
+        var line = lines[i];
+
+        /* Headings */
+        var hm = line.match(/^(#{1,6})\s+(.*)/);
+        if (hm) {
+            var lvl = hm[1].length;
+            out.push('<h' + lvl + '>' + inlineMarkdown(hm[2]) + '</h' + lvl + '>');
+            i++; continue;
+        }
+
+        /* Horizontal rule */
+        if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
+            out.push('<hr>'); i++; continue;
+        }
+
+        /* Blockquote */
+        if (line.startsWith('&gt;')) {
+            var qlines = [];
+            while (i < lines.length && lines[i].startsWith('&gt;')) {
+                qlines.push(lines[i].replace(/^&gt;\s?/, ''));
+                i++;
+            }
+            out.push('<blockquote>' + inlineMarkdown(qlines.join('\n')) + '</blockquote>');
+            continue;
+        }
+
+        /* Unordered list */
+        if (/^[\*\-]\s/.test(line)) {
+            var items = [];
+            while (i < lines.length && /^[\*\-]\s/.test(lines[i])) {
+                items.push('<li>' + inlineMarkdown(lines[i].replace(/^[\*\-]\s/, '')) + '</li>');
+                i++;
+            }
+            out.push('<ul>' + items.join('') + '</ul>');
+            continue;
+        }
+
+        /* Ordered list */
+        if (/^\d+\.\s/.test(line)) {
+            var oitems = [];
+            while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
+                oitems.push('<li>' + inlineMarkdown(lines[i].replace(/^\d+\.\s/, '')) + '</li>');
+                i++;
+            }
+            out.push('<ol>' + oitems.join('') + '</ol>');
+            continue;
+        }
+
+        /* Table (pipe-separated) */
+        if (line.includes('|') && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i+1])) {
+            var trows = [];
+            var headers = line.split('|').filter(function(c,idx,arr){ return idx > 0 || c.trim(); }).map(function(c){ return c.trim(); }).filter(Boolean);
+            trows.push('<thead><tr>' + headers.map(function(h){ return '<th>' + inlineMarkdown(h) + '</th>'; }).join('') + '</tr></thead>');
+            i += 2; /* skip separator */
+            var tbody = [];
+            while (i < lines.length && lines[i].includes('|')) {
+                var cells = lines[i].split('|').filter(function(c,idx,arr){ return idx > 0 || c.trim(); }).map(function(c){ return c.trim(); }).filter(Boolean);
+                tbody.push('<tr>' + cells.map(function(c){ return '<td>' + inlineMarkdown(c) + '</td>'; }).join('') + '</tr>');
+                i++;
+            }
+            out.push('<table><' + trows[0] + '<tbody>' + tbody.join('') + '</tbody></table>');
+            continue;
+        }
+
+        /* Blank line */
+        if (line.trim() === '') { out.push(''); i++; continue; }
+
+        /* Paragraph / inline text */
+        var para = [];
+        while (i < lines.length && lines[i].trim() !== '' &&
+               !/^(#{1,6}\s|[\*\-]\s|\d+\.\s|&gt;|(\*{3,}|-{3,}|_{3,})\s*$)/.test(lines[i]) &&
+               !lines[i].includes('\x01CB')) {
+            para.push(lines[i]);
+            i++;
+        }
+        if (para.length) out.push('<p>' + inlineMarkdown(para.join(' ')) + '</p>');
+    }
+
+    var html = out.join('\n');
+
+    /* Restore inline codes and code blocks */
+    inlineCodes.forEach(function(c, idx) { html = html.split('\x01IC' + idx + '\x01').join(c); });
+    codeBlocks.forEach(function(b, idx) { html = html.split('\x01CB' + idx + '\x01').join(b); });
+
+    return html;
+}
+
+function inlineMarkdown(s) {
+    /* images before links */
+    s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2">');
+    s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,  '<a href="$2" target="_blank" rel="noopener">$1</a>');
+    s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+    s = s.replace(/\*\*(.+?)\*\*/g,     '<strong>$1</strong>');
+    s = s.replace(/__(.+?)__/g,         '<strong>$1</strong>');
+    s = s.replace(/\*(.+?)\*/g,         '<em>$1</em>');
+    s = s.replace(/_(.+?)_/g,           '<em>$1</em>');
+    s = s.replace(/~~(.+?)~~/g,         '<del>$1</del>');
+    /* restore inline code placeholders pass-through */
+    return s;
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   LOAD FILE
+═══════════════════════════════════════════════════════════════ */
+function loadFile(file) {
+    currentFile = file;
+    var ext = file.filename.split('.').pop().toLowerCase();
+    currentType = getFileType(ext);
+
+    /* Toolbar badge */
+    var badge = document.getElementById('tbFileIcon');
+    badge.textContent  = TYPE_LABELS[currentType] || 'FILE';
+    badge.style.background = TYPE_COLORS[currentType] || '#8E8E93';
+    badge.style.fontSize   = currentType === 'markdown' ? '10px' : '11px';
+
+    document.getElementById('tbFileName').textContent = file.filename;
+    ao_module_setWindowTitle('Preview — ' + file.filename);
+
+    /* Info sidebar */
+    document.getElementById('infoName').textContent = file.filename;
+    document.getElementById('infoPath').textContent = file.filepath;
+    document.getElementById('infoExt').textContent  = '.' + ext.toUpperCase();
+    document.getElementById('infoType').textContent = TYPE_NAMES[currentType] || 'File';
+
+    /* Reset zoom */
+    zoom = 1.0;
+    imgPan.tx = 0; imgPan.ty = 0; imgPan.baseTx = 0; imgPan.baseTy = 0;
+    updateZoomLabel();
+
+    showViewer(currentType);
+
+    /* Support data URLs for files dragged in from the OS desktop */
+    var fileUrl = file.dataUrl
+        ? file.dataUrl
+        : '../media?file=' + encodeURIComponent(file.filepath);
+
+    if (currentType === 'html') {
+        setZoomEnabled(true);
+        var fr = document.getElementById('iframeViewer');
+        fr.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
+        if (file.dataUrl) {
+            /* Use srcdoc so the sandboxed iframe renders the HTML directly */
+            fetch(file.dataUrl).then(function(r){ return r.text(); }).then(function(html){
+                fr.removeAttribute('src');
+                fr.srcdoc = html;
+            });
+        } else {
+            fr.removeAttribute('srcdoc');
+            fr.src = fileUrl;
+        }
+    } else if (currentType === 'pdf') {
+        setZoomEnabled(false);
+        var fr = document.getElementById('iframeViewer');
+        fr.removeAttribute('sandbox');
+        fr.removeAttribute('srcdoc');
+        fr.src = fileUrl;
+    } else if (currentType === 'image') {
+        setZoomEnabled(true);
+        document.getElementById('imgEl').src = fileUrl;
+    } else if (currentType === 'code') {
+        setZoomEnabled(true);
+        fetchAndRenderCode(fileUrl);
+    } else if (currentType === 'markdown') {
+        setZoomEnabled(true);
+        fetchAndRenderMarkdown(fileUrl);
+    }
+}
+
+function fetchAndRenderCode(url) {
+    var tbody = document.getElementById('codeTbody');
+    tbody.innerHTML = '<tr><td class="cl-num">…</td><td class="cl-code" style="color:#8E8E93">Loading…</td></tr>';
+
+    fetch(url)
+        .then(function(r) {
+            if (!r.ok) throw new Error('HTTP ' + r.status);
+            return r.text();
+        })
+        .then(function(text) {
+            var lines = text.split('\n');
+            /* Remove trailing empty line that split() adds */
+            if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
+            var rows = '';
+            for (var i = 0; i < lines.length; i++) {
+                rows += '<tr><td class="cl-num">' + (i + 1) + '</td>'
+                      + '<td class="cl-code">' + escHtml(lines[i]) + '</td></tr>';
+            }
+            tbody.innerHTML = rows;
+        })
+        .catch(function(e) {
+            tbody.innerHTML = '<tr><td class="cl-num">!</td><td class="cl-code" style="color:#FF3B30">Failed to load file: ' + escHtml(e.message) + '</td></tr>';
+        });
+}
+
+function fetchAndRenderMarkdown(url) {
+    var body = document.getElementById('mdBody');
+    body.innerHTML = '<p style="color:#8E8E93">Loading…</p>';
+
+    fetch(url)
+        .then(function(r) {
+            if (!r.ok) throw new Error('HTTP ' + r.status);
+            return r.text();
+        })
+        .then(function(text) {
+            body.innerHTML = renderMarkdown(text);
+        })
+        .catch(function(e) {
+            body.innerHTML = '<p style="color:#FF3B30">Failed to load file: ' + escHtml(e.message) + '</p>';
+        });
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   INFO SIDEBAR
+═══════════════════════════════════════════════════════════════ */
+function toggleInfo() {
+    var sb = document.getElementById('infoSidebar');
+    sb.classList.toggle('open');
+    document.getElementById('btnInfo').classList.toggle('active', sb.classList.contains('open'));
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   EVENT WIRING
+═══════════════════════════════════════════════════════════════ */
+
+/* Ctrl/Cmd +/- zoom */
+document.addEventListener('keydown', function(e) {
+    var ctrl = e.ctrlKey || e.metaKey;
+    if (!ctrl) return;
+    if (e.key === '=' || e.key === '+') { e.preventDefault(); adjustZoom(0.15); }
+    else if (e.key === '-')             { e.preventDefault(); adjustZoom(-0.15); }
+    else if (e.key === '0')             { e.preventDefault(); resetZoom(); }
+    else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); toggleInfo(); }
+});
+
+/* Scroll-wheel zoom on image viewer */
+document.getElementById('imageViewer').addEventListener('wheel', function(e) {
+    e.preventDefault();
+    var delta = e.deltaY < 0 ? 0.12 : -0.12;
+    adjustZoom(delta);
+    showHint(Math.round(zoom * 100) + '%');
+}, { passive: false });
+
+/* Ctrl+scroll zoom on code viewer */
+document.getElementById('codeViewer').addEventListener('wheel', function(e) {
+    if (!(e.ctrlKey || e.metaKey)) return;
+    e.preventDefault();
+    adjustZoom(e.deltaY < 0 ? 0.12 : -0.12);
+}, { passive: false });
+
+/* Ctrl+scroll zoom on markdown viewer */
+document.getElementById('mdViewer').addEventListener('wheel', function(e) {
+    if (!(e.ctrlKey || e.metaKey)) return;
+    e.preventDefault();
+    adjustZoom(e.deltaY < 0 ? 0.12 : -0.12);
+}, { passive: false });
+
+/* ═══════════════════════════════════════════════════════════════
+   INIT
+═══════════════════════════════════════════════════════════════ */
+initImagePan();
+
+var inputFiles = ao_module_loadInputFiles();
+if (inputFiles && inputFiles.length > 0) {
+    loadFile(inputFiles[0]);
+} else {
+    document.getElementById('emptyMsg').textContent = 'No file was passed to Preview';
+    document.getElementById('emptyState').style.display = 'flex';
+}
+
+/* ── ArozOS system theme binding ── */
+function applyAozTheme(theme) {
+    document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : 'light');
+}
+
+ao_module_onThemeChanged(applyAozTheme);
+
+$.get(ao_root + 'system/file_system/preference?key=file_explorer/theme', function(data) {
+    applyAozTheme(data === 'darkTheme' ? 'dark' : 'light');
+});
+</script>
+</body>
+</html>

+ 39 - 0
src/web/Productivity/img/module_icon.svg

@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0%" stop-color="#5B9CF6"/>
+      <stop offset="100%" stop-color="#2563EB"/>
+    </linearGradient>
+    <linearGradient id="doc" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0%" stop-color="#FFFFFF"/>
+      <stop offset="100%" stop-color="#F0F4FF"/>
+    </linearGradient>
+  </defs>
+  <!-- App background -->
+  <rect width="128" height="128" rx="24" fill="url(#bg)"/>
+  <!-- Document shadow -->
+  <rect x="20" y="16" width="64" height="82" rx="6" fill="rgba(0,0,0,0.15)" transform="translate(2,2)"/>
+  <!-- Document body -->
+  <rect x="20" y="16" width="64" height="82" rx="6" fill="url(#doc)"/>
+  <!-- Page fold triangle -->
+  <path d="M64 16 L84 16 L84 36 Z" fill="#DBEAFE"/>
+  <path d="M64 16 L64 36 L84 36" fill="none" stroke="#93C5FD" stroke-width="1"/>
+  <!-- Document lines -->
+  <rect x="28" y="46" width="46" height="4" rx="2" fill="#94A3B8"/>
+  <rect x="28" y="55" width="38" height="4" rx="2" fill="#94A3B8" opacity="0.7"/>
+  <rect x="28" y="64" width="42" height="4" rx="2" fill="#94A3B8" opacity="0.6"/>
+  <rect x="28" y="73" width="28" height="4" rx="2" fill="#94A3B8" opacity="0.5"/>
+  <!-- Magnifying glass handle -->
+  <line x1="88" y1="90" x2="110" y2="112" stroke="#F97316" stroke-width="9" stroke-linecap="round"/>
+  <!-- Magnifying glass circle - shadow -->
+  <circle cx="77" cy="79" r="24" fill="rgba(0,0,0,0.18)" transform="translate(1,1)"/>
+  <!-- Magnifying glass circle -->
+  <circle cx="77" cy="79" r="24" fill="white" opacity="0.95"/>
+  <circle cx="77" cy="79" r="18" fill="#EFF6FF"/>
+  <!-- Magnifying glass inner icon - lines representing content -->
+  <rect x="68" y="72" width="18" height="2.5" rx="1.25" fill="#60A5FA" opacity="0.9"/>
+  <rect x="68" y="77" width="14" height="2.5" rx="1.25" fill="#60A5FA" opacity="0.7"/>
+  <rect x="68" y="82" width="16" height="2.5" rx="1.25" fill="#60A5FA" opacity="0.6"/>
+  <!-- Magnifying glass rim -->
+  <circle cx="77" cy="79" r="24" fill="none" stroke="#F97316" stroke-width="4.5"/>
+</svg>

+ 648 - 0
src/web/Productivity/index.html

@@ -0,0 +1,648 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Productivity</title>
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <style>
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+        :root {
+            --sidebar-w: 172px;
+            --bg: #F2F2F7;
+            --sidebar-bg: #EFEFF4;
+            --text: #1C1C1E;
+            --sub: #6B6B70;
+            --accent: #007AFF;
+            --card-bg: #FFFFFF;
+            --border: #D1D1D6;
+            --inp-bg: #FFFFFF;
+            --progress-bg: #E5E5EA;
+            --danger: #FF3B30;
+        }
+
+        @media (prefers-color-scheme: dark) {
+            :root {
+                --bg: #1C1C1E;
+                --sidebar-bg: #2C2C2E;
+                --text: #F2F2F7;
+                --sub: #8E8E93;
+                --card-bg: #2C2C2E;
+                --border: #48484A;
+                --inp-bg: #3A3A3C;
+                --progress-bg: #3A3A3C;
+                --accent: #0A84FF;
+            }
+        }
+
+        /* ArozOS system theme override via data-theme attribute */
+        html[data-theme="dark"] {
+            --bg: #1C1C1E;
+            --sidebar-bg: #2C2C2E;
+            --text: #F2F2F7;
+            --sub: #8E8E93;
+            --card-bg: #2C2C2E;
+            --border: #48484A;
+            --inp-bg: #3A3A3C;
+            --progress-bg: #3A3A3C;
+            --accent: #0A84FF;
+        }
+
+        html[data-theme="light"] {
+            --bg: #F2F2F7;
+            --sidebar-bg: #EFEFF4;
+            --text: #1C1C1E;
+            --sub: #6B6B70;
+            --card-bg: #FFFFFF;
+            --border: #D1D1D6;
+            --inp-bg: #FFFFFF;
+            --progress-bg: #E5E5EA;
+            --accent: #007AFF;
+        }
+
+        html, body {
+            width: 100%; height: 100%;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+            background: var(--bg);
+            color: var(--text);
+            overflow: hidden;
+        }
+
+        #app { display: flex; width: 100%; height: 100%; }
+
+        /* ── Sidebar ── */
+        #sidebar {
+            width: var(--sidebar-w);
+            min-width: var(--sidebar-w);
+            height: 100%;
+            background: var(--sidebar-bg);
+            border-right: 1px solid var(--border);
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+        }
+
+        #brand {
+            display: flex; align-items: center; gap: 9px;
+            padding: 14px 14px 12px;
+            border-bottom: 1px solid var(--border);
+            flex-shrink: 0;
+        }
+
+        #brand img { width: 28px; height: 28px; border-radius: 6px; }
+        #brand span { font-size: 14px; font-weight: 700; }
+
+        .cat-section { padding: 8px 0; flex: 1; overflow-y: auto; }
+
+        .cat-section-label {
+            font-size: 10px; font-weight: 700;
+            letter-spacing: 0.7px;
+            color: var(--sub);
+            text-transform: uppercase;
+            padding: 4px 14px 4px;
+        }
+
+        .cat-item {
+            display: flex; align-items: center; gap: 8px;
+            padding: 7px 14px;
+            border-radius: 7px;
+            margin: 1px 6px;
+            cursor: pointer;
+            font-size: 13px; font-weight: 500;
+            color: var(--text);
+            transition: background 0.1s;
+            -webkit-user-select: none; user-select: none;
+        }
+
+        .cat-item:hover { background: rgba(0,0,0,0.06); }
+
+        @media (prefers-color-scheme: dark) {
+            .cat-item:hover { background: rgba(255,255,255,0.07); }
+        }
+
+        .cat-item.active { background: var(--accent); color: #FFF; }
+        .cat-item svg { flex-shrink: 0; }
+
+        /* ── Main ── */
+        #main { flex: 1; height: 100%; overflow: hidden; position: relative; }
+
+        #toolGrid {
+            position: absolute; inset: 0;
+            padding: 20px;
+            overflow-y: auto;
+        }
+
+        .grid-title { font-size: 16px; font-weight: 700; margin-bottom: 14px; }
+
+        .tools-grid {
+            display: grid;
+            grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+            gap: 12px;
+        }
+
+        .tool-card {
+            background: var(--card-bg);
+            border: 1px solid var(--border);
+            border-radius: 12px;
+            padding: 16px;
+            cursor: pointer;
+            display: flex; flex-direction: column; gap: 9px;
+            transition: box-shadow 0.15s, transform 0.1s;
+        }
+
+        .tool-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,0.1); transform: translateY(-1px); }
+        .tool-card:active { transform: scale(0.98); box-shadow: none; }
+
+        .tc-icon {
+            width: 42px; height: 42px;
+            border-radius: 10px;
+            display: flex; align-items: center; justify-content: center;
+        }
+
+        .tc-name { font-size: 13.5px; font-weight: 700; }
+        .tc-desc { font-size: 12px; color: var(--sub); line-height: 1.4; }
+
+        /* ── Tool panel ── */
+        #toolPanel {
+            position: absolute; inset: 0;
+            display: none;
+            flex-direction: column;
+        }
+
+        #toolPanel.open { display: flex; }
+
+        .tp-header {
+            display: flex; align-items: center; gap: 10px;
+            padding: 11px 16px;
+            border-bottom: 1px solid var(--border);
+            background: var(--sidebar-bg);
+            flex-shrink: 0;
+        }
+
+        .back-btn {
+            display: flex; align-items: center; gap: 4px;
+            font-size: 13px; font-weight: 500;
+            color: var(--accent);
+            cursor: pointer;
+            padding: 4px 8px;
+            border-radius: 6px;
+            border: none; background: none;
+            font-family: inherit;
+            transition: background 0.1s;
+        }
+
+        .back-btn:hover { background: rgba(0,122,255,0.1); }
+        .tp-name { font-size: 14px; font-weight: 700; }
+
+        .tp-body { flex: 1; overflow-y: auto; padding: 20px; }
+
+        /* ── Form ── */
+        .field { margin-bottom: 15px; }
+
+        .field-label {
+            display: block;
+            font-size: 11px; font-weight: 700;
+            color: var(--sub);
+            text-transform: uppercase; letter-spacing: 0.5px;
+            margin-bottom: 6px;
+        }
+
+        .row { display: flex; align-items: center; gap: 9px; }
+
+        .btn {
+            padding: 7px 14px;
+            border-radius: 8px; border: none;
+            font-size: 13px; font-weight: 600;
+            font-family: inherit;
+            cursor: pointer;
+            transition: opacity 0.12s;
+            display: inline-flex; align-items: center; gap: 5px;
+        }
+
+        .btn-primary { background: var(--accent); color: #FFF; }
+        .btn-primary:hover { opacity: 0.87; }
+
+        .btn-outline {
+            background: transparent;
+            color: var(--accent);
+            border: 1.5px solid var(--accent);
+        }
+
+        .btn-outline:hover { background: rgba(0,122,255,0.08); }
+
+        .btn-action {
+            padding: 9px 22px;
+            background: var(--accent); color: #FFF;
+            border-radius: 10px; border: none;
+            font-size: 14px; font-weight: 600;
+            font-family: inherit;
+            cursor: pointer;
+            transition: opacity 0.12s;
+        }
+
+        .btn-action:hover { opacity: 0.87; }
+        .btn-action:disabled { opacity: 0.42; cursor: not-allowed; }
+
+        .selected-file {
+            font-size: 13px; color: var(--sub);
+            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+            flex: 1;
+        }
+
+        .selected-file.has-value { color: var(--text); }
+
+        .radio-group { display: flex; gap: 14px; }
+
+        .radio-item {
+            display: flex; align-items: center; gap: 6px;
+            font-size: 13px; cursor: pointer;
+        }
+
+        .radio-item input { cursor: pointer; accent-color: var(--accent); }
+
+        input[type=range] { flex: 1; accent-color: var(--accent); }
+        .range-row { display: flex; align-items: center; gap: 10px; }
+        .range-val { font-size: 13px; font-weight: 600; min-width: 32px; text-align: right; }
+
+        input[type=text] {
+            width: 100%;
+            padding: 8px 10px;
+            background: var(--inp-bg);
+            border: 1px solid var(--border);
+            border-radius: 8px;
+            font-size: 13px; color: var(--text);
+            font-family: inherit;
+            outline: none;
+            transition: border-color 0.15s;
+        }
+
+        input[type=text]:focus { border-color: var(--accent); }
+
+        /* Progress */
+        .progress-wrap { margin-top: 14px; }
+
+        .progress-label { font-size: 12px; color: var(--sub); margin-bottom: 5px; }
+
+        .progress-track {
+            height: 5px;
+            background: var(--progress-bg);
+            border-radius: 3px; overflow: hidden;
+        }
+
+        .progress-fill {
+            height: 100%; background: var(--accent);
+            border-radius: 3px; width: 0%;
+            transition: width 0.25s;
+        }
+
+        /* Result boxes */
+        .result-box {
+            margin-top: 14px;
+            padding: 11px 14px;
+            border-radius: 10px;
+            font-size: 13px; line-height: 1.5;
+        }
+
+        .result-ok {
+            background: rgba(48,176,110,0.12);
+            border: 1px solid rgba(48,176,110,0.28);
+            color: #1B7A43;
+        }
+
+        .result-err {
+            background: rgba(255,59,48,0.1);
+            border: 1px solid rgba(255,59,48,0.25);
+            color: #C0392B;
+        }
+
+        @media (prefers-color-scheme: dark) {
+            .result-ok { color: #4ADE80; }
+            .result-err { color: #FF6B6B; }
+        }
+
+        /* Image list */
+        .img-list {
+            border: 1px solid var(--border);
+            border-radius: 10px;
+            overflow: hidden;
+        }
+
+        .img-list-empty {
+            padding: 18px; text-align: center;
+            font-size: 13px; color: var(--sub);
+        }
+
+        .img-row {
+            display: flex; align-items: center; gap: 10px;
+            padding: 7px 10px;
+            border-bottom: 1px solid var(--border);
+            background: var(--card-bg);
+        }
+
+        .img-row:last-child { border-bottom: none; }
+
+        .img-thumb {
+            width: 34px; height: 34px;
+            border-radius: 5px;
+            object-fit: cover;
+            background: var(--progress-bg);
+            flex-shrink: 0;
+        }
+
+        .img-row-name {
+            flex: 1; font-size: 12.5px;
+            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+        }
+
+        .img-row-btns { display: flex; gap: 2px; }
+
+        .icon-btn {
+            background: none; border: none;
+            cursor: pointer; padding: 4px;
+            border-radius: 5px;
+            color: var(--sub);
+            display: flex; align-items: center; justify-content: center;
+            transition: background 0.1s;
+        }
+
+        .icon-btn:hover { background: rgba(0,0,0,0.07); color: var(--text); }
+        .icon-btn.rm:hover { color: var(--danger); background: rgba(255,59,48,0.1); }
+
+        .divider { height: 1px; background: var(--border); margin: 14px 0; }
+    </style>
+</head>
+<body>
+
+<script>
+if (window.location.hash && window.location.hash.length > 1) {
+    window.location.href = 'embedded.html' + window.location.hash;
+}
+</script>
+
+<div id="app">
+    <nav id="sidebar">
+        <div id="brand">
+            <img src="img/module_icon.svg" alt="">
+            <span>Productivity</span>
+        </div>
+        <div class="cat-section" id="catList"></div>
+    </nav>
+
+    <main id="main">
+        <div id="toolGrid"></div>
+        <div id="toolPanel"></div>
+    </main>
+</div>
+
+<script>
+/* ── Category and tool definitions ── */
+var CATEGORIES = [
+    {
+        id: 'pdf',
+        label: 'PDF Tools',
+        icon: '<svg width="15" height="15" viewBox="0 0 15 15" fill="none"' +
+              ' stroke="currentColor" stroke-width="1.6"' +
+              ' stroke-linecap="round" stroke-linejoin="round">' +
+              '<rect x="1.5" y="1" width="8" height="11" rx="1.5"/>' +
+              '<path d="M5.5 1v3.5h4.5"/>' +
+              '<rect x="8" y="8" width="5.5" height="5.5" rx="1"/>' +
+              '</svg>'
+    }
+];
+
+var TOOLS = {
+    pdf: [
+        {
+            id: 'pdf2img',
+            label: 'PDF to Images',
+            desc: 'Extract each PDF page as a JPEG or PNG image',
+            color: '#FF3B30',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<rect x="2" y="2" width="9" height="12" rx="1.5"/>' +
+                  '<path d="M6 2v4h5"/>' +
+                  '<rect x="11.5" y="10.5" width="8.5" height="9" rx="1.3"/>' +
+                  '<circle cx="14" cy="13" r="1" fill="#FFF" stroke="none"/>' +
+                  '<path d="M11.5 18.5l2.5-3 1.8 1.5 2-3 2.2 3"/>' +
+                  '</svg>',
+            template: 'tools/pdf2img.html'
+        },
+        {
+            id: 'img2pdf',
+            label: 'Images to PDF',
+            desc: 'Combine multiple images into a single PDF file',
+            color: '#007AFF',
+            icon: '<svg width="22" height="22" viewBox="0 0 22 22" fill="none"' +
+                  ' stroke="#FFF" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">' +
+                  '<rect x="1" y="5" width="10" height="9" rx="1.5"/>' +
+                  '<circle cx="4" cy="8" r="1.1" fill="#FFF" stroke="none"/>' +
+                  '<path d="M1 13l2.5-3 2 1.5 2.5-3L10 11"/>' +
+                  '<rect x="12.5" y="2" width="8.5" height="11" rx="1.5"/>' +
+                  '<path d="M16.5 2v4h4.5"/>' +
+                  '<line x1="14" y1="16" x2="19.5" y2="16"/>' +
+                  '<line x1="16.8" y1="13.5" x2="16.8" y2="18.5"/>' +
+                  '</svg>',
+            template: 'tools/img2pdf.html'
+        }
+    ]
+};
+
+var toolModules = window.ProductivityToolModules = window.ProductivityToolModules || {};
+var toolTemplateCache = {};
+var activeToolLoadToken = 0;
+
+/* ── Utilities ── */
+function dirOf(vpath) {
+    var p = vpath.split('/'); p.pop(); return p.join('/');
+}
+
+function basenameNoExt(vpath) {
+    var n = vpath.split('/').pop();
+    var d = n.lastIndexOf('.');
+    return d > 0 ? n.slice(0, d) : n;
+}
+
+function mediaUrl(vpath) { return '/media?file=' + encodeURIComponent(vpath); }
+
+function uploadBlob(blob, filename, targetDir) {
+    return new Promise(function(resolve, reject) {
+        ao_module_uploadFile(new File([blob], filename, {type: blob.type}),
+            targetDir, resolve, undefined, reject);
+    });
+}
+
+function escHtml(s) {
+    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+function allTools() {
+    var out = [];
+    Object.keys(TOOLS).forEach(function(k) { out = out.concat(TOOLS[k]); });
+    return out;
+}
+
+/* ── Navigation ── */
+var currentCat = null;
+
+function selectCat(id) {
+    currentCat = id;
+    renderSidebar();
+    showGrid(id);
+}
+
+function selectTool(id) {
+    var tool = allTools().find(function(t) { return t.id === id; });
+    if (tool) showPanel(tool);
+}
+
+function backToGrid() {
+    document.getElementById('toolPanel').className = '';
+    document.getElementById('toolGrid').style.display = 'block';
+}
+
+/* ── Sidebar render ── */
+function renderSidebar() {
+    var html = '<div class="cat-section-label">Categories</div>';
+    CATEGORIES.forEach(function(c) {
+        html += '<div class="cat-item' + (c.id === currentCat ? ' active' : '') + '"' +
+                ' onclick="selectCat(\'' + c.id + '\')">' +
+                c.icon + '<span>' + c.label + '</span></div>';
+    });
+    document.getElementById('catList').innerHTML = html;
+}
+
+/* ── Tool grid render ── */
+function showGrid(catId) {
+    document.getElementById('toolPanel').className = '';
+    var grid = document.getElementById('toolGrid');
+    grid.style.display = 'block';
+    var cat = CATEGORIES.find(function(c) { return c.id === catId; });
+    var tools = TOOLS[catId] || [];
+    var html = '<div class="grid-title">' + (cat ? escHtml(cat.label) : catId) + '</div>';
+    html += '<div class="tools-grid">';
+    tools.forEach(function(t) {
+        html += '<div class="tool-card" onclick="selectTool(\'' + t.id + '\')">' +
+                '<div class="tc-icon" style="background:' + t.color + '">' + t.icon + '</div>' +
+                '<div class="tc-name">' + escHtml(t.label) + '</div>' +
+                '<div class="tc-desc">' + escHtml(t.desc) + '</div>' +
+                '</div>';
+    });
+    html += '</div>';
+    grid.innerHTML = html;
+}
+
+/* ── Tool panel render ── */
+function showPanel(tool) {
+    document.getElementById('toolGrid').style.display = 'none';
+    var panel = document.getElementById('toolPanel');
+    panel.className = 'open';
+    panel.innerHTML =
+        '<div class="tp-header">' +
+            '<button class="back-btn" onclick="backToGrid()">' +
+                svgChevronLeft() + 'Back' +
+            '</button>' +
+            '<span class="tp-name">' + escHtml(tool.label) + '</span>' +
+        '</div>' +
+        '<div class="tp-body" id="tpBody"></div>';
+    loadToolTemplate(tool, document.getElementById('tpBody'));
+}
+
+function loadToolTemplate(tool, mountEl) {
+    activeToolLoadToken += 1;
+    var loadToken = activeToolLoadToken;
+
+    mountEl.innerHTML = '<div class="progress-label">Loading tool...</div>';
+
+    if (toolTemplateCache[tool.id]) {
+        renderToolTemplate(tool, mountEl, toolTemplateCache[tool.id], loadToken);
+        return;
+    }
+
+    $.ajax({
+        url: tool.template,
+        method: 'GET',
+        dataType: 'html',
+        cache: true
+    }).done(function(response) {
+        toolTemplateCache[tool.id] = response;
+        renderToolTemplate(tool, mountEl, response, loadToken);
+    }).fail(function() {
+        if (loadToken !== activeToolLoadToken) { return; }
+        mountEl.innerHTML =
+            '<div class="result-box result-err"><strong>Error:</strong> Failed to load tool UI.</div>';
+    });
+}
+
+function renderToolTemplate(tool, mountEl, html, loadToken) {
+    if (loadToken !== activeToolLoadToken) { return; }
+
+    var wrapper = document.createElement('div');
+    var nodes = $.parseHTML(html, document, true) || [];
+    nodes.forEach(function(node) {
+        wrapper.appendChild(node);
+    });
+
+    var scripts = wrapper.querySelectorAll('script');
+    Array.prototype.forEach.call(scripts, function(script) {
+        script.parentNode.removeChild(script);
+    });
+
+    mountEl.innerHTML = wrapper.innerHTML;
+
+    Array.prototype.forEach.call(scripts, function(script) {
+        $.globalEval(script.text || script.textContent || script.innerHTML || '');
+    });
+
+    if (!toolModules[tool.id] || typeof toolModules[tool.id].init !== 'function') {
+        mountEl.innerHTML =
+            '<div class="result-box result-err"><strong>Error:</strong> Tool module failed to initialize.</div>';
+        return;
+    }
+
+    toolModules[tool.id].init(mountEl);
+}
+
+/* ── Micro SVG icons ── */
+function svgChevronLeft() {
+    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
+           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
+           '<path d="M8.5 2 L4 6.5 L8.5 11"/></svg>';
+}
+
+function svgUp() {
+    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
+           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
+           '<path d="M2.5 8.5 L6.5 4 L10.5 8.5"/></svg>';
+}
+
+function svgDown() {
+    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
+           ' stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
+           '<path d="M2.5 4.5 L6.5 9 L10.5 4.5"/></svg>';
+}
+
+function svgX() {
+    return '<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor"' +
+           ' stroke-width="2" stroke-linecap="round">' +
+           '<line x1="2" y1="2" x2="11" y2="11"/><line x1="11" y1="2" x2="2" y2="11"/></svg>';
+}
+
+/* ── Boot ── */
+renderSidebar();
+if (CATEGORIES.length) selectCat(CATEGORIES[0].id);
+
+/* ── ArozOS system theme binding ── */
+function applyAozTheme(theme) {
+    document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : 'light');
+}
+
+ao_module_onThemeChanged(applyAozTheme);
+
+$.get(ao_root + 'system/file_system/preference?key=file_explorer/theme', function(data) {
+    applyAozTheme(data === 'darkTheme' ? 'dark' : 'light');
+});
+</script>
+</body>
+</html>

+ 37 - 0
src/web/Productivity/init.agi

@@ -0,0 +1,37 @@
+/*
+    Productivity
+    Universal file previewer and Productivity tool for ArozOS
+*/
+
+var moduleLaunchInfo = {
+    Name: "Productivity",
+    Desc: "PDF tools, image conversion, and file previewer",
+    Group: "Utilities",
+    IconPath: "Productivity/img/module_icon.svg",
+    Version: "1.0",
+    StartDir: "Productivity/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Productivity/index.html",
+    InitFWSize: [780, 560],
+    SupportEmb: true,
+    LaunchEmb: "Productivity/embedded.html",
+    InitEmbSize: [1000, 700],
+    SupportedExt: [
+        ".html", ".htm",
+        ".pdf",
+        ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico",
+        ".md", ".markdown",
+        ".txt", ".log", ".conf", ".cfg", ".ini", ".toml",
+        ".js", ".mjs", ".cjs", ".jsx",
+        ".ts", ".tsx",
+        ".py", ".rb", ".pl", ".r",
+        ".go", ".rs", ".swift", ".kt", ".scala",
+        ".java", ".c", ".h", ".cpp", ".hpp", ".cs",
+        ".php", ".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1",
+        ".json", ".yaml", ".yml", ".xml", ".css", ".scss", ".less",
+        ".sql", ".lua", ".agi", ".dockerfile", ".makefile",
+        ".gitignore", ".editorconfig", ".env"
+    ]
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));

+ 332 - 0
src/web/Productivity/tools/img2pdf.html

@@ -0,0 +1,332 @@
+<div class="field">
+    <span class="field-label">Images</span>
+    <div class="img-list" id="i2pList">
+        <div class="img-list-empty">No images added yet.</div>
+    </div>
+    <button class="btn btn-outline" style="margin-top:8px" id="i2pAddBtn">+ Add Images...</button>
+</div>
+<div class="divider"></div>
+<div class="field">
+    <span class="field-label">Output Filename</span>
+    <input type="text" id="i2pName" placeholder="output.pdf">
+</div>
+<div class="field">
+    <span class="field-label">Save to Folder</span>
+    <div class="row">
+        <button class="btn btn-primary" id="i2pFolderBtn">Select Folder...</button>
+        <span class="selected-file" id="i2pFolderLbl">Auto-detect from first image</span>
+    </div>
+</div>
+<button class="btn-action" id="i2pBtn">Create PDF</button>
+<div id="i2pProg" style="display:none" class="progress-wrap">
+    <div class="progress-label" id="i2pProgLbl">Preparing...</div>
+    <div class="progress-track"><div class="progress-fill" id="i2pFill"></div></div>
+</div>
+<div id="i2pResult"></div>
+
+<script>
+(function() {
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+    var state = { images: [], overrideDir: null, root: null };
+
+    function getRoot() {
+        return state.root && document.body.contains(state.root) ? state.root : null;
+    }
+
+    function renderList() {
+        var root = getRoot();
+        if (!root) { return; }
+
+        var list = root.querySelector('#i2pList');
+        if (!state.images.length) {
+            list.innerHTML = '<div class="img-list-empty">No images added yet.</div>';
+            return;
+        }
+
+        var html = '';
+        state.images.forEach(function(img, idx) {
+            html += '<div class="img-row">' +
+                '<img class="img-thumb" src="' + mediaUrl(img.filepath) + '" alt="">' +
+                '<span class="img-row-name">' + escHtml(img.filename) + '</span>' +
+                '<div class="img-row-btns">';
+            if (idx > 0) {
+                html += '<button class="icon-btn" data-move="-1" data-idx="' + idx + '" title="Move up">' + svgUp() + '</button>';
+            }
+            if (idx < state.images.length - 1) {
+                html += '<button class="icon-btn" data-move="1" data-idx="' + idx + '" title="Move down">' + svgDown() + '</button>';
+            }
+            html += '<button class="icon-btn rm" data-remove="1" data-idx="' + idx + '" title="Remove">' + svgX() + '</button>';
+            html += '</div></div>';
+        });
+
+        list.innerHTML = html;
+
+        Array.prototype.forEach.call(list.querySelectorAll('[data-move]'), function(btn) {
+            btn.addEventListener('click', function() {
+                var idx = parseInt(this.getAttribute('data-idx'), 10);
+                var dir = parseInt(this.getAttribute('data-move'), 10);
+                var nextIdx = idx + dir;
+                if (nextIdx < 0 || nextIdx >= state.images.length) { return; }
+                var tmp = state.images[idx];
+                state.images[idx] = state.images[nextIdx];
+                state.images[nextIdx] = tmp;
+                renderList();
+            });
+        });
+
+        Array.prototype.forEach.call(list.querySelectorAll('[data-remove]'), function(btn) {
+            btn.addEventListener('click', function() {
+                var idx = parseInt(this.getAttribute('data-idx'), 10);
+                state.images.splice(idx, 1);
+                renderList();
+            });
+        });
+    }
+
+    function imgToJpeg(url, onDone, onError) {
+        var img = new Image();
+        img.crossOrigin = 'anonymous';
+        img.onload = function() {
+            var w = img.naturalWidth;
+            var h = img.naturalHeight;
+            var canvas = document.createElement('canvas');
+            canvas.width = w;
+            canvas.height = h;
+
+            var ctx = canvas.getContext('2d');
+            ctx.fillStyle = '#FFFFFF';
+            ctx.fillRect(0, 0, w, h);
+            ctx.drawImage(img, 0, 0);
+            canvas.toBlob(function(blob) {
+                if (!blob) {
+                    onError(new Error('Canvas export failed'));
+                    return;
+                }
+
+                var reader = new FileReader();
+                reader.onload = function(ev) {
+                    onDone({ jpeg: new Uint8Array(ev.target.result), width: w, height: h });
+                };
+                reader.onerror = onError;
+                reader.readAsArrayBuffer(blob);
+            }, 'image/jpeg', 0.92);
+        };
+        img.onerror = function() { onError(new Error('Could not load image')); };
+        img.src = url;
+    }
+
+    function buildPdfFromJpegs(pages) {
+        var parts = [];
+        var offsets = {};
+        var pos = 0;
+        var enc = new TextEncoder();
+
+        function wr(s) {
+            var b = typeof s === 'string' ? enc.encode(s) : s;
+            parts.push(b);
+            pos += b.length;
+        }
+
+        function objStart(id) {
+            offsets[id] = pos;
+            wr(id + ' 0 obj\n');
+        }
+
+        function objEnd() {
+            wr('\nendobj\n');
+        }
+
+        wr('%PDF-1.4\n');
+        wr(new Uint8Array([0x25, 0xC2, 0xB4, 0xC3, 0xA5, 0x0A]));
+
+        var n = pages.length;
+        objStart(1);
+        wr('<< /Type /Catalog /Pages 2 0 R >>');
+        objEnd();
+
+        var kids = '';
+        objStart(2);
+        for (var k = 0; k < n; k++) {
+            kids += (3 + k * 3) + ' 0 R ';
+        }
+        wr('<< /Type /Pages /Kids [' + kids.trim() + '] /Count ' + n + ' >>');
+        objEnd();
+
+        for (var i = 0; i < n; i++) {
+            var pg = pages[i];
+            var pId = 3 + i * 3;
+            var cId = pId + 1;
+            var xId = pId + 2;
+            var name = 'Im' + (i + 1);
+
+            objStart(pId);
+            wr('<< /Type /Page /Parent 2 0 R' +
+               ' /MediaBox [0 0 ' + pg.width + ' ' + pg.height + ']' +
+               ' /Contents ' + cId + ' 0 R' +
+               ' /Resources << /XObject << /' + name + ' ' + xId + ' 0 R >> >> >>');
+            objEnd();
+
+            var content = enc.encode('q ' + pg.width + ' 0 0 ' + pg.height + ' 0 0 cm /' + name + ' Do Q');
+            objStart(cId);
+            wr('<< /Length ' + content.length + ' >>\nstream\n');
+            wr(content);
+            wr('\nendstream');
+            objEnd();
+
+            objStart(xId);
+            wr('<< /Type /XObject /Subtype /Image' +
+               ' /Width ' + pg.width + ' /Height ' + pg.height +
+               ' /ColorSpace /DeviceRGB /BitsPerComponent 8' +
+               ' /Filter /DCTDecode /Length ' + pg.jpeg.length + ' >>\nstream\n');
+            wr(pg.jpeg);
+            wr('\nendstream');
+            objEnd();
+        }
+
+        var xrefPos = pos;
+        var totalObjs = 2 + n * 3;
+        wr('xref\n0 ' + (totalObjs + 1) + '\n');
+        wr('0000000000 65535 f \n');
+        for (var id = 1; id <= totalObjs; id++) {
+            var off = (offsets[id] || 0).toString();
+            while (off.length < 10) {
+                off = '0' + off;
+            }
+            wr(off + ' 00000 n \n');
+        }
+        wr('trailer\n<< /Size ' + (totalObjs + 1) + ' /Root 1 0 R >>\n');
+        wr('startxref\n' + xrefPos + '\n%%EOF\n');
+
+        var total = 0;
+        for (var j = 0; j < parts.length; j++) {
+            total += parts[j].length;
+        }
+
+        var out = new Uint8Array(total);
+        var offset = 0;
+        for (var k2 = 0; k2 < parts.length; k2++) {
+            out.set(parts[k2], offset);
+            offset += parts[k2].length;
+        }
+        return out;
+    }
+
+    registry.img2pdf = {
+        init: function(root) {
+            state = { images: [], overrideDir: null, root: root };
+
+            root.querySelector('#i2pAddBtn').addEventListener('click', function() {
+                ao_module_openFileSelector('_i2pAddCb', 'user:/', 'file', true);
+            });
+
+            root.querySelector('#i2pFolderBtn').addEventListener('click', function() {
+                ao_module_openFileSelector('_i2pFolderCb', 'user:/', 'folder', false);
+            });
+
+            root.querySelector('#i2pBtn').addEventListener('click', function() {
+                if (!state.images.length) {
+                    alert('Please add at least one image.');
+                    return;
+                }
+
+                var nameValue = root.querySelector('#i2pName').value.trim() || 'output';
+                if (!nameValue.toLowerCase().endsWith('.pdf')) {
+                    nameValue += '.pdf';
+                }
+
+                var outDir = state.overrideDir || dirOf(state.images[0].filepath);
+                var btn = root.querySelector('#i2pBtn');
+                var prog = root.querySelector('#i2pProg');
+                var lbl = root.querySelector('#i2pProgLbl');
+                var fill = root.querySelector('#i2pFill');
+                var result = root.querySelector('#i2pResult');
+                var pages = [];
+                var total = state.images.length;
+                var idx = 0;
+                var outName = nameValue;
+
+                btn.disabled = true;
+                prog.style.display = 'block';
+                result.innerHTML = '';
+
+                function loadNext() {
+                    if (idx >= total) {
+                        lbl.textContent = 'Building PDF...';
+                        fill.style.width = '88%';
+                        setTimeout(function() {
+                            try {
+                                var pdfBytes = buildPdfFromJpegs(pages);
+                                var blob = new Blob([pdfBytes], { type: 'application/pdf' });
+                                lbl.textContent = 'Uploading...';
+                                fill.style.width = '94%';
+                                uploadBlob(blob, outName, outDir).then(function() {
+                                    fill.style.width = '100%';
+                                    lbl.textContent = 'Done!';
+                                    result.innerHTML =
+                                        '<div class="result-box result-ok"><strong>' + escHtml(outName) +
+                                        '</strong> saved to<br>' + escHtml(outDir) + '</div>';
+                                    btn.disabled = false;
+                                }, function(err) {
+                                    result.innerHTML =
+                                        '<div class="result-box result-err"><strong>Upload failed:</strong> ' +
+                                        escHtml(String(err)) + '</div>';
+                                    btn.disabled = false;
+                                });
+                            } catch (err) {
+                                result.innerHTML =
+                                    '<div class="result-box result-err"><strong>Error:</strong> ' +
+                                    escHtml(err && err.message ? err.message : String(err)) + '</div>';
+                                btn.disabled = false;
+                            }
+                        }, 0);
+                        return;
+                    }
+
+                    lbl.textContent = 'Loading image ' + (idx + 1) + ' of ' + total + '...';
+                    fill.style.width = ((idx / total) * 80) + '%';
+
+                    imgToJpeg(mediaUrl(state.images[idx].filepath), function(pageData) {
+                        pages.push(pageData);
+                        idx += 1;
+                        loadNext();
+                    }, function(err) {
+                        result.innerHTML =
+                            '<div class="result-box result-err"><strong>Failed on image ' +
+                            (idx + 1) + ':</strong> ' + escHtml(String(err)) + '</div>';
+                        btn.disabled = false;
+                    });
+                }
+
+                loadNext();
+            });
+
+            renderList();
+        }
+    };
+
+    window._i2pAddCb = function(files) {
+        var root = getRoot();
+        if (!root || !files || !files.length) { return; }
+
+        files.forEach(function(file) {
+            var exists = state.images.find(function(img) {
+                return img.filepath === file.filepath;
+            });
+            if (!exists) {
+                state.images.push({ filepath: file.filepath, filename: file.filename });
+            }
+        });
+        renderList();
+    };
+
+    window._i2pFolderCb = function(files) {
+        var root = getRoot();
+        if (!root || !files || !files.length) { return; }
+
+        state.overrideDir = files[0].filepath;
+        var label = root.querySelector('#i2pFolderLbl');
+        label.textContent = files[0].filepath;
+        label.className = 'selected-file has-value';
+    };
+})();
+</script>

+ 177 - 0
src/web/Productivity/tools/pdf2img.html

@@ -0,0 +1,177 @@
+<div class="field">
+    <span class="field-label">Input PDF</span>
+    <div class="row">
+        <button class="btn btn-primary" id="p2iPickBtn">Select PDF...</button>
+        <span class="selected-file" id="p2iFile">No file selected</span>
+    </div>
+</div>
+<div class="field">
+    <span class="field-label">Output Format</span>
+    <div class="radio-group">
+        <label class="radio-item"><input type="radio" name="p2iFmt" value="jpg" checked> JPEG</label>
+        <label class="radio-item"><input type="radio" name="p2iFmt" value="png"> PNG</label>
+    </div>
+</div>
+<div class="field" id="p2iQfield">
+    <span class="field-label">JPEG Quality: <span id="p2iQval">90</span>%</span>
+    <div class="range-row">
+        <span style="font-size:11px;color:var(--sub)">50</span>
+        <input type="range" id="p2iQrange" min="50" max="100" value="90">
+        <span style="font-size:11px;color:var(--sub)">100</span>
+    </div>
+</div>
+<button class="btn-action" id="p2iBtn" disabled>Convert</button>
+<div id="p2iProg" style="display:none" class="progress-wrap">
+    <div class="progress-label" id="p2iProgLbl">Preparing...</div>
+    <div class="progress-track"><div class="progress-fill" id="p2iFill"></div></div>
+</div>
+<div id="p2iResult"></div>
+
+<script>
+(function() {
+    var registry = window.ProductivityToolModules = window.ProductivityToolModules || {};
+    var state = { filepath: null, root: null };
+    var pdfJsReady = false;
+
+    function getRoot() {
+        return state.root && document.body.contains(state.root) ? state.root : null;
+    }
+
+    function loadPdfJs(onReady, onError) {
+        if (pdfJsReady || typeof pdfjsLib !== 'undefined') {
+            if (!pdfJsReady) {
+                pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+                pdfJsReady = true;
+            }
+            onReady();
+            return;
+        }
+
+        var s = document.createElement('script');
+        s.src = '../PDF Viewer/js/pdf.js';
+        s.onload = function() {
+            pdfjsLib.GlobalWorkerOptions.workerSrc = '../PDF Viewer/js/pdf.worker.js';
+            pdfJsReady = true;
+            onReady();
+        };
+        s.onerror = function() { onError(new Error('Failed to load PDF library')); };
+        document.head.appendChild(s);
+    }
+
+    function runConversion(filepath, fmt, quality, onProgress, onDone, onError) {
+        loadPdfJs(function() {
+            pdfjsLib.getDocument(mediaUrl(filepath)).promise.then(function(pdf) {
+                var numPages = pdf.numPages;
+                var basename = basenameNoExt(filepath);
+                var outDir = dirOf(filepath);
+                var mime = fmt === 'png' ? 'image/png' : 'image/jpeg';
+                var ext = fmt === 'png' ? '.png' : '.jpg';
+                var i = 1;
+
+                function nextPage() {
+                    if (i > numPages) {
+                        onDone(numPages, outDir);
+                        return;
+                    }
+
+                    onProgress(i, numPages);
+                    pdf.getPage(i).then(function(page) {
+                        var vp = page.getViewport({ scale: 2.0 });
+                        var canvas = document.createElement('canvas');
+                        canvas.width = vp.width;
+                        canvas.height = vp.height;
+
+                        var ctx = canvas.getContext('2d');
+                        if (fmt !== 'png') {
+                            ctx.fillStyle = '#FFF';
+                            ctx.fillRect(0, 0, vp.width, vp.height);
+                        }
+
+                        page.render({ canvasContext: ctx, viewport: vp }).promise.then(function() {
+                            canvas.toBlob(function(blob) {
+                                uploadBlob(blob, basename + '_page' + i + ext, outDir).then(function() {
+                                    i += 1;
+                                    nextPage();
+                                }, onError);
+                            }, mime, quality / 100);
+                        }, onError);
+                    }, onError);
+                }
+
+                nextPage();
+            }, onError);
+        }, onError);
+    }
+
+    registry.pdf2img = {
+        init: function(root) {
+            state = { filepath: null, root: root };
+
+            var qRange = root.querySelector('#p2iQrange');
+            var qValue = root.querySelector('#p2iQval');
+            var qField = root.querySelector('#p2iQfield');
+            var pickBtn = root.querySelector('#p2iPickBtn');
+            var convertBtn = root.querySelector('#p2iBtn');
+
+            qRange.addEventListener('input', function() {
+                qValue.textContent = this.value;
+            });
+
+            Array.prototype.forEach.call(root.querySelectorAll('input[name=p2iFmt]'), function(input) {
+                input.addEventListener('change', function() {
+                    qField.style.display = this.value === 'jpg' ? '' : 'none';
+                });
+            });
+
+            pickBtn.addEventListener('click', function() {
+                ao_module_openFileSelector('_p2iPickCb', 'user:/', 'file', false);
+            });
+
+            convertBtn.addEventListener('click', function() {
+                if (!state.filepath) { return; }
+
+                var btn = root.querySelector('#p2iBtn');
+                var prog = root.querySelector('#p2iProg');
+                var lbl = root.querySelector('#p2iProgLbl');
+                var fill = root.querySelector('#p2iFill');
+                var result = root.querySelector('#p2iResult');
+                var fmt = root.querySelector('input[name=p2iFmt]:checked').value;
+                var qual = parseInt(root.querySelector('#p2iQrange').value, 10);
+
+                btn.disabled = true;
+                prog.style.display = 'block';
+                result.innerHTML = '';
+
+                runConversion(state.filepath, fmt, qual, function(cur, total) {
+                    lbl.textContent = 'Page ' + cur + ' of ' + total + '...';
+                    fill.style.width = ((cur / total) * 100) + '%';
+                }, function(numPages, outDir) {
+                    lbl.textContent = 'Done!';
+                    fill.style.width = '100%';
+                    result.innerHTML =
+                        '<div class="result-box result-ok"><strong>' + numPages +
+                        ' image' + (numPages !== 1 ? 's' : '') + ' saved</strong> to<br>' +
+                        escHtml(outDir) + '</div>';
+                    btn.disabled = false;
+                }, function(err) {
+                    result.innerHTML =
+                        '<div class="result-box result-err"><strong>Error:</strong> ' +
+                        escHtml(err && err.message ? err.message : String(err)) + '</div>';
+                    btn.disabled = false;
+                });
+            });
+        }
+    };
+
+    window._p2iPickCb = function(files) {
+        var root = getRoot();
+        if (!root || !files || !files.length) { return; }
+
+        state.filepath = files[0].filepath;
+        var label = root.querySelector('#p2iFile');
+        label.textContent = files[0].filename;
+        label.className = 'selected-file has-value';
+        root.querySelector('#p2iBtn').disabled = false;
+    };
+})();
+</script>