From 155a8c0305476f7cadb75c02a28648fdd89b5d96 Mon Sep 17 00:00:00 2001 From: ggq-admin Date: Sun, 19 Oct 2025 01:55:44 +0200 Subject: [PATCH] feat: NaviDocs MVP - Complete codebase extraction from lilian1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Backend (server/) - Express 5 API with security middleware (helmet, rate limiting) - SQLite database with WAL mode (schema from docs/architecture/) - Meilisearch integration with tenant tokens - BullMQ + Redis background job queue - OCR pipeline with Tesseract.js - File safety validation (extension, MIME, size) - 4 API route modules: upload, jobs, search, documents ## Frontend (client/) - Vue 3 with Composition API ( + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..e201aa7 --- /dev/null +++ b/client/package.json @@ -0,0 +1,26 @@ +{ + "name": "navidocs-client", + "version": "1.0.0", + "description": "NaviDocs frontend - Vue 3 boat manual management UI", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0", + "pdfjs-dist": "^4.0.0", + "meilisearch": "^0.41.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0", + "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "playwright": "^1.40.0" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..c5288cc --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,9 @@ + + + diff --git a/client/src/assets/main.css b/client/src/assets/main.css new file mode 100644 index 0000000..797a9b1 --- /dev/null +++ b/client/src/assets/main.css @@ -0,0 +1,107 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom styles */ +@layer base { + * { + @apply border-dark-200; + } + + body { + @apply font-sans antialiased; + } +} + +@layer components { + /* Button styles */ + .btn { + @apply inline-flex items-center justify-center px-6 py-3 font-medium rounded transition-all duration-200; + @apply focus:outline-none focus:ring-2 focus:ring-offset-2; + } + + .btn-primary { + @apply bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500; + } + + .btn-secondary { + @apply bg-secondary-500 text-white hover:bg-secondary-600 focus:ring-secondary-500; + } + + .btn-outline { + @apply border-2 border-dark-300 text-dark-700 hover:bg-dark-50 focus:ring-dark-500; + } + + .btn-sm { + @apply px-4 py-2 text-sm; + } + + .btn-lg { + @apply px-8 py-4 text-lg; + } + + /* Input styles */ + .input { + @apply w-full px-4 py-3 border border-dark-300 rounded bg-white; + @apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; + @apply transition-all duration-200; + } + + /* Card styles */ + .card { + @apply bg-white rounded-lg shadow-soft p-6; + } + + .card-hover { + @apply card hover:shadow-soft-lg transition-shadow duration-200; + } + + /* Search bar */ + .search-bar { + @apply relative w-full max-w-2xl mx-auto; + } + + .search-input { + @apply w-full h-14 px-6 pr-12 rounded-lg border-2 border-dark-200; + @apply focus:outline-none focus:border-primary-500 focus:ring-4 focus:ring-primary-100; + @apply transition-all duration-200 text-lg; + } + + /* Loading spinner */ + .spinner { + @apply inline-block w-6 h-6 border-4 border-dark-200 border-t-primary-500 rounded-full; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Modal */ + .modal-overlay { + @apply fixed inset-0 bg-dark-900 bg-opacity-50 flex items-center justify-center z-50; + } + + .modal-content { + @apply bg-white rounded-lg shadow-soft-lg p-8 max-w-2xl w-full mx-4; + @apply max-h-screen overflow-y-auto; + } + + /* Toast notification */ + .toast { + @apply fixed bottom-6 right-6 bg-white rounded-lg shadow-soft-lg p-4 z-50; + @apply border-l-4 border-success-500; + animation: slideIn 0.3s ease-out; + } + + @keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } +} diff --git a/client/src/components/FigureZoom.vue b/client/src/components/FigureZoom.vue new file mode 100644 index 0000000..1210e43 --- /dev/null +++ b/client/src/components/FigureZoom.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/client/src/components/UploadModal.vue b/client/src/components/UploadModal.vue new file mode 100644 index 0000000..dcb39a4 --- /dev/null +++ b/client/src/components/UploadModal.vue @@ -0,0 +1,418 @@ + + + + + diff --git a/client/src/composables/useJobPolling.js b/client/src/composables/useJobPolling.js new file mode 100644 index 0000000..0c66c75 --- /dev/null +++ b/client/src/composables/useJobPolling.js @@ -0,0 +1,81 @@ +/** + * Job Polling Composable + * Polls job status every 2 seconds until completion or failure + */ + +import { ref, onUnmounted } from 'vue' + +export function useJobPolling() { + const jobId = ref(null) + const jobStatus = ref('pending') + const jobProgress = ref(0) + const jobError = ref(null) + let pollInterval = null + + async function startPolling(id) { + jobId.value = id + jobStatus.value = 'pending' + jobProgress.value = 0 + jobError.value = null + + // Clear any existing interval + if (pollInterval) { + clearInterval(pollInterval) + } + + // Poll immediately + await pollStatus() + + // Then poll every 2 seconds + pollInterval = setInterval(async () => { + await pollStatus() + + // Stop polling if job is complete or failed + if (jobStatus.value === 'completed' || jobStatus.value === 'failed') { + stopPolling() + } + }, 2000) + } + + async function pollStatus() { + if (!jobId.value) return + + try { + const response = await fetch(`/api/jobs/${jobId.value}`) + const data = await response.json() + + if (response.ok) { + jobStatus.value = data.status + jobProgress.value = data.progress || 0 + jobError.value = data.error || null + } else { + console.error('Poll error:', data.error) + // Don't stop polling on transient errors + } + } catch (error) { + console.error('Poll request failed:', error) + // Don't stop polling on network errors + } + } + + function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = null + } + } + + // Cleanup on unmount + onUnmounted(() => { + stopPolling() + }) + + return { + jobId, + jobStatus, + jobProgress, + jobError, + startPolling, + stopPolling + } +} diff --git a/client/src/composables/useSearch.js b/client/src/composables/useSearch.js new file mode 100644 index 0000000..1637faa --- /dev/null +++ b/client/src/composables/useSearch.js @@ -0,0 +1,181 @@ +/** + * Meilisearch Composable + * Handles search with tenant tokens for secure client-side search + */ + +import { ref } from 'vue' +import { MeiliSearch } from 'meilisearch' + +export function useSearch() { + const searchClient = ref(null) + const tenantToken = ref(null) + const tokenExpiresAt = ref(null) + const indexName = ref('navidocs-pages') + const results = ref([]) + const loading = ref(false) + const error = ref(null) + const searchTime = ref(0) + + /** + * Get or refresh tenant token from backend + */ + async function getTenantToken() { + // Check if existing token is still valid (with 5 min buffer) + if (tenantToken.value && tokenExpiresAt.value) { + const now = Date.now() + const expiresIn = tokenExpiresAt.value - now + if (expiresIn > 5 * 60 * 1000) { // 5 minutes buffer + return tenantToken.value + } + } + + try { + const response = await fetch('/api/search/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + // TODO: Add JWT auth header when auth is implemented + // 'Authorization': `Bearer ${jwtToken}` + } + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get search token') + } + + tenantToken.value = data.token + tokenExpiresAt.value = new Date(data.expiresAt).getTime() + indexName.value = data.indexName + + // Initialize Meilisearch client with tenant token + searchClient.value = new MeiliSearch({ + host: data.searchUrl || 'http://127.0.0.1:7700', + apiKey: data.token + }) + + return data.token + } catch (err) { + console.error('Failed to get tenant token:', err) + error.value = err.message + throw err + } + } + + /** + * Perform search against Meilisearch + */ + async function search(query, options = {}) { + if (!query.trim()) { + results.value = [] + return results.value + } + + loading.value = true + error.value = null + const startTime = performance.now() + + try { + // Ensure we have a valid token + await getTenantToken() + + if (!searchClient.value) { + throw new Error('Search client not initialized') + } + + const index = searchClient.value.index(indexName.value) + + // Build search params + const searchParams = { + limit: options.limit || 20, + attributesToHighlight: ['text', 'title'], + highlightPreTag: '', + highlightPostTag: '', + ...options.filters && { filter: buildFilters(options.filters) }, + ...options.sort && { sort: options.sort } + } + + const searchResults = await index.search(query, searchParams) + + results.value = searchResults.hits + searchTime.value = Math.round(performance.now() - startTime) + + return searchResults + } catch (err) { + console.error('Search failed:', err) + error.value = err.message + results.value = [] + throw err + } finally { + loading.value = false + } + } + + /** + * Build Meilisearch filter string from filter object + */ + function buildFilters(filters) { + const conditions = [] + + if (filters.documentType) { + conditions.push(`documentType = "${filters.documentType}"`) + } + + if (filters.boatMake) { + conditions.push(`boatMake = "${filters.boatMake}"`) + } + + if (filters.boatModel) { + conditions.push(`boatModel = "${filters.boatModel}"`) + } + + if (filters.systems && filters.systems.length > 0) { + const systemFilters = filters.systems.map(s => `"${s}"`).join(', ') + conditions.push(`systems IN [${systemFilters}]`) + } + + if (filters.categories && filters.categories.length > 0) { + const categoryFilters = filters.categories.map(c => `"${c}"`).join(', ') + conditions.push(`categories IN [${categoryFilters}]`) + } + + return conditions.join(' AND ') + } + + /** + * Get facet values for filters + */ + async function getFacets(attributes = ['documentType', 'boatMake', 'boatModel', 'systems', 'categories']) { + try { + await getTenantToken() + + if (!searchClient.value) { + throw new Error('Search client not initialized') + } + + const index = searchClient.value.index(indexName.value) + + const searchResults = await index.search('', { + facets: attributes, + limit: 0 + }) + + return searchResults.facetDistribution + } catch (err) { + console.error('Failed to get facets:', err) + error.value = err.message + throw err + } + } + + return { + results, + loading, + error, + searchTime, + search, + getFacets, + getTenantToken + } +} diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..bad15fe --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,29 @@ +/** + * NaviDocs Frontend - Vue 3 Entry Point + */ + +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './assets/main.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') + +// Register service worker for PWA +if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js') + .then(registration => { + console.log('Service Worker registered:', registration); + }) + .catch(error => { + console.error('Service Worker registration failed:', error); + }); + }); +} diff --git a/client/src/router.js b/client/src/router.js new file mode 100644 index 0000000..20bb474 --- /dev/null +++ b/client/src/router.js @@ -0,0 +1,29 @@ +/** + * Vue Router configuration + */ + +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from './views/HomeView.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'home', + component: HomeView + }, + { + path: '/search', + name: 'search', + component: () => import('./views/SearchView.vue') + }, + { + path: '/document/:id', + name: 'document', + component: () => import('./views/DocumentView.vue') + } + ] +}) + +export default router diff --git a/client/src/views/DocumentView.vue b/client/src/views/DocumentView.vue new file mode 100644 index 0000000..6f53c56 --- /dev/null +++ b/client/src/views/DocumentView.vue @@ -0,0 +1,47 @@ + + + diff --git a/client/src/views/HomeView.vue b/client/src/views/HomeView.vue new file mode 100644 index 0000000..aa07456 --- /dev/null +++ b/client/src/views/HomeView.vue @@ -0,0 +1,119 @@ + + + diff --git a/client/src/views/SearchView.vue b/client/src/views/SearchView.vue new file mode 100644 index 0000000..164f44d --- /dev/null +++ b/client/src/views/SearchView.vue @@ -0,0 +1,113 @@ + + + diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 0000000..8ef8116 --- /dev/null +++ b/client/tailwind.config.js @@ -0,0 +1,79 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + './index.html', + './src/**/*.{vue,js,ts,jsx,tsx}', + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + secondary: { + 50: '#eef2ff', + 100: '#e0e7ff', + 200: '#c7d2fe', + 300: '#a5b4fc', + 400: '#818cf8', + 500: '#6366f1', + 600: '#4f46e5', + 700: '#4338ca', + 800: '#3730a3', + 900: '#312e81', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#10b981', + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + }, + dark: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + } + }, + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + mono: ['Fira Code', 'Menlo', 'Monaco', 'Courier New', 'monospace'], + }, + borderRadius: { + DEFAULT: '12px', + lg: '16px', + xl: '20px', + }, + boxShadow: { + 'soft': '0 4px 24px rgba(0, 0, 0, 0.08)', + 'soft-lg': '0 8px 40px rgba(0, 0, 0, 0.12)', + }, + spacing: { + '18': '4.5rem', + '22': '5.5rem', + } + }, + }, + plugins: [], +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..c97516c --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,33 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true + } + } + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + 'vendor': ['vue', 'vue-router', 'pinia'], + 'pdf': ['pdfjs-dist'] + } + } + } + } +}) diff --git a/docs/analysis/lilian1-extraction-plan.md b/docs/analysis/lilian1-extraction-plan.md new file mode 100644 index 0000000..d72d3ae --- /dev/null +++ b/docs/analysis/lilian1-extraction-plan.md @@ -0,0 +1,621 @@ +# lilian1 (FRANK-AI) Code Extraction Plan + +**Date:** 2025-10-19 +**Purpose:** Extract clean, production-ready code from lilian1 prototype; discard experimental Frank-AI features +**Target:** NaviDocs MVP with Meilisearch-inspired design + +--- + +## Executive Summary + +lilian1 is a working boat manual assistant prototype called "FRANK-AI" with: +- **Total size:** 2794 lines of JavaScript (7 files) +- **Clean code:** ~940 lines worth extracting +- **Frank-AI junk:** ~1850 lines to discard +- **Documentation:** 56+ experimental markdown files to discard + +### Key Decision: What to Extract vs Discard + +| Category | Extract | Discard | Reason | +|----------|---------|---------|--------| +| Manual management | ✅ | | Core upload/job polling logic is solid | +| Figure zoom | ✅ | | Excellent UX, accessibility-first, production-ready | +| Service worker | ✅ | | PWA pattern is valuable for offline boat manuals | +| Quiz system | | ❌ | Gamification - not in NaviDocs MVP scope | +| Persona system | | ❌ | AI personality - not needed | +| Gamification | | ❌ | Points/achievements - not in MVP scope | +| Debug overlay | | ❌ | Development tool - replace with proper logging | + +--- + +## Files to Extract + +### 1. app/js/manuals.js (451 lines) + +**What it does:** +- Upload PDF to backend +- Poll job status with progress tracking +- Catalog loading (manuals list) +- Modal controls for upload UI +- Toast notifications + +**Clean patterns to port to Vue:** +```javascript +// Job polling pattern (lines 288-322) +async function startPolling(jobId) { + pollInterval = setInterval(async () => { + const response = await fetch(`${apiBase}/api/manuals/jobs/${jobId}`); + const data = await response.json(); + updateJobStatus(data); + if (data.status === 'completed' || data.status === 'failed') { + clearInterval(pollInterval); + } + }, 2000); +} +``` + +**Port to NaviDocs as:** +- `client/src/components/UploadModal.vue` - Upload UI +- `client/src/composables/useJobPolling.js` - Polling logic +- `client/src/composables/useManualsCatalog.js` - Catalog state + +**Discard:** +- Line 184: `ingestFromUrl()` - Claude CLI integration (not in MVP) +- Line 134: `findManuals()` - Claude search (replace with Meilisearch) + +--- + +### 2. app/js/figure-zoom.js (299 lines) + +**What it does:** +- Pan/zoom for PDF page images +- Mouse wheel, drag, touch pinch controls +- Keyboard shortcuts (+, -, 0) +- Accessibility (aria-labels, prefers-reduced-motion) +- Premium UX (spring easing) + +**This is EXCELLENT code - port as-is to Vue:** +- `client/src/components/FigureZoom.vue` - Wrap in Vue component +- Keep all logic: updateTransform, bindMouseEvents, bindTouchEvents +- Keep accessibility features + +**Why it's good:** +- Respects `prefers-reduced-motion` +- Proper event cleanup +- Touch support for mobile +- Smooth animations with cubic-bezier easing + +--- + +### 3. app/service-worker.js (192 lines) + +**What it does:** +- PWA offline caching +- Precache critical files (index.html, CSS, JS, data files) +- Cache-first strategy for data, network-first for HTML +- Background sync hooks (future) +- Push notification hooks (future) + +**Port to NaviDocs as:** +- `client/public/service-worker.js` - Adapt for Vue/Vite build +- Update PRECACHE_URLS to match Vite build output +- Keep cache-first strategy for manuals (important for boats with poor connectivity) + +**Changes needed:** +```javascript +// OLD: FRANK-AI hardcoded paths +const PRECACHE_URLS = ['/index.html', '/css/app.css', ...]; + +// NEW: Vite build output (generated from manifest) +const PRECACHE_URLS = [ + '/', + '/assets/index-[hash].js', + '/assets/index-[hash].css', + '/data/manuals.json' +]; +``` + +--- + +### 4. data/glossary.json (184 lines) + +**What it is:** +- Boat manual terminology index +- Maps terms to page numbers +- Examples: "Bilge", "Blackwater", "Windlass", "Galley", "Seacock" + +**How to use:** +- Extract unique terms +- Add to Meilisearch synonyms config (we already have 40+, this adds more) +- Use for autocomplete suggestions in search bar + +**Example extraction:** +```javascript +// Terms we don't have yet in meilisearch-config.json: +"seacock": ["through-hull", "thru-hull"], // ✅ Already have +"demister": ["defroster", "windscreen demister"], // ➕ Add +"reboarding": ["ladder", "swim platform"], // ➕ Add +"mooring": ["docking", "tie-up"], // ➕ Add +``` + +--- + +## Files to Discard + +### Gamification / AI Persona (Frank-AI Experiments) + +| File | Lines | Reason to Discard | +|------|-------|-------------------| +| app/js/quiz.js | 209 | Quiz game - not in MVP scope | +| app/js/persona.js | 209 | AI personality system - not needed | +| app/js/gamification.js | 304 | Points/badges/achievements - not in MVP | +| app/js/debug-overlay.js | ~100 | Dev tool - replace with proper logging | + +**Total discarded:** ~820 lines + +--- + +### Documentation Files (56+ files to discard) + +All files starting with: +- `CLAUDE_SUPERPROMPT_*.md` (8 files) - AI experiment prompts +- `FRANK_AI_*.md` (3 files) - Frank-AI specific docs +- `FIGURE_*.md` (6 files) - Figure implementation docs (interesting but not needed) +- `TEST_*.md` (8 files) - Test reports (good to read, but don't copy) +- `*_REPORT.md` (12 files) - Sprint reports +- `*_SUMMARY.md` (10 files) - Session summaries +- `SECURITY-*.md` (3 files) - Security audits (good insights, already captured in hardened-production-guide.md) +- `UX-*.md` (3 files) - UX reviews + +**Keep for reference (read but don't copy):** +- `README.md` - Understand the project +- `CHANGES.md` - What was changed over time +- `DEMO_ACCESS.txt` - How to run lilian1 + +**Total:** ~1200 lines of markdown to discard + +--- + +## Migration Strategy + +### Phase 1: Bootstrap NaviDocs Structure + +```bash +cd ~/navidocs + +# Create directories +mkdir -p server/{routes,services,workers,db,config} +mkdir -p client/{src/{components,composables,views,stores,assets},public} + +# Initialize package.json files +``` + +**server/package.json:** +```json +{ + "name": "navidocs-server", + "version": "1.0.0", + "type": "module", + "dependencies": { + "express": "^5.0.0", + "better-sqlite3": "^11.0.0", + "meilisearch": "^0.41.0", + "bullmq": "^5.0.0", + "helmet": "^7.0.0", + "express-rate-limit": "^7.0.0", + "tesseract.js": "^5.0.0", + "uuid": "^10.0.0", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.0" + } +} +``` + +**client/package.json:** +```json +{ + "name": "navidocs-client", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0", + "pdfjs-dist": "^4.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0", + "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0" + } +} +``` + +--- + +### Phase 2: Port Clean Code + +#### Step 1: Figure Zoom Component + +**From:** lilian1/app/js/figure-zoom.js +**To:** navidocs/client/src/components/FigureZoom.vue + +**Changes:** +- Wrap in Vue component +- Use Vue refs for state (`scale`, `translateX`, `translateY`) +- Use Vue lifecycle hooks (`onMounted`, `onUnmounted`) +- Keep all UX logic identical + +**Implementation:** +```vue + + + +``` + +#### Step 2: Upload Modal Component + +**From:** lilian1/app/js/manuals.js (lines 228-263) +**To:** navidocs/client/src/components/UploadModal.vue + +**Changes:** +- Replace vanilla DOM manipulation with Vue reactivity +- Use `