diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..434cad2 Binary files /dev/null and b/app.ico differ diff --git a/app.py b/app.py index 1bc01f0..c10fac8 100644 --- a/app.py +++ b/app.py @@ -4,10 +4,11 @@ import pyautogui import base64 from io import BytesIO import socket -import requests -import json -import asyncio from threading import Thread +import pystray +from PIL import Image, ImageDraw +import pyperclip +from models import ModelFactory app = Flask(__name__) socketio = SocketIO(app, cors_allowed_origins="*") @@ -23,6 +24,33 @@ def get_local_ip(): except Exception: return "127.0.0.1" +def create_tray_icon(): + # Create a simple icon (a colored circle) + icon_size = 64 + icon_image = Image.new('RGB', (icon_size, icon_size), color='white') + draw = ImageDraw.Draw(icon_image) + draw.ellipse([4, 4, icon_size-4, icon_size-4], fill='#2196F3') # Using the primary color from our CSS + + # Get server URL + ip_address = get_local_ip() + server_url = f"http://{ip_address}:5000" + + # Create menu + menu = pystray.Menu( + pystray.MenuItem(server_url, lambda icon, item: None, enabled=False), + pystray.MenuItem("Exit", lambda icon, item: icon.stop()) + ) + + # Create icon + icon = pystray.Icon( + "SnapSolver", + icon_image, + "Snap Solver", + menu + ) + + return icon + @app.route('/') def index(): local_ip = get_local_ip() @@ -36,23 +64,28 @@ def handle_connect(): def handle_disconnect(): print('Client disconnected') -def stream_claude_response(response, sid): - """Stream Claude's response to the client""" +def stream_model_response(response_generator, sid): + """Stream model responses to the client""" try: - for chunk in response.iter_lines(): - if chunk: - data = json.loads(chunk.decode('utf-8').removeprefix('data: ')) - if data['type'] == 'content_block_delta': - socketio.emit('claude_response', { - 'content': data['delta']['text'] - }, room=sid) - elif data['type'] == 'error': - socketio.emit('claude_response', { - 'error': data['error']['message'] - }, room=sid) - except Exception as e: + print("Starting response streaming...") + + # Send initial status socketio.emit('claude_response', { - 'error': str(e) + 'status': 'started', + 'content': '' + }, room=sid) + print("Sent initial status to client") + + # Stream responses + for response in response_generator: + socketio.emit('claude_response', response, room=sid) + + except Exception as e: + error_msg = f"Streaming error: {str(e)}" + print(error_msg) + socketio.emit('claude_response', { + 'status': 'error', + 'error': error_msg }, room=sid) @socketio.on('request_screenshot') @@ -80,62 +113,68 @@ def handle_screenshot_request(): @socketio.on('analyze_image') def handle_image_analysis(data): try: + print("Starting image analysis...") settings = data['settings'] image_data = data['image'] # Base64 encoded image - headers = { - 'x-api-key': settings['apiKey'], - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - } + # Validate required settings + if not settings.get('apiKey'): + raise ValueError("API key is required") - payload = { - 'model': settings['model'], - 'max_tokens': 4096, - 'temperature': settings['temperature'], - 'system': settings['systemPrompt'], - 'messages': [{ - 'role': 'user', - 'content': [ - { - 'type': 'image', - 'source': { - 'type': 'base64', - 'media_type': 'image/png', - 'data': image_data - } - }, - { - 'type': 'text', - 'text': "Please analyze this image and provide a detailed explanation." - } - ] - }] - } + print("Using API key:", settings['apiKey'][:6] + "..." if settings.get('apiKey') else "None") + print("Selected model:", settings.get('model', 'claude-3-5-sonnet-20241022')) + + # Configure proxy settings if enabled + proxies = None + if settings.get('proxyEnabled', False): + proxy_host = settings.get('proxyHost', '127.0.0.1') + proxy_port = settings.get('proxyPort', '4780') + proxies = { + 'http': f'http://{proxy_host}:{proxy_port}', + 'https': f'http://{proxy_host}:{proxy_port}' + } - response = requests.post( - 'https://api.anthropic.com/v1/messages', - headers=headers, - json=payload, - stream=True - ) + try: + # Create model instance using factory + model = ModelFactory.create_model( + model_name=settings.get('model', 'claude-3-5-sonnet-20241022'), + api_key=settings['apiKey'], + temperature=float(settings.get('temperature', 0.7)), + system_prompt=settings.get('systemPrompt') + ) + + # Start streaming in a separate thread + Thread( + target=stream_model_response, + args=(model.analyze_image(image_data, proxies), request.sid) + ).start() - if response.status_code != 200: + except Exception as e: socketio.emit('claude_response', { - 'error': f'Claude API error: {response.status_code} - {response.text}' - }) - return - - # Start streaming in a separate thread to not block - Thread(target=stream_claude_response, args=(response, request.sid)).start() + 'status': 'error', + 'error': f'API error: {str(e)}' + }, room=request.sid) except Exception as e: + print(f"Analysis error: {str(e)}") socketio.emit('claude_response', { - 'error': str(e) - }) + 'status': 'error', + 'error': f'Analysis error: {str(e)}' + }, room=request.sid) + +def run_tray(): + icon = create_tray_icon() + icon.run() if __name__ == '__main__': local_ip = get_local_ip() print(f"Local IP Address: {local_ip}") print(f"Connect from your mobile device using: {local_ip}:5000") - socketio.run(app, host='0.0.0.0', port=5000, debug=True) + + # Run system tray icon in a separate thread + tray_thread = Thread(target=run_tray) + tray_thread.daemon = True + tray_thread.start() + + # Run Flask in the main thread without debug mode + socketio.run(app, host='0.0.0.0', port=5000, allow_unsafe_werkzeug=True) diff --git a/icon.py b/icon.py new file mode 100644 index 0000000..74ef89e --- /dev/null +++ b/icon.py @@ -0,0 +1,14 @@ +from PIL import Image, ImageDraw + +def create_icon(): + # Create a simple icon (a colored circle) + icon_size = 64 + icon_image = Image.new('RGB', (icon_size, icon_size), color='white') + draw = ImageDraw.Draw(icon_image) + draw.ellipse([4, 4, icon_size-4, icon_size-4], fill='#2196F3') + + # Save as ICO file + icon_image.save('app.ico', format='ICO') + +if __name__ == '__main__': + create_icon() diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e7e9945 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,13 @@ +from .base import BaseModel +from .claude import ClaudeModel +from .gpt4o import GPT4oModel +from .deepseek import DeepSeekModel +from .factory import ModelFactory + +__all__ = [ + 'BaseModel', + 'ClaudeModel', + 'GPT4oModel', + 'DeepSeekModel', + 'ModelFactory' +] diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2da4776 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/base.cpython-312.pyc b/models/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..60dff01 Binary files /dev/null and b/models/__pycache__/base.cpython-312.pyc differ diff --git a/models/__pycache__/claude.cpython-312.pyc b/models/__pycache__/claude.cpython-312.pyc new file mode 100644 index 0000000..bc97c58 Binary files /dev/null and b/models/__pycache__/claude.cpython-312.pyc differ diff --git a/models/__pycache__/deepseek.cpython-312.pyc b/models/__pycache__/deepseek.cpython-312.pyc new file mode 100644 index 0000000..d08926d Binary files /dev/null and b/models/__pycache__/deepseek.cpython-312.pyc differ diff --git a/models/__pycache__/factory.cpython-312.pyc b/models/__pycache__/factory.cpython-312.pyc new file mode 100644 index 0000000..8781072 Binary files /dev/null and b/models/__pycache__/factory.cpython-312.pyc differ diff --git a/models/__pycache__/gpt4o.cpython-312.pyc b/models/__pycache__/gpt4o.cpython-312.pyc new file mode 100644 index 0000000..243eaa1 Binary files /dev/null and b/models/__pycache__/gpt4o.cpython-312.pyc differ diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..c225373 --- /dev/null +++ b/models/base.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from typing import Generator, Any + +class BaseModel(ABC): + def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None): + self.api_key = api_key + self.temperature = temperature + self.system_prompt = system_prompt or self.get_default_system_prompt() + + @abstractmethod + def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]: + """ + Analyze the given image and yield response chunks. + + Args: + image_data: Base64 encoded image data + proxies: Optional proxy configuration + + Yields: + dict: Response chunks with status and content + """ + pass + + @abstractmethod + def get_default_system_prompt(self) -> str: + """Return the default system prompt for this model""" + pass + + @abstractmethod + def get_model_identifier(self) -> str: + """Return the model identifier used in API calls""" + pass + + def validate_api_key(self) -> bool: + """Validate if the API key is in the correct format""" + return bool(self.api_key and self.api_key.strip()) diff --git a/models/claude.py b/models/claude.py new file mode 100644 index 0000000..dbcc18e --- /dev/null +++ b/models/claude.py @@ -0,0 +1,121 @@ +import json +import requests +from typing import Generator +from .base import BaseModel + +class ClaudeModel(BaseModel): + def get_default_system_prompt(self) -> str: + return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: +1. First read and understand the question carefully +2. Break down the key components of the question +3. Provide a clear, step-by-step solution +4. If relevant, explain any concepts or theories involved +5. If there are multiple approaches, explain the most efficient one first""" + + def get_model_identifier(self) -> str: + return "claude-3-5-sonnet-20241022" + + def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]: + """Stream Claude's response for image analysis""" + try: + # Initial status + yield {"status": "started", "content": ""} + + api_key = self.api_key.strip() + if api_key.startswith('Bearer '): + api_key = api_key[7:] + + headers = { + 'x-api-key': api_key, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + 'accept': 'application/json', + } + + payload = { + 'model': self.get_model_identifier(), + 'stream': True, + 'max_tokens': 4096, + 'temperature': self.temperature, + 'system': self.system_prompt, + 'messages': [{ + 'role': 'user', + 'content': [ + { + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': 'image/png', + 'data': image_data + } + }, + { + 'type': 'text', + 'text': "Please analyze this question and provide a detailed solution. If you see multiple questions, focus on solving them one at a time." + } + ] + }] + } + + response = requests.post( + 'https://api.anthropic.com/v1/messages', + headers=headers, + json=payload, + stream=True, + proxies=proxies, + timeout=60 + ) + + if response.status_code != 200: + error_msg = f'API error: {response.status_code}' + try: + error_data = response.json() + if 'error' in error_data: + error_msg += f" - {error_data['error']['message']}" + except: + error_msg += f" - {response.text}" + yield {"status": "error", "error": error_msg} + return + + for chunk in response.iter_lines(): + if not chunk: + continue + + try: + chunk_str = chunk.decode('utf-8') + if not chunk_str.startswith('data: '): + continue + + chunk_str = chunk_str[6:] + data = json.loads(chunk_str) + + if data.get('type') == 'content_block_delta': + if 'delta' in data and 'text' in data['delta']: + yield { + "status": "streaming", + "content": data['delta']['text'] + } + + elif data.get('type') == 'message_stop': + yield { + "status": "completed", + "content": "" + } + + elif data.get('type') == 'error': + error_msg = data.get('error', {}).get('message', 'Unknown error') + yield { + "status": "error", + "error": error_msg + } + break + + except json.JSONDecodeError as e: + print(f"JSON decode error: {str(e)}") + continue + + except Exception as e: + yield { + "status": "error", + "error": f"Streaming error: {str(e)}" + } diff --git a/models/deepseek.py b/models/deepseek.py new file mode 100644 index 0000000..d45f6e1 --- /dev/null +++ b/models/deepseek.py @@ -0,0 +1,84 @@ +import json +import requests +from typing import Generator +from openai import OpenAI +from .base import BaseModel + +class DeepSeekModel(BaseModel): + def get_default_system_prompt(self) -> str: + return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: +1. First read and understand the question carefully +2. Break down the key components of the question +3. Provide a clear, step-by-step solution +4. If relevant, explain any concepts or theories involved +5. If there are multiple approaches, explain the most efficient one first""" + + def get_model_identifier(self) -> str: + return "deepseek-reasoner" + + def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]: + """Stream DeepSeek's response for image analysis""" + try: + # Initial status + yield {"status": "started", "content": ""} + + # Configure client with proxy if needed + client_args = { + "api_key": self.api_key, + "base_url": "https://api.deepseek.com" + } + + if proxies: + session = requests.Session() + session.proxies = proxies + client_args["http_client"] = session + + client = OpenAI(**client_args) + + response = client.chat.completions.create( + model=self.get_model_identifier(), + messages=[{ + 'role': 'user', + 'content': f"Here's an image of a question to analyze: data:image/png;base64,{image_data}" + }], + stream=True + ) + + for chunk in response: + try: + if hasattr(chunk.choices[0].delta, 'reasoning_content'): + content = chunk.choices[0].delta.reasoning_content + if content: + yield { + "status": "streaming", + "content": content + } + elif hasattr(chunk.choices[0].delta, 'content'): + content = chunk.choices[0].delta.content + if content: + yield { + "status": "streaming", + "content": content + } + + except Exception as e: + print(f"Chunk processing error: {str(e)}") + continue + + # Send completion status + yield { + "status": "completed", + "content": "" + } + + except Exception as e: + error_msg = str(e) + if "invalid_api_key" in error_msg.lower(): + error_msg = "Invalid API key provided" + elif "rate_limit" in error_msg.lower(): + error_msg = "Rate limit exceeded. Please try again later." + + yield { + "status": "error", + "error": f"DeepSeek API error: {error_msg}" + } diff --git a/models/factory.py b/models/factory.py new file mode 100644 index 0000000..6490e2c --- /dev/null +++ b/models/factory.py @@ -0,0 +1,55 @@ +from typing import Dict, Type +from .base import BaseModel +from .claude import ClaudeModel +from .gpt4o import GPT4oModel +from .deepseek import DeepSeekModel + +class ModelFactory: + _models: Dict[str, Type[BaseModel]] = { + 'claude-3-5-sonnet-20241022': ClaudeModel, + 'gpt-4o-2024-11-20': GPT4oModel, + 'deepseek-reasoner': DeepSeekModel + } + + @classmethod + def create_model(cls, model_name: str, api_key: str, temperature: float = 0.7, system_prompt: str = None) -> BaseModel: + """ + Create and return an instance of the specified model. + + Args: + model_name: The identifier of the model to create + api_key: The API key for the model + temperature: Optional temperature parameter for response generation + system_prompt: Optional custom system prompt + + Returns: + An instance of the specified model + + Raises: + ValueError: If the model_name is not recognized + """ + model_class = cls._models.get(model_name) + if not model_class: + raise ValueError(f"Unknown model: {model_name}") + + return model_class( + api_key=api_key, + temperature=temperature, + system_prompt=system_prompt + ) + + @classmethod + def get_available_models(cls) -> list[str]: + """Return a list of available model identifiers""" + return list(cls._models.keys()) + + @classmethod + def register_model(cls, model_name: str, model_class: Type[BaseModel]) -> None: + """ + Register a new model type with the factory. + + Args: + model_name: The identifier for the model + model_class: The model class to register + """ + cls._models[model_name] = model_class diff --git a/models/gpt4o.py b/models/gpt4o.py new file mode 100644 index 0000000..0d2bc00 --- /dev/null +++ b/models/gpt4o.py @@ -0,0 +1,94 @@ +import json +import requests +from typing import Generator +from openai import OpenAI +from .base import BaseModel + +class GPT4oModel(BaseModel): + def get_default_system_prompt(self) -> str: + return """You are an expert at analyzing questions and providing detailed solutions. When presented with an image of a question: +1. First read and understand the question carefully +2. Break down the key components of the question +3. Provide a clear, step-by-step solution +4. If relevant, explain any concepts or theories involved +5. If there are multiple approaches, explain the most efficient one first""" + + def get_model_identifier(self) -> str: + return "gpt-4o-2024-11-20" + + def analyze_image(self, image_data: str, proxies: dict = None) -> Generator[dict, None, None]: + """Stream GPT-4o's response for image analysis""" + try: + # Initial status + yield {"status": "started", "content": ""} + + # Configure client with proxy if needed + client_args = { + "api_key": self.api_key, + "base_url": "https://api.openai.com/v1" # Replace with actual GPT-4o API endpoint + } + + if proxies: + session = requests.Session() + session.proxies = proxies + client_args["http_client"] = session + + client = OpenAI(**client_args) + + messages = [ + { + "role": "system", + "content": self.system_prompt + }, + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{image_data}", + "detail": "high" + } + }, + { + "type": "text", + "text": "Please analyze this question and provide a detailed solution. If you see multiple questions, focus on solving them one at a time." + } + ] + } + ] + + response = client.chat.completions.create( + model=self.get_model_identifier(), + messages=messages, + temperature=self.temperature, + stream=True, + max_tokens=4000 + ) + + for chunk in response: + if hasattr(chunk.choices[0].delta, 'content'): + content = chunk.choices[0].delta.content + if content: + yield { + "status": "streaming", + "content": content + } + + # Send completion status + yield { + "status": "completed", + "content": "" + } + + except Exception as e: + error_msg = str(e) + if "invalid_api_key" in error_msg.lower(): + error_msg = "Invalid API key provided" + elif "rate_limit" in error_msg.lower(): + error_msg = "Rate limit exceeded. Please try again later." + + yield { + "status": "error", + "error": f"GPT-4o API error: {error_msg}" + } diff --git a/requirements.txt b/requirements.txt index eaeaec6..31de3d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -flask==3.0.0 +flask==3.1.0 pyautogui==0.9.54 -Pillow==10.1.0 -flask-socketio==5.3.6 -python-engineio==4.8.0 -python-socketio==5.10.0 -requests==2.31.0 +Pillow==11.1.0 +flask-socketio==5.5.1 +python-engineio==4.11.2 +python-socketio==5.12.1 +requests==2.32.3 +openai==1.61.0 diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..3d835eb --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,391 @@ +class SnapSolver { + constructor() { + this.initializeElements(); + this.initializeState(); + this.setupEventListeners(); + this.initializeConnection(); + + // Initialize managers + window.uiManager = new UIManager(); + window.settingsManager = new SettingsManager(); + } + + initializeElements() { + // Capture elements + this.captureBtn = document.getElementById('captureBtn'); + this.cropBtn = document.getElementById('cropBtn'); + this.connectionStatus = document.getElementById('connectionStatus'); + this.screenshotImg = document.getElementById('screenshotImg'); + this.cropContainer = document.getElementById('cropContainer'); + this.imagePreview = document.getElementById('imagePreview'); + this.sendToClaudeBtn = document.getElementById('sendToClaude'); + this.responseContent = document.getElementById('responseContent'); + this.claudePanel = document.getElementById('claudePanel'); + } + + initializeState() { + this.socket = null; + this.cropper = null; + this.croppedImage = null; + this.history = JSON.parse(localStorage.getItem('snapHistory') || '[]'); + } + + updateConnectionStatus(connected) { + this.connectionStatus.textContent = connected ? 'Connected' : 'Disconnected'; + this.connectionStatus.className = `status ${connected ? 'connected' : 'disconnected'}`; + this.captureBtn.disabled = !connected; + + if (!connected) { + this.imagePreview.classList.add('hidden'); + this.cropBtn.classList.add('hidden'); + this.sendToClaudeBtn.classList.add('hidden'); + } + } + + initializeConnection() { + try { + this.socket = io(window.location.origin); + + this.socket.on('connect', () => { + console.log('Connected to server'); + this.updateConnectionStatus(true); + }); + + this.socket.on('disconnect', () => { + console.log('Disconnected from server'); + this.updateConnectionStatus(false); + this.socket = null; + setTimeout(() => this.initializeConnection(), 5000); + }); + + this.setupSocketEventHandlers(); + + } catch (error) { + console.error('Connection error:', error); + this.updateConnectionStatus(false); + setTimeout(() => this.initializeConnection(), 5000); + } + } + + setupSocketEventHandlers() { + this.socket.on('screenshot_response', (data) => { + if (data.success) { + this.screenshotImg.src = `data:image/png;base64,${data.image}`; + this.imagePreview.classList.remove('hidden'); + this.cropBtn.classList.remove('hidden'); + this.captureBtn.disabled = false; + this.captureBtn.innerHTML = 'Capture'; + this.sendToClaudeBtn.classList.add('hidden'); + window.showToast('Screenshot captured successfully'); + } else { + window.showToast('Failed to capture screenshot: ' + data.error, 'error'); + this.captureBtn.disabled = false; + this.captureBtn.innerHTML = 'Capture'; + } + }); + + this.socket.on('claude_response', (data) => { + console.log('Received claude_response:', data); + + switch (data.status) { + case 'started': + console.log('Analysis started'); + this.responseContent.textContent = 'Starting analysis...\n'; + this.sendToClaudeBtn.disabled = true; + break; + + case 'streaming': + if (data.content) { + console.log('Received content:', data.content); + if (this.responseContent.textContent === 'Starting analysis...\n') { + this.responseContent.textContent = data.content; + } else { + this.responseContent.textContent += data.content; + } + this.responseContent.scrollTo({ + top: this.responseContent.scrollHeight, + behavior: 'smooth' + }); + } + break; + + case 'completed': + console.log('Analysis completed'); + this.responseContent.textContent += '\n\nAnalysis complete.'; + this.sendToClaudeBtn.disabled = false; + this.addToHistory(this.croppedImage, this.responseContent.textContent); + window.showToast('Analysis completed successfully'); + this.responseContent.scrollTo({ + top: this.responseContent.scrollHeight, + behavior: 'smooth' + }); + break; + + case 'error': + console.error('Claude analysis error:', data.error); + const errorMessage = data.error || 'Unknown error occurred'; + this.responseContent.textContent += '\n\nError: ' + errorMessage; + this.sendToClaudeBtn.disabled = false; + this.responseContent.scrollTop = this.responseContent.scrollHeight; + window.showToast('Analysis failed: ' + errorMessage, 'error'); + break; + + default: + console.warn('Unknown response status:', data.status); + if (data.error) { + this.responseContent.textContent += '\n\nError: ' + data.error; + this.sendToClaudeBtn.disabled = false; + this.responseContent.scrollTop = this.responseContent.scrollHeight; + window.showToast('Unknown error occurred', 'error'); + } + } + }); + + this.socket.on('connect_error', (error) => { + console.error('Connection error:', error); + this.updateConnectionStatus(false); + this.socket = null; + setTimeout(() => this.initializeConnection(), 5000); + }); + } + + initializeCropper() { + if (this.cropper) { + this.cropper.destroy(); + this.cropper = null; + } + + const cropArea = document.querySelector('.crop-area'); + cropArea.innerHTML = ''; + const clonedImage = this.screenshotImg.cloneNode(true); + clonedImage.style.display = 'block'; + cropArea.appendChild(clonedImage); + + this.cropContainer.classList.remove('hidden'); + + this.cropper = new Cropper(clonedImage, { + viewMode: 1, + dragMode: 'move', + autoCropArea: 1, + restore: false, + modal: true, + guides: true, + highlight: true, + cropBoxMovable: true, + cropBoxResizable: true, + toggleDragModeOnDblclick: false, + minContainerWidth: window.innerWidth, + minContainerHeight: window.innerHeight - 100, + minCropBoxWidth: 100, + minCropBoxHeight: 100, + background: true, + responsive: true, + checkOrientation: true, + ready: function() { + this.cropper.crop(); + } + }); + } + + addToHistory(imageData, response) { + const historyItem = { + id: Date.now(), + timestamp: new Date().toISOString(), + image: imageData, + response: response + }; + this.history.unshift(historyItem); + if (this.history.length > 10) this.history.pop(); + localStorage.setItem('snapHistory', JSON.stringify(this.history)); + window.renderHistory(); + } + + setupEventListeners() { + // Capture button + this.captureBtn.addEventListener('click', async () => { + if (!this.socket || !this.socket.connected) { + window.showToast('Not connected to server', 'error'); + return; + } + + try { + this.captureBtn.disabled = true; + this.captureBtn.innerHTML = 'Capturing...'; + this.socket.emit('request_screenshot'); + } catch (error) { + window.showToast('Error requesting screenshot: ' + error.message, 'error'); + this.captureBtn.disabled = false; + this.captureBtn.innerHTML = 'Capture'; + } + }); + + // Crop button + this.cropBtn.addEventListener('click', () => { + if (this.screenshotImg.src) { + this.initializeCropper(); + } + }); + + // Crop confirm button + document.getElementById('cropConfirm').addEventListener('click', () => { + if (this.cropper) { + try { + const canvas = this.cropper.getCroppedCanvas({ + maxWidth: 4096, + maxHeight: 4096, + fillColor: '#fff', + imageSmoothingEnabled: true, + imageSmoothingQuality: 'high', + }); + + if (!canvas) { + throw new Error('Failed to create cropped canvas'); + } + + this.croppedImage = canvas.toDataURL('image/png'); + + this.cropper.destroy(); + this.cropper = null; + this.cropContainer.classList.add('hidden'); + document.querySelector('.crop-area').innerHTML = ''; + this.settingsPanel.classList.add('hidden'); + + this.screenshotImg.src = this.croppedImage; + this.imagePreview.classList.remove('hidden'); + this.cropBtn.classList.remove('hidden'); + this.sendToClaudeBtn.classList.remove('hidden'); + window.showToast('Image cropped successfully'); + } catch (error) { + console.error('Cropping error:', error); + window.showToast('Error while cropping image', 'error'); + } + } + }); + + // Crop cancel button + document.getElementById('cropCancel').addEventListener('click', () => { + if (this.cropper) { + this.cropper.destroy(); + this.cropper = null; + } + this.cropContainer.classList.add('hidden'); + this.sendToClaudeBtn.classList.add('hidden'); + document.querySelector('.crop-area').innerHTML = ''; + }); + + // Send to Claude button + this.sendToClaudeBtn.addEventListener('click', () => { + if (!this.croppedImage) { + window.showToast('Please crop the image first', 'error'); + return; + } + + const settings = window.settingsManager.getSettings(); + if (!settings.apiKey) { + window.showToast('Please enter your API key in settings', 'error'); + this.settingsPanel.classList.remove('hidden'); + return; + } + + this.claudePanel.classList.remove('hidden'); + this.responseContent.textContent = 'Preparing to analyze image...\n'; + this.sendToClaudeBtn.disabled = true; + + try { + this.socket.emit('analyze_image', { + image: this.croppedImage.split(',')[1], + settings: { + apiKey: settings.apiKey, + model: settings.model || 'claude-3-5-sonnet-20241022', + temperature: parseFloat(settings.temperature) || 0.7, + systemPrompt: settings.systemPrompt || 'You are an expert at analyzing questions and providing detailed solutions.', + proxyEnabled: settings.proxyEnabled || false, + proxyHost: settings.proxyHost || '127.0.0.1', + proxyPort: settings.proxyPort || '4780' + } + }); + } catch (error) { + this.responseContent.textContent += '\nError: Failed to send image for analysis - ' + error.message; + this.sendToClaudeBtn.disabled = false; + window.showToast('Failed to send image for analysis', 'error'); + } + }); + + // Keyboard shortcuts for capture and crop + document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch(e.key) { + case 'c': + if (!this.captureBtn.disabled) this.captureBtn.click(); + break; + case 'x': + if (!this.cropBtn.disabled) this.cropBtn.click(); + break; + } + } + }); + } +} + +// Initialize the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + window.app = new SnapSolver(); +}); + +// Global function for history rendering +window.renderHistory = function() { + const content = document.querySelector('.history-content'); + const history = JSON.parse(localStorage.getItem('snapHistory') || '[]'); + + if (history.length === 0) { + content.innerHTML = ` +
+ +

No history yet

+
+ `; + return; + } + + content.innerHTML = history.map(item => ` +
+
+ ${new Date(item.timestamp).toLocaleString()} + +
+ Historical screenshot +
+ `).join(''); + + // Add click handlers for history items + content.querySelectorAll('.delete-history').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = parseInt(btn.dataset.id); + const updatedHistory = history.filter(item => item.id !== id); + localStorage.setItem('snapHistory', JSON.stringify(updatedHistory)); + window.renderHistory(); + window.showToast('History item deleted'); + }); + }); + + content.querySelectorAll('.history-item').forEach(item => { + item.addEventListener('click', () => { + const historyItem = history.find(h => h.id === parseInt(item.dataset.id)); + if (historyItem) { + window.app.screenshotImg.src = historyItem.image; + window.app.imagePreview.classList.remove('hidden'); + document.getElementById('historyPanel').classList.add('hidden'); + window.app.cropBtn.classList.add('hidden'); + window.app.captureBtn.classList.add('hidden'); + window.app.sendToClaudeBtn.classList.add('hidden'); + if (historyItem.response) { + window.app.claudePanel.classList.remove('hidden'); + window.app.responseContent.textContent = historyItem.response; + } + } + }); + }); +}; diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..8a2dfdc --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,100 @@ +class SettingsManager { + constructor() { + this.initializeElements(); + this.loadSettings(); + this.setupEventListeners(); + } + + initializeElements() { + // Settings panel elements + this.settingsPanel = document.getElementById('settingsPanel'); + this.apiKeyInput = document.getElementById('apiKey'); + this.modelSelect = document.getElementById('modelSelect'); + this.temperatureInput = document.getElementById('temperature'); + this.temperatureValue = document.getElementById('temperatureValue'); + this.systemPromptInput = document.getElementById('systemPrompt'); + this.proxyEnabledInput = document.getElementById('proxyEnabled'); + this.proxyHostInput = document.getElementById('proxyHost'); + this.proxyPortInput = document.getElementById('proxyPort'); + this.proxySettings = document.getElementById('proxySettings'); + + // Settings toggle elements + this.settingsToggle = document.getElementById('settingsToggle'); + this.closeSettings = document.getElementById('closeSettings'); + this.toggleApiKey = document.getElementById('toggleApiKey'); + } + + loadSettings() { + const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}'); + + if (settings.apiKey) this.apiKeyInput.value = settings.apiKey; + if (settings.model) this.modelSelect.value = settings.model; + if (settings.temperature) { + this.temperatureInput.value = settings.temperature; + this.temperatureValue.textContent = settings.temperature; + } + if (settings.systemPrompt) this.systemPromptInput.value = settings.systemPrompt; + if (settings.proxyEnabled !== undefined) { + this.proxyEnabledInput.checked = settings.proxyEnabled; + } + if (settings.proxyHost) this.proxyHostInput.value = settings.proxyHost; + if (settings.proxyPort) this.proxyPortInput.value = settings.proxyPort; + + this.proxySettings.style.display = this.proxyEnabledInput.checked ? 'block' : 'none'; + } + + saveSettings() { + const settings = { + apiKey: this.apiKeyInput.value, + model: this.modelSelect.value, + temperature: this.temperatureInput.value, + systemPrompt: this.systemPromptInput.value, + proxyEnabled: this.proxyEnabledInput.checked, + proxyHost: this.proxyHostInput.value, + proxyPort: this.proxyPortInput.value + }; + localStorage.setItem('aiSettings', JSON.stringify(settings)); + window.showToast('Settings saved successfully'); + } + + getSettings() { + return JSON.parse(localStorage.getItem('aiSettings') || '{}'); + } + + setupEventListeners() { + // Save settings on change + this.apiKeyInput.addEventListener('change', () => this.saveSettings()); + this.modelSelect.addEventListener('change', () => this.saveSettings()); + this.temperatureInput.addEventListener('input', (e) => { + this.temperatureValue.textContent = e.target.value; + this.saveSettings(); + }); + this.systemPromptInput.addEventListener('change', () => this.saveSettings()); + this.proxyEnabledInput.addEventListener('change', (e) => { + this.proxySettings.style.display = e.target.checked ? 'block' : 'none'; + this.saveSettings(); + }); + this.proxyHostInput.addEventListener('change', () => this.saveSettings()); + this.proxyPortInput.addEventListener('change', () => this.saveSettings()); + + // Toggle API key visibility + this.toggleApiKey.addEventListener('click', () => { + const type = this.apiKeyInput.type === 'password' ? 'text' : 'password'; + this.apiKeyInput.type = type; + this.toggleApiKey.innerHTML = ``; + }); + + // Panel visibility + this.settingsToggle.addEventListener('click', () => { + window.closeAllPanels(); + this.settingsPanel.classList.toggle('hidden'); + }); + + this.closeSettings.addEventListener('click', () => { + this.settingsPanel.classList.add('hidden'); + }); + } +} + +// Export for use in other modules +window.SettingsManager = SettingsManager; diff --git a/static/js/ui.js b/static/js/ui.js new file mode 100644 index 0000000..bd75366 --- /dev/null +++ b/static/js/ui.js @@ -0,0 +1,140 @@ +class UIManager { + constructor() { + this.initializeElements(); + this.setupTheme(); + this.setupEventListeners(); + } + + initializeElements() { + // Theme elements + this.themeToggle = document.getElementById('themeToggle'); + + // Panel elements + this.settingsPanel = document.getElementById('settingsPanel'); + this.historyPanel = document.getElementById('historyPanel'); + this.claudePanel = document.getElementById('claudePanel'); + + // History elements + this.historyToggle = document.getElementById('historyToggle'); + this.closeHistory = document.getElementById('closeHistory'); + + // Claude panel elements + this.closeClaudePanel = document.getElementById('closeClaudePanel'); + + // Toast container + this.toastContainer = document.getElementById('toastContainer'); + } + + setupTheme() { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); + + // Initialize theme + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + this.setTheme(savedTheme === 'dark'); + } else { + this.setTheme(prefersDark.matches); + } + + // Listen for system theme changes + prefersDark.addEventListener('change', (e) => this.setTheme(e.matches)); + } + + setTheme(isDark) { + document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light'); + this.themeToggle.innerHTML = ``; + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + } + + showToast(message, type = 'success') { + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` + + ${message} + `; + this.toastContainer.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + closeAllPanels() { + this.settingsPanel.classList.add('hidden'); + this.historyPanel.classList.add('hidden'); + } + + setupEventListeners() { + // Theme toggle + this.themeToggle.addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + this.setTheme(!isDark); + }); + + // History panel + this.historyToggle.addEventListener('click', () => { + this.closeAllPanels(); + this.historyPanel.classList.toggle('hidden'); + window.renderHistory(); // Call global renderHistory function + }); + + this.closeHistory.addEventListener('click', () => { + this.historyPanel.classList.add('hidden'); + }); + + // Claude panel + this.closeClaudePanel.addEventListener('click', () => { + this.claudePanel.classList.add('hidden'); + }); + + // Mobile touch events + let touchStartX = 0; + let touchEndX = 0; + + document.addEventListener('touchstart', (e) => { + touchStartX = e.changedTouches[0].screenX; + }); + + document.addEventListener('touchend', (e) => { + touchEndX = e.changedTouches[0].screenX; + this.handleSwipe(touchStartX, touchEndX); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch(e.key) { + case ',': + this.settingsPanel.classList.toggle('hidden'); + break; + case 'h': + this.historyPanel.classList.toggle('hidden'); + window.renderHistory(); + break; + } + } else if (e.key === 'Escape') { + this.closeAllPanels(); + } + }); + } + + handleSwipe(startX, endX) { + const swipeThreshold = 50; + const diff = endX - startX; + + if (Math.abs(diff) > swipeThreshold) { + if (diff > 0) { + this.closeAllPanels(); + } else { + this.settingsPanel.classList.remove('hidden'); + } + } + } +} + +// Export for use in other modules +window.UIManager = UIManager; +window.showToast = (message, type) => window.uiManager.showToast(message, type); +window.closeAllPanels = () => window.uiManager.closeAllPanels(); diff --git a/static/script.js b/static/script.js deleted file mode 100644 index f29b125..0000000 --- a/static/script.js +++ /dev/null @@ -1,316 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - const captureBtn = document.getElementById('captureBtn'); - const cropBtn = document.getElementById('cropBtn'); - const connectBtn = document.getElementById('connectBtn'); - const ipInput = document.getElementById('ipInput'); - const connectionStatus = document.getElementById('connectionStatus'); - const screenshotImg = document.getElementById('screenshotImg'); - const cropContainer = document.getElementById('cropContainer'); - const claudeActions = document.getElementById('claudeActions'); - const claudeResponse = document.getElementById('claudeResponse'); - const responseContent = document.getElementById('responseContent'); - const aiSettingsToggle = document.getElementById('aiSettingsToggle'); - const aiSettings = document.getElementById('aiSettings'); - const temperatureInput = document.getElementById('temperature'); - const temperatureValue = document.getElementById('temperatureValue'); - - let socket = null; - let cropper = null; - let croppedImage = null; - - // Load saved AI settings - function loadAISettings() { - const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}'); - if (settings.apiKey) document.getElementById('apiKey').value = settings.apiKey; - if (settings.model) document.getElementById('modelSelect').value = settings.model; - if (settings.temperature) { - temperatureInput.value = settings.temperature; - temperatureValue.textContent = settings.temperature; - } - if (settings.systemPrompt) document.getElementById('systemPrompt').value = settings.systemPrompt; - } - - // Save AI settings - function saveAISettings() { - const settings = { - apiKey: document.getElementById('apiKey').value, - model: document.getElementById('modelSelect').value, - temperature: temperatureInput.value, - systemPrompt: document.getElementById('systemPrompt').value - }; - localStorage.setItem('aiSettings', JSON.stringify(settings)); - } - - // Initialize settings - loadAISettings(); - - // AI Settings panel toggle - aiSettingsToggle.addEventListener('click', () => { - aiSettings.classList.toggle('hidden'); - }); - - // Save settings when changed - document.getElementById('apiKey').addEventListener('change', saveAISettings); - document.getElementById('modelSelect').addEventListener('change', saveAISettings); - temperatureInput.addEventListener('input', (e) => { - temperatureValue.textContent = e.target.value; - saveAISettings(); - }); - document.getElementById('systemPrompt').addEventListener('change', saveAISettings); - - function updateConnectionStatus(connected) { - connectionStatus.textContent = connected ? 'Connected' : 'Disconnected'; - connectionStatus.className = `status ${connected ? 'connected' : 'disconnected'}`; - captureBtn.disabled = !connected; - cropBtn.disabled = !screenshotImg.src; - connectBtn.textContent = connected ? 'Disconnect' : 'Connect'; - } - - function connectToServer(serverUrl) { - if (socket) { - socket.disconnect(); - socket = null; - updateConnectionStatus(false); - return; - } - - try { - socket = io(serverUrl); - - socket.on('connect', () => { - console.log('Connected to server'); - updateConnectionStatus(true); - }); - - socket.on('disconnect', () => { - console.log('Disconnected from server'); - updateConnectionStatus(false); - socket = null; - }); - - socket.on('screenshot_response', (data) => { - if (data.success) { - screenshotImg.src = `data:image/png;base64,${data.image}`; - cropBtn.disabled = false; - captureBtn.disabled = false; - captureBtn.textContent = 'Capture Screenshot'; - claudeActions.classList.add('hidden'); - } else { - alert('Failed to capture screenshot: ' + data.error); - captureBtn.disabled = false; - captureBtn.textContent = 'Capture Screenshot'; - } - }); - - socket.on('claude_response', (data) => { - if (data.error) { - responseContent.textContent += '\nError: ' + data.error; - } else { - responseContent.textContent += data.content; - } - responseContent.scrollTop = responseContent.scrollHeight; - }); - - socket.on('connect_error', (error) => { - console.error('Connection error:', error); - alert('Failed to connect to server. Please check the IP address and ensure the server is running.'); - updateConnectionStatus(false); - socket = null; - }); - - } catch (error) { - console.error('Connection error:', error); - alert('Failed to connect to server: ' + error.message); - updateConnectionStatus(false); - } - } - - function initializeCropper() { - if (cropper) { - cropper.destroy(); - cropper = null; - } - - // Reset the image container and move it to crop area - const cropArea = document.querySelector('.crop-area'); - cropArea.innerHTML = ''; - const clonedImage = screenshotImg.cloneNode(true); - clonedImage.style.maxWidth = '100%'; - clonedImage.style.maxHeight = '100%'; - cropArea.appendChild(clonedImage); - - // Show crop container - cropContainer.classList.remove('hidden'); - - // Initialize cropper with mobile-friendly settings - cropper = new Cropper(clonedImage, { - viewMode: 1, - dragMode: 'move', - autoCropArea: 0.8, - restore: false, - modal: true, - guides: true, - highlight: true, - cropBoxMovable: true, - cropBoxResizable: true, - toggleDragModeOnDblclick: false, - minContainerWidth: 100, - minContainerHeight: 100, - minCropBoxWidth: 50, - minCropBoxHeight: 50, - background: true, - responsive: true, - checkOrientation: true, - ready: function() { - // Ensure the cropper is properly sized - this.cropper.crop(); - } - }); - } - - // Capture and Crop Event Listeners - connectBtn.addEventListener('click', () => { - const serverUrl = ipInput.value.trim(); - if (!serverUrl) { - alert('Please enter the server IP address'); - return; - } - if (!serverUrl.startsWith('http://')) { - connectToServer('http://' + serverUrl); - } else { - connectToServer(serverUrl); - } - }); - - cropBtn.addEventListener('click', () => { - if (screenshotImg.src) { - initializeCropper(); - } - }); - - captureBtn.addEventListener('click', async () => { - if (!socket || !socket.connected) { - alert('Not connected to server'); - return; - } - - try { - captureBtn.disabled = true; - captureBtn.textContent = 'Capturing...'; - socket.emit('request_screenshot'); - } catch (error) { - alert('Error requesting screenshot: ' + error.message); - captureBtn.disabled = false; - captureBtn.textContent = 'Capture Screenshot'; - } - }); - - // Crop confirmation - document.getElementById('cropConfirm').addEventListener('click', () => { - if (cropper) { - try { - const canvas = cropper.getCroppedCanvas({ - maxWidth: 4096, - maxHeight: 4096, - fillColor: '#fff', - imageSmoothingEnabled: true, - imageSmoothingQuality: 'high', - }); - - if (!canvas) { - throw new Error('Failed to create cropped canvas'); - } - - croppedImage = canvas.toDataURL('image/png'); - - // Clean up - cropper.destroy(); - cropper = null; - cropContainer.classList.add('hidden'); - document.querySelector('.crop-area').innerHTML = ''; - - // Show the cropped image and Claude actions - screenshotImg.src = croppedImage; - cropBtn.disabled = false; - claudeActions.classList.remove('hidden'); - } catch (error) { - console.error('Cropping error:', error); - alert('Error while cropping image. Please try again.'); - } - } - }); - - // Crop cancellation - document.getElementById('cropCancel').addEventListener('click', () => { - if (cropper) { - cropper.destroy(); - cropper = null; - } - cropContainer.classList.add('hidden'); - claudeActions.classList.add('hidden'); - document.querySelector('.crop-area').innerHTML = ''; - }); - - // Send to Claude - document.getElementById('sendToClaude').addEventListener('click', () => { - if (!croppedImage) { - alert('Please crop the image first'); - return; - } - - const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}'); - if (!settings.apiKey) { - alert('Please enter your Claude API key in the settings'); - aiSettings.classList.remove('hidden'); - return; - } - - claudeResponse.classList.remove('hidden'); - responseContent.textContent = 'Analyzing image...'; - - socket.emit('analyze_image', { - image: croppedImage.split(',')[1], - settings: { - apiKey: settings.apiKey, - model: settings.model || 'claude-3-opus', - temperature: parseFloat(settings.temperature) || 0.7, - systemPrompt: settings.systemPrompt || 'You are a helpful AI assistant. Analyze the image and provide detailed explanations.' - } - }); - }); - - // Close Claude response - document.getElementById('closeResponse').addEventListener('click', () => { - claudeResponse.classList.add('hidden'); - responseContent.textContent = ''; - }); - - // Handle touch events for mobile - let touchStartX = 0; - let touchEndX = 0; - - document.addEventListener('touchstart', (e) => { - touchStartX = e.changedTouches[0].screenX; - }); - - document.addEventListener('touchend', (e) => { - touchEndX = e.changedTouches[0].screenX; - handleSwipe(); - }); - - function handleSwipe() { - const swipeThreshold = 50; - const diff = touchEndX - touchStartX; - - if (Math.abs(diff) > swipeThreshold) { - if (diff > 0) { - // Swipe right - hide panels - aiSettings.classList.add('hidden'); - claudeResponse.classList.add('hidden'); - } else { - // Swipe left - show AI settings - aiSettings.classList.remove('hidden'); - } - } - } -}); diff --git a/static/style.css b/static/style.css index 4101409..3fbcbb5 100644 --- a/static/style.css +++ b/static/style.css @@ -1,226 +1,480 @@ -body { - font-family: Arial, sans-serif; +:root { + /* Light theme colors */ + --primary-color: #2196F3; + --primary-dark: #1976D2; + --secondary-color: #4CAF50; + --secondary-dark: #45a049; + --background: #f8f9fa; + --surface: #ffffff; + --text-primary: #212121; + --text-secondary: #666666; + --border-color: #e0e0e0; + --shadow-color: rgba(0, 0, 0, 0.1); + --error-color: #f44336; + --success-color: #4CAF50; +} + +[data-theme="dark"] { + --primary-color: #64B5F6; + --primary-dark: #42A5F5; + --secondary-color: #81C784; + --secondary-dark: #66BB6A; + --background: #121212; + --surface: #1E1E1E; + --text-primary: #FFFFFF; + --text-secondary: #B0B0B0; + --border-color: #333333; + --shadow-color: rgba(0, 0, 0, 0.3); +} + +/* Base Styles */ +* { margin: 0; - padding: 20px; - background-color: #f0f0f0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; + transition: background-color 0.3s, color 0.3s; touch-action: manipulation; -webkit-tap-highlight-color: transparent; } -.container { - max-width: 1000px; - margin: 0 auto; - text-align: center; +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; } -.connection-panel { - background-color: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; +/* Header Styles */ +.app-header { + background-color: var(--surface); + padding: 1rem; + box-shadow: 0 2px 4px var(--shadow-color); + display: flex; + justify-content: space-between; + align-items: center; + z-index: 100; +} + +.header-left { display: flex; align-items: center; - justify-content: center; - gap: 10px; - flex-wrap: wrap; + gap: 2rem; +} + +.header-left h1 { + font-size: 1.5rem; + color: var(--primary-color); + margin: 0; +} + +.connection-status { + display: flex; + align-items: center; + gap: 1rem; +} + +.connection-form { + display: flex; + gap: 0.5rem; } .status { - padding: 8px 16px; - border-radius: 20px; - font-weight: bold; - font-size: 14px; + padding: 0.4rem 0.8rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 500; } .status.connected { - background-color: #4CAF50; + background-color: var(--success-color); color: white; } .status.disconnected { - background-color: #f44336; + background-color: var(--error-color); color: white; } -#ipInput { - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - width: 200px; -} - -#connectBtn { - padding: 8px 16px; - font-size: 14px; - background-color: #2196F3; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.3s; -} - -#connectBtn:hover { - background-color: #1976D2; -} - -.action-buttons { +.header-right { + display: flex; + gap: 0.5rem; +} + +/* Main Content */ +.app-main { + flex: 1; + display: flex; + padding: 1rem; + gap: 1rem; + position: relative; + overflow: hidden; +} + +.content-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Capture Section */ +.capture-section { + background-color: var(--surface); + border-radius: 0.5rem; + box-shadow: 0 2px 4px var(--shadow-color); + padding: 1rem; +} + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding: 0.5rem; + background-color: var(--surface); + border-radius: 0.5rem; +} + +.toolbar-buttons { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min-content, max-content)); + gap: 1rem; + justify-content: start; + align-items: center; +} + +.analysis-button { display: flex; - gap: 10px; justify-content: center; - margin-bottom: 20px; + margin-top: 1rem; + padding: 1rem; } -#captureBtn, #cropBtn { - padding: 12px 24px; - font-size: 16px; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.3s; - min-width: 160px; -} - -#captureBtn { - background-color: #4CAF50; -} - -#captureBtn:hover { - background-color: #45a049; -} - -#cropBtn { - background-color: #2196F3; -} - -#cropBtn:hover { - background-color: #1976D2; -} - -#captureBtn:disabled, #cropBtn:disabled { - background-color: #cccccc; - cursor: not-allowed; +.image-preview { + position: relative; + border-radius: 0.5rem; + overflow: hidden; + background-color: var(--background); + margin: 0; + padding: 1rem; } .image-container { - background-color: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - min-height: 300px; - display: flex; - align-items: center; - justify-content: center; + display: inline-block; + position: relative; + margin: 0 auto; } #screenshotImg { - max-width: 100%; - height: auto; - display: none; -} - -#screenshotImg[src] { display: block; + width: auto; + height: auto; + max-width: 100%; + border-radius: 0.5rem; } -.toggle-button { - position: fixed; - top: 20px; - right: 20px; - z-index: 1000; - padding: 10px; - border-radius: 50%; - width: 40px; - height: 40px; - background: #2196F3; - color: white; - border: none; - cursor: pointer; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); +@media (max-width: 768px) { + .toolbar-buttons { + flex-direction: row; + gap: 0.5rem; + } +} + +/* Claude Panel */ +.claude-panel { + background-color: var(--surface); + border-radius: 0.5rem; + box-shadow: 0 2px 4px var(--shadow-color); + padding: 1rem; + flex: 1; display: flex; + flex-direction: column; +} + +.panel-header { + display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - font-size: 20px; + margin-bottom: 1rem; } -.ai-settings { +.panel-header h2 { + font-size: 1.25rem; + color: var(--text-primary); +} + +.response-content { + flex: 1; + overflow-y: auto; + padding: 1rem; + background-color: var(--background); + border-radius: 0.5rem; + white-space: pre-wrap; + font-size: 0.9375rem; + line-height: 1.6; +} + +/* Settings Panel */ +.settings-panel { position: fixed; - top: 70px; - right: 20px; - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - z-index: 999; - width: 300px; - max-width: 90vw; + top: 0; + right: 0; + bottom: 0; + width: 400px; + max-width: 100vw; + background-color: var(--surface); + box-shadow: -2px 0 4px var(--shadow-color); + z-index: 1000; + transform: translateX(100%); transition: transform 0.3s ease; + display: flex; + flex-direction: column; } -.ai-settings.hidden { - transform: translateX(120%); +.settings-panel:not(.hidden) { + transform: translateX(0); } +.settings-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.settings-section { + margin-bottom: 2rem; +} + +.settings-section h3 { + color: var(--text-primary); + margin-bottom: 1rem; + font-size: 1.1rem; +} + +/* Form Elements */ .setting-group { - margin-bottom: 15px; + margin-bottom: 1rem; } .setting-group label { display: block; - margin-bottom: 5px; - font-weight: bold; - color: #333; + margin-bottom: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; } -.setting-group input[type="password"], -.setting-group input[type="text"], -.setting-group select, -.setting-group textarea { +input[type="text"], +input[type="password"], +input[type="number"], +select, +textarea { width: 100%; - padding: 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background-color: var(--background); + color: var(--text-primary); + font-size: 0.9375rem; + transition: border-color 0.3s, box-shadow 0.3s; } -.setting-group input[type="range"] { - width: 80%; - vertical-align: middle; +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1); } -#temperatureValue { - display: inline-block; - width: 15%; - text-align: right; +.input-group { + position: relative; + display: flex; + align-items: center; } +.input-group input { + padding-right: 2.5rem; +} + +.input-group .btn-icon { + position: absolute; + right: 0.5rem; +} + +.range-group { + display: flex; + align-items: center; + gap: 1rem; +} + +input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + background: var(--primary-color); + border-radius: 2px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + border: 2px solid var(--surface); + box-shadow: 0 1px 3px var(--shadow-color); +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +/* Buttons */ +.btn-primary, +.btn-secondary, +.btn-icon { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.375rem; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-dark); +} + +.btn-secondary { + background-color: var(--background); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background-color: var(--border-color); +} + +.btn-icon { + padding: 0.5rem; + border-radius: 0.375rem; + background: transparent; + color: var(--text-secondary); +} + +.btn-icon:hover { + background-color: var(--background); + color: var(--text-primary); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Floating Action Button */ +.fab { + position: fixed; + right: 2rem; + bottom: 2rem; + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background-color: var(--primary-color); + color: white; + border: none; + cursor: pointer; + box-shadow: 0 2px 8px var(--shadow-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + transition: transform 0.2s, background-color 0.2s; + z-index: 900; +} + +.fab:hover { + transform: scale(1.05); + background-color: var(--primary-dark); +} + +/* Toast Notifications */ +.toast-container { + position: fixed; + bottom: 2rem; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background-color: var(--surface); + color: var(--text-primary); + padding: 1rem 1.5rem; + border-radius: 0.375rem; + box-shadow: 0 4px 6px var(--shadow-color); + display: flex; + align-items: center; + gap: 0.75rem; + pointer-events: auto; + animation: toast-in 0.3s ease; +} + +.toast.success { + border-left: 4px solid var(--success-color); +} + +.toast.error { + border-left: 4px solid var(--error-color); +} + +/* Crop Container */ .crop-container { position: fixed; top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.9); + background-color: rgba(0, 0, 0, 0.9); z-index: 1000; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - touch-action: none; - overflow: hidden; } .crop-wrapper { + flex: 1; position: relative; - width: 100%; - height: calc(100% - 80px); + overflow: hidden; display: flex; align-items: center; justify-content: center; - overflow: hidden; } .crop-area { @@ -231,183 +485,193 @@ body { justify-content: center; } -/* Cropper.js custom styles */ -.cropper-container { - width: 100% !important; - height: 100% !important; - max-width: none !important; - max-height: none !important; -} - -.cropper-wrap-box { - background-color: rgba(0,0,0,0.8); -} - -.cropper-view-box, -.cropper-face { - border-radius: 0; -} - -.cropper-point { - width: 20px; - height: 20px; - opacity: 0.9; -} - -.cropper-point.point-se, -.cropper-point.point-sw, -.cropper-point.point-ne, -.cropper-point.point-nw { - width: 20px; - height: 20px; -} - -.cropper-line, -.cropper-point { - background-color: #2196F3; -} - -.crop-container.hidden { - display: none; +.crop-area img { + max-width: 100%; + max-height: 100%; } .crop-actions { - position: fixed; - bottom: 0; - left: 0; - right: 0; + padding: 1rem; display: flex; - justify-content: space-between; - padding: 15px; - z-index: 1001; - background: rgba(0,0,0,0.8); - gap: 10px; + justify-content: center; + gap: 1rem; + background-color: var(--surface); } -.crop-actions .action-button { - flex: 1; - max-width: 200px; - margin: 0 5px; - font-size: 16px; - padding: 12px; - border-radius: 8px; +/* Animations */ +@keyframes toast-in { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } } -.crop-actions .action-button.confirm { - background-color: #4CAF50; +/* Responsive Design */ +@media (max-width: 768px) { + .app-header { + flex-direction: column; + gap: 1rem; + padding: 0.75rem; + } + + .header-left { + flex-direction: column; + gap: 1rem; + width: 100%; + } + + .connection-status { + flex-direction: column; + width: 100%; + } + + .connection-form { + width: 100%; + } + + .connection-form input { + flex: 1; + } + + .header-right { + width: 100%; + justify-content: center; + } + + .settings-panel { + width: 100%; + } + + .fab { + right: 1rem; + bottom: 1rem; + } } -.crop-actions .action-button.confirm:hover { - background-color: #45a049; -} - -.action-button { - padding: 12px 24px; - font-size: 16px; - border: none; - border-radius: 4px; - cursor: pointer; - background: #2196F3; - color: white; - transition: background-color 0.3s; -} - -.action-button:hover { - background: #1976D2; -} - -.claude-actions { - margin-top: 20px; - text-align: center; -} - -.claude-actions.hidden { - display: none; -} - -.claude-response { +/* History Panel */ +.history-panel { position: fixed; - bottom: 0; - left: 0; + top: 0; right: 0; - background: white; - padding: 20px; - border-radius: 8px 8px 0 0; - box-shadow: 0 -2px 10px rgba(0,0,0,0.1); - z-index: 998; - max-height: 50vh; - overflow-y: auto; + bottom: 0; + width: 400px; + max-width: 100vw; + background-color: var(--surface); + box-shadow: -2px 0 4px var(--shadow-color); + z-index: 1000; + transform: translateX(100%); transition: transform 0.3s ease; + display: flex; + flex-direction: column; } -.claude-response.hidden { - transform: translateY(100%); +.history-panel:not(.hidden) { + transform: translateX(0); } -.response-header { +.history-content { + flex: 1; + overflow-y: auto; + padding: 1rem; +} + +.history-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + gap: 1rem; +} + +.history-empty i { + font-size: 3rem; +} + +.history-item { + background-color: var(--background); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; + cursor: pointer; + transition: all 0.2s; + position: relative; + border: 1px solid var(--border-color); +} + +.history-item:hover { + transform: translateY(-2px); + box-shadow: 0 2px 8px var(--shadow-color); +} + +.history-item[data-has-response="true"]::after { + content: "Has Analysis"; + position: absolute; + top: 0.5rem; + right: 0.5rem; + background-color: var(--primary-color); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 500; +} + +.history-item-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 15px; + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary); } -.response-header h3 { +.history-image { + width: 100%; + height: auto; + border-radius: 0.25rem; + margin-bottom: 1rem; +} + +.history-response { + background-color: var(--background); + border-radius: 0.5rem; + padding: 1rem; + margin-top: 1rem; +} + +.history-response h4 { + color: var(--text-primary); + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.history-response pre { + white-space: pre-wrap; + font-family: inherit; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--text-secondary); margin: 0; } -.close-button { - background: none; - border: none; - font-size: 24px; - cursor: pointer; - color: #666; +/* Utility Classes */ +.hidden { + display: none !important; } -.response-content { - white-space: pre-wrap; - font-size: 14px; - line-height: 1.5; -} - -@media (max-width: 600px) { - body { - padding: 10px; - } - - .connection-panel { - flex-direction: column; - gap: 15px; - padding: 15px; - } - - #ipInput { +/* Additional Responsive Styles */ +@media (max-width: 768px) { + .history-panel { width: 100%; - max-width: 300px; } - .toggle-button { - top: 10px; - right: 10px; - } - - .ai-settings { - top: 60px; - right: 10px; - padding: 15px; - } - - .crop-actions { - bottom: 10px; - gap: 10px; - } - - .action-button { - padding: 10px 20px; - font-size: 14px; - } - - .claude-response { - padding: 15px; - max-height: 60vh; + .history-item { + padding: 0.75rem; } } diff --git a/templates/index.html b/templates/index.html index 9ace523..8641199 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,76 +1,172 @@ - + - Screen Capture + Snap Solver + - -
- -