✨ Add Claude Arabic Voice Input extension - Arabic speech-to-text for Claude.ai with Gemini AI processing
This commit is contained in:
83
claude-arabic-voice/background.js
Normal file
83
claude-arabic-voice/background.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// background.js — Service Worker for Claude Arabic Voice
|
||||
// Handles Gemini API calls for voice text processing via the existing server proxy
|
||||
|
||||
const SERVER_URL = 'https://cv.intaleqapp.com/cv/server/generate_cv.php';
|
||||
|
||||
// ─── Message Listener ────────────────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'GEMINI_PROCESS_VOICE') {
|
||||
handleVoiceProcessing(message.payload)
|
||||
.then(result => sendResponse({ success: true, data: result }))
|
||||
.catch(err => sendResponse({ success: false, error: err.message }));
|
||||
return true; // Keep message channel open for async
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Voice Text Processing with Gemini (via server proxy) ────────────────────
|
||||
|
||||
async function handleVoiceProcessing({ apiKey, model, text, language }) {
|
||||
if (!apiKey) {
|
||||
throw new Error('Gemini API key is required');
|
||||
}
|
||||
|
||||
// Detect if text is Arabic
|
||||
const isArabic = /[\u0600-\u06FF]/.test(text);
|
||||
const langName = isArabic ? 'Arabic' : 'the detected language';
|
||||
|
||||
const prompt = `You are a text refinement assistant. Your task is to:
|
||||
|
||||
1. Correct any speech recognition errors in the following text
|
||||
2. Fix punctuation, capitalization, and formatting
|
||||
3. Keep the original meaning and content intact
|
||||
4. Do NOT add any new information or commentary
|
||||
5. Return ONLY the corrected text, nothing else
|
||||
|
||||
The text is in ${langName}. Preserve the original language.
|
||||
|
||||
TEXT TO REFINE:
|
||||
${text}
|
||||
|
||||
CORRECTED TEXT:`;
|
||||
|
||||
const response = await fetch(SERVER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'generateText',
|
||||
apiKey: apiKey,
|
||||
prompt: prompt
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => ({}));
|
||||
const errMsg = errData.error?.message || `HTTP ${response.status}`;
|
||||
throw new Error(`Gemini API error: ${errMsg}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const resultText = data.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
|
||||
if (!resultText) {
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
|
||||
return { text: resultText.trim() };
|
||||
}
|
||||
|
||||
// ─── Installation Handler ────────────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
// Set default settings
|
||||
chrome.storage.sync.set({
|
||||
language: 'ar-SA',
|
||||
autoSend: false,
|
||||
useGemini: false,
|
||||
geminiApiKey: '',
|
||||
geminiModel: 'gemini-flash-lite-latest'
|
||||
});
|
||||
console.log('[ClaudeVoice] ✅ Extension installed with default settings');
|
||||
}
|
||||
});
|
||||
635
claude-arabic-voice/content.js
Normal file
635
claude-arabic-voice/content.js
Normal file
@@ -0,0 +1,635 @@
|
||||
// content.js — Arabic Voice Input for Claude.ai
|
||||
// Injects a microphone button that uses Web Speech API for Arabic speech recognition
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Prevent double injection
|
||||
if (window.__claudeArabicVoiceLoaded) return;
|
||||
window.__claudeArabicVoiceLoaded = true;
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────
|
||||
let recognition = null;
|
||||
let isListening = false;
|
||||
let micButton = null;
|
||||
let statusIndicator = null;
|
||||
let interimText = '';
|
||||
let finalText = '';
|
||||
let autoSendTimer = null;
|
||||
let silenceTimer = null;
|
||||
let lastSpeechTime = 0;
|
||||
let isGeminiProcessing = false;
|
||||
|
||||
const SILENCE_TIMEOUT = 2000; // 2 seconds of silence = auto-stop
|
||||
const MAX_RECORDING_TIME = 30000; // 30 seconds max recording
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────
|
||||
let settings = {
|
||||
language: 'ar-SA', // Default: Arabic (Saudi Arabia)
|
||||
autoSend: false, // Auto-send after stopping
|
||||
useGemini: false, // Use Gemini AI to refine text
|
||||
geminiApiKey: '',
|
||||
geminiModel: 'gemini-flash-lite-latest'
|
||||
};
|
||||
|
||||
// ─── Load Settings ───────────────────────────────────────────────────────
|
||||
async function loadSettings() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.sync.get(
|
||||
['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'],
|
||||
(data) => {
|
||||
if (data.language) settings.language = data.language;
|
||||
if (data.autoSend !== undefined) settings.autoSend = data.autoSend;
|
||||
if (data.useGemini !== undefined) settings.useGemini = data.useGemini;
|
||||
if (data.geminiApiKey) settings.geminiApiKey = data.geminiApiKey;
|
||||
if (data.geminiModel) settings.geminiModel = data.geminiModel;
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Speech Recognition Setup ────────────────────────────────────────────
|
||||
function initSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
console.warn('[ClaudeVoice] Speech recognition not supported in this browser');
|
||||
return null;
|
||||
}
|
||||
|
||||
const recog = new SpeechRecognition();
|
||||
recog.continuous = true;
|
||||
recog.interimResults = true;
|
||||
recog.lang = settings.language;
|
||||
recog.maxAlternatives = 1;
|
||||
|
||||
recog.onresult = (event) => {
|
||||
lastSpeechTime = Date.now();
|
||||
interimText = '';
|
||||
finalText = '';
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
if (result.isFinal) {
|
||||
finalText += result[0].transcript + ' ';
|
||||
} else {
|
||||
interimText += result[0].transcript;
|
||||
}
|
||||
}
|
||||
|
||||
updateInputField();
|
||||
|
||||
// Reset silence timer on new speech
|
||||
clearTimeout(silenceTimer);
|
||||
silenceTimer = setTimeout(() => {
|
||||
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
|
||||
stopListening();
|
||||
}
|
||||
}, SILENCE_TIMEOUT);
|
||||
};
|
||||
|
||||
recog.onerror = (event) => {
|
||||
console.error('[ClaudeVoice] Recognition error:', event.error);
|
||||
if (event.error === 'no-speech') {
|
||||
// No speech detected, restart if still listening
|
||||
if (isListening) {
|
||||
try { recog.stop(); } catch (e) { }
|
||||
setTimeout(() => {
|
||||
if (isListening) {
|
||||
try { recog.start(); } catch (e) { }
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
return;
|
||||
}
|
||||
stopListening();
|
||||
showStatus('error', '❌ خطأ: ' + getArabicError(event.error));
|
||||
};
|
||||
|
||||
recog.onend = () => {
|
||||
// If we're still supposed to be listening, restart
|
||||
if (isListening) {
|
||||
try {
|
||||
recog.start();
|
||||
} catch (e) {
|
||||
console.warn('[ClaudeVoice] Restart failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return recog;
|
||||
}
|
||||
|
||||
function getArabicError(error) {
|
||||
const errors = {
|
||||
'no-speech': 'لم يتم اكتشاف كلام',
|
||||
'aborted': 'تم الإلغاء',
|
||||
'audio-capture': 'الميكروفون غير متاح',
|
||||
'network': 'خطأ في الشبكة',
|
||||
'not-allowed': 'الرجاء السماح باستخدام الميكروفون',
|
||||
'service-not-allowed': 'الخدمة غير متاحة',
|
||||
'bad-grammar': 'خطأ في القواعد'
|
||||
};
|
||||
return errors[error] || error;
|
||||
}
|
||||
|
||||
// ─── Start / Stop Listening ─────────────────────────────────────────────
|
||||
async function startListening() {
|
||||
await loadSettings();
|
||||
|
||||
if (!recognition) {
|
||||
recognition = initSpeechRecognition();
|
||||
}
|
||||
|
||||
if (!recognition) {
|
||||
showStatus('error', '❌ المتصفح لا يدعم التعرف على الصوت');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update language in case it changed
|
||||
recognition.lang = settings.language;
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
isListening = true;
|
||||
lastSpeechTime = Date.now();
|
||||
updateMicButton(true);
|
||||
showStatus('listening', '🎤 جارٍ الاستماع...');
|
||||
|
||||
// Auto-stop after max time
|
||||
clearTimeout(autoSendTimer);
|
||||
autoSendTimer = setTimeout(() => {
|
||||
if (isListening) stopListening();
|
||||
}, MAX_RECORDING_TIME);
|
||||
|
||||
// Silence detection
|
||||
clearTimeout(silenceTimer);
|
||||
silenceTimer = setTimeout(() => {
|
||||
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
|
||||
stopListening();
|
||||
}
|
||||
}, SILENCE_TIMEOUT);
|
||||
|
||||
} catch (e) {
|
||||
console.error('[ClaudeVoice] Start failed:', e);
|
||||
showStatus('error', '❌ فشل بدء التسجيل');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopListening() {
|
||||
if (!recognition) return;
|
||||
|
||||
isListening = false;
|
||||
clearTimeout(silenceTimer);
|
||||
clearTimeout(autoSendTimer);
|
||||
|
||||
try {
|
||||
recognition.stop();
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
updateMicButton(false);
|
||||
|
||||
// If we have final text, process it
|
||||
const text = finalText.trim() || interimText.trim();
|
||||
if (text) {
|
||||
showStatus('processing', '⏳ جارٍ المعالجة...');
|
||||
|
||||
if (settings.useGemini && settings.geminiApiKey) {
|
||||
await processWithGemini(text);
|
||||
} else {
|
||||
// Just insert the text directly
|
||||
insertTextIntoClaude(text);
|
||||
showStatus('done', '✅ تم الإدراج');
|
||||
setTimeout(() => hideStatus(), 2000);
|
||||
}
|
||||
} else {
|
||||
showStatus('idle', '🎤 اضغط للتحدث');
|
||||
setTimeout(() => hideStatus(), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update Claude Input ─────────────────────────────────────────────────
|
||||
function updateInputField() {
|
||||
const fullText = (finalText + interimText).trim();
|
||||
if (!fullText) return;
|
||||
|
||||
// Find Claude's input area
|
||||
const inputArea = findClaudeInput();
|
||||
if (!inputArea) return;
|
||||
|
||||
// For interim results, we update a placeholder
|
||||
// For final results, we insert the text
|
||||
if (interimText) {
|
||||
// Show interim in a floating preview
|
||||
showInterimPreview(interimText);
|
||||
} else {
|
||||
hideInterimPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function findClaudeInput() {
|
||||
// Claude.ai uses a contenteditable div or textarea
|
||||
// Try multiple selectors as Claude's UI may change
|
||||
const selectors = [
|
||||
'[contenteditable="true"][role="textbox"]',
|
||||
'[contenteditable="true"].ProseMirror',
|
||||
'div[contenteditable="true"]',
|
||||
'textarea[placeholder*="Message"]',
|
||||
'textarea[placeholder*="Ask"]',
|
||||
'textarea[placeholder*="مراسلة"]',
|
||||
'textarea[placeholder*="اسأل"]',
|
||||
'.ProseMirror[contenteditable="true"]',
|
||||
'div[role="textbox"][contenteditable="true"]'
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findSendButton() {
|
||||
const selectors = [
|
||||
'button[aria-label*="Send"]',
|
||||
'button[aria-label*="إرسال"]',
|
||||
'button[data-testid*="send"]',
|
||||
'button[class*="send"]',
|
||||
'button[class*="Send"]',
|
||||
'form button[type="submit"]',
|
||||
// Claude's specific send button
|
||||
'button:has(svg[class*="send"])',
|
||||
'button:has(svg[data-icon*="arrow"])'
|
||||
];
|
||||
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) return el;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function insertTextIntoClaude(text) {
|
||||
const inputArea = findClaudeInput();
|
||||
if (!inputArea) {
|
||||
showStatus('error', '❌ لم يتم العثور على حقل الإدخال');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a contenteditable div (ProseMirror) or textarea
|
||||
if (inputArea.isContentEditable || inputArea.tagName === 'DIV') {
|
||||
// For contenteditable (Claude's ProseMirror editor)
|
||||
// Insert text at cursor position or append
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
|
||||
// Focus the input area
|
||||
inputArea.focus();
|
||||
|
||||
// Try to place cursor at end
|
||||
if (inputArea.lastChild) {
|
||||
range.setStartAfter(inputArea.lastChild);
|
||||
range.setEndAfter(inputArea.lastChild);
|
||||
} else {
|
||||
range.selectNodeContents(inputArea);
|
||||
range.collapse(false);
|
||||
}
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
// Insert text
|
||||
document.execCommand('insertText', false, text);
|
||||
|
||||
// Dispatch input event for React/ProseMirror to detect
|
||||
inputArea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inputArea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
} else if (inputArea.tagName === 'TEXTAREA' || inputArea.tagName === 'INPUT') {
|
||||
// For textarea/input
|
||||
const start = inputArea.selectionStart || inputArea.value.length;
|
||||
const end = inputArea.selectionEnd || inputArea.value.length;
|
||||
inputArea.value = inputArea.value.substring(0, start) + text + inputArea.value.substring(end);
|
||||
inputArea.selectionStart = inputArea.selectionEnd = start + text.length;
|
||||
inputArea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
inputArea.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
// Auto-send if enabled
|
||||
if (settings.autoSend) {
|
||||
setTimeout(() => {
|
||||
const sendBtn = findSendButton();
|
||||
if (sendBtn && !sendBtn.disabled) {
|
||||
sendBtn.click();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Interim Preview ─────────────────────────────────────────────────────
|
||||
let previewEl = null;
|
||||
|
||||
function showInterimPreview(text) {
|
||||
if (!previewEl) {
|
||||
previewEl = document.createElement('div');
|
||||
previewEl.id = 'claude-voice-interim';
|
||||
previewEl.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.85);
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
z-index: 999999;
|
||||
direction: rtl;
|
||||
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(previewEl);
|
||||
}
|
||||
previewEl.textContent = '🎤 ' + text + ' ▊';
|
||||
previewEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideInterimPreview() {
|
||||
if (previewEl) {
|
||||
previewEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Gemini AI Processing ────────────────────────────────────────────────
|
||||
async function processWithGemini(text) {
|
||||
isGeminiProcessing = true;
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GEMINI_PROCESS_VOICE',
|
||||
payload: {
|
||||
apiKey: settings.geminiApiKey,
|
||||
model: settings.geminiModel,
|
||||
text: text,
|
||||
language: settings.language
|
||||
}
|
||||
});
|
||||
|
||||
if (response && response.success) {
|
||||
const processedText = response.data.text;
|
||||
insertTextIntoClaude(processedText);
|
||||
showStatus('done', '✅ تمت المعالجة بواسطة Gemini');
|
||||
} else {
|
||||
// Fallback: insert original text
|
||||
insertTextIntoClaude(text);
|
||||
showStatus('done', '✅ تم الإدراج (بدون معالجة)');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ClaudeVoice] Gemini error:', e);
|
||||
insertTextIntoClaude(text);
|
||||
showStatus('done', '✅ تم الإدراج (بدون معالجة)');
|
||||
}
|
||||
|
||||
isGeminiProcessing = false;
|
||||
setTimeout(() => hideStatus(), 2000);
|
||||
}
|
||||
|
||||
// ─── UI: Mic Button ──────────────────────────────────────────────────────
|
||||
function createMicButton() {
|
||||
if (micButton) return;
|
||||
|
||||
micButton = document.createElement('button');
|
||||
micButton.id = 'claude-voice-mic-btn';
|
||||
micButton.innerHTML = '🎤';
|
||||
micButton.title = 'إملاء صوتي عربي - اضغط للتحدث';
|
||||
micButton.setAttribute('aria-label', 'إملاء صوتي عربي');
|
||||
|
||||
micButton.addEventListener('click', () => {
|
||||
if (isListening) {
|
||||
stopListening();
|
||||
} else {
|
||||
startListening();
|
||||
}
|
||||
});
|
||||
|
||||
// Status indicator
|
||||
statusIndicator = document.createElement('div');
|
||||
statusIndicator.id = 'claude-voice-status';
|
||||
statusIndicator.textContent = '🎤 اضغط للتحدث';
|
||||
statusIndicator.style.display = 'none';
|
||||
|
||||
// Container
|
||||
const container = document.createElement('div');
|
||||
container.id = 'claude-voice-container';
|
||||
container.appendChild(micButton);
|
||||
container.appendChild(statusIndicator);
|
||||
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Position the button near Claude's input area
|
||||
positionMicButton();
|
||||
}
|
||||
|
||||
function positionMicButton() {
|
||||
// Try to find the input area and position near it
|
||||
const observer = new MutationObserver(() => {
|
||||
const inputArea = findClaudeInput();
|
||||
if (inputArea && micButton) {
|
||||
const rect = inputArea.getBoundingClientRect();
|
||||
if (rect && rect.top > 0) {
|
||||
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||
micButton.style.right = '20px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class']
|
||||
});
|
||||
|
||||
// Initial positioning
|
||||
setTimeout(() => {
|
||||
const inputArea = findClaudeInput();
|
||||
if (inputArea && micButton) {
|
||||
const rect = inputArea.getBoundingClientRect();
|
||||
if (rect && rect.top > 0) {
|
||||
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateMicButton(listening) {
|
||||
if (!micButton) return;
|
||||
|
||||
if (listening) {
|
||||
micButton.classList.add('listening');
|
||||
micButton.innerHTML = '🔴';
|
||||
micButton.title = 'إيقاف التسجيل';
|
||||
} else {
|
||||
micButton.classList.remove('listening');
|
||||
micButton.innerHTML = '🎤';
|
||||
micButton.title = 'إملاء صوتي عربي - اضغط للتحدث';
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
if (!statusIndicator) return;
|
||||
statusIndicator.textContent = message;
|
||||
statusIndicator.className = 'claude-voice-status-' + type;
|
||||
statusIndicator.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideStatus() {
|
||||
if (!statusIndicator) return;
|
||||
statusIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
// ─── Inject Styles ───────────────────────────────────────────────────────
|
||||
function injectStyles() {
|
||||
// Styles are loaded from styles.css via manifest
|
||||
// But we also inject some critical inline styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#claude-voice-container {
|
||||
position: fixed;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
bottom: 120px;
|
||||
right: 20px;
|
||||
}
|
||||
#claude-voice-mic-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
#claude-voice-mic-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.6);
|
||||
}
|
||||
#claude-voice-mic-btn.listening {
|
||||
background: linear-gradient(135deg, #ff4444, #cc0000);
|
||||
animation: claude-voice-pulse 1.5s infinite;
|
||||
box-shadow: 0 4px 20px rgba(255, 68, 68, 0.6);
|
||||
}
|
||||
@keyframes claude-voice-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.6); }
|
||||
50% { box-shadow: 0 0 0 15px rgba(255, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0); }
|
||||
}
|
||||
#claude-voice-status {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.claude-voice-status-listening {
|
||||
background: rgba(255, 68, 68, 0.9) !important;
|
||||
}
|
||||
.claude-voice-status-processing {
|
||||
background: rgba(255, 165, 0, 0.9) !important;
|
||||
}
|
||||
.claude-voice-status-done {
|
||||
background: rgba(0, 200, 83, 0.9) !important;
|
||||
}
|
||||
.claude-voice-status-error {
|
||||
background: rgba(255, 0, 0, 0.9) !important;
|
||||
}
|
||||
.claude-voice-status-idle {
|
||||
background: rgba(108, 99, 255, 0.9) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ─── Message Listener (for popup communication) ─────────────────────────
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'GET_STATUS') {
|
||||
sendResponse({ isListening });
|
||||
return true;
|
||||
}
|
||||
if (message.type === 'SETTINGS_UPDATED') {
|
||||
if (message.payload.language) settings.language = message.payload.language;
|
||||
if (message.payload.autoSend !== undefined) settings.autoSend = message.payload.autoSend;
|
||||
if (message.payload.useGemini !== undefined) settings.useGemini = message.payload.useGemini;
|
||||
if (message.payload.geminiApiKey) settings.geminiApiKey = message.payload.geminiApiKey;
|
||||
if (message.payload.geminiModel) settings.geminiModel = message.payload.geminiModel;
|
||||
console.log('[ClaudeVoice] Settings updated:', settings);
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Initialize ──────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
await loadSettings();
|
||||
injectStyles();
|
||||
createMicButton();
|
||||
|
||||
// Re-position on scroll and resize
|
||||
window.addEventListener('scroll', () => {
|
||||
const inputArea = findClaudeInput();
|
||||
if (inputArea && micButton) {
|
||||
const rect = inputArea.getBoundingClientRect();
|
||||
if (rect && rect.top > 0) {
|
||||
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const inputArea = findClaudeInput();
|
||||
if (inputArea && micButton) {
|
||||
const rect = inputArea.getBoundingClientRect();
|
||||
if (rect && rect.top > 0) {
|
||||
micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[ClaudeVoice] ✅ Arabic voice input extension loaded');
|
||||
console.log('[ClaudeVoice] Language:', settings.language);
|
||||
console.log('[ClaudeVoice] Gemini:', settings.useGemini ? 'Enabled' : 'Disabled');
|
||||
}
|
||||
|
||||
// Wait for page to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
4
claude-arabic-voice/icons/icon128.svg
Normal file
4
claude-arabic-voice/icons/icon128.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<circle cx="64" cy="64" r="64" fill="#6c63ff"/>
|
||||
<text x="64" y="90" text-anchor="middle" font-size="72" fill="white">🎤</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
4
claude-arabic-voice/icons/icon16.svg
Normal file
4
claude-arabic-voice/icons/icon16.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
|
||||
<circle cx="8" cy="8" r="8" fill="#6c63ff"/>
|
||||
<text x="8" y="12" text-anchor="middle" font-size="10" fill="white"><EFBFBD><EFBFBD></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 222 B |
4
claude-arabic-voice/icons/icon48.svg
Normal file
4
claude-arabic-voice/icons/icon48.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
|
||||
<circle cx="24" cy="24" r="24" fill="#6c63ff"/>
|
||||
<text x="24" y="34" text-anchor="middle" font-size="28" fill="white">🎤</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
43
claude-arabic-voice/manifest.json
Normal file
43
claude-arabic-voice/manifest.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "كلود - إملاء صوتي عربي",
|
||||
"version": "1.0.0",
|
||||
"description": "يضيف زر مايكروفون لموقع Claude.ai للكتابة بالصوت باللغة العربية - Arabic speech-to-text for Claude.ai",
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://claude.ai/*",
|
||||
"https://cv.intaleqapp.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://claude.ai/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"css": [
|
||||
"styles.css"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.svg",
|
||||
"48": "icons/icon48.svg",
|
||||
"128": "icons/icon128.svg"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.svg",
|
||||
"48": "icons/icon48.svg",
|
||||
"128": "icons/icon128.svg"
|
||||
}
|
||||
}
|
||||
96
claude-arabic-voice/popup.html
Normal file
96
claude-arabic-voice/popup.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>كلود - إملاء صوتي عربي</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-container">
|
||||
<!-- Header -->
|
||||
<div class="popup-header">
|
||||
<div class="popup-icon">🎤</div>
|
||||
<div class="popup-title">
|
||||
<h1>الإملاء الصوتي لكلود</h1>
|
||||
<p class="subtitle">Claude Arabic Voice Input</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="status-section">
|
||||
<div class="status-indicator" id="statusIndicator">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">جاهز للاستخدام</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="settings-section">
|
||||
<h2>⚙️ الإعدادات</h2>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="setting-group">
|
||||
<label for="language">لغة التعرف على الصوت</label>
|
||||
<select id="language">
|
||||
<option value="ar-SA">العربية (السعودية)</option>
|
||||
<option value="ar-AE">العربية (الإمارات)</option>
|
||||
<option value="ar-EG">العربية (مصر)</option>
|
||||
<option value="ar-JO">العربية (الأردن)</option>
|
||||
<option value="ar-SY">العربية (سوريا)</option>
|
||||
<option value="ar-IQ">العربية (العراق)</option>
|
||||
<option value="ar">العربية (عام)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Auto Send -->
|
||||
<div class="setting-group checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="autoSend">
|
||||
<span>إرسال تلقائي بعد الانتهاء من الكلام</span>
|
||||
</label>
|
||||
<p class="setting-hint">بعد التوقف عن الكلام لمدة ثانيتين، يتم إرسال النص تلقائياً</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini AI -->
|
||||
<div class="setting-group divider">
|
||||
<h3>🤖 معالجة النص بـ Gemini AI</h3>
|
||||
<p class="setting-hint">يحسن دقة النص المنطوق ويصحح الأخطاء الإملائية</p>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="useGemini">
|
||||
<span>تفعيل معالجة Gemini</span>
|
||||
</label>
|
||||
|
||||
<div id="geminiSettings" class="gemini-settings" style="display:none;">
|
||||
<label for="geminiApiKey">مفتاح API</label>
|
||||
<input type="password" id="geminiApiKey" placeholder="أدخل مفتاح Gemini API">
|
||||
<p class="setting-hint">استخدم نفس مفتاح API الموجود في إعدادات LinkedIn Analyzer</p>
|
||||
<p class="setting-hint">النموذج المستخدم: <strong>gemini-flash-lite-latest</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- How to use -->
|
||||
<div class="help-section">
|
||||
<h2>📖 كيفية الاستخدام</h2>
|
||||
<ol>
|
||||
<li>اذهب إلى <strong>claude.ai</strong></li>
|
||||
<li>ستجد زر المايك 🎤 بجانب حقل الإدخال</li>
|
||||
<li>اضغط على الزر وابدأ بالتحدث بالعربية</li>
|
||||
<li>عند التوقف عن الكلام، سيتم إدراج النص تلقائياً</li>
|
||||
<li>يمكنك تفعيل Gemini لتحسين دقة النص</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="popup-footer">
|
||||
<button id="saveBtn" class="save-btn">💾 حفظ الإعدادات</button>
|
||||
<p id="saveMessage" class="save-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
claude-arabic-voice/popup.js
Normal file
83
claude-arabic-voice/popup.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// popup.js — Settings UI for Claude Arabic Voice
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ─── Load Settings ───────────────────────────────────────────────────────
|
||||
chrome.storage.sync.get(
|
||||
['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'],
|
||||
(data) => {
|
||||
if (data.language) document.getElementById('language').value = data.language;
|
||||
if (data.autoSend) document.getElementById('autoSend').checked = data.autoSend;
|
||||
if (data.useGemini) {
|
||||
document.getElementById('useGemini').checked = data.useGemini;
|
||||
document.getElementById('geminiSettings').style.display = 'block';
|
||||
}
|
||||
if (data.geminiApiKey) document.getElementById('geminiApiKey').value = data.geminiApiKey;
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Toggle Gemini Settings ──────────────────────────────────────────────
|
||||
document.getElementById('useGemini').addEventListener('change', (e) => {
|
||||
document.getElementById('geminiSettings').style.display = e.target.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// ─── Save Settings ───────────────────────────────────────────────────────
|
||||
document.getElementById('saveBtn').addEventListener('click', () => {
|
||||
const settings = {
|
||||
language: document.getElementById('language').value,
|
||||
autoSend: document.getElementById('autoSend').checked,
|
||||
useGemini: document.getElementById('useGemini').checked,
|
||||
geminiApiKey: document.getElementById('geminiApiKey').value.trim(),
|
||||
geminiModel: 'gemini-flash-lite-latest'
|
||||
};
|
||||
|
||||
chrome.storage.sync.set(settings, () => {
|
||||
const message = document.getElementById('saveMessage');
|
||||
message.textContent = '✅ تم حفظ الإعدادات بنجاح!';
|
||||
message.style.color = '#00c853';
|
||||
setTimeout(() => {
|
||||
message.textContent = '';
|
||||
}, 2500);
|
||||
|
||||
// Notify content script of settings change
|
||||
chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
|
||||
tabs.forEach(tab => {
|
||||
chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'SETTINGS_UPDATED',
|
||||
payload: settings
|
||||
}).catch(() => { /* tab may not have content script */ });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update Status ──────────────────────────────────────────────────────
|
||||
function updateStatus() {
|
||||
const statusDot = document.getElementById('statusDot');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
chrome.tabs.query({ url: 'https://claude.ai/*' }, (tabs) => {
|
||||
if (tabs.length === 0) {
|
||||
statusDot.className = 'status-dot offline';
|
||||
statusText.textContent = '🔴 Claude.ai غير مفتوح';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if content script is loaded
|
||||
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_STATUS' }, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
statusDot.className = 'status-dot offline';
|
||||
statusText.textContent = '🔴 الإضافة غير نشطة - أعد تحميل الصفحة';
|
||||
} else if (response && response.isListening) {
|
||||
statusDot.className = 'status-dot listening';
|
||||
statusText.textContent = '🔴 جارٍ الاستماع...';
|
||||
} else {
|
||||
statusDot.className = 'status-dot online';
|
||||
statusText.textContent = '✅ جاهز للاستخدام';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
setInterval(updateStatus, 3000);
|
||||
});
|
||||
290
claude-arabic-voice/styles.css
Normal file
290
claude-arabic-voice/styles.css
Normal file
@@ -0,0 +1,290 @@
|
||||
/* ─── Popup Styles ─────────────────────────────────────────────────────────── */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
width: 380px;
|
||||
min-height: 400px;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ─── Header ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.popup-icon {
|
||||
font-size: 32px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.popup-title h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-title .subtitle {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
/* ─── Status ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.status-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: #00c853;
|
||||
box-shadow: 0 0 8px rgba(0, 200, 83, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #ff5252;
|
||||
box-shadow: 0 0 8px rgba(255, 82, 82, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.listening {
|
||||
background: #ff1744;
|
||||
animation: pulse-dot 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 23, 68, 0.6);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(255, 23, 68, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 23, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Settings ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #bbb;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.setting-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #aaa;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.setting-group select,
|
||||
.setting-group input[type="password"],
|
||||
.setting-group input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-group select:focus,
|
||||
.setting-group input:focus {
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
.setting-group select option {
|
||||
background: #1a1a2e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setting-hint a {
|
||||
color: #6c63ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.setting-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── Checkbox ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.checkbox-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px !important;
|
||||
color: #ddd !important;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #6c63ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ─── Divider ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.divider {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ─── Gemini Settings ─────────────────────────────────────────────────────── */
|
||||
|
||||
.gemini-settings {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
border: 1px solid rgba(108, 99, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.gemini-settings label {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gemini-settings label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ─── Help Section ────────────────────────────────────────────────────────── */
|
||||
|
||||
.help-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.help-section h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-section ol {
|
||||
padding-right: 20px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.help-section ol li strong {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* ─── Footer ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.popup-footer {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, #6c63ff, #4834d4);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4);
|
||||
}
|
||||
|
||||
.save-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.save-message {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
Reference in New Issue
Block a user