diff --git a/.gitignore b/.gitignore index 510818df..9504316c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,113 @@ -__pycache__/ -*.pyc -*.pyo -*.egg-info/ -*.egg +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs dist/ build/ -.venv/ +*.dist + +# Environment variables .env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files .DS_Store -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ -*.so -/.cache* -/exp*/ -/.tmp/ -/results/ -/data/ -/download -/local/ -/run* -example.py -results/ -examples/data* -examples/download* -examples/exp* -.claude/ -*.wav -*.jsonl +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Webdev artifacts (checkpoint zips, migrations, etc.) +.webdev/ + +# Manus version file (auto-generated, not part of source) +client/public/__manus__/version.json +.project-config.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..27a587df --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +.git +*.min.js +*.min.css diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..67c0bc83 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/LAUNCH_INSTRUCTIONS.md b/LAUNCH_INSTRUCTIONS.md new file mode 100644 index 00000000..aa280293 --- /dev/null +++ b/LAUNCH_INSTRUCTIONS.md @@ -0,0 +1,162 @@ +# Hebrew Transcription App - Launch Instructions + +Welcome to the Hebrew Transcription App! This guide will help you set up and launch the application with sample Hebrew audio and transcription files. + +## Project Structure + +The project is organized as follows: + +``` +hebrew-transcription-app/ +├── Materials/ # Folder containing sample files +│ ├── sample_recording.wav # Hebrew audio sample +│ └── sample_transcription.txt # Hebrew transcription sample +├── launch.sh # Launch script for Linux/macOS +├── launch.bat # Launch script for Windows +├── client/ # React frontend source code +├── server/ # Backend server (placeholder) +├── package.json # Project dependencies +└── LAUNCH_INSTRUCTIONS.md # This file +``` + +## Prerequisites + +Before launching the application, ensure you have the following installed: + +- **Node.js** (version 18 or higher) +- **pnpm** (package manager) +- **Web Browser** (Chrome, Firefox, Safari, or Edge) + +## Setup Instructions + +### Step 1: Install Dependencies + +Navigate to the project directory and install all required dependencies: + +```bash +cd hebrew-transcription-app +pnpm install +``` + +### Step 2: Start the Development Server + +Run the development server: + +```bash +pnpm dev +``` + +The server will start on `http://localhost:3000/`. You should see output similar to: + +``` +VITE v7.1.9 ready in 497 ms +➜ Local: http://localhost:3000/ +➜ Network: http://169.254.0.21:3000/ +``` + +Keep this terminal window open while using the application. + +## Launching with Sample Files + +Once the development server is running, use one of the following launch scripts to automatically load the sample Hebrew audio and transcription. + +### For Linux/macOS Users + +Run the launch script: + +```bash +./launch.sh +``` + +This script will: +1. Locate the sample audio file (`Materials/sample_recording.wav`) +2. Locate the sample transcription file (`Materials/sample_transcription.txt`) +3. Open your default web browser with the application +4. Automatically load both files into the interface + +### For Windows Users + +Double-click the batch file: + +``` +launch.bat +``` + +Alternatively, run it from Command Prompt: + +```cmd +launch.bat +``` + +This script will: +1. Locate the sample audio file (`Materials\sample_recording.wav`) +2. Locate the sample transcription file (`Materials\sample_transcription.txt`) +3. Open your default web browser with the application +4. Automatically load both files into the interface + +## Using Your Own Files + +To use your own Hebrew audio and transcription files: + +1. Replace the sample files in the `Materials/` folder with your own files: + - `sample_recording.wav` → Replace with your audio file (`.wav`, `.mp3`, etc.) + - `sample_transcription.txt` → Replace with your transcription file (`.txt`) + +2. Run the appropriate launch script for your operating system. + +Alternatively, you can manually pass file paths as URL parameters: + +``` +http://localhost:3000/?audio=file:///path/to/your/audio.wav&transcription=file:///path/to/your/transcription.txt +``` + +## Application Features + +The Hebrew Transcription App provides the following features: + +- **Audio Playback**: Play Hebrew audio recordings with standard browser controls (play, pause, volume, progress bar) +- **Transcription Display**: View the Hebrew transcription alongside the audio player +- **Automatic Loading**: Automatically load audio and transcription files via URL parameters +- **Responsive Design**: Works seamlessly on desktop and mobile devices + +## Troubleshooting + +### Issue: Browser doesn't open automatically + +**Solution**: Manually navigate to `http://localhost:3000/` in your web browser. + +### Issue: Audio file doesn't load + +**Solution**: Ensure the file path is correct and the file format is supported by your browser (`.wav`, `.mp3`, `.ogg`, `.flac`). + +### Issue: Transcription text doesn't appear + +**Solution**: Verify that the transcription file exists and is in `.txt` format with proper encoding (UTF-8 recommended for Hebrew text). + +### Issue: Development server won't start + +**Solution**: +1. Ensure Node.js and pnpm are installed: `node --version` and `pnpm --version` +2. Delete `node_modules` and `pnpm-lock.yaml`, then run `pnpm install` again +3. Try a different port: `pnpm dev -- --port 3001` + +## Sample Files + +The project includes sample Hebrew files for testing: + +- **sample_recording.wav**: A Hebrew audio recording with the text "שלום, זה קובץ תמלול לדוגמה בעברית..." +- **sample_transcription.txt**: The corresponding Hebrew transcription + +These files are automatically loaded when you run the launch scripts. + +## Development + +For more information about the project structure and development workflow, refer to the main `README.md` file in the project root. + +## Support + +If you encounter any issues or have questions, please refer to the troubleshooting section above or consult the project documentation. + +--- + +**Happy transcribing!** 🎙️ diff --git a/Materials/sample_recording.wav b/Materials/sample_recording.wav new file mode 100644 index 00000000..b8f17c4c Binary files /dev/null and b/Materials/sample_recording.wav differ diff --git a/Materials/sample_transcription.txt b/Materials/sample_transcription.txt new file mode 100644 index 00000000..94fca679 --- /dev/null +++ b/Materials/sample_transcription.txt @@ -0,0 +1 @@ +בעתיד אני הולך להעלות את מחירי הקריינות שלי. ואתם יודעים למה? כשהעולם הופך יותר ויותר, פחות אותנטי ומלאכותי, זה בדיוק הזמן שלי לבנות. להדגיש שאני לא מכונה. אני לא מכונה, אלא קריין עם רגשות. אין לי שום כוונה להתחרות עם המודלים שהולכים ומשתפרים. באמת. וכן, אולי תהיה גרסה ממודלת משל עצמי. מי יודע? אני רק יודע דבר אחד, אני אהיה מה שהיא אף פעם לא תהיה, וזה להיות עם רגש אנושי. כקריין, בעל מלאכה, כשהשוק ילך ויתמלא בקולות מלאכותיים, הערך שלי רק ילך ויעלה. ולקוחות שבאמת מבינים את העניין, כשהמותג שלהם חשוב להם, ולא מחפשים את הזום מביניהם, הם ילכו על הדבר האמיתי. וזו בדיוק הסיבה למה. רוצים לשמוע את קובץ התחנה הבאה אצל כל הלקוחות שלי בתחבורה הציבורית? תגידו לי מה הכי אהבתם. באם זה נשמע טומא? התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה התחנה הבאה סליחה, אזעקה, אין מקום לטילים מאיראן, מחזבאללה ומהחותים במדינה זו. התחנה הבאה המרחב המוגן, המערכת זיהתה, נוסע בשם ניסים. הנוסע זיהה את הטיל. הטיל לא זיהה את הנוסע. כולם הגיעו ליעדם. קרן קיימת לישראל, יחד למען המדינה. אם יש משהו שאני נורא אוהב לעשות, זה לספר סיפור. קבלו את קקר. הסיפור של הארץ הזאת לא נכתב רק בהיסטוריה, אלא בכל יום מחדש, מלפני הצריחה ועד אחרי השקיעה. בגשם ובחום, בכל יום ובכל עונה. אנשי קק״ל תמיד כאן, בשבילכם, בשביל הארץ, בשביל המדינה. כבר יותר מ-120 שנה, יום אחרי יום מטפחים יערות ומנגישים אתרים, משביכים את האדמה, חושבים על העתיד של כולנו, לא עוצרים, לא מתעייפים, ממשיכים באש ובמים. זוכרים לעזור לכולם ולדחוף קדימה בעשייה הציונית הנמשכת תמיד. אנשי קק״ל, תמיד כאן, תמיד איתכם, תמיד למען המדינה. קרן קיימת לישראל יחד למען המדינה. נושאים נכבדים, שימו לב, והדרת פני אנגלי. תחנה הבאה, בית חולים איכילוב diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 00000000..7668bd02 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,31 @@ +# Hebrew Transcription App - Quick Start Guide + +## Setup Instructions + +### Step 1: Start the Development Server +1. Open PowerShell or Command Prompt in the `OmniVoice` folder. +2. Run: `pnpm install` (if you haven't already). +3. Run: `pnpm dev`. +4. Keep this terminal open. The server runs on `http://localhost:3000`. + +### Step 2: Launch the App +1. Double-click `launch.bat` in the `OmniVoice` folder, or manually visit `http://localhost:3000`. +2. The app will open in your default browser. + +### Step 3: Load Your Files (New!) +The app now uses a secure file upload interface to bypass browser security restrictions: + +1. **Audio File**: Drag and drop your `.wav`, `.mp3`, `.ogg`, or `.flac` file into the blue "Audio File" box, or click it to browse. +2. **Transcription File**: Drag and drop your `.txt` file into the green "Transcription File" box, or click it to browse. + +Sample files are located in the `Materials` folder: +- `Materials/sample_recording.wav` +- `Materials/sample_transcription.txt` + +## Why the Change? +Modern browsers block the app from automatically reading files from your hard drive via the URL (CORS security). The new upload interface is the standard, secure way to load local files into a web application. + +## Troubleshooting +- **"pnpm not recognized"**: Ensure pnpm is installed, or use `npm run dev`. +- **"Files won't load"**: Make sure you are using supported file types (.wav/.mp3 for audio, .txt for text). +- **"Port 3000 in use"**: The app might be running on a different port (check the terminal output). diff --git a/backend/index.ts b/backend/index.ts new file mode 100644 index 00000000..7e6c455a --- /dev/null +++ b/backend/index.ts @@ -0,0 +1,130 @@ +import express from "express"; +import { createServer } from "http"; +import path from "path"; +import { fileURLToPath } from "url"; +import multer from "multer"; +import fs from "fs"; +import archiver from "archiver"; +import { spawn } from "child_process"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function startServer() { + const app = express(); + const server = createServer(app); + + // Serve static files from dist/public in production + const staticPath = + process.env.NODE_ENV === "production" + ? path.resolve(__dirname, "public") + : path.resolve(__dirname, "..", "dist", "public"); + + app.use(express.static(staticPath)); + + // API: POST /api/synthesize + // Accepts multipart form: ref_audio (file, optional), texts (array of strings), output_name (string) + const upload = multer({ dest: path.resolve(__dirname, "..", "tmp") }); + + app.post( + "/api/synthesize", + upload.single("ref_audio"), + express.urlencoded({ extended: true }), + async (req, res) => { + try { + const textsRaw = req.body.texts; + let texts: string[] = []; + if (Array.isArray(textsRaw)) { + texts = textsRaw.filter(Boolean); + } else if (typeof textsRaw === "string") { + // single text or newline-separated + texts = textsRaw.split("\n").map((s) => s.trim()).filter(Boolean); + } + + if (!texts.length) { + return res.status(400).json({ error: "No texts provided" }); + } + + const language = typeof req.body.language === 'string' ? req.body.language : undefined; + const refText = typeof req.body.ref_text === 'string' ? req.body.ref_text : undefined; + const refAudioPath = req.file ? req.file.path : null; + const outDir = path.resolve(__dirname, "..", "tmp", `synth_${Date.now()}`); + fs.mkdirSync(outDir, { recursive: true }); + + // Build command to call the Python CLI infer.py for each sentence + // Use the repo's omnivoice CLI script + const python = process.env.PYTHON || "python3"; + const modelArg = process.env.OMNIVOICE_MODEL || "k2-fsa/OmniVoice"; + + for (let i = 0; i < texts.length; i++) { + const t = texts[i]; + const outPath = path.join(outDir, `out_${String(i + 1).padStart(3, "0")}.wav`); + const args = [ + path.resolve(__dirname, "..", "omnivoice", "cli", "infer.py"), + "--model", + modelArg, + "--text", + t, + "--output", + outPath, + ]; + if (refAudioPath) { + args.push("--ref_audio", refAudioPath); + } + if (refText) { + args.push("--ref_text", refText); + } + if (language) { + args.push("--language", language); + } + + // Call synchronously-like by awaiting a child process promise + await new Promise((resolve, reject) => { + const p = spawn(python, args, { stdio: "inherit" }); + p.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`infer.py exit ${code}`)); + }); + p.on("error", reject); + }); + } + + // Zip the outputs + const zipName = `synth_${Date.now()}.zip`; + const zipPath = path.join(outDir, zipName); + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(zipPath); + const archive = archiver("zip", { zlib: { level: 9 } }); + output.on("close", () => resolve()); + archive.on("error", (err) => reject(err)); + archive.pipe(output); + archive.directory(outDir, false); + archive.finalize(); + }); + + res.download(zipPath, zipName, (err) => { + // cleanup + try { + if (refAudioPath) fs.unlinkSync(refAudioPath); + } catch {} + }); + } catch (err: any) { + console.error(err); + res.status(500).json({ error: String(err) }); + } + } + ); + + // Handle client-side routing - serve index.html for all routes + app.get("*", (_req, res) => { + res.sendFile(path.join(staticPath, "index.html")); + }); + + const port = process.env.PORT || 3000; + + server.listen(port, () => { + console.log(`Server running on http://localhost:${port}/`); + }); +} + +startServer().catch(console.error); diff --git a/busman.mp3 b/busman.mp3 new file mode 100644 index 00000000..249a7d1d Binary files /dev/null and b/busman.mp3 differ diff --git a/client/index.html b/client/index.html new file mode 100644 index 00000000..82b2b8a7 --- /dev/null +++ b/client/index.html @@ -0,0 +1,26 @@ + + + + + + + Hebrew Transcription UI + + + + +
+ + + + + diff --git a/client/public/.gitkeep b/client/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/client/public/__manus__/debug-collector.js b/client/public/__manus__/debug-collector.js new file mode 100644 index 00000000..05045556 --- /dev/null +++ b/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 00000000..43c910d4 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,44 @@ +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/NotFound"; +import { Route, Switch } from "wouter"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import Home from "./pages/Home"; +import Synthesize from "./pages/Synthesize"; + + +function Router() { + return ( + + + + + {/* Final fallback route */} + + + ); +} + +// NOTE: About Theme +// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css +// to keep consistent foreground/background color across components +// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/client/src/components/ErrorBoundary.tsx b/client/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000..14229860 --- /dev/null +++ b/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +import { cn } from "@/lib/utils"; +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + +

An unexpected error occurred.

+ +
+
+                {this.state.error?.stack}
+              
+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/client/src/components/ManusDialog.tsx b/client/src/components/ManusDialog.tsx new file mode 100644 index 00000000..0aeff4bc --- /dev/null +++ b/client/src/components/ManusDialog.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ManusDialogProps { + title?: string; + logo?: string; + open?: boolean; + onLogin: () => void; + onOpenChange?: (open: boolean) => void; + onClose?: () => void; +} + +export function ManusDialog({ + title, + logo, + open = false, + onLogin, + onOpenChange, + onClose, +}: ManusDialogProps) { + const [internalOpen, setInternalOpen] = useState(open); + + useEffect(() => { + if (!onOpenChange) { + setInternalOpen(open); + } + }, [open, onOpenChange]); + + const handleOpenChange = (nextOpen: boolean) => { + if (onOpenChange) { + onOpenChange(nextOpen); + } else { + setInternalOpen(nextOpen); + } + + if (!nextOpen) { + onClose?.(); + } + }; + + return ( + + +
+ {logo ? ( +
+ Dialog graphic +
+ ) : null} + + {/* Title and subtitle */} + {title ? ( + + {title} + + ) : null} + + Please login with Manus to continue + +
+ + + {/* Login button */} + + +
+
+ ); +} diff --git a/client/src/components/Map.tsx b/client/src/components/Map.tsx new file mode 100644 index 00000000..4849e056 --- /dev/null +++ b/client/src/components/Map.tsx @@ -0,0 +1,155 @@ +/** + * GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE + * + * USAGE FROM PARENT COMPONENT: + * ====== + * + * const mapRef = useRef(null); + * + * { + * mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state. + * + * + * ====== + * Available Libraries and Core Features: + * ------------------------------- + * 📍 MARKER (from `marker` library) + * - Attaches to map using { map, position } + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: { lat: 37.7749, lng: -122.4194 }, + * title: "San Francisco", + * }); + * + * ------------------------------- + * 🏢 PLACES (from `places` library) + * - Does not attach directly to map; use data with your map manually. + * const place = new google.maps.places.Place({ id: PLACE_ID }); + * await place.fetchFields({ fields: ["displayName", "location"] }); + * map.setCenter(place.location); + * new google.maps.marker.AdvancedMarkerElement({ map, position: place.location }); + * + * ------------------------------- + * 🧭 GEOCODER (from `geocoding` library) + * - Standalone service; manually apply results to map. + * const geocoder = new google.maps.Geocoder(); + * geocoder.geocode({ address: "New York" }, (results, status) => { + * if (status === "OK" && results[0]) { + * map.setCenter(results[0].geometry.location); + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: results[0].geometry.location, + * }); + * } + * }); + * + * ------------------------------- + * 📐 GEOMETRY (from `geometry` library) + * - Pure utility functions; not attached to map. + * const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); + * + * ------------------------------- + * 🛣️ ROUTES (from `routes` library) + * - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached) + * const directionsService = new google.maps.DirectionsService(); + * const directionsRenderer = new google.maps.DirectionsRenderer({ map }); + * directionsService.route( + * { origin, destination, travelMode: "DRIVING" }, + * (res, status) => status === "OK" && directionsRenderer.setDirections(res) + * ); + * + * ------------------------------- + * 🌦️ MAP LAYERS (attach directly to map) + * - new google.maps.TrafficLayer().setMap(map); + * - new google.maps.TransitLayer().setMap(map); + * - new google.maps.BicyclingLayer().setMap(map); + * + * ------------------------------- + * ✅ SUMMARY + * - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers. + * - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService. + * - “data-only” → Place, Geometry utilities. + */ + +/// + +import { useEffect, useRef } from "react"; +import { usePersistFn } from "@/hooks/usePersistFn"; +import { cn } from "@/lib/utils"; + +declare global { + interface Window { + google?: typeof google; + } +} + +const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY; +const FORGE_BASE_URL = + import.meta.env.VITE_FRONTEND_FORGE_API_URL || + "https://forge.butterfly-effect.dev"; +const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`; + +function loadMapScript() { + return new Promise(resolve => { + const script = document.createElement("script"); + script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`; + script.async = true; + script.crossOrigin = "anonymous"; + script.onload = () => { + resolve(null); + script.remove(); // Clean up immediately + }; + script.onerror = () => { + console.error("Failed to load Google Maps script"); + }; + document.head.appendChild(script); + }); +} + +interface MapViewProps { + className?: string; + initialCenter?: google.maps.LatLngLiteral; + initialZoom?: number; + onMapReady?: (map: google.maps.Map) => void; +} + +export function MapView({ + className, + initialCenter = { lat: 37.7749, lng: -122.4194 }, + initialZoom = 12, + onMapReady, +}: MapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const init = usePersistFn(async () => { + await loadMapScript(); + if (!mapContainer.current) { + console.error("Map container not found"); + return; + } + map.current = new window.google.maps.Map(mapContainer.current, { + zoom: initialZoom, + center: initialCenter, + mapTypeControl: true, + fullscreenControl: true, + zoomControl: true, + streetViewControl: true, + mapId: "DEMO_MAP_ID", + }); + if (onMapReady) { + onMapReady(map.current); + } + }); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ ); +} diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx new file mode 100644 index 00000000..62705e3d --- /dev/null +++ b/client/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..69499798 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx new file mode 100644 index 00000000..5b1a0b5e --- /dev/null +++ b/client/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..01d045dd --- /dev/null +++ b/client/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,9 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx new file mode 100644 index 00000000..02305fd4 --- /dev/null +++ b/client/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx new file mode 100644 index 00000000..83750ed1 --- /dev/null +++ b/client/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..9d88a372 --- /dev/null +++ b/client/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return