From 0fe31f0358fa82b6194a467406d0e6a53e8c0856 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:17:54 +0000 Subject: [PATCH] Add Nutq: AI voice dictation PWA for mobile and desktop Wispr Flow-style voice keyboard app: speak naturally, get polished ready-to-send text. Installable PWA (iPhone/Android/desktop) with: - Live dictation via Web Speech API, or Whisper via any OpenAI-compatible endpoint - AI polish with writing modes (message/email/notes/formal/raw) - 80+ recognition languages with auto-detect, personal dictionary - Local-only history, Arabic RTL + English UI, light/dark themes - Offline app shell via service worker, GitHub Pages deploy workflow Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XzXuVcjCFcBhTQT1wsEANC --- .github/workflows/deploy-nutq.yml | 31 ++ nutq/README.md | 73 ++++ nutq/app.css | 288 +++++++++++++++ nutq/app.js | 586 ++++++++++++++++++++++++++++++ nutq/icons/apple-touch-icon.png | Bin 0 -> 872 bytes nutq/icons/icon-192.png | Bin 0 -> 1251 bytes nutq/icons/icon-512.png | Bin 0 -> 3897 bytes nutq/icons/icon-maskable-512.png | Bin 0 -> 2742 bytes nutq/index.html | 216 +++++++++++ nutq/manifest.webmanifest | 18 + nutq/sw.js | 43 +++ 11 files changed, 1255 insertions(+) create mode 100644 .github/workflows/deploy-nutq.yml create mode 100644 nutq/README.md create mode 100644 nutq/app.css create mode 100644 nutq/app.js create mode 100644 nutq/icons/apple-touch-icon.png create mode 100644 nutq/icons/icon-192.png create mode 100644 nutq/icons/icon-512.png create mode 100644 nutq/icons/icon-maskable-512.png create mode 100644 nutq/index.html create mode 100644 nutq/manifest.webmanifest create mode 100644 nutq/sw.js diff --git a/.github/workflows/deploy-nutq.yml b/.github/workflows/deploy-nutq.yml new file mode 100644 index 00000000..efce6c5a --- /dev/null +++ b/.github/workflows/deploy-nutq.yml @@ -0,0 +1,31 @@ +name: Deploy Nutq to GitHub Pages + +on: + push: + branches: [main, claude/mobile-desktop-app-y1g2sm] + paths: ["nutq/**", ".github/workflows/deploy-nutq.yml"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: nutq + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/nutq/README.md b/nutq/README.md new file mode 100644 index 00000000..343cdf78 --- /dev/null +++ b/nutq/README.md @@ -0,0 +1,73 @@ +# نُطق (Nutq) — لوحة المفاتيح الصوتية بالذكاء الاصطناعي 🎙️ + +**تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال.** + +تطبيق ويب تقدمي (PWA) احترافي مستوحى من Wispr Flow — يعمل على **iPhone و Android والحاسوب (Windows / Mac / Linux)** من كود واحد، ويُثبَّت كتطبيق حقيقي بأيقونة على الشاشة الرئيسية وسطح المكتب. + +![Nutq](icons/icon-192.png) + +## المميزات + +- 🎙️ **إملاء صوتي فوري** — النص يظهر مباشرة أثناء كلامك (محرك المتصفح المجاني)، أو عبر Whisper API لدقة أعلى. +- ✨ **صقل بالذكاء الاصطناعي** — إزالة كلمات الحشو، تصحيح علامات الترقيم، وإعادة صياغة بأسلوبك. +- 📝 **أنماط كتابة** — رسالة، بريد إلكتروني، ملاحظات، رسمي، أو نص خام بلا تعديل. +- 🌍 **أكثر من 100 لغة** — منها العربية بلهجاتها، مع اكتشاف تلقائي للغة. +- 📖 **قاموس شخصي** — أضف أسماءً ومصطلحات خاصة ليحترمها التطبيق عند الكتابة. +- 🕘 **سجل محلي** — كل إملاءاتك محفوظة على جهازك فقط (لا تُرسل لأي خادم). +- 🌗 **واجهة عربية RTL** كاملة + إنجليزية، مع مظهر فاتح وداكن. +- 📵 **يعمل دون إنترنت** — واجهة التطبيق تعمل offline بعد أول زيارة. + +## التشغيل والنشر + +### 1) تجربة محلية سريعة + +```bash +cd nutq +python3 -m http.server 8080 +# افتح http://localhost:8080 +``` + +> ملاحظة: الميكروفون يتطلب HTTPS أو localhost. + +### 2) النشر المجاني على GitHub Pages (موصى به) + +المستودع يتضمن Workflow جاهزاً (`.github/workflows/deploy-nutq.yml`): + +1. من إعدادات المستودع على GitHub: **Settings → Pages → Source: GitHub Actions**. +2. ادفع أي تعديل لمجلد `nutq/` وسيُنشر تلقائياً. +3. سيكون التطبيق على: `https://<اسم-المستخدم>.github.io/<اسم-المستودع>/` + +يمكن أيضاً النشر على Netlify أو Vercel أو Cloudflare Pages بسحب مجلد `nutq/` فقط. + +### 3) التثبيت كتطبيق + +- **iPhone (Safari):** افتح الرابط → زر المشاركة → **إضافة إلى الشاشة الرئيسية**. +- **Android (Chrome):** افتح الرابط → قائمة ⋮ → **تثبيت التطبيق**. +- **الحاسوب (Chrome / Edge):** افتح الرابط → أيقونة التثبيت ⊕ في شريط العنوان → **تثبيت**. يصبح تطبيقاً مستقلاً بنافذته الخاصة. + +## إعداد الذكاء الاصطناعي (اختياري لكن موصى به) + +بدون مفتاح API يعمل الإملاء الفوري مجاناً عبر محرك المتصفح. لتفعيل **الصقل الذكي** و **Whisper**: + +1. افتح **الإعدادات ⚙️** داخل التطبيق. +2. أدخل مفتاح API من أي خدمة متوافقة مع OpenAI: + +| الخدمة | Base URL | ملاحظات | +|---|---|---| +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` + `whisper-1` | +| Groq (مجاني) | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` + `whisper-large-v3` | +| OpenRouter | `https://openrouter.ai/api/v1` | نماذج متعددة | + +> 🔒 **الخصوصية:** المفتاح والسجل والقاموس تُحفظ في متصفحك فقط (localStorage)، ولا يمر أي شيء عبر خوادم وسيطة — الاتصال مباشر من جهازك إلى مزوّد الخدمة الذي تختاره. + +## البنية التقنية + +- HTML/CSS/JS خالص بدون أي اعتماديات أو خطوة بناء — سهل الصيانة والنشر في أي مكان. +- `Web Speech API` للإملاء الفوري، و`MediaRecorder` + Whisper للتفريغ عالي الدقة. +- Service Worker للعمل دون اتصال + Web App Manifest للتثبيت. + +--- + +# Nutq — AI Voice Keyboard (English) + +A professional Wispr Flow–style PWA: speak naturally and get polished, ready-to-send text. Installs as a real app on iPhone, Android, and desktop from a single codebase. Live dictation via the free browser speech engine or any OpenAI-compatible Whisper endpoint, AI polishing with writing modes (message / email / notes / formal), 100+ languages with auto-detect, a personal dictionary, local-only history, full RTL Arabic + English UI, and offline support. Serve the `nutq/` folder from any static host (a GitHub Pages workflow is included) and add your API key in Settings to enable AI features. diff --git a/nutq/app.css b/nutq/app.css new file mode 100644 index 00000000..5e55670c --- /dev/null +++ b/nutq/app.css @@ -0,0 +1,288 @@ +/* ===== Nutq — AI voice keyboard ===== */ +:root { + --bg: #FAF6E9; + --bg-soft: #F3EDDA; + --card: #FFFDF4; + --ink: #17151A; + --ink-soft: #5C5850; + --line: rgba(23, 21, 26, .1); + --accent: #6D4FE0; + --accent-soft: #E9DFFF; + --accent-ink: #4A32AC; + --danger: #C43D3D; + --shadow: 0 10px 30px rgba(23, 21, 26, .08); + --radius: 22px; + --serif: "Noto Naskh Arabic", "Amiri", Georgia, "Times New Roman", serif; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, "Helvetica Neue", Arial, sans-serif; +} +[data-theme="dark"] { + --bg: #111014; + --bg-soft: #1A181F; + --card: #1E1C24; + --ink: #F3F0E8; + --ink-soft: #A5A09A; + --line: rgba(243, 240, 232, .12); + --accent: #8F75FF; + --accent-soft: #2C2542; + --accent-ink: #C9BAFF; + --shadow: 0 10px 30px rgba(0, 0, 0, .4); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #111014; --bg-soft: #1A181F; --card: #1E1C24; + --ink: #F3F0E8; --ink-soft: #A5A09A; --line: rgba(243,240,232,.12); + --accent: #8F75FF; --accent-soft: #2C2542; --accent-ink: #C9BAFF; + --shadow: 0 10px 30px rgba(0,0,0,.4); + } +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +[hidden] { display: none !important; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + min-height: 100dvh; + padding-bottom: calc(190px + env(safe-area-inset-bottom)); + transition: background .3s, color .3s; +} + +svg { width: 22px; height: 22px; fill: currentColor; } + +/* ===== Top bar ===== */ +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; justify-content: space-between; + padding: calc(10px + env(safe-area-inset-top)) 18px 10px; + background: color-mix(in srgb, var(--bg) 82%, transparent); + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); +} +.brand { display: flex; align-items: center; gap: 9px; } +.brand-mark { width: 26px; height: 26px; fill: var(--ink); } +.brand-name { font-family: var(--serif); font-size: 24px; font-weight: 700; } +.topbar-actions { display: flex; gap: 8px; } +.icon-btn { + width: 42px; height: 42px; border-radius: 50%; + border: 1px solid var(--line); background: var(--card); color: var(--ink); + display: grid; place-items: center; cursor: pointer; + transition: transform .15s, background .2s; +} +.icon-btn:active { transform: scale(.92); } + +/* ===== Main ===== */ +.app { max-width: 680px; margin: 0 auto; padding: 8px 18px 0; } + +.hero { text-align: center; margin: 18px 0 22px; } +.hero-title { + font-family: var(--serif); + font-size: clamp(34px, 9vw, 52px); + line-height: 1.15; margin: 0 0 12px; font-weight: 700; +} +.hero-sub { color: var(--ink-soft); font-size: 16px; line-height: 1.65; margin: 0 auto; max-width: 46ch; } +.hero.compact { margin: 6px 0 10px; } +.hero.compact .hero-title { font-size: 24px; } +.hero.compact .hero-sub { display: none; } + +/* ===== Mode chips ===== */ +.modes { + display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 10px; + scrollbar-width: none; -ms-overflow-style: none; +} +.modes::-webkit-scrollbar { display: none; } +.chip { + flex: 0 0 auto; padding: 9px 18px; border-radius: 999px; + border: 1px solid var(--line); background: var(--card); color: var(--ink); + font-size: 14.5px; font-weight: 600; cursor: pointer; font-family: var(--sans); + transition: background .2s, color .2s, border-color .2s; +} +.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); } + +/* ===== Language pill ===== */ +.lang-pill { + display: inline-flex; align-items: center; gap: 7px; + margin: 2px 0 14px; padding: 8px 14px; border-radius: 999px; + border: 1px solid var(--line); background: var(--accent-soft); color: var(--accent-ink); + font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--sans); +} +.lang-pill svg { width: 17px; height: 17px; } + +/* ===== Cards ===== */ +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 18px; margin-bottom: 14px; + box-shadow: var(--shadow); +} +.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.card-title { font-size: 13px; font-weight: 700; letter-spacing: .3px; color: var(--ink-soft); } +.badge { + background: var(--accent-soft); color: var(--accent-ink); + padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; +} +.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #E0483F; animation: blink 1.1s infinite; } +@keyframes blink { 50% { opacity: .25; } } + +.transcript, .result { + min-height: 54px; font-size: 17px; line-height: 1.75; outline: none; + white-space: pre-wrap; word-break: break-word; +} +.transcript:empty::before, .result:empty::before { + content: attr(data-placeholder); color: var(--ink-soft); opacity: .6; +} +.transcript .interim { color: var(--ink-soft); opacity: .75; } +.result-card { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); } + +.card-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; } +.btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 10px 16px; border-radius: 14px; cursor: pointer; + border: 1px solid var(--line); background: var(--bg-soft); color: var(--ink); + font-size: 14px; font-weight: 700; font-family: var(--sans); + transition: transform .15s, opacity .2s; +} +.btn:active { transform: scale(.96); } +.btn svg { width: 17px; height: 17px; } +.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); } +.btn.danger { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, transparent); } +.btn.small { padding: 7px 13px; font-size: 13px; } +.btn.wide { width: 100%; justify-content: center; padding: 14px; font-size: 16px; margin-top: 6px; } + +.status { text-align: center; color: var(--ink-soft); font-size: 14px; min-height: 22px; margin: 4px 0; } +.status.error { color: var(--danger); font-weight: 600; } + +/* ===== Features ===== */ +.features { display: grid; gap: 12px; margin-top: 10px; } +.feature { + display: flex; gap: 13px; align-items: flex-start; + background: var(--card); border: 1px solid var(--line); + border-radius: 18px; padding: 15px 16px; +} +.f-ico { font-size: 22px; line-height: 1.3; } +.feature b { font-size: 15.5px; } +.feature p { margin: 3px 0 0; color: var(--ink-soft); font-size: 14px; line-height: 1.6; } + +/* ===== Mic dock ===== */ +.mic-dock { + position: fixed; bottom: 0; left: 0; right: 0; z-index: 30; + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 14px 0 calc(18px + env(safe-area-inset-bottom)); + background: linear-gradient(to top, var(--bg) 62%, transparent); + pointer-events: none; +} +.mic-dock > * { pointer-events: auto; } +.mic-btn { + position: relative; width: 84px; height: 84px; border-radius: 50%; + border: none; cursor: pointer; + background: var(--ink); color: var(--bg); + display: grid; place-items: center; + box-shadow: 0 14px 34px rgba(0,0,0,.25); + transition: transform .15s, background .25s; +} +.mic-btn:active { transform: scale(.93); } +.mic-btn svg { width: 34px; height: 34px; } +.mic-btn.rec { background: #E0483F; color: #fff; } +.mic-ring { + position: absolute; inset: 0; border-radius: 50%; + border: 2px solid #E0483F; opacity: 0; pointer-events: none; +} +.mic-btn.rec .mic-ring { animation: ripple 1.8s infinite; } +.mic-btn.rec .r2 { animation-delay: .9s; } +@keyframes ripple { + 0% { transform: scale(1); opacity: .7; } + 100% { transform: scale(1.85); opacity: 0; } +} +.mic-hint { font-size: 13.5px; color: var(--ink-soft); font-weight: 600; } +.timer { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 15px; color: #E0483F; } + +.waveform { display: flex; align-items: center; gap: 3px; height: 34px; } +.waveform span { + width: 4px; height: 8px; border-radius: 3px; background: var(--accent); + animation: wave 1s ease-in-out infinite; +} +.waveform span:nth-child(3n) { animation-delay: .15s; } +.waveform span:nth-child(3n+1){ animation-delay: .35s; } +.waveform span:nth-child(4n) { animation-delay: .5s; } +.waveform span:nth-child(5n) { animation-delay: .7s; } +@keyframes wave { 0%,100% { height: 7px; } 50% { height: 30px; } } + +/* ===== Sheets ===== */ +.sheet-backdrop { + position: fixed; inset: 0; z-index: 50; + background: rgba(0,0,0,.45); + display: flex; align-items: flex-end; justify-content: center; + animation: fadeIn .2s; +} +@keyframes fadeIn { from { opacity: 0; } } +.sheet { + background: var(--bg); width: 100%; max-width: 680px; + max-height: 88dvh; overflow-y: auto; + border-radius: 26px 26px 0 0; padding: 10px 20px calc(26px + env(safe-area-inset-bottom)); + animation: slideUp .28s cubic-bezier(.2,.9,.3,1); +} +@keyframes slideUp { from { transform: translateY(60px); opacity: .4; } } +.sheet-grab { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 14px; } +.sheet h2 { font-family: var(--serif); font-size: 24px; margin: 4px 0 16px; } +.sheet-title-row { display: flex; align-items: center; justify-content: space-between; } +.sheet-title-row h2 { margin: 4px 0 12px; } + +.search { + width: 100%; padding: 12px 16px; border-radius: 14px; + border: 1px solid var(--line); background: var(--card); color: var(--ink); + font-size: 16px; margin-bottom: 12px; font-family: var(--sans); +} +.lang-list { display: flex; flex-direction: column; } +.lang-item { + display: flex; align-items: center; justify-content: space-between; + padding: 13px 10px; border-radius: 14px; cursor: pointer; + font-size: 16px; border: none; background: none; color: var(--ink); + text-align: start; font-family: var(--sans); width: 100%; +} +.lang-item .flag { margin-inline-end: 10px; } +.lang-item.selected { background: var(--accent-soft); color: var(--accent-ink); font-weight: 700; } +.lang-item.selected::after { content: "✓"; font-weight: 900; } + +/* ===== History ===== */ +.history-list { display: flex; flex-direction: column; gap: 10px; } +.history-item { + background: var(--card); border: 1px solid var(--line); + border-radius: 16px; padding: 14px; +} +.history-item .h-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--ink-soft); margin-bottom: 7px; } +.history-item .h-text { font-size: 15px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; } +.history-item .h-actions { display: flex; gap: 6px; margin-top: 10px; } +.empty { text-align: center; color: var(--ink-soft); padding: 34px 0; font-size: 15px; } + +/* ===== Settings fields ===== */ +.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 15px; } +.field > span { font-size: 13.5px; font-weight: 700; color: var(--ink-soft); } +.field input[type="text"], .field input[type="password"], .field input[type="url"], +.field select, .field textarea { + padding: 12px 14px; border-radius: 13px; border: 1px solid var(--line); + background: var(--card); color: var(--ink); font-size: 16px; font-family: var(--sans); + width: 100%; +} +.field.row { flex-direction: row; align-items: center; justify-content: space-between; } +.field.row input[type="checkbox"] { width: 22px; height: 22px; accent-color: var(--accent); } +.field-group { + border: 1px dashed var(--line); border-radius: 16px; + padding: 14px; margin-bottom: 15px; +} +.fg-title { font-size: 13.5px; font-weight: 800; margin-bottom: 12px; color: var(--accent-ink); } +.hint { font-size: 12.5px; color: var(--ink-soft); line-height: 1.6; margin: 4px 0 0; } +.hint.center { text-align: center; margin-top: 14px; } + +/* ===== Toast ===== */ +.toast { + position: fixed; bottom: calc(200px + env(safe-area-inset-bottom)); + left: 50%; transform: translateX(-50%); z-index: 60; + background: var(--ink); color: var(--bg); + padding: 11px 20px; border-radius: 999px; font-size: 14.5px; font-weight: 700; + box-shadow: var(--shadow); animation: toastIn .25s; + max-width: 88vw; text-align: center; +} +@keyframes toastIn { from { transform: translate(-50%, 14px); opacity: 0; } } + +@media (min-width: 720px) { + .hero-title { font-size: 56px; } + body { padding-bottom: 210px; } +} diff --git a/nutq/app.js b/nutq/app.js new file mode 100644 index 00000000..e8b6c77c --- /dev/null +++ b/nutq/app.js @@ -0,0 +1,586 @@ +/* ===== Nutq — AI voice keyboard ===== + * Speak naturally → get polished, ready-to-send text. + * Engines: Web Speech API (free, live) or any OpenAI-compatible + * Whisper endpoint. Polishing via OpenAI-compatible chat API. + * Everything is stored locally on the device. + */ +"use strict"; + +/* ---------- i18n ---------- */ +const I18N = { + ar: { + appName: "نُطق", + heroTitle: "إملاء صوتي
بلا أخطاء", + heroSub: "تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.", + modeMessage: "رسالة", modeEmail: "بريد إلكتروني", modeNotes: "ملاحظات", modeFormal: "رسمي", modeRaw: "نص خام", + rawTranscript: "النص المسموع", polished: "✨ النص المصقول", + transcriptPh: "سيظهر كلامك هنا…", + copy: "نسخ", copied: "تم النسخ ✓", share: "مشاركة", repolish: "إعادة الصقل", + tapToTalk: "اضغط وتكلّم", tapToStop: "اضغط للإيقاف", + listening: "أستمع إليك…", transcribing: "جارٍ تفريغ الصوت…", polishing: "جارٍ صقل النص…", + history: "السجل", clearAll: "مسح الكل", settings: "الإعدادات", + uiLang: "لغة الواجهة", engine: "محرك التعرّف على الصوت", + engineAuto: "تلقائي (الأفضل المتاح)", engineWeb: "المتصفح (مجاني، فوري)", engineWhisper: "Whisper عبر API (أدق)", + aiSection: "إعدادات الذكاء الاصطناعي (للصقل و Whisper)", + apiKey: "مفتاح API", baseUrl: "عنوان الخدمة (Base URL)", model: "نموذج الصقل", whisperModel: "نموذج التفريغ الصوتي", + apiHint: "يدعم أي خدمة متوافقة مع OpenAI: ‏OpenAI، ‏Groq (مجاني)، ‏OpenRouter وغيرها. المفتاح يُحفظ على جهازك فقط.", + dictionary: "القاموس الشخصي (أسماء ومصطلحات، افصل بينها بفواصل)", + dictPh: "مثال: جلال، شركة لُمى، GPT-SoVITS", + autoPolish: "صقل تلقائي بعد التوقف", + theme: "المظهر", themeAuto: "تلقائي", themeLight: "فاتح", themeDark: "داكن", + save: "حفظ", saved: "تم الحفظ ✓", + searchLang: "ابحث عن لغة…", autoDetect: "اكتشاف تلقائي", + f1t: "يعمل مع كل التطبيقات", f1d: "أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.", + f2t: "صقل بالذكاء الاصطناعي", f2d: "بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.", + f3t: "أكثر من 100 لغة", f3d: "العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.", + f4t: "قاموسك الشخصي", f4d: "أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.", + errNoMic: "تعذّر الوصول إلى الميكروفون. تحقّق من الأذونات.", + errNoSpeech: "المتصفح لا يدعم التعرّف على الصوت. أضف مفتاح API في الإعدادات لاستخدام Whisper.", + errNoKey: "أضف مفتاح API في الإعدادات أولاً لاستخدام هذه الميزة.", + errApi: "خطأ من الخدمة: ", errNet: "تعذّر الاتصال بالخدمة. تحقّق من الإنترنت.", + noHistory: "لا يوجد شيء في السجل بعد.", confirmClear: "هل تريد مسح كل السجل؟", + deleted: "تم الحذف", noPolishKey: "أُبقي النص كما هو — أضف مفتاح API في الإعدادات لتفعيل الصقل الذكي.", + installHint: "لتثبيت التطبيق: شارك ← أضف إلى الشاشة الرئيسية", + }, + en: { + appName: "Nutq", + heroTitle: "Dictation
without the mistakes", + heroSub: "Talk naturally — Nutq turns your speech into polished, ready-to-send text, in any app and 100+ languages.", + modeMessage: "Message", modeEmail: "Email", modeNotes: "Notes", modeFormal: "Formal", modeRaw: "Raw", + rawTranscript: "Raw transcript", polished: "✨ Polished text", + transcriptPh: "Your speech will appear here…", + copy: "Copy", copied: "Copied ✓", share: "Share", repolish: "Re-polish", + tapToTalk: "Tap & talk", tapToStop: "Tap to stop", + listening: "Listening…", transcribing: "Transcribing…", polishing: "Polishing…", + history: "History", clearAll: "Clear all", settings: "Settings", + uiLang: "Interface language", engine: "Speech engine", + engineAuto: "Auto (best available)", engineWeb: "Browser (free, live)", engineWhisper: "Whisper via API (accurate)", + aiSection: "AI settings (polish & Whisper)", + apiKey: "API key", baseUrl: "Base URL", model: "Polish model", whisperModel: "Transcription model", + apiHint: "Works with any OpenAI-compatible service: OpenAI, Groq (free), OpenRouter… Your key never leaves this device.", + dictionary: "Personal dictionary (names & terms, comma-separated)", + dictPh: "e.g. Jalal, Luma Inc, GPT-SoVITS", + autoPolish: "Auto-polish after stopping", + theme: "Theme", themeAuto: "Auto", themeLight: "Light", themeDark: "Dark", + save: "Save", saved: "Saved ✓", + searchLang: "Search languages…", autoDetect: "Auto-detect", + f1t: "Works with every app", f1d: "Dictate, then paste into WhatsApp, email, or anywhere else.", + f2t: "AI-polished output", f2d: "No filler words, no typos — your voice, cleaned up and ready to send.", + f3t: "100+ languages", f3d: "Arabic, English, French and more — with auto-detect.", + f4t: "Personal dictionary", f4d: "Add custom names and terms so Nutq learns your lingo.", + errNoMic: "Microphone access failed. Check permissions.", + errNoSpeech: "This browser has no speech recognition. Add an API key in Settings to use Whisper.", + errNoKey: "Add an API key in Settings first.", + errApi: "Service error: ", errNet: "Could not reach the service. Check your connection.", + noHistory: "Nothing here yet.", confirmClear: "Clear all history?", + deleted: "Deleted", noPolishKey: "Kept raw text — add an API key in Settings to enable AI polish.", + installHint: "To install: Share → Add to Home Screen", + }, +}; + +/* ---------- Languages (speech recognition targets) ---------- */ +const LANGS = [ + ["auto", "", ""], + ["ar-SA", "🇸🇦", "العربية"], ["ar-EG", "🇪🇬", "العربية (مصر)"], ["ar-MA", "🇲🇦", "العربية (المغرب)"], + ["en-US", "🇺🇸", "English (US)"], ["en-GB", "🇬🇧", "English (UK)"], ["en-AU", "🇦🇺", "English (AU)"], ["en-IN", "🇮🇳", "English (India)"], + ["fr-FR", "🇫🇷", "Français"], ["fr-CA", "🇨🇦", "Français (Canada)"], + ["es-ES", "🇪🇸", "Español"], ["es-MX", "🇲🇽", "Español (México)"], ["es-AR", "🇦🇷", "Español (Argentina)"], + ["de-DE", "🇩🇪", "Deutsch"], ["it-IT", "🇮🇹", "Italiano"], + ["pt-BR", "🇧🇷", "Português (Brasil)"], ["pt-PT", "🇵🇹", "Português"], + ["ru-RU", "🇷🇺", "Русский"], ["uk-UA", "🇺🇦", "Українська"], + ["tr-TR", "🇹🇷", "Türkçe"], ["fa-IR", "🇮🇷", "فارسی"], ["ur-PK", "🇵🇰", "اردو"], + ["hi-IN", "🇮🇳", "हिन्दी"], ["bn-BD", "🇧🇩", "বাংলা"], ["ta-IN", "🇮🇳", "தமிழ்"], ["te-IN", "🇮🇳", "తెలుగు"], + ["ja-JP", "🇯🇵", "日本語"], ["ko-KR", "🇰🇷", "한국어"], + ["zh-CN", "🇨🇳", "中文 (简体)"], ["zh-TW", "🇹🇼", "中文 (繁體)"], ["zh-HK", "🇭🇰", "粵語 (香港)"], + ["id-ID", "🇮🇩", "Bahasa Indonesia"], ["ms-MY", "🇲🇾", "Bahasa Melayu"], + ["th-TH", "🇹🇭", "ไทย"], ["vi-VN", "🇻🇳", "Tiếng Việt"], ["fil-PH", "🇵🇭", "Filipino"], + ["nl-NL", "🇳🇱", "Nederlands"], ["sv-SE", "🇸🇪", "Svenska"], ["nb-NO", "🇳🇴", "Norsk"], + ["da-DK", "🇩🇰", "Dansk"], ["fi-FI", "🇫🇮", "Suomi"], ["is-IS", "🇮🇸", "Íslenska"], + ["pl-PL", "🇵🇱", "Polski"], ["cs-CZ", "🇨🇿", "Čeština"], ["sk-SK", "🇸🇰", "Slovenčina"], + ["hu-HU", "🇭🇺", "Magyar"], ["ro-RO", "🇷🇴", "Română"], ["bg-BG", "🇧🇬", "Български"], + ["el-GR", "🇬🇷", "Ελληνικά"], ["sr-RS", "🇷🇸", "Српски"], ["hr-HR", "🇭🇷", "Hrvatski"], + ["sl-SI", "🇸🇮", "Slovenščina"], ["lt-LT", "🇱🇹", "Lietuvių"], ["lv-LV", "🇱🇻", "Latviešu"], ["et-EE", "🇪🇪", "Eesti"], + ["he-IL", "🇮🇱", "עברית"], ["am-ET", "🇪🇹", "አማርኛ"], ["sw-KE", "🇰🇪", "Kiswahili"], + ["af-ZA", "🇿🇦", "Afrikaans"], ["zu-ZA", "🇿🇦", "isiZulu"], + ["ka-GE", "🇬🇪", "ქართული"], ["hy-AM", "🇦🇲", "Հայերեն"], ["az-AZ", "🇦🇿", "Azərbaycanca"], + ["kk-KZ", "🇰🇿", "Қазақша"], ["uz-UZ", "🇺🇿", "Oʻzbekcha"], + ["ne-NP", "🇳🇵", "नेपाली"], ["si-LK", "🇱🇰", "සිංහල"], ["km-KH", "🇰🇭", "ខ្មែរ"], ["lo-LA", "🇱🇦", "ລາວ"], + ["my-MM", "🇲🇲", "မြန်မာ"], ["mn-MN", "🇲🇳", "Монгол"], + ["ca-ES", "🏴", "Català"], ["eu-ES", "🏴", "Euskara"], ["gl-ES", "🏴", "Galego"], + ["mr-IN", "🇮🇳", "मराठी"], ["gu-IN", "🇮🇳", "ગુજરાતી"], ["kn-IN", "🇮🇳", "ಕನ್ನಡ"], ["ml-IN", "🇮🇳", "മലയാളം"], ["pa-IN", "🇮🇳", "ਪੰਜਾਬੀ"], +]; + +/* ---------- Polish prompts ---------- */ +const MODE_PROMPTS = { + message: "Rewrite the dictated text as a natural, friendly chat message. Keep it short and casual, as if sent on WhatsApp or iMessage.", + email: "Rewrite the dictated text as a clear, well-structured email body. Add a suitable greeting and sign-off placeholder if appropriate. Use short paragraphs and bullet points where they help.", + notes: "Rewrite the dictated text as concise personal notes: short bullet points capturing every idea, no fluff.", + formal: "Rewrite the dictated text in polished, formal, professional language suitable for official correspondence.", + raw: null, +}; + +/* ---------- State ---------- */ +const DEFAULTS = { + uiLang: (navigator.language || "ar").startsWith("ar") ? "ar" : "en", + lang: "auto", + mode: "message", + engine: "auto", + apiKey: "", + baseUrl: "https://api.openai.com/v1", + model: "gpt-4o-mini", + whisperModel: "whisper-1", + dictionary: "", + autoPolish: true, + theme: "auto", +}; +let S = loadSettings(); +let history = loadJSON("nutq.history", []); +let recState = "idle"; // idle | listening | transcribing | polishing +let recognition = null, mediaRecorder = null, mediaStream = null, chunks = []; +let finalText = "", timerInt = null, timerStart = 0; + +function loadSettings() { + return Object.assign({}, DEFAULTS, loadJSON("nutq.settings", {})); +} +function loadJSON(k, fb) { + try { return JSON.parse(localStorage.getItem(k)) ?? fb; } catch { return fb; } +} +function saveJSON(k, v) { localStorage.setItem(k, JSON.stringify(v)); } + +/* ---------- DOM helpers ---------- */ +const $ = (id) => document.getElementById(id); +const t = (key) => (I18N[S.uiLang] || I18N.ar)[key] || key; + +function applyI18n() { + document.documentElement.lang = S.uiLang; + document.documentElement.dir = S.uiLang === "ar" ? "rtl" : "ltr"; + document.querySelectorAll("[data-i18n]").forEach((el) => { + const key = el.dataset.i18n; + if (key === "heroTitle") el.innerHTML = t(key); + else el.textContent = t(key); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + const v = t(el.dataset.i18nPlaceholder); + if ("placeholder" in el) el.placeholder = v; + else el.dataset.placeholder = v; + }); + $("transcript").dataset.placeholder = t("transcriptPh"); + updateLangLabel(); +} + +function applyTheme() { + const root = document.documentElement; + if (S.theme === "auto") root.removeAttribute("data-theme"); + else root.setAttribute("data-theme", S.theme); +} + +function toast(msg, ms = 2200) { + const el = $("toast"); + el.textContent = msg; + el.hidden = false; + clearTimeout(el._t); + el._t = setTimeout(() => (el.hidden = true), ms); +} + +function setStatus(msg, isErr = false) { + const el = $("status"); + el.textContent = msg || ""; + el.classList.toggle("error", isErr); +} + +function updateLangLabel() { + const item = LANGS.find(([code]) => code === S.lang); + $("langLabel").textContent = + !item || item[0] === "auto" ? t("autoDetect") : `${item[1]} ${item[2]}`; +} + +/* ---------- Recording UI ---------- */ +function setRecUI(on) { + $("btnMic").classList.toggle("rec", on); + $("btnMic").querySelector(".mic-ico").hidden = on; + $("btnMic").querySelector(".stop-ico").hidden = !on; + $("waveform").hidden = !on; + $("timer").hidden = !on; + $("liveDot").hidden = !on; + $("micHint").textContent = on ? t("tapToStop") : t("tapToTalk"); + $("hero").classList.toggle("compact", on || !!finalText); + $("features").hidden = on || !!finalText || !!$("result").textContent; + if (on) { + $("transcriptCard").hidden = false; + timerStart = Date.now(); + timerInt = setInterval(() => { + const s = Math.floor((Date.now() - timerStart) / 1000); + $("timer").textContent = + String(Math.floor(s / 60)).padStart(2, "0") + ":" + String(s % 60).padStart(2, "0"); + }, 500); + } else { + clearInterval(timerInt); + $("timer").textContent = "00:00"; + } +} + +function renderTranscript(interim = "") { + const el = $("transcript"); + el.innerHTML = ""; + el.appendChild(document.createTextNode(finalText)); + if (interim) { + const span = document.createElement("span"); + span.className = "interim"; + span.textContent = (finalText ? " " : "") + interim; + el.appendChild(span); + } +} + +/* ---------- Engine selection ---------- */ +const SR = window.SpeechRecognition || window.webkitSpeechRecognition; +function pickEngine() { + if (S.engine === "webspeech") return SR ? "webspeech" : null; + if (S.engine === "whisper") return S.apiKey ? "whisper" : null; + // auto: prefer live browser engine, fall back to whisper + if (SR) return "webspeech"; + if (S.apiKey) return "whisper"; + return null; +} + +/* ---------- Web Speech engine ---------- */ +function startWebSpeech() { + recognition = new SR(); + recognition.continuous = true; + recognition.interimResults = true; + if (S.lang !== "auto") recognition.lang = S.lang; + else if (S.uiLang === "ar") recognition.lang = "ar-SA"; + + recognition.onresult = (e) => { + let interim = ""; + for (let i = e.resultIndex; i < e.results.length; i++) { + const txt = e.results[i][0].transcript; + if (e.results[i].isFinal) finalText = (finalText + " " + txt).trim(); + else interim += txt; + } + renderTranscript(interim); + }; + recognition.onerror = (e) => { + if (e.error === "not-allowed" || e.error === "service-not-allowed") { + stopDictation(false); + setStatus(t("errNoMic"), true); + } + // "no-speech" and "aborted" are benign; onend handles restart + }; + recognition.onend = () => { + // Browsers stop after silence — keep listening until user taps stop. + if (recState === "listening") { + try { recognition.start(); } catch { /* already started */ } + } + }; + recognition.start(); +} + +/* ---------- Whisper engine ---------- */ +async function startWhisperRecording() { + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + chunks = []; + const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"] + .find((m) => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || ""; + mediaRecorder = new MediaRecorder(mediaStream, mime ? { mimeType: mime } : undefined); + mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data); + mediaRecorder.start(1000); +} + +async function transcribeWithWhisper() { + const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" }); + const ext = (mediaRecorder.mimeType || "audio/webm").includes("mp4") ? "mp4" + : (mediaRecorder.mimeType || "").includes("ogg") ? "ogg" : "webm"; + const form = new FormData(); + form.append("file", blob, "audio." + ext); + form.append("model", S.whisperModel || "whisper-1"); + if (S.lang !== "auto") form.append("language", S.lang.split("-")[0]); + if (S.dictionary.trim()) form.append("prompt", S.dictionary.trim()); + + const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/audio/transcriptions", { + method: "POST", + headers: { Authorization: "Bearer " + S.apiKey }, + body: form, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(t("errApi") + res.status + " " + body.slice(0, 200)); + } + const data = await res.json(); + return (data.text || "").trim(); +} + +/* ---------- AI polish ---------- */ +async function polish(text) { + const sysMode = MODE_PROMPTS[S.mode]; + if (!sysMode) return text; // raw mode + if (!S.apiKey) { + toast(t("noPolishKey"), 3200); + return text; + } + const dict = S.dictionary.trim() + ? `\nPersonal dictionary (respect these exact spellings): ${S.dictionary.trim()}` + : ""; + const sys = + "You are Nutq, an AI dictation cleanup engine. The user dictated text by voice. " + + "Fix transcription artifacts, remove filler words, add correct punctuation and formatting, " + + "and keep the user's own voice and meaning. Always answer in the SAME language as the dictated text. " + + "Output ONLY the rewritten text, no explanations." + + "\nStyle: " + sysMode + dict; + + const res = await fetch(S.baseUrl.replace(/\/$/, "") + "/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + S.apiKey, + }, + body: JSON.stringify({ + model: S.model || "gpt-4o-mini", + temperature: 0.3, + messages: [ + { role: "system", content: sys }, + { role: "user", content: text }, + ], + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(t("errApi") + res.status + " " + body.slice(0, 200)); + } + const data = await res.json(); + return (data.choices?.[0]?.message?.content || "").trim() || text; +} + +/* ---------- Dictation flow ---------- */ +async function startDictation() { + finalText = ""; + $("result").textContent = ""; + $("resultCard").hidden = true; + renderTranscript(); + const engine = pickEngine(); + if (!engine) { + setStatus(SR ? t("errNoKey") : t("errNoSpeech"), true); + openSheet("settingsSheet"); + return; + } + try { + if (engine === "webspeech") startWebSpeech(); + else await startWhisperRecording(); + recState = "listening"; + setRecUI(true); + setStatus(t("listening")); + } catch (err) { + console.error(err); + setStatus(t("errNoMic"), true); + } +} + +async function stopDictation(process = true) { + const wasEngine = recognition ? "webspeech" : "whisper"; + recState = "idle"; + if (recognition) { try { recognition.stop(); } catch {} recognition = null; } + if (mediaRecorder && mediaRecorder.state !== "inactive") { + await new Promise((r) => { mediaRecorder.onstop = r; mediaRecorder.stop(); }); + } + if (mediaStream) { mediaStream.getTracks().forEach((tr) => tr.stop()); mediaStream = null; } + setRecUI(false); + if (!process) return; + + try { + if (wasEngine === "whisper" && chunks.length) { + recState = "transcribing"; + setStatus(t("transcribing")); + finalText = await transcribeWithWhisper(); + renderTranscript(); + } + finalText = $("transcript").textContent.trim(); + if (!finalText) { setStatus(""); return; } + if (S.autoPolish) await runPolish(); + else setStatus(""); + } catch (err) { + console.error(err); + setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true); + } finally { + recState = "idle"; + } +} + +async function runPolish() { + const text = $("transcript").textContent.trim(); + if (!text) return; + recState = "polishing"; + setStatus(t("polishing")); + try { + const out = await polish(text); + $("result").textContent = out; + $("resultCard").hidden = false; + $("resultMode").textContent = t("mode" + S.mode[0].toUpperCase() + S.mode.slice(1)); + setStatus(""); + addHistory({ raw: text, polished: out, mode: S.mode, ts: Date.now() }); + $("result").scrollIntoView({ behavior: "smooth", block: "center" }); + } catch (err) { + console.error(err); + setStatus(err.message.includes("fetch") ? t("errNet") : err.message, true); + } finally { + recState = "idle"; + } +} + +/* ---------- History ---------- */ +function addHistory(item) { + history.unshift(item); + history = history.slice(0, 200); + saveJSON("nutq.history", history); +} +function renderHistory() { + const list = $("historyList"); + list.innerHTML = ""; + if (!history.length) { + list.innerHTML = `
${t("noHistory")}
`; + return; + } + history.forEach((item, i) => { + const div = document.createElement("div"); + div.className = "history-item"; + const date = new Date(item.ts).toLocaleString(S.uiLang === "ar" ? "ar" : "en", { + dateStyle: "medium", timeStyle: "short", + }); + div.innerHTML = ` +
${date}${t("mode" + item.mode[0].toUpperCase() + item.mode.slice(1))}
+
+
+ + +
`; + div.querySelector(".h-text").textContent = item.polished || item.raw; + div.querySelector(".h-copy").onclick = () => copyText(item.polished || item.raw); + div.querySelector(".h-del").onclick = () => { + history.splice(i, 1); + saveJSON("nutq.history", history); + renderHistory(); + toast(t("deleted")); + }; + list.appendChild(div); + }); +} + +/* ---------- Clipboard / share ---------- */ +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + } catch { + const ta = document.createElement("textarea"); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + ta.remove(); + } + toast(t("copied")); +} + +/* ---------- Sheets ---------- */ +function openSheet(id) { $(id).hidden = false; document.body.style.overflow = "hidden"; } +function closeSheet(id) { $(id).hidden = true; document.body.style.overflow = ""; } +["langSheet", "historySheet", "settingsSheet"].forEach((id) => { + $(id).addEventListener("click", (e) => { if (e.target === $(id)) closeSheet(id); }); +}); + +/* ---------- Language list ---------- */ +function renderLangs(filter = "") { + const list = $("langList"); + list.innerHTML = ""; + const q = filter.trim().toLowerCase(); + LANGS.filter(([code, , name]) => + !q || name.toLowerCase().includes(q) || code.toLowerCase().includes(q) + ).forEach(([code, flag, name]) => { + const btn = document.createElement("button"); + btn.className = "lang-item" + (S.lang === code ? " selected" : ""); + btn.innerHTML = code === "auto" + ? `🌐${t("autoDetect")}` + : `${flag}${name}`; + btn.onclick = () => { + S.lang = code; + saveJSON("nutq.settings", S); + updateLangLabel(); + closeSheet("langSheet"); + }; + list.appendChild(btn); + }); +} + +/* ---------- Settings ---------- */ +function fillSettingsForm() { + $("setUiLang").value = S.uiLang; + $("setEngine").value = S.engine; + $("setApiKey").value = S.apiKey; + $("setBaseUrl").value = S.baseUrl; + $("setModel").value = S.model; + $("setWhisperModel").value = S.whisperModel; + $("setDictionary").value = S.dictionary; + $("setAutoPolish").checked = S.autoPolish; + $("setTheme").value = S.theme; +} +function saveSettingsForm() { + S.uiLang = $("setUiLang").value; + S.engine = $("setEngine").value; + S.apiKey = $("setApiKey").value.trim(); + S.baseUrl = $("setBaseUrl").value.trim() || DEFAULTS.baseUrl; + S.model = $("setModel").value.trim() || DEFAULTS.model; + S.whisperModel = $("setWhisperModel").value.trim() || DEFAULTS.whisperModel; + S.dictionary = $("setDictionary").value; + S.autoPolish = $("setAutoPolish").checked; + S.theme = $("setTheme").value; + saveJSON("nutq.settings", S); + applyI18n(); + applyTheme(); + closeSheet("settingsSheet"); + toast(t("saved")); +} + +/* ---------- Wire up ---------- */ +$("btnMic").onclick = () => { + if (recState === "listening") stopDictation(); + else if (recState === "idle") startDictation(); +}; +$("btnCopy").onclick = () => copyText($("result").textContent); +$("btnShare").onclick = async () => { + const text = $("result").textContent; + if (navigator.share) { try { await navigator.share({ text }); } catch {} } + else copyText(text); +}; +$("btnRepolish").onclick = () => { if (recState === "idle") runPolish(); }; +$("btnLang").onclick = () => { renderLangs(); openSheet("langSheet"); $("langSearch").value = ""; }; +$("langSearch").oninput = (e) => renderLangs(e.target.value); +$("btnHistory").onclick = () => { renderHistory(); openSheet("historySheet"); }; +$("btnClearHistory").onclick = () => { + if (confirm(t("confirmClear"))) { + history = []; + saveJSON("nutq.history", history); + renderHistory(); + } +}; +$("btnSettings").onclick = () => { fillSettingsForm(); openSheet("settingsSheet"); }; +$("btnSaveSettings").onclick = saveSettingsForm; + +document.querySelectorAll(".chip").forEach((chip) => { + chip.onclick = () => { + document.querySelectorAll(".chip").forEach((c) => c.classList.remove("active")); + chip.classList.add("active"); + S.mode = chip.dataset.mode; + saveJSON("nutq.settings", S); + }; +}); +// restore active mode chip +document.querySelectorAll(".chip").forEach((c) => + c.classList.toggle("active", c.dataset.mode === S.mode)); + +/* ---------- Init ---------- */ +applyI18n(); +applyTheme(); +$("verInfo").textContent = "Nutq v1.0"; + +if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("sw.js").catch(() => {}); + }); +} diff --git a/nutq/icons/apple-touch-icon.png b/nutq/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..608b4c8b7b3f46d32c9ca6ce835f504104765882 GIT binary patch literal 872 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6VAl6^aSW-L^Y+eJ-#Y;k4uQg{ z&Vqqi9+oSWO9R-S1~4bNGP@)>iv><$k$C35c;o%r=WmWBB}Ps^UuW#Uud1%9CQ{+i z%2>0=t`89?KjdbW_wKrXwB=dv#P7R# zOc%P(l~TUB{eJ4}>oqetZ9iR+a82BQTEOwP>hXnU@+FB`1(vh;e~1@(~Z3jte=S#k$WdOi^ne3J9ka#h3MPv&@;z{znm&_)qZyQhg=o&k!rik zJ}(a0v3{Ga_9As-c0OA$>t3DY_rVGKHRms6y<>W}Wtr~@O1TaS?83{1OO~SZq5Jz literal 0 HcmV?d00001 diff --git a/nutq/icons/icon-192.png b/nutq/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..6e6cd4463c1d93d00822401d06f3ccf811f11c74 GIT binary patch literal 1251 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 SuK~7489ZJ6T-G@yGywoKJtY|c literal 0 HcmV?d00001 diff --git a/nutq/index.html b/nutq/index.html new file mode 100644 index 00000000..61433f30 --- /dev/null +++ b/nutq/index.html @@ -0,0 +1,216 @@ + + + + + +نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي + + + + + + + + + + + + + +
+
+ + نُطق +
+
+ + +
+
+ +
+ + +
+

إملاء صوتي
بلا أخطاء

+

تكلّم بشكل طبيعي، ونُطق يحوّل كلامك إلى نص مصقول جاهز للإرسال — في أي تطبيق وبأكثر من 100 لغة.

+
+ + +
+ + + + + +
+ + + + + + + + + + +
+ + +
+
⌨️
يعمل مع كل التطبيقات

أملِ النص ثم انسخه إلى واتساب، البريد، أو أي تطبيق آخر.

+
صقل بالذكاء الاصطناعي

بلا كلمات حشو ولا أخطاء — نص بأسلوبك، جاهز للإرسال.

+
🌍
أكثر من 100 لغة

العربية، الإنجليزية، الفرنسية وغيرها — مع اكتشاف تلقائي.

+
📖
قاموسك الشخصي

أضف أسماءً ومصطلحات خاصة ليتعلم نُطق أسلوبك.

+
+
+ + +
+ + + +
اضغط وتكلّم
+
+ + + + + + + + + + + + + + + diff --git a/nutq/manifest.webmanifest b/nutq/manifest.webmanifest new file mode 100644 index 00000000..189c0af9 --- /dev/null +++ b/nutq/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "نُطق — لوحة المفاتيح الصوتية بالذكاء الاصطناعي", + "short_name": "نُطق", + "description": "تكلّم بشكل طبيعي، ونُطق يكتب نصاً مصقولاً جاهزاً للإرسال. يدعم أكثر من 100 لغة.", + "lang": "ar", + "dir": "rtl", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#FAF6E9", + "theme_color": "#FAF6E9", + "icons": [ + { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/nutq/sw.js b/nutq/sw.js new file mode 100644 index 00000000..842ee9e8 --- /dev/null +++ b/nutq/sw.js @@ -0,0 +1,43 @@ +/* Nutq service worker — offline-first app shell */ +const CACHE = "nutq-v1"; +const SHELL = [ + "./", + "./index.html", + "./app.css", + "./app.js", + "./manifest.webmanifest", + "./icons/icon-192.png", + "./icons/icon-512.png", + "./icons/apple-touch-icon.png", +]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (e) => { + const url = new URL(e.request.url); + // Never intercept API calls — only cache same-origin static assets. + if (url.origin !== location.origin || e.request.method !== "GET") return; + e.respondWith( + caches.match(e.request).then( + (hit) => + hit || + fetch(e.request).then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(e.request, copy)); + return res; + }) + ) + ); +});