Fred808 commited on
Commit
74f7b0e
·
verified ·
1 Parent(s): 523da32

Upload 5 files

Browse files
Files changed (5) hide show
  1. app.py +125 -48
  2. index.html +56 -41
  3. requirements.txt +6 -3
  4. script.js +206 -108
  5. style.css +280 -232
app.py CHANGED
@@ -2,10 +2,13 @@ import os
2
  import asyncio
3
  import logging
4
  from telethon import TelegramClient
 
5
  from huggingface_hub import upload_file
6
  from dotenv import load_dotenv
7
- from flask import Flask, request, render_template, jsonify
8
- import threading
 
 
9
 
10
  # === Load secrets from .env ===
11
  load_dotenv()
@@ -58,7 +61,7 @@ def upload_to_dataset(filepath):
58
  logging.error(f"[!] Upload failed: {filepath} — {e}")
59
  return False, f"❌ Upload failed: {os.path.basename(filepath)} — {e}"
60
 
61
- # === Main file processing logic ===
62
  async def process_filenames(name_input):
63
  if not client:
64
  return "❌ Error: Telegram client not initialized. Please check your API credentials."
@@ -67,14 +70,26 @@ async def process_filenames(name_input):
67
  return "❌ Error: Channel username not configured."
68
 
69
  try:
70
- await client.start()
71
-
 
 
 
 
 
 
72
  filenames = [name.strip().lower() for name in name_input.replace(",", "\n").splitlines() if name.strip()]
73
  results = []
74
  found = set()
75
 
76
- messages = [msg async for msg in client.iter_messages(CHANNEL, limit=300)]
77
- total = len(messages)
 
 
 
 
 
 
78
 
79
  for i, msg in enumerate(messages):
80
  if msg.media and msg.file:
@@ -86,9 +101,13 @@ async def process_filenames(name_input):
86
  path = f"downloads/{fname}"
87
 
88
  if not os.path.exists(path):
89
- await msg.download_media(file=path)
90
- success, msg_text = upload_to_dataset(path)
91
- results.append(msg_text)
 
 
 
 
92
  else:
93
  results.append(f"⏩ Already exists: {fname}")
94
  break
@@ -100,52 +119,63 @@ async def process_filenames(name_input):
100
 
101
  return "\n".join(results) if results else "❌ No files matched."
102
 
 
 
 
 
 
 
 
 
 
103
  except Exception as e:
104
  logging.error(f"Error in process_filenames: {e}")
105
  return f"❌ Error: {str(e)}"
106
 
107
- def run_async_in_thread(coro):
108
- """Run async function in a separate thread with its own event loop"""
109
- def run_in_thread():
110
- loop = asyncio.new_event_loop()
111
- asyncio.set_event_loop(loop)
112
- try:
113
- return loop.run_until_complete(coro)
114
- finally:
115
- loop.close()
116
-
117
- import concurrent.futures
118
- with concurrent.futures.ThreadPoolExecutor() as executor:
119
- future = executor.submit(run_in_thread)
120
- return future.result()
121
 
122
- app = Flask(__name__)
 
 
 
123
 
124
- @app.route('/')
125
- def index():
126
- return render_template('index.html')
 
127
 
128
- @app.route('/upload', methods=['POST'])
129
- def upload():
 
130
  try:
131
- filenames_input = request.form.get('filenames', '').strip()
132
- if not filenames_input:
133
- return "❌ Error: No filenames provided", 400
134
 
135
  # Check if credentials are configured
136
  if not client:
137
- return "❌ Error: Application not configured. Please set up your .env file with API credentials.", 500
138
 
139
- # Run the async function in a separate thread
140
- results = run_async_in_thread(process_filenames(filenames_input))
141
- return results
142
 
143
  except Exception as e:
144
  logging.error(f"Error in upload route: {e}")
145
- return f"❌ Error: {str(e)}", 500
146
 
147
- @app.route('/health')
148
- def health():
 
149
  status = {
150
  "status": "healthy",
151
  "message": "Hugging Face Uploader is running",
@@ -153,12 +183,18 @@ def health():
153
  "telegram": bool(client),
154
  "huggingface": bool(HF_TOKEN and REPO_ID),
155
  "channel": bool(CHANNEL)
 
 
 
 
 
 
156
  }
157
  }
158
- return jsonify(status)
159
 
160
- @app.route('/config')
161
- def config():
162
  """Show configuration status"""
163
  config_status = {
164
  "API_ID": "✅ Set" if API_ID else "❌ Missing",
@@ -167,19 +203,60 @@ def config():
167
  "CHANNEL_USERNAME": "✅ Set" if CHANNEL else "❌ Missing",
168
  "DATASET_REPO": "✅ Set" if REPO_ID else "❌ Missing"
169
  }
 
 
 
 
 
 
170
 
171
- return jsonify(config_status)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  if __name__ == '__main__':
174
- print("Starting Hugging Face Uploader...")
175
  print("Configuration status:")
176
  print(f" API_ID: {'✅ Set' if API_ID else '❌ Missing'}")
177
  print(f" API_HASH: {'✅ Set' if API_HASH else '❌ Missing'}")
178
  print(f" HF_TOKEN: {'✅ Set' if HF_TOKEN else '❌ Missing'}")
179
  print(f" CHANNEL_USERNAME: {'✅ Set' if CHANNEL else '❌ Missing'}")
180
  print(f" DATASET_REPO: {'✅ Set' if REPO_ID else '❌ Missing'}")
181
- print("\nTo configure, copy .env.example to .env and fill in your credentials.")
182
- print("Visit http://localhost:5000 to use the application.")
 
 
 
 
 
 
 
183
 
184
- app.run(host='0.0.0.0', port=5000, debug=True)
 
185
 
 
2
  import asyncio
3
  import logging
4
  from telethon import TelegramClient
5
+ from telethon.errors import SessionPasswordNeededError, PhoneCodeInvalidError, AuthKeyError
6
  from huggingface_hub import upload_file
7
  from dotenv import load_dotenv
8
+ from fastapi import FastAPI, Form, HTTPException
9
+ from fastapi.responses import HTMLResponse, FileResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ import uvicorn
12
 
13
  # === Load secrets from .env ===
14
  load_dotenv()
 
61
  logging.error(f"[!] Upload failed: {filepath} — {e}")
62
  return False, f"❌ Upload failed: {os.path.basename(filepath)} — {e}"
63
 
64
+ # === Main file processing logic with improved error handling ===
65
  async def process_filenames(name_input):
66
  if not client:
67
  return "❌ Error: Telegram client not initialized. Please check your API credentials."
 
70
  return "❌ Error: Channel username not configured."
71
 
72
  try:
73
+ # Check if client is already connected
74
+ if not client.is_connected():
75
+ await client.connect()
76
+
77
+ # Check if we're authorized
78
+ if not await client.is_user_authorized():
79
+ return "❌ Error: Telegram client not authorized. This application requires a pre-authenticated session file."
80
+
81
  filenames = [name.strip().lower() for name in name_input.replace(",", "\n").splitlines() if name.strip()]
82
  results = []
83
  found = set()
84
 
85
+ # Use a more conservative approach to message iteration
86
+ try:
87
+ messages = []
88
+ async for msg in client.iter_messages(CHANNEL, limit=300):
89
+ messages.append(msg)
90
+ except Exception as e:
91
+ logging.error(f"Error iterating messages: {e}")
92
+ return f"❌ Error accessing channel messages: {str(e)}"
93
 
94
  for i, msg in enumerate(messages):
95
  if msg.media and msg.file:
 
101
  path = f"downloads/{fname}"
102
 
103
  if not os.path.exists(path):
104
+ try:
105
+ await msg.download_media(file=path)
106
+ success, msg_text = upload_to_dataset(path)
107
+ results.append(msg_text)
108
+ except Exception as download_error:
109
+ logging.error(f"Download error for {fname}: {download_error}")
110
+ results.append(f"❌ Download failed: {fname} — {str(download_error)}")
111
  else:
112
  results.append(f"⏩ Already exists: {fname}")
113
  break
 
119
 
120
  return "\n".join(results) if results else "❌ No files matched."
121
 
122
+ except AuthKeyError:
123
+ logging.error("Auth key error - session may be corrupted")
124
+ return "❌ Error: Session authentication failed. The session file may be corrupted or expired."
125
+ except SessionPasswordNeededError:
126
+ logging.error("Two-factor authentication required")
127
+ return "❌ Error: Two-factor authentication is enabled. This application requires a pre-authenticated session."
128
+ except EOFError as e:
129
+ logging.error(f"EOF Error: {e}")
130
+ return "❌ Error: Connection interrupted. This may be due to network issues or session problems."
131
  except Exception as e:
132
  logging.error(f"Error in process_filenames: {e}")
133
  return f"❌ Error: {str(e)}"
134
 
135
+ # === FastAPI App ===
136
+ app = FastAPI(title="Hugging Face Uploader", description="Upload files from Telegram to Hugging Face datasets")
137
+
138
+ @app.get("/", response_class=HTMLResponse)
139
+ async def index():
140
+ """Serve the main HTML page"""
141
+ try:
142
+ with open("index.html", "r", encoding="utf-8") as f:
143
+ return HTMLResponse(content=f.read())
144
+ except FileNotFoundError:
145
+ raise HTTPException(status_code=404, detail="index.html not found")
 
 
 
146
 
147
+ @app.get("/style.css")
148
+ async def get_css():
149
+ """Serve the CSS file"""
150
+ return FileResponse("style.css", media_type="text/css")
151
 
152
+ @app.get("/script.js")
153
+ async def get_js():
154
+ """Serve the JavaScript file"""
155
+ return FileResponse("script.js", media_type="application/javascript")
156
 
157
+ @app.post("/upload")
158
+ async def upload(filenames: str = Form(...)):
159
+ """Handle file upload requests"""
160
  try:
161
+ if not filenames.strip():
162
+ raise HTTPException(status_code=400, detail="❌ Error: No filenames provided")
 
163
 
164
  # Check if credentials are configured
165
  if not client:
166
+ raise HTTPException(status_code=500, detail="❌ Error: Application not configured. Please set up your environment variables with API credentials.")
167
 
168
+ # Process filenames using the async function
169
+ results = await process_filenames(filenames)
170
+ return {"results": results}
171
 
172
  except Exception as e:
173
  logging.error(f"Error in upload route: {e}")
174
+ raise HTTPException(status_code=500, detail=f"❌ Error: {str(e)}")
175
 
176
+ @app.get("/health")
177
+ async def health():
178
+ """Health check endpoint"""
179
  status = {
180
  "status": "healthy",
181
  "message": "Hugging Face Uploader is running",
 
183
  "telegram": bool(client),
184
  "huggingface": bool(HF_TOKEN and REPO_ID),
185
  "channel": bool(CHANNEL)
186
+ },
187
+ "files": {
188
+ "index_html_exists": os.path.exists("index.html"),
189
+ "style_css_exists": os.path.exists("style.css"),
190
+ "script_js_exists": os.path.exists("script.js"),
191
+ "session_file_exists": os.path.exists("my_session.session")
192
  }
193
  }
194
+ return status
195
 
196
+ @app.get("/config")
197
+ async def config():
198
  """Show configuration status"""
199
  config_status = {
200
  "API_ID": "✅ Set" if API_ID else "❌ Missing",
 
203
  "CHANNEL_USERNAME": "✅ Set" if CHANNEL else "❌ Missing",
204
  "DATASET_REPO": "✅ Set" if REPO_ID else "❌ Missing"
205
  }
206
+ return config_status
207
+
208
+ @app.get("/debug")
209
+ async def debug():
210
+ """Debug endpoint to check file structure"""
211
+ import glob
212
 
213
+ debug_info = {
214
+ "current_directory": os.getcwd(),
215
+ "files_in_current_dir": os.listdir('.'),
216
+ "html_exists": os.path.exists('index.html'),
217
+ "css_exists": os.path.exists('style.css'),
218
+ "js_exists": os.path.exists('script.js'),
219
+ "session_file_exists": os.path.exists('my_session.session'),
220
+ "downloads_folder_exists": os.path.exists('downloads'),
221
+ "log_file_exists": os.path.exists('upload.log')
222
+ }
223
+
224
+ return debug_info
225
+
226
+ @app.get("/session-info")
227
+ async def session_info():
228
+ """Check Telegram session status"""
229
+ if not client:
230
+ return {"error": "Client not initialized"}
231
+
232
+ try:
233
+ session_status = {
234
+ "session_file_exists": os.path.exists('my_session.session'),
235
+ "client_initialized": bool(client),
236
+ "session_file_size": os.path.getsize('my_session.session') if os.path.exists('my_session.session') else 0
237
+ }
238
+ return session_status
239
+ except Exception as e:
240
+ return {"error": str(e)}
241
 
242
  if __name__ == '__main__':
243
+ print("Starting Hugging Face Uploader with FastAPI...")
244
  print("Configuration status:")
245
  print(f" API_ID: {'✅ Set' if API_ID else '❌ Missing'}")
246
  print(f" API_HASH: {'✅ Set' if API_HASH else '❌ Missing'}")
247
  print(f" HF_TOKEN: {'✅ Set' if HF_TOKEN else '❌ Missing'}")
248
  print(f" CHANNEL_USERNAME: {'✅ Set' if CHANNEL else '❌ Missing'}")
249
  print(f" DATASET_REPO: {'✅ Set' if REPO_ID else '❌ Missing'}")
250
+ print(f"\nFile structure:")
251
+ print(f" index.html exists: {os.path.exists('index.html')}")
252
+ print(f" style.css exists: {os.path.exists('style.css')}")
253
+ print(f" script.js exists: {os.path.exists('script.js')}")
254
+ print(f" Session file exists: {os.path.exists('my_session.session')}")
255
+ print("\n⚠️ IMPORTANT: This application requires a pre-authenticated Telegram session.")
256
+ print(" You must create the session file locally first, then upload it to your deployment.")
257
+ print("\nTo configure, set environment variables in your deployment environment.")
258
+ print("Visit http://localhost:7860 to use the application.")
259
 
260
+ # Use port 7860 for Hugging Face Spaces compatibility
261
+ uvicorn.run(app, host="0.0.0.0", port=7860)
262
 
index.html CHANGED
@@ -4,76 +4,91 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Hugging Face Uploader</title>
7
- <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
10
  </head>
11
  <body>
12
  <div class="container">
13
  <header class="header">
14
  <div class="header-content">
15
- <div class="logo">
16
- <i class="fas fa-cloud-upload-alt"></i>
17
- <h1>Hugging Face Uploader</h1>
18
- </div>
19
- <p class="subtitle">Upload files from Telegram to Hugging Face datasets</p>
20
  </div>
21
  </header>
22
 
23
  <main class="main-content">
24
- <div class="upload-card">
25
- <div class="card-header">
26
- <h2><i class="fas fa-file-upload"></i> File Upload</h2>
27
- <p>Enter filenames to search and upload from Telegram channel</p>
28
- </div>
29
-
30
  <form id="uploadForm" class="upload-form">
31
- <div class="input-group">
32
- <label for="filenames">Filenames (comma or newline separated)</label>
 
 
33
  <textarea
34
  id="filenames"
35
  name="filenames"
36
- placeholder="e.g.&#10;report.pdf&#10;summary.docx&#10;meeting_notes.txt"
 
37
  rows="6"
38
  required
39
  ></textarea>
40
- <div class="input-hint">
41
- <i class="fas fa-info-circle"></i>
42
- Separate multiple filenames with commas or new lines
43
  </div>
44
  </div>
45
 
46
- <button type="submit" class="upload-btn" id="uploadBtn">
47
- <span class="btn-text">
48
- <i class="fas fa-upload"></i>
49
- Start Upload
50
- </span>
51
- <div class="loading-spinner" style="display: none;">
52
- <i class="fas fa-spinner fa-spin"></i>
53
- Processing...
54
- </div>
55
- </button>
56
- </form>
57
-
58
- <div class="results-section" id="resultsSection" style="display: none;">
59
- <div class="results-header">
60
- <h3><i class="fas fa-list-check"></i> Upload Results</h3>
61
- <button class="clear-btn" id="clearBtn">
62
- <i class="fas fa-trash"></i>
63
- Clear
64
  </button>
65
  </div>
66
- <div class="results-content" id="resultsContent"></div>
 
 
 
 
 
 
 
 
 
67
  </div>
 
 
 
 
 
 
68
  </div>
69
  </main>
70
 
71
  <footer class="footer">
72
- <p>&copy; 2024 Hugging Face Uploader. Built with Flask.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </footer>
74
  </div>
75
 
76
- <script src="{{ url_for('static', filename='js/script.js') }}"></script>
77
  </body>
78
  </html>
79
 
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Hugging Face Uploader</title>
7
+ <link rel="stylesheet" href="/style.css">
 
 
8
  </head>
9
  <body>
10
  <div class="container">
11
  <header class="header">
12
  <div class="header-content">
13
+ <h1 class="title">🤗 Hugging Face Uploader</h1>
14
+ <p class="subtitle">Upload files from Telegram channels to Hugging Face datasets</p>
 
 
 
15
  </div>
16
  </header>
17
 
18
  <main class="main-content">
19
+ <div class="upload-section">
 
 
 
 
 
20
  <form id="uploadForm" class="upload-form">
21
+ <div class="form-group">
22
+ <label for="filenames" class="form-label">
23
+ 📁 Enter filenames to search and upload
24
+ </label>
25
  <textarea
26
  id="filenames"
27
  name="filenames"
28
+ class="form-textarea"
29
+ placeholder="Enter filenames (one per line or comma-separated)&#10;Example:&#10;document.pdf&#10;image.jpg, video.mp4"
30
  rows="6"
31
  required
32
  ></textarea>
33
+ <div class="form-hint">
34
+ 💡 Tip: You can enter multiple filenames separated by commas or new lines
 
35
  </div>
36
  </div>
37
 
38
+ <div class="form-actions">
39
+ <button type="submit" class="btn btn-primary" id="uploadBtn">
40
+ <span class="btn-icon">🚀</span>
41
+ <span class="btn-text">Start Upload</span>
42
+ </button>
43
+ <button type="button" class="btn btn-secondary" id="clearBtn">
44
+ <span class="btn-icon">🗑️</span>
45
+ <span class="btn-text">Clear</span>
 
 
 
 
 
 
 
 
 
 
46
  </button>
47
  </div>
48
+ </form>
49
+ </div>
50
+
51
+ <div class="results-section" id="resultsSection" style="display: none;">
52
+ <div class="results-header">
53
+ <h3 class="results-title">📊 Upload Results</h3>
54
+ <button class="btn btn-ghost" id="clearResultsBtn">
55
+ <span class="btn-icon">✖️</span>
56
+ Clear Results
57
+ </button>
58
  </div>
59
+ <div class="results-content" id="resultsContent"></div>
60
+ </div>
61
+
62
+ <div class="loading-section" id="loadingSection" style="display: none;">
63
+ <div class="loading-spinner"></div>
64
+ <p class="loading-text">Processing your request...</p>
65
  </div>
66
  </main>
67
 
68
  <footer class="footer">
69
+ <div class="footer-content">
70
+ <div class="status-indicators">
71
+ <div class="status-item" id="configStatus">
72
+ <span class="status-icon">⚙️</span>
73
+ <span class="status-text">Configuration</span>
74
+ <span class="status-badge" id="configBadge">Checking...</span>
75
+ </div>
76
+ <div class="status-item" id="healthStatus">
77
+ <span class="status-icon">💚</span>
78
+ <span class="status-text">Health</span>
79
+ <span class="status-badge" id="healthBadge">Checking...</span>
80
+ </div>
81
+ </div>
82
+ <div class="footer-links">
83
+ <a href="/health" target="_blank" class="footer-link">Health Check</a>
84
+ <a href="/config" target="_blank" class="footer-link">Configuration</a>
85
+ <a href="/debug" target="_blank" class="footer-link">Debug Info</a>
86
+ </div>
87
+ </div>
88
  </footer>
89
  </div>
90
 
91
+ <script src="/script.js"></script>
92
  </body>
93
  </html>
94
 
requirements.txt CHANGED
@@ -1,4 +1,7 @@
1
- telethon==1.34.0
2
- flask
3
- huggingface_hub==0.23.0
 
4
  python-dotenv==1.0.1
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ telethon==1.36.0
4
+ huggingface-hub==0.26.5
5
  python-dotenv==1.0.1
6
+ python-multipart==0.0.6
7
+
script.js CHANGED
@@ -1,159 +1,257 @@
1
  document.addEventListener('DOMContentLoaded', function() {
 
2
  const uploadForm = document.getElementById('uploadForm');
 
3
  const uploadBtn = document.getElementById('uploadBtn');
4
- const btnText = uploadBtn.querySelector('.btn-text');
5
- const loadingSpinner = uploadBtn.querySelector('.loading-spinner');
6
  const resultsSection = document.getElementById('resultsSection');
7
  const resultsContent = document.getElementById('resultsContent');
8
- const clearBtn = document.getElementById('clearBtn');
9
- const filenamesInput = document.getElementById('filenames');
 
 
 
 
10
 
11
- // Form submission handler
12
- uploadForm.addEventListener('submit', async function(e) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  e.preventDefault();
14
 
15
- const filenames = filenamesInput.value.trim();
16
  if (!filenames) {
17
- alert('Please enter at least one filename');
18
  return;
19
  }
20
 
21
- // Show loading state
22
- setLoadingState(true);
23
-
24
  try {
25
  const formData = new FormData();
26
  formData.append('filenames', filenames);
27
-
28
  const response = await fetch('/upload', {
29
  method: 'POST',
30
  body: formData
31
  });
32
-
33
  if (!response.ok) {
34
- throw new Error(`HTTP error! status: ${response.status}`);
 
35
  }
36
-
37
- const results = await response.text();
38
- displayResults(results);
39
 
40
  } catch (error) {
41
  console.error('Upload error:', error);
42
- displayResults(`❌ Error: ${error.message}`);
43
  } finally {
44
- setLoadingState(false);
45
  }
46
- });
47
-
48
- // Clear results handler
49
- clearBtn.addEventListener('click', function() {
50
- resultsContent.textContent = '';
51
- resultsSection.style.display = 'none';
52
- });
53
 
54
- // Auto-resize textarea
55
- filenamesInput.addEventListener('input', function() {
56
- this.style.height = 'auto';
57
- this.style.height = Math.min(this.scrollHeight, 200) + 'px';
58
- });
59
 
60
- // Functions
61
- function setLoadingState(isLoading) {
62
- uploadBtn.disabled = isLoading;
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  if (isLoading) {
65
- btnText.style.display = 'none';
66
- loadingSpinner.style.display = 'flex';
 
67
  } else {
68
- btnText.style.display = 'flex';
69
- loadingSpinner.style.display = 'none';
 
70
  }
71
  }
72
 
73
- function displayResults(results) {
74
- // Format results with proper styling
75
- const formattedResults = formatResults(results);
76
- resultsContent.innerHTML = formattedResults;
77
- resultsSection.style.display = 'block';
78
-
79
- // Scroll to results
80
- resultsSection.scrollIntoView({
81
- behavior: 'smooth',
82
- block: 'start'
83
- });
84
  }
85
 
86
- function formatResults(results) {
87
- if (!results) return '<span class="error">No results received</span>';
 
 
 
 
 
 
88
 
89
- return results.split('\n').map(line => {
90
- line = line.trim();
91
- if (!line) return '';
 
 
 
 
 
 
 
 
 
 
92
 
93
- if (line.startsWith('✅')) {
94
- return `<div class="success">${escapeHtml(line)}</div>`;
95
- } else if (line.startsWith('❌')) {
96
- return `<div class="error">${escapeHtml(line)}</div>`;
97
- } else if (line.startsWith('⏩')) {
98
- return `<div class="warning">${escapeHtml(line)}</div>`;
99
  } else {
100
- return `<div>${escapeHtml(line)}</div>`;
 
101
  }
102
- }).join('');
 
 
 
 
103
  }
104
 
105
- function escapeHtml(text) {
106
- const div = document.createElement('div');
107
- div.textContent = text;
108
- return div.innerHTML;
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  }
110
 
111
- // Add some interactive effects
112
- const uploadCard = document.querySelector('.upload-card');
113
-
114
- // Add subtle hover effect to form elements
115
- const formElements = document.querySelectorAll('input, textarea, button');
116
- formElements.forEach(element => {
117
- element.addEventListener('focus', function() {
118
- uploadCard.style.transform = 'translateY(-2px)';
119
- });
120
-
121
- element.addEventListener('blur', function() {
122
- uploadCard.style.transform = 'translateY(-5px)';
123
- });
124
- });
125
 
126
- // Add keyboard shortcuts
127
- document.addEventListener('keydown', function(e) {
128
- // Ctrl/Cmd + Enter to submit form
129
- if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
130
- e.preventDefault();
131
- uploadForm.dispatchEvent(new Event('submit'));
132
- }
 
 
 
 
 
 
133
 
134
- // Escape to clear results
135
- if (e.key === 'Escape' && resultsSection.style.display !== 'none') {
136
- clearBtn.click();
 
 
 
 
 
 
 
 
137
  }
138
  });
139
 
140
- // Add tooltip for keyboard shortcuts
141
- const tooltip = document.createElement('div');
142
- tooltip.innerHTML = `
143
- <div style="position: fixed; bottom: 20px; right: 20px; background: rgba(0,0,0,0.8); color: white; padding: 10px; border-radius: 8px; font-size: 0.8rem; z-index: 1000;">
144
- <div><kbd>Ctrl+Enter</kbd> Submit form</div>
145
- <div><kbd>Esc</kbd> Clear results</div>
146
- </div>
147
- `;
148
-
149
- // Show tooltip on first visit
150
- setTimeout(() => {
151
- document.body.appendChild(tooltip);
152
- setTimeout(() => {
153
- tooltip.style.opacity = '0';
154
- tooltip.style.transition = 'opacity 0.5s';
155
- setTimeout(() => tooltip.remove(), 500);
156
- }, 3000);
157
- }, 1000);
158
  });
159
 
 
1
  document.addEventListener('DOMContentLoaded', function() {
2
+ // DOM elements
3
  const uploadForm = document.getElementById('uploadForm');
4
+ const filenamesTextarea = document.getElementById('filenames');
5
  const uploadBtn = document.getElementById('uploadBtn');
6
+ const clearBtn = document.getElementById('clearBtn');
7
+ const clearResultsBtn = document.getElementById('clearResultsBtn');
8
  const resultsSection = document.getElementById('resultsSection');
9
  const resultsContent = document.getElementById('resultsContent');
10
+ const loadingSection = document.getElementById('loadingSection');
11
+ const configBadge = document.getElementById('configBadge');
12
+ const healthBadge = document.getElementById('healthBadge');
13
+
14
+ // Initialize the application
15
+ init();
16
 
17
+ async function init() {
18
+ await checkConfiguration();
19
+ await checkHealth();
20
+ setupEventListeners();
21
+ setupKeyboardShortcuts();
22
+ }
23
+
24
+ function setupEventListeners() {
25
+ // Form submission
26
+ uploadForm.addEventListener('submit', handleUpload);
27
+
28
+ // Clear buttons
29
+ clearBtn.addEventListener('click', clearForm);
30
+ clearResultsBtn.addEventListener('click', clearResults);
31
+
32
+ // Auto-resize textarea
33
+ filenamesTextarea.addEventListener('input', autoResizeTextarea);
34
+
35
+ // Real-time validation
36
+ filenamesTextarea.addEventListener('input', validateInput);
37
+ }
38
+
39
+ function setupKeyboardShortcuts() {
40
+ document.addEventListener('keydown', function(e) {
41
+ // Ctrl+Enter or Cmd+Enter to submit
42
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
43
+ e.preventDefault();
44
+ if (!uploadBtn.disabled) {
45
+ handleUpload(e);
46
+ }
47
+ }
48
+
49
+ // Escape to clear results
50
+ if (e.key === 'Escape') {
51
+ clearResults();
52
+ }
53
+ });
54
+ }
55
+
56
+ async function handleUpload(e) {
57
  e.preventDefault();
58
 
59
+ const filenames = filenamesTextarea.value.trim();
60
  if (!filenames) {
61
+ showError('Please enter at least one filename');
62
  return;
63
  }
64
 
65
+ setLoading(true);
66
+ clearResults();
67
+
68
  try {
69
  const formData = new FormData();
70
  formData.append('filenames', filenames);
71
+
72
  const response = await fetch('/upload', {
73
  method: 'POST',
74
  body: formData
75
  });
76
+
77
  if (!response.ok) {
78
+ const errorData = await response.json();
79
+ throw new Error(errorData.detail || `HTTP ${response.status}`);
80
  }
81
+
82
+ const data = await response.json();
83
+ displayResults(data.results);
84
 
85
  } catch (error) {
86
  console.error('Upload error:', error);
87
+ showError(`Upload failed: ${error.message}`);
88
  } finally {
89
+ setLoading(false);
90
  }
91
+ }
 
 
 
 
 
 
92
 
93
+ function displayResults(results) {
94
+ if (!results) {
95
+ showError('No results received from server');
96
+ return;
97
+ }
98
 
99
+ resultsContent.innerHTML = '';
100
+
101
+ // Split results by lines and process each
102
+ const lines = results.split('\n').filter(line => line.trim());
103
 
104
+ lines.forEach(line => {
105
+ const resultDiv = document.createElement('div');
106
+ resultDiv.className = 'result-line';
107
+
108
+ // Determine result type and apply appropriate styling
109
+ if (line.includes('✅')) {
110
+ resultDiv.classList.add('result-success');
111
+ } else if (line.includes('❌')) {
112
+ resultDiv.classList.add('result-error');
113
+ } else if (line.includes('⏩')) {
114
+ resultDiv.classList.add('result-info');
115
+ } else {
116
+ resultDiv.classList.add('result-warning');
117
+ }
118
+
119
+ resultDiv.textContent = line;
120
+ resultsContent.appendChild(resultDiv);
121
+ });
122
+
123
+ resultsSection.style.display = 'block';
124
+ resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
125
+ }
126
+
127
+ function showError(message) {
128
+ resultsContent.innerHTML = '';
129
+ const errorDiv = document.createElement('div');
130
+ errorDiv.className = 'result-line result-error';
131
+ errorDiv.textContent = `❌ ${message}`;
132
+ resultsContent.appendChild(errorDiv);
133
+ resultsSection.style.display = 'block';
134
+ }
135
+
136
+ function setLoading(isLoading) {
137
  if (isLoading) {
138
+ loadingSection.style.display = 'block';
139
+ uploadBtn.disabled = true;
140
+ uploadBtn.querySelector('.btn-text').textContent = 'Processing...';
141
  } else {
142
+ loadingSection.style.display = 'none';
143
+ uploadBtn.disabled = false;
144
+ uploadBtn.querySelector('.btn-text').textContent = 'Start Upload';
145
  }
146
  }
147
 
148
+ function clearForm() {
149
+ filenamesTextarea.value = '';
150
+ filenamesTextarea.style.height = 'auto';
151
+ validateInput();
152
+ filenamesTextarea.focus();
153
+ }
154
+
155
+ function clearResults() {
156
+ resultsSection.style.display = 'none';
157
+ resultsContent.innerHTML = '';
 
158
  }
159
 
160
+ function autoResizeTextarea() {
161
+ filenamesTextarea.style.height = 'auto';
162
+ filenamesTextarea.style.height = filenamesTextarea.scrollHeight + 'px';
163
+ }
164
+
165
+ function validateInput() {
166
+ const value = filenamesTextarea.value.trim();
167
+ uploadBtn.disabled = !value;
168
 
169
+ if (value) {
170
+ uploadBtn.classList.remove('btn-disabled');
171
+ } else {
172
+ uploadBtn.classList.add('btn-disabled');
173
+ }
174
+ }
175
+
176
+ async function checkConfiguration() {
177
+ try {
178
+ const response = await fetch('/config');
179
+ const config = await response.json();
180
+
181
+ const allConfigured = Object.values(config).every(status => status.includes('✅'));
182
 
183
+ if (allConfigured) {
184
+ configBadge.textContent = 'Ready';
185
+ configBadge.className = 'status-badge success';
 
 
 
186
  } else {
187
+ configBadge.textContent = 'Incomplete';
188
+ configBadge.className = 'status-badge warning';
189
  }
190
+ } catch (error) {
191
+ console.error('Config check failed:', error);
192
+ configBadge.textContent = 'Error';
193
+ configBadge.className = 'status-badge error';
194
+ }
195
  }
196
 
197
+ async function checkHealth() {
198
+ try {
199
+ const response = await fetch('/health');
200
+ const health = await response.json();
201
+
202
+ if (health.status === 'healthy') {
203
+ healthBadge.textContent = 'Healthy';
204
+ healthBadge.className = 'status-badge success';
205
+ } else {
206
+ healthBadge.textContent = 'Issues';
207
+ healthBadge.className = 'status-badge warning';
208
+ }
209
+ } catch (error) {
210
+ console.error('Health check failed:', error);
211
+ healthBadge.textContent = 'Error';
212
+ healthBadge.className = 'status-badge error';
213
+ }
214
  }
215
 
216
+ // Refresh status indicators every 30 seconds
217
+ setInterval(() => {
218
+ checkConfiguration();
219
+ checkHealth();
220
+ }, 30000);
 
 
 
 
 
 
 
 
 
221
 
222
+ // Show keyboard shortcuts hint on first visit
223
+ if (!localStorage.getItem('keyboardHintShown')) {
224
+ setTimeout(() => {
225
+ showKeyboardHint();
226
+ localStorage.setItem('keyboardHintShown', 'true');
227
+ }, 2000);
228
+ }
229
+
230
+ function showKeyboardHint() {
231
+ const hint = document.createElement('div');
232
+ hint.className = 'keyboard-hint';
233
+ hint.innerHTML = '💡 Tip: Use Ctrl+Enter to upload, Esc to clear results';
234
+ document.body.appendChild(hint);
235
 
236
+ setTimeout(() => hint.classList.add('show'), 100);
237
+ setTimeout(() => {
238
+ hint.classList.remove('show');
239
+ setTimeout(() => document.body.removeChild(hint), 300);
240
+ }, 4000);
241
+ }
242
+
243
+ // Add some visual feedback for better UX
244
+ uploadBtn.addEventListener('mouseenter', function() {
245
+ if (!this.disabled) {
246
+ this.style.transform = 'translateY(-2px)';
247
  }
248
  });
249
 
250
+ uploadBtn.addEventListener('mouseleave', function() {
251
+ this.style.transform = 'translateY(0)';
252
+ });
253
+
254
+ // Initialize textarea validation
255
+ validateInput();
 
 
 
 
 
 
 
 
 
 
 
 
256
  });
257
 
style.css CHANGED
@@ -6,373 +6,421 @@
6
  }
7
 
8
  body {
9
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 
 
10
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
11
  min-height: 100vh;
12
- color: #333;
13
- line-height: 1.6;
14
  }
15
 
16
  .container {
17
- max-width: 1200px;
18
  margin: 0 auto;
19
- padding: 20px;
20
- min-height: 100vh;
21
- display: flex;
22
- flex-direction: column;
23
- }
24
-
25
- /* Header styles */
26
- .header {
27
- text-align: center;
28
- margin-bottom: 40px;
29
- padding: 40px 0;
30
- }
31
-
32
- .header-content {
33
- background: rgba(255, 255, 255, 0.1);
34
- backdrop-filter: blur(10px);
35
  border-radius: 20px;
36
- padding: 40px;
37
- border: 1px solid rgba(255, 255, 255, 0.2);
 
 
38
  }
39
 
40
- .logo {
41
- display: flex;
42
- align-items: center;
43
- justify-content: center;
44
- gap: 15px;
45
- margin-bottom: 15px;
 
 
 
46
  }
47
 
48
- .logo i {
49
- font-size: 2.5rem;
50
- color: #fff;
51
- background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
52
- -webkit-background-clip: text;
53
- -webkit-text-fill-color: transparent;
54
- background-clip: text;
55
  }
56
 
57
- .logo h1 {
58
  font-size: 2.5rem;
59
  font-weight: 700;
60
- color: #fff;
61
- text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
62
  }
63
 
64
  .subtitle {
65
  font-size: 1.1rem;
66
- color: rgba(255, 255, 255, 0.9);
67
- font-weight: 400;
68
  }
69
 
70
  /* Main content */
71
  .main-content {
72
- flex: 1;
73
- display: flex;
74
- justify-content: center;
75
- align-items: flex-start;
76
  }
77
 
78
- .upload-card {
79
- background: #fff;
80
- border-radius: 20px;
81
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
82
- padding: 40px;
83
- width: 100%;
84
- max-width: 600px;
85
- transition: transform 0.3s ease, box-shadow 0.3s ease;
86
- }
87
-
88
- .upload-card:hover {
89
- transform: translateY(-5px);
90
- box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
91
- }
92
-
93
- .card-header {
94
- text-align: center;
95
  margin-bottom: 30px;
96
  }
97
 
98
- .card-header h2 {
99
- font-size: 1.8rem;
100
- font-weight: 600;
101
- color: #2d3748;
102
- margin-bottom: 10px;
103
- display: flex;
104
- align-items: center;
105
- justify-content: center;
106
- gap: 10px;
107
- }
108
-
109
- .card-header h2 i {
110
- color: #667eea;
111
- }
112
-
113
- .card-header p {
114
- color: #718096;
115
- font-size: 1rem;
116
- }
117
-
118
- /* Form styles */
119
- .upload-form {
120
- margin-bottom: 30px;
121
- }
122
-
123
- .input-group {
124
  margin-bottom: 25px;
125
  }
126
 
127
- .input-group label {
128
  display: block;
129
- font-weight: 500;
130
- color: #2d3748;
131
- margin-bottom: 8px;
132
- font-size: 0.95rem;
133
  }
134
 
135
- .input-group textarea {
136
  width: 100%;
137
  padding: 15px;
138
- border: 2px solid #e2e8f0;
139
  border-radius: 12px;
140
  font-size: 1rem;
141
- font-family: inherit;
142
  resize: vertical;
143
- transition: border-color 0.3s ease, box-shadow 0.3s ease;
144
- background: #f7fafc;
145
  }
146
 
147
- .input-group textarea:focus {
148
  outline: none;
149
  border-color: #667eea;
150
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
151
- background: #fff;
152
  }
153
 
154
- .input-hint {
 
 
155
  margin-top: 8px;
156
- font-size: 0.85rem;
157
- color: #718096;
158
- display: flex;
159
- align-items: center;
160
- gap: 5px;
161
  }
162
 
163
- .input-hint i {
164
- color: #667eea;
 
 
165
  }
166
 
167
- /* Button styles */
168
- .upload-btn {
169
- width: 100%;
170
- padding: 15px 30px;
171
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
172
- color: #fff;
173
  border: none;
174
- border-radius: 12px;
175
- font-size: 1.1rem;
176
  font-weight: 600;
177
  cursor: pointer;
178
  transition: all 0.3s ease;
 
179
  position: relative;
180
  overflow: hidden;
181
  }
182
 
183
- .upload-btn:hover {
184
- transform: translateY(-2px);
185
- box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  }
187
 
188
- .upload-btn:active {
189
- transform: translateY(0);
 
190
  }
191
 
192
- .upload-btn:disabled {
193
- opacity: 0.7;
194
  cursor: not-allowed;
195
  transform: none;
 
196
  }
197
 
198
- .btn-text {
199
- display: flex;
200
- align-items: center;
201
- justify-content: center;
202
- gap: 10px;
203
  }
204
 
205
- .loading-spinner {
206
- display: flex;
207
- align-items: center;
208
- justify-content: center;
209
- gap: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
 
212
  /* Results section */
213
  .results-section {
214
  margin-top: 30px;
215
- padding-top: 30px;
216
- border-top: 2px solid #e2e8f0;
 
 
 
 
 
 
 
217
  }
218
 
219
  .results-header {
 
 
 
220
  display: flex;
221
  justify-content: space-between;
222
  align-items: center;
223
- margin-bottom: 20px;
224
  }
225
 
226
- .results-header h3 {
227
- font-size: 1.3rem;
228
  font-weight: 600;
229
- color: #2d3748;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  display: flex;
 
231
  align-items: center;
232
- gap: 10px;
 
233
  }
234
 
235
- .results-header h3 i {
236
- color: #667eea;
 
 
237
  }
238
 
239
- .clear-btn {
240
- padding: 8px 16px;
241
- background: #e53e3e;
242
- color: #fff;
243
- border: none;
244
- border-radius: 8px;
245
- font-size: 0.9rem;
246
- cursor: pointer;
247
- transition: background 0.3s ease;
248
  display: flex;
249
  align-items: center;
250
- gap: 5px;
 
251
  }
252
 
253
- .clear-btn:hover {
254
- background: #c53030;
 
 
 
 
 
255
  }
256
 
257
- .results-content {
258
- background: #f7fafc;
259
- border: 2px solid #e2e8f0;
260
- border-radius: 12px;
261
- padding: 20px;
262
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  font-size: 0.9rem;
264
- line-height: 1.6;
265
- white-space: pre-wrap;
266
- max-height: 400px;
267
- overflow-y: auto;
268
  }
269
 
270
- .results-content:empty::before {
271
- content: "No results yet...";
272
- color: #a0aec0;
273
- font-style: italic;
274
  }
275
 
276
- /* Result status styling */
277
- .results-content .success {
278
- color: #38a169;
 
 
 
279
  }
280
 
281
- .results-content .error {
282
- color: #e53e3e;
 
 
283
  }
284
 
285
- .results-content .warning {
286
- color: #d69e2e;
 
 
287
  }
288
 
289
- /* Footer */
290
- .footer {
291
- text-align: center;
292
- padding: 20px 0;
293
- margin-top: 40px;
294
- color: rgba(255, 255, 255, 0.8);
295
- font-size: 0.9rem;
 
 
 
296
  }
297
 
298
  /* Responsive design */
299
  @media (max-width: 768px) {
 
 
 
 
300
  .container {
301
- padding: 15px;
302
  }
303
 
304
- .header-content {
305
  padding: 30px 20px;
306
  }
307
 
308
- .logo h1 {
309
  font-size: 2rem;
310
  }
311
 
312
- .upload-card {
313
  padding: 30px 20px;
314
  }
315
 
316
- .results-header {
317
  flex-direction: column;
318
- gap: 15px;
319
- align-items: stretch;
320
  }
321
 
322
- .clear-btn {
323
- align-self: flex-end;
324
- }
325
- }
326
-
327
- @media (max-width: 480px) {
328
- .logo {
329
- flex-direction: column;
330
- gap: 10px;
331
  }
332
 
333
- .logo h1 {
334
- font-size: 1.8rem;
335
- }
336
-
337
- .card-header h2 {
338
- font-size: 1.5rem;
339
  flex-direction: column;
340
- gap: 5px;
341
- }
342
- }
343
-
344
- /* Animation for results appearing */
345
- @keyframes fadeInUp {
346
- from {
347
- opacity: 0;
348
- transform: translateY(20px);
349
  }
350
- to {
351
- opacity: 1;
352
- transform: translateY(0);
353
  }
354
  }
355
 
356
- .results-section {
357
- animation: fadeInUp 0.5s ease;
358
- }
359
-
360
- /* Custom scrollbar for results */
361
- .results-content::-webkit-scrollbar {
362
- width: 8px;
363
- }
364
-
365
- .results-content::-webkit-scrollbar-track {
366
- background: #e2e8f0;
367
- border-radius: 4px;
 
 
368
  }
369
 
370
- .results-content::-webkit-scrollbar-thumb {
371
- background: #cbd5e0;
372
- border-radius: 4px;
373
  }
374
 
375
- .results-content::-webkit-scrollbar-thumb:hover {
376
- background: #a0aec0;
 
377
  }
378
 
 
6
  }
7
 
8
  body {
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
10
+ line-height: 1.6;
11
+ color: #333;
12
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
13
  min-height: 100vh;
14
+ padding: 20px;
 
15
  }
16
 
17
  .container {
18
+ max-width: 800px;
19
  margin: 0 auto;
20
+ background: rgba(255, 255, 255, 0.95);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  border-radius: 20px;
22
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
23
+ backdrop-filter: blur(10px);
24
+ overflow: hidden;
25
+ animation: slideUp 0.6s ease-out;
26
  }
27
 
28
+ @keyframes slideUp {
29
+ from {
30
+ opacity: 0;
31
+ transform: translateY(30px);
32
+ }
33
+ to {
34
+ opacity: 1;
35
+ transform: translateY(0);
36
+ }
37
  }
38
 
39
+ /* Header */
40
+ .header {
41
+ background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
42
+ color: white;
43
+ padding: 40px 30px;
44
+ text-align: center;
 
45
  }
46
 
47
+ .title {
48
  font-size: 2.5rem;
49
  font-weight: 700;
50
+ margin-bottom: 10px;
51
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
52
  }
53
 
54
  .subtitle {
55
  font-size: 1.1rem;
56
+ opacity: 0.9;
57
+ font-weight: 300;
58
  }
59
 
60
  /* Main content */
61
  .main-content {
62
+ padding: 40px 30px;
 
 
 
63
  }
64
 
65
+ .upload-section {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  margin-bottom: 30px;
67
  }
68
 
69
+ .form-group {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  margin-bottom: 25px;
71
  }
72
 
73
+ .form-label {
74
  display: block;
75
+ font-size: 1.1rem;
76
+ font-weight: 600;
77
+ color: #2c3e50;
78
+ margin-bottom: 10px;
79
  }
80
 
81
+ .form-textarea {
82
  width: 100%;
83
  padding: 15px;
84
+ border: 2px solid #e1e8ed;
85
  border-radius: 12px;
86
  font-size: 1rem;
87
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
88
  resize: vertical;
89
+ transition: all 0.3s ease;
90
+ background: #f8f9fa;
91
  }
92
 
93
+ .form-textarea:focus {
94
  outline: none;
95
  border-color: #667eea;
96
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
97
+ background: white;
98
  }
99
 
100
+ .form-hint {
101
+ font-size: 0.9rem;
102
+ color: #6c757d;
103
  margin-top: 8px;
104
+ font-style: italic;
 
 
 
 
105
  }
106
 
107
+ .form-actions {
108
+ display: flex;
109
+ gap: 15px;
110
+ flex-wrap: wrap;
111
  }
112
 
113
+ /* Buttons */
114
+ .btn {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ gap: 8px;
118
+ padding: 12px 24px;
119
  border: none;
120
+ border-radius: 10px;
121
+ font-size: 1rem;
122
  font-weight: 600;
123
  cursor: pointer;
124
  transition: all 0.3s ease;
125
+ text-decoration: none;
126
  position: relative;
127
  overflow: hidden;
128
  }
129
 
130
+ .btn::before {
131
+ content: '';
132
+ position: absolute;
133
+ top: 0;
134
+ left: -100%;
135
+ width: 100%;
136
+ height: 100%;
137
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
138
+ transition: left 0.5s;
139
+ }
140
+
141
+ .btn:hover::before {
142
+ left: 100%;
143
+ }
144
+
145
+ .btn-primary {
146
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
147
+ color: white;
148
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
149
  }
150
 
151
+ .btn-primary:hover {
152
+ transform: translateY(-2px);
153
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
154
  }
155
 
156
+ .btn-primary:disabled {
157
+ background: #95a5a6;
158
  cursor: not-allowed;
159
  transform: none;
160
+ box-shadow: none;
161
  }
162
 
163
+ .btn-secondary {
164
+ background: #f8f9fa;
165
+ color: #495057;
166
+ border: 2px solid #dee2e6;
 
167
  }
168
 
169
+ .btn-secondary:hover {
170
+ background: #e9ecef;
171
+ border-color: #adb5bd;
172
+ transform: translateY(-1px);
173
+ }
174
+
175
+ .btn-ghost {
176
+ background: transparent;
177
+ color: #6c757d;
178
+ border: 1px solid #dee2e6;
179
+ padding: 8px 16px;
180
+ font-size: 0.9rem;
181
+ }
182
+
183
+ .btn-ghost:hover {
184
+ background: #f8f9fa;
185
+ color: #495057;
186
  }
187
 
188
  /* Results section */
189
  .results-section {
190
  margin-top: 30px;
191
+ border: 2px solid #e1e8ed;
192
+ border-radius: 12px;
193
+ overflow: hidden;
194
+ animation: fadeIn 0.5s ease-out;
195
+ }
196
+
197
+ @keyframes fadeIn {
198
+ from { opacity: 0; }
199
+ to { opacity: 1; }
200
  }
201
 
202
  .results-header {
203
+ background: #f8f9fa;
204
+ padding: 15px 20px;
205
+ border-bottom: 1px solid #e1e8ed;
206
  display: flex;
207
  justify-content: space-between;
208
  align-items: center;
 
209
  }
210
 
211
+ .results-title {
212
+ font-size: 1.2rem;
213
  font-weight: 600;
214
+ color: #2c3e50;
215
+ margin: 0;
216
+ }
217
+
218
+ .results-content {
219
+ padding: 20px;
220
+ background: white;
221
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
222
+ font-size: 0.9rem;
223
+ line-height: 1.8;
224
+ white-space: pre-wrap;
225
+ max-height: 400px;
226
+ overflow-y: auto;
227
+ }
228
+
229
+ /* Loading section */
230
+ .loading-section {
231
+ text-align: center;
232
+ padding: 40px 20px;
233
+ animation: fadeIn 0.3s ease-out;
234
+ }
235
+
236
+ .loading-spinner {
237
+ width: 50px;
238
+ height: 50px;
239
+ border: 4px solid #f3f3f3;
240
+ border-top: 4px solid #667eea;
241
+ border-radius: 50%;
242
+ animation: spin 1s linear infinite;
243
+ margin: 0 auto 20px;
244
+ }
245
+
246
+ @keyframes spin {
247
+ 0% { transform: rotate(0deg); }
248
+ 100% { transform: rotate(360deg); }
249
+ }
250
+
251
+ .loading-text {
252
+ font-size: 1.1rem;
253
+ color: #6c757d;
254
+ font-weight: 500;
255
+ }
256
+
257
+ /* Footer */
258
+ .footer {
259
+ background: #f8f9fa;
260
+ padding: 25px 30px;
261
+ border-top: 1px solid #e1e8ed;
262
+ }
263
+
264
+ .footer-content {
265
  display: flex;
266
+ justify-content: space-between;
267
  align-items: center;
268
+ flex-wrap: wrap;
269
+ gap: 20px;
270
  }
271
 
272
+ .status-indicators {
273
+ display: flex;
274
+ gap: 20px;
275
+ flex-wrap: wrap;
276
  }
277
 
278
+ .status-item {
 
 
 
 
 
 
 
 
279
  display: flex;
280
  align-items: center;
281
+ gap: 8px;
282
+ font-size: 0.9rem;
283
  }
284
 
285
+ .status-badge {
286
+ padding: 4px 8px;
287
+ border-radius: 6px;
288
+ font-size: 0.8rem;
289
+ font-weight: 600;
290
+ background: #e9ecef;
291
+ color: #495057;
292
  }
293
 
294
+ .status-badge.success {
295
+ background: #d4edda;
296
+ color: #155724;
297
+ }
298
+
299
+ .status-badge.error {
300
+ background: #f8d7da;
301
+ color: #721c24;
302
+ }
303
+
304
+ .status-badge.warning {
305
+ background: #fff3cd;
306
+ color: #856404;
307
+ }
308
+
309
+ .footer-links {
310
+ display: flex;
311
+ gap: 15px;
312
+ flex-wrap: wrap;
313
+ }
314
+
315
+ .footer-link {
316
+ color: #6c757d;
317
+ text-decoration: none;
318
  font-size: 0.9rem;
319
+ padding: 5px 10px;
320
+ border-radius: 6px;
321
+ transition: all 0.3s ease;
 
322
  }
323
 
324
+ .footer-link:hover {
325
+ background: #e9ecef;
326
+ color: #495057;
 
327
  }
328
 
329
+ /* Result styling */
330
+ .result-line {
331
+ margin: 5px 0;
332
+ padding: 8px 12px;
333
+ border-radius: 6px;
334
+ border-left: 4px solid transparent;
335
  }
336
 
337
+ .result-success {
338
+ background: #d4edda;
339
+ border-left-color: #28a745;
340
+ color: #155724;
341
  }
342
 
343
+ .result-error {
344
+ background: #f8d7da;
345
+ border-left-color: #dc3545;
346
+ color: #721c24;
347
  }
348
 
349
+ .result-warning {
350
+ background: #fff3cd;
351
+ border-left-color: #ffc107;
352
+ color: #856404;
353
+ }
354
+
355
+ .result-info {
356
+ background: #d1ecf1;
357
+ border-left-color: #17a2b8;
358
+ color: #0c5460;
359
  }
360
 
361
  /* Responsive design */
362
  @media (max-width: 768px) {
363
+ body {
364
+ padding: 10px;
365
+ }
366
+
367
  .container {
368
+ border-radius: 15px;
369
  }
370
 
371
+ .header {
372
  padding: 30px 20px;
373
  }
374
 
375
+ .title {
376
  font-size: 2rem;
377
  }
378
 
379
+ .main-content {
380
  padding: 30px 20px;
381
  }
382
 
383
+ .form-actions {
384
  flex-direction: column;
 
 
385
  }
386
 
387
+ .btn {
388
+ justify-content: center;
389
+ width: 100%;
 
 
 
 
 
 
390
  }
391
 
392
+ .footer-content {
 
 
 
 
 
393
  flex-direction: column;
394
+ text-align: center;
 
 
 
 
 
 
 
 
395
  }
396
+
397
+ .status-indicators {
398
+ justify-content: center;
399
  }
400
  }
401
 
402
+ /* Keyboard shortcuts hint */
403
+ .keyboard-hint {
404
+ position: fixed;
405
+ bottom: 20px;
406
+ right: 20px;
407
+ background: rgba(0, 0, 0, 0.8);
408
+ color: white;
409
+ padding: 10px 15px;
410
+ border-radius: 8px;
411
+ font-size: 0.8rem;
412
+ opacity: 0;
413
+ transition: opacity 0.3s ease;
414
+ pointer-events: none;
415
+ z-index: 1000;
416
  }
417
 
418
+ .keyboard-hint.show {
419
+ opacity: 1;
 
420
  }
421
 
422
+ /* Smooth scrolling */
423
+ html {
424
+ scroll-behavior: smooth;
425
  }
426