Shveiauto commited on
Commit
aed9ed8
·
verified ·
1 Parent(s): 5d725f1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +473 -449
app.py CHANGED
@@ -1,12 +1,12 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash
2
  import json
3
  import os
4
  import logging
5
  import threading
6
  import time
7
  from datetime import datetime
8
- from huggingface_hub import HfApi, hf_hub_download
9
- from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
  import uuid
@@ -19,30 +19,32 @@ load_dotenv()
19
  app = Flask(__name__)
20
  app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
21
  DATA_FILE = 'tontalent_data.json'
22
- SYNC_FILES = [DATA_FILE]
23
- UPLOAD_FOLDER_HF = "uploads"
 
 
24
 
25
  REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
26
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
27
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
29
- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "YOUR_TELEGRAM_BOT_TOKEN") # Ensure this is set in .env
30
 
31
  DOWNLOAD_RETRIES = 3
32
  DOWNLOAD_DELAY = 5
33
 
34
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
35
 
36
- if not TELEGRAM_BOT_TOKEN or TELEGRAM_BOT_TOKEN == "YOUR_TELEGRAM_BOT_TOKEN":
37
- logging.warning("TELEGRAM_BOT_TOKEN is not set or is using the default placeholder. Telegram authentication will likely fail.")
38
-
39
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
40
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
41
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
42
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
 
 
43
  files_to_download = [specific_file] if specific_file else SYNC_FILES
44
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
45
  all_successful = True
 
46
  for file_name in files_to_download:
47
  success = False
48
  for attempt in range(retries + 1):
@@ -61,16 +63,16 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
61
  return False
62
  except HfHubHTTPError as e:
63
  if e.response.status_code == 404:
64
- logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
65
  if attempt == 0 and not os.path.exists(file_name):
66
  try:
67
  if file_name == DATA_FILE:
68
  with open(file_name, 'w', encoding='utf-8') as f:
69
  json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
70
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
 
71
  except Exception as create_e:
72
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
73
- success = True
74
  break
75
  else:
76
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
@@ -80,33 +82,107 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
80
  if not success:
81
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
82
  all_successful = False
83
- logging.info(f"Download process finished. Overall success: {all_successful}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  return all_successful
85
 
 
86
  def upload_db_to_hf(specific_file=None):
87
  if not HF_TOKEN_WRITE:
88
  logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
89
  return
 
90
  try:
91
- api = HfApi(token=HF_TOKEN_WRITE)
92
- files_to_upload = [specific_file] if specific_file else SYNC_FILES
93
- logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
94
- for file_name in files_to_upload:
95
- if os.path.exists(file_name):
96
- try:
97
- api.upload_file(
98
- path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
99
- repo_type="dataset",
100
- commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
101
- )
102
- logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
103
- except Exception as e:
104
- logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
105
- else:
106
- logging.warning(f"File {file_name} not found locally, skipping upload.")
107
- logging.info("Finished uploading files to HF.")
108
  except Exception as e:
109
- logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
  def periodic_backup():
112
  backup_interval = 1800
@@ -125,7 +201,7 @@ def load_data():
125
  logging.info(f"Local data loaded successfully from {DATA_FILE}")
126
  if not isinstance(data, dict):
127
  logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
128
- raise FileNotFoundError
129
  for key in default_data:
130
  if key not in data: data[key] = default_data[key]
131
  return data
@@ -168,16 +244,13 @@ def save_data(data):
168
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
169
  json.dump(data, file, ensure_ascii=False, indent=4)
170
  logging.info(f"Data successfully saved to {DATA_FILE}")
171
- upload_db_to_hf(specific_file=DATA_FILE)
172
  except Exception as e:
173
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
174
 
175
  def verify_telegram_auth_data(auth_data_str, bot_token):
176
  if not auth_data_str:
177
  return False, None
178
- if not bot_token:
179
- logging.error("Telegram Bot Token is not configured for verification.")
180
- return False, None
181
 
182
  params = dict(urllib.parse.parse_qsl(auth_data_str))
183
  if 'hash' not in params:
@@ -203,7 +276,6 @@ def verify_telegram_auth_data(auth_data_str, bot_token):
203
  return False, None
204
  return False, None
205
 
206
-
207
  MAIN_APP_TEMPLATE = '''
208
  <!DOCTYPE html>
209
  <html lang="en">
@@ -264,7 +336,7 @@ MAIN_APP_TEMPLATE = '''
264
  transition: color 0.2s, border-bottom-color 0.2s;
265
  }
266
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
267
- .content { flex-grow: 1; padding: 15px; overflow-x: hidden; }
268
  .list-item {
269
  background-color: var(--tg-theme-section-bg-color);
270
  border-radius: 8px;
@@ -273,22 +345,32 @@ MAIN_APP_TEMPLATE = '''
273
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
274
  cursor: pointer;
275
  transition: background-color 0.2s;
276
- display: flex; /* For thumbnail alignment */
277
- align-items: flex-start; /* Align items to the top */
278
  }
279
  .list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
280
- .list-item-content { flex-grow: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
282
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
283
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
284
- .thumbnail-image {
285
- width: 50px; height: 50px; object-fit: cover; border-radius: 4px;
286
- margin-right: 12px; border: 1px solid var(--tg-theme-secondary-bg-color);
287
- }
288
  .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
289
  .form-group { margin-bottom: 15px; }
290
  .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; }
291
- .form-group input, .form-group textarea, .form-group input[type="file"] {
292
  width: 100%;
293
  padding: 10px;
294
  border: 1px solid var(--tg-theme-secondary-bg-color);
@@ -298,16 +380,10 @@ MAIN_APP_TEMPLATE = '''
298
  color: var(--tg-theme-text-color);
299
  box-sizing: border-box;
300
  }
301
- .form-group input[type="file"] { padding: 5px; }
302
  .form-group textarea { min-height: 80px; resize: vertical; }
303
- .image-previews { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
304
- .preview-image-container { position: relative; }
305
- .preview-image { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--tg-theme-secondary-bg-color); }
306
- .remove-preview-btn {
307
- position: absolute; top: -5px; right: -5px; background: var(--tg-theme-destructive-text-color); color: white;
308
- border-radius: 50%; width: 20px; height: 20px; font-size: 12px; text-align: center; line-height: 18px;
309
- border: none; cursor: pointer; box-shadow: 0 0 5px rgba(0,0,0,0.2); z-index: 1;
310
- }
311
  .fab {
312
  position: fixed;
313
  bottom: 20px;
@@ -330,21 +406,15 @@ MAIN_APP_TEMPLATE = '''
330
  .detail-view h2 { margin-top: 0; font-size: 20px; color: var(--tg-theme-text-color); }
331
  .detail-view p { margin-bottom: 8px; line-height: 1.5; font-size: 16px; }
332
  .detail-view strong { font-weight: 600; color: var(--tg-theme-section-header-text-color); }
333
- .image-gallery { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 15px; margin-bottom: 10px; }
334
- .gallery-image {
335
- width: calc(33.333% - 7px); /* Adjust for gap */
336
- aspect-ratio: 1 / 1;
337
- object-fit: cover; border-radius: 6px;
338
- border: 1px solid var(--tg-theme-secondary-bg-color);
339
- cursor: zoom-in;
340
- }
341
- @media (max-width: 400px) {
342
- .gallery-image { width: calc(50% - 5px); }
343
  }
 
344
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
345
  .user-info { padding: 10px 15px; background-color: var(--tg-theme-secondary-bg-color); font-size: 13px; text-align: center; color: var(--tg-theme-hint-color); }
346
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
347
- .max-images-note { font-size: 12px; color: var(--tg-theme-hint-color); margin-top: 5px; }
348
  </style>
349
  </head>
350
  <body>
@@ -364,13 +434,10 @@ MAIN_APP_TEMPLATE = '''
364
 
365
  <script>
366
  const tg = window.Telegram.WebApp;
367
- const HF_REPO_ID = "{{ HF_REPO_ID }}";
368
- const MAX_IMAGES = 10;
369
  let currentUser = null;
370
  let currentView = 'resumes';
371
  let currentItem = null;
372
- let selectedFilesForUpload = [];
373
- let existingImagePaths = [];
374
 
375
  function applyThemeParams() {
376
  document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
@@ -388,17 +455,23 @@ MAIN_APP_TEMPLATE = '''
388
  }
389
 
390
  async function apiCall(endpoint, method = 'GET', body = null) {
391
- const headers = { 'Content-Type': 'application/json' };
392
  if (tg.initData) {
393
  headers['X-Telegram-Auth'] = tg.initData;
394
  }
395
  const options = { method, headers };
396
- if (body) options.body = JSON.stringify(body);
 
 
 
 
 
 
397
  try {
398
  const response = await fetch(endpoint, options);
399
  if (!response.ok) {
400
  const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
401
- throw new Error(errorData.error || `HTTP error ${response.status}`);
402
  }
403
  return response.json();
404
  } catch (error) {
@@ -411,226 +484,239 @@ MAIN_APP_TEMPLATE = '''
411
  function renderList(items, type) {
412
  const contentDiv = document.getElementById('mainContent');
413
  if (!items || items.length === 0) {
414
- contentDiv.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
415
  return;
416
  }
417
- contentDiv.innerHTML = items.map(item => {
418
- let thumbnailHtml = '';
419
- if (item.images && item.images.length > 0) {
420
- const imageUrl = `https://huggingface.co/datasets/${HF_REPO_ID}/resolve/main/${item.images[0]}`;
421
- thumbnailHtml = `<img src="${imageUrl}" class="thumbnail-image" alt="Thumbnail">`;
422
- }
423
- return `
424
- <div class="list-item" onclick="handleListItemClick('${type}', '${item.id}')">
425
- ${thumbnailHtml}
426
- <div class="list-item-content">
427
- <h3>${item.title || item.name || 'Untitled'}</h3>
428
- ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
429
- ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
430
- <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
431
  </div>
432
- </div>`;
433
- }).join('');
434
- }
435
-
436
- function handleListItemClick(type, id) {
437
- tg.HapticFeedback.impactOccurred('light');
438
- showDetailView(type, id);
 
439
  }
440
 
441
  function showDetailView(type, id) {
 
442
  tg.BackButton.show();
443
- tg.BackButton.onClick(() => {
444
- tg.HapticFeedback.selectionChanged();
445
- loadView(type);
446
- });
447
  tg.MainButton.hide();
448
  document.getElementById('fabButton').style.display = 'none';
449
 
450
- apiCall(`/api/${type}/${id}`)
451
  .then(item => {
452
  currentItem = item;
453
  const contentDiv = document.getElementById('mainContent');
454
- let imageGalleryHtml = '';
 
455
  if (item.images && item.images.length > 0) {
456
- imageGalleryHtml = '<div class="image-gallery">';
457
- item.images.forEach(imgPath => {
458
- const imageUrl = `https://huggingface.co/datasets/${HF_REPO_ID}/resolve/main/${imgPath}`;
459
- imageGalleryHtml += `<img src="${imageUrl}" class="gallery-image" alt="Item image" onclick="tg.openLink('${imageUrl}')">`;
460
- });
461
- imageGalleryHtml += '</div>';
462
  }
463
 
464
- let detailsHtml = `<div class="detail-view"><h2>${item.title || item.name}</h2>`;
465
- detailsHtml += imageGalleryHtml;
466
  if (type === 'resumes') {
467
- detailsHtml += `
468
- <p><strong>Skills:</strong> ${item.skills || 'N/A'}</p>
469
- <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
470
- <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
471
- <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
472
- ${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank" onclick="tg.openLink('${item.portfolio_link}'); return false;">${item.portfolio_link}</a></p>` : ''}
473
- `;
474
  } else if (type === 'vacancies') {
475
- detailsHtml += `
476
- <p><strong>Company:</strong> ${item.company_name || 'N/A'}</p>
477
- <p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
478
- <p><strong>Requirements:</strong><br>${item.requirements ? item.requirements.replace(/\\n/g, '<br>') : 'N/A'}</p>
479
- <p><strong>Salary:</strong> ${item.salary || 'N/A'}</p>
480
- <p><strong>Location:</strong> ${item.location || 'N/A'}</p>
481
- <p><strong>Contact/Apply:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
482
- `;
483
  } else if (type === 'freelance_offers') {
484
- detailsHtml += `
485
- <p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
486
- <p><strong>Budget:</strong> ${item.budget || 'N/A'}</p>
487
- <p><strong>Deadline:</strong> ${item.deadline || 'N/A'}</p>
488
- <p><strong>Skills Needed:</strong> ${item.skills_needed || 'N/A'}</p>
489
- <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
490
- `;
491
  }
492
- detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
493
  contentDiv.innerHTML = detailsHtml;
494
 
495
  if (currentUser && item.user_id === currentUser.id) {
496
  tg.MainButton.setText('Edit My Post');
497
- tg.MainButton.onClick(() => showForm(type, item));
498
  tg.MainButton.show();
499
  }
500
  })
501
  .catch(err => {
502
- document.getElementById('mainContent').innerHTML = `<div class="empty-state">Error loading details.</div>`;
503
  });
504
  }
505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
506
  function showForm(type, itemToEdit = null) {
 
507
  currentItem = itemToEdit;
508
- selectedFilesForUpload = [];
509
- existingImagePaths = itemToEdit?.images || [];
510
-
511
  tg.BackButton.show();
512
  tg.BackButton.onClick(() => {
513
- tg.HapticFeedback.selectionChanged();
514
  if (itemToEdit) showDetailView(type, itemToEdit.id);
515
  else loadView(type);
516
  });
517
  document.getElementById('fabButton').style.display = 'none';
518
 
519
  const contentDiv = document.getElementById('mainContent');
520
- let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
521
 
522
  if (type === 'resumes') {
523
- formHtml += `
524
- <div class="form-group"><label for="name">Full Name</label><input type="text" id="name" value="${itemToEdit?.name || ''}" required></div>
525
- <div class="form-group"><label for="title">Job Title / Desired Position</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
526
- <div class="form-group"><label for="skills">Skills (comma separated)</label><textarea id="skills">${itemToEdit?.skills || ''}</textarea></div>
527
- <div class="form-group"><label for="experience">Experience</label><textarea id="experience">${itemToEdit?.experience || ''}</textarea></div>
528
- <div class="form-group"><label for="education">Education</label><textarea id="education">${itemToEdit?.education || ''}</textarea></div>
529
- <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
530
- <div class="form-group"><label for="portfolio_link">Portfolio Link</label><input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}"></div>
531
- `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  } else if (type === 'vacancies') {
533
- formHtml += `
534
- <div class="form-group"><label for="company_name">Company Name</label><input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required></div>
535
- <div class="form-group"><label for="title">Job Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
536
- <div class="form-group"><label for="description">Description</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
537
- <div class="form-group"><label for="requirements">Requirements</label><textarea id="requirements">${itemToEdit?.requirements || ''}</textarea></div>
538
- <div class="form-group"><label for="salary">Salary/Compensation</label><input type="text" id="salary" value="${itemToEdit?.salary || ''}"></div>
539
- <div class="form-group"><label for="location">Location</label><input type="text" id="location" value="${itemToEdit?.location || ''}"></div>
540
- <div class="form-group"><label for="contact">Contact Info / How to Apply</label><textarea id="contact">${itemToEdit?.contact || ''}</textarea></div>
541
- `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  } else if (type === 'freelance_offers') {
543
- formHtml += `
544
- <div class="form-group"><label for="title">Project Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
545
- <div class="form-group"><label for="description">Description of Work</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
546
- <div class="form-group"><label for="budget">Budget</label><input type="text" id="budget" value="${itemToEdit?.budget || ''}"></div>
547
- <div class="form-group"><label for="deadline">Expected Deadline</label><input type="text" id="deadline" value="${itemToEdit?.deadline || ''}"></div>
548
- <div class="form-group"><label for="skills_needed">Skills Needed</label><textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea></div>
549
- <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
550
- `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  }
552
- formHtml += `
553
  <div class="form-group">
554
- <label for="itemImages">Images (up to ${MAX_IMAGES} total)</label>
555
- <input type="file" id="itemImages" multiple accept="image/*" onchange="handleFileSelect(event)">
556
- <div class="max-images-note">Current: <span id="currentImageCount">${existingImagePaths.length}</span>, Max: ${MAX_IMAGES}</div>
557
- <div id="imagePreviews" class="image-previews"></div>
558
  </div>
559
- `;
560
- formHtml += `<div id="formError" class="error-message"></div></div>`;
 
 
 
 
 
 
 
 
561
  contentDiv.innerHTML = formHtml;
562
- renderImagePreviews();
563
 
564
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
565
  tg.MainButton.show();
566
  tg.MainButton.onClick(() => handleSubmit(type, itemToEdit ? itemToEdit.id : null));
567
  }
568
 
569
- function handleFileSelect(event) {
570
- const files = event.target.files;
571
- const totalAfterAdd = existingImagePaths.length + selectedFilesForUpload.length + files.length;
572
- if (totalAfterAdd > MAX_IMAGES) {
573
- tg.showAlert(`You can select a maximum of ${MAX_IMAGES} images in total. You already have ${existingImagePaths.length + selectedFilesForUpload.length} and tried to add ${files.length}.`);
574
- event.target.value = ''; // Clear selection
575
- return;
576
- }
577
- for (let i = 0; i < files.length; i++) {
578
- selectedFilesForUpload.push(files[i]);
579
- }
580
- renderImagePreviews();
581
- }
582
-
583
- function renderImagePreviews() {
584
- const previewContainer = document.getElementById('imagePreviews');
585
- if (!previewContainer) return;
586
- previewContainer.innerHTML = '';
587
-
588
- existingImagePaths.forEach((path, index) => {
589
- const imgContainer = document.createElement('div');
590
- imgContainer.className = 'preview-image-container';
591
- const img = document.createElement('img');
592
- img.src = `https://huggingface.co/datasets/${HF_REPO_ID}/resolve/main/${path}`;
593
- img.className = 'preview-image';
594
- const removeBtn = document.createElement('button');
595
- removeBtn.innerHTML = '×';
596
- removeBtn.className = 'remove-preview-btn';
597
- removeBtn.onclick = () => {
598
- existingImagePaths.splice(index, 1);
599
- renderImagePreviews();
600
- };
601
- imgContainer.appendChild(img);
602
- imgContainer.appendChild(removeBtn);
603
- previewContainer.appendChild(imgContainer);
604
- });
605
-
606
- selectedFilesForUpload.forEach((file, index) => {
607
- const imgContainer = document.createElement('div');
608
- imgContainer.className = 'preview-image-container';
609
- const reader = new FileReader();
610
- const img = document.createElement('img');
611
- img.className = 'preview-image';
612
- reader.onload = e => img.src = e.target.result;
613
- reader.readAsDataURL(file);
614
- const removeBtn = document.createElement('button');
615
- removeBtn.innerHTML = '×';
616
- removeBtn.className = 'remove-preview-btn';
617
- removeBtn.onclick = () => {
618
- selectedFilesForUpload.splice(index, 1);
619
- document.getElementById('itemImages').value = ''; // Reset file input to allow re-selection of same file if needed
620
- renderImagePreviews();
621
- };
622
- imgContainer.appendChild(img);
623
- imgContainer.appendChild(removeBtn);
624
- previewContainer.appendChild(imgContainer);
625
- });
626
- document.getElementById('currentImageCount').textContent = existingImagePaths.length + selectedFilesForUpload.length;
627
- }
628
-
629
  function handleSubmit(type, itemId = null) {
630
  const payload = {};
631
  let isValid = true;
632
- const formErrorDiv = document.getElementById('formError');
633
- formErrorDiv.textContent = '';
634
 
635
  if (type === 'resumes') {
636
  payload.name = document.getElementById('name').value.trim();
@@ -661,50 +747,26 @@ MAIN_APP_TEMPLATE = '''
661
  }
662
 
663
  if (!isValid) {
664
- formErrorDiv.textContent = 'Please fill in all required fields.';
665
  tg.HapticFeedback.notificationOccurred('error');
666
  return;
667
  }
668
 
669
  tg.MainButton.showProgress();
670
 
671
- if (selectedFilesForUpload.length > 0) {
672
- formErrorDiv.textContent = 'Uploading images...';
673
- const formData = new FormData();
674
- selectedFilesForUpload.forEach(file => formData.append('images', file));
675
-
676
- fetch('/api/upload_image', {
677
- method: 'POST',
678
- headers: { 'X-Telegram-Auth': tg.initData },
679
- body: formData
680
- })
681
- .then(response => {
682
- if (!response.ok) {
683
- return response.json().then(err => { throw new Error(err.error || 'Image upload failed'); });
684
- }
685
- return response.json();
686
- })
687
- .then(uploadResponse => {
688
- payload.images = [...existingImagePaths, ...uploadResponse.image_paths];
689
- submitItemData(type, itemId, payload);
690
- })
691
- .catch(err => {
692
- tg.HapticFeedback.notificationOccurred('error');
693
- tg.MainButton.hideProgress();
694
- formErrorDiv.textContent = err.message || 'Failed to upload images.';
695
- });
696
- } else {
697
- payload.images = existingImagePaths;
698
- submitItemData(type, itemId, payload);
699
  }
700
- }
 
 
 
701
 
702
- function submitItemData(type, itemId, payload) {
703
  const method = itemId ? 'PUT' : 'POST';
704
- const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
705
- document.getElementById('formError').textContent = itemId ? 'Saving changes...' : 'Posting...';
706
 
707
- apiCall(endpoint, method, payload)
708
  .then(response => {
709
  tg.HapticFeedback.notificationOccurred('success');
710
  tg.MainButton.hideProgress();
@@ -720,73 +782,38 @@ MAIN_APP_TEMPLATE = '''
720
  function loadView(tabName) {
721
  currentView = tabName;
722
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
723
- document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
724
 
725
- document.getElementById('mainContent').innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
726
  tg.BackButton.hide();
727
  tg.MainButton.hide();
728
  document.getElementById('fabButton').style.display = 'block';
729
 
730
- apiCall(`/api/${tabName}`)
731
  .then(data => renderList(data, tabName))
732
  .catch(err => {
733
- document.getElementById('mainContent').innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`;
734
  });
735
  }
736
 
737
- function setupSwipeNavigation() {
738
- const contentElement = document.getElementById('mainContent');
739
- let touchstartX = 0;
740
- let touchstartY = 0;
741
- let touchendX = 0;
742
- let touchendY = 0;
743
- const swipeThreshold = 75;
744
- let isSwiping = false;
745
-
746
- contentElement.addEventListener('touchstart', function(event) {
747
- touchstartX = event.changedTouches[0].screenX;
748
- touchstartY = event.changedTouches[0].screenY;
749
- isSwiping = false;
750
- }, { passive: true });
751
-
752
- contentElement.addEventListener('touchmove', function(event) {
753
- if (isSwiping) return;
754
- const deltaX = event.changedTouches[0].screenX - touchstartX;
755
- const deltaY = event.changedTouches[0].screenY - touchstartY;
756
- if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) { // Prioritize horizontal swipe
757
- isSwiping = true; // Mark that we started a horizontal swipe
758
- }
759
- }, { passive: false });
760
 
 
 
 
761
 
762
- contentElement.addEventListener('touchend', function(event) {
763
- touchendX = event.changedTouches[0].screenX;
764
- touchendY = event.changedTouches[0].screenY;
765
- handleSwipe();
766
- isSwiping = false;
767
- }, { passive: true });
768
 
769
- function handleSwipe() {
770
- const deltaX = touchendX - touchstartX;
771
- const deltaY = touchendY - touchstartY;
772
-
773
- if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) {
774
- const tabButtons = Array.from(document.querySelectorAll('.tab-button'));
775
- const currentTabIndex = tabButtons.findIndex(btn => btn.classList.contains('active'));
776
-
777
- if (touchendX < touchstartX) { // Swiped left
778
- if (currentTabIndex < tabButtons.length - 1) {
779
- loadView(tabButtons[currentTabIndex + 1].dataset.tab);
780
- tg.HapticFeedback.selectionChanged();
781
- }
782
- } else { // Swiped right
783
- if (currentTabIndex > 0) {
784
- loadView(tabButtons[currentTabIndex - 1].dataset.tab);
785
- tg.HapticFeedback.selectionChanged();
786
- }
787
- }
788
- }
789
  }
 
 
790
  }
791
 
792
  async function init() {
@@ -795,31 +822,36 @@ MAIN_APP_TEMPLATE = '''
795
  tg.expand();
796
  tg.enableClosingConfirmation();
797
 
798
- document.getElementById('userInfo').textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}! (@${tg.initDataUnsafe.user?.username || 'anonymous'})`;
799
 
800
  try {
801
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
802
  currentUser = authResponse.user;
803
  if (currentUser) {
804
- document.getElementById('userInfo').textContent = `Logged in as: ${currentUser.first_name} (@${currentUser.username || 'id'+currentUser.id})`;
805
  }
806
  } catch (error) {
807
  console.error("Auth error:", error);
808
- document.getElementById('userInfo').textContent = `Auth failed. Limited functionality.`;
 
809
  }
810
 
 
811
  document.querySelectorAll('.tab-button').forEach(button => {
812
  button.addEventListener('click', () => {
813
- tg.HapticFeedback.selectionChanged();
814
  loadView(button.dataset.tab);
815
  });
816
  });
817
  document.getElementById('fabButton').addEventListener('click', () => {
818
- tg.HapticFeedback.impactOccurred('medium');
819
  showForm(currentView);
820
  });
821
 
822
- setupSwipeNavigation();
 
 
 
823
  loadView('resumes');
824
  }
825
 
@@ -872,14 +904,13 @@ ADMIN_TEMPLATE = '''
872
  <h2>Data Synchronization with Hugging Face</h2>
873
  <div class="sync-buttons">
874
  <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
875
- <button type="submit" class="button button-primary">Upload DB to HF</button>
876
  </form>
877
  <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
878
- <button type="submit" class="button button-secondary">Download DB from HF</button>
879
  </form>
880
  </div>
881
  <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
882
- <p style="font-size: 0.8em; color: #666;">Image files are stored in the '{{ UPLOAD_FOLDER_HF }}' directory in the HF repository.</p>
883
  </div>
884
 
885
  <div class="section">
@@ -888,8 +919,8 @@ ADMIN_TEMPLATE = '''
888
  <div class="item">
889
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
890
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
 
891
  <p>Images: {{ resume.images|length if resume.images else 0 }}</p>
892
- <p>Posted: {{ resume.timestamp|format_datetime }}</p>
893
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
894
  <input type="hidden" name="item_type" value="resumes">
895
  <input type="hidden" name="item_id" value="{{ resume.id }}">
@@ -907,8 +938,8 @@ ADMIN_TEMPLATE = '''
907
  <div class="item">
908
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
909
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
 
910
  <p>Images: {{ vacancy.images|length if vacancy.images else 0 }}</p>
911
- <p>Posted: {{ vacancy.timestamp|format_datetime }}</p>
912
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
913
  <input type="hidden" name="item_type" value="vacancies">
914
  <input type="hidden" name="item_id" value="{{ vacancy.id }}">
@@ -927,8 +958,8 @@ ADMIN_TEMPLATE = '''
927
  <h3>{{ offer.title }}</h3>
928
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
929
  <p>Budget: {{ offer.budget }}</p>
 
930
  <p>Images: {{ offer.images|length if offer.images else 0 }}</p>
931
- <p>Posted: {{ offer.timestamp|format_datetime }}</p>
932
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');">
933
  <input type="hidden" name="item_type" value="freelance_offers">
934
  <input type="hidden" name="item_id" value="{{ offer.id }}">
@@ -944,20 +975,13 @@ ADMIN_TEMPLATE = '''
944
  </html>
945
  '''
946
 
947
- @app.template_filter('format_datetime')
948
- def format_datetime_filter(s):
949
- if not s:
950
- return "N/A"
951
- try:
952
- dt = datetime.fromisoformat(s)
953
- return dt.strftime("%Y-%m-%d %H:%M:%S")
954
- except:
955
- return s
956
-
957
-
958
  @app.route('/')
959
  def main_app_view():
960
- return render_template_string(MAIN_APP_TEMPLATE, HF_REPO_ID=REPO_ID)
 
 
 
 
961
 
962
  @app.route('/api/auth_user', methods=['POST'])
963
  def auth_user():
@@ -969,24 +993,23 @@ def auth_user():
969
  else:
970
  return jsonify({"error": "Authentication data not provided"}), 401
971
 
972
- is_valid, user_data = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
973
 
974
- if not is_valid or not user_data:
975
- logging.warning(f"Invalid auth data. Valid: {is_valid}, UserData: {user_data is not None}")
976
  return jsonify({"error": "Invalid authentication data"}), 403
977
 
978
  data = load_data()
979
  users = data.get('users', {})
980
- user_id_str = str(user_data.get('id'))
981
 
982
  if user_id_str not in users:
983
  users[user_id_str] = {
984
- 'id': user_data.get('id'),
985
- 'first_name': user_data.get('first_name'),
986
- 'last_name': user_data.get('last_name'),
987
- 'username': user_data.get('username'),
988
- 'language_code': user_data.get('language_code'),
989
- 'photo_url': user_data.get('photo_url'),
990
  'first_seen': datetime.now().isoformat()
991
  }
992
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
@@ -999,58 +1022,11 @@ def get_authenticated_user(request_obj):
999
  auth_data_str = request_obj.headers.get('X-Telegram-Auth')
1000
  if not auth_data_str:
1001
  return None
1002
- is_valid, user_data = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1003
- if is_valid and user_data:
1004
- return user_data
1005
- logging.warning(f"get_authenticated_user failed. Valid: {is_valid}, UserData: {user_data is not None}")
1006
  return None
1007
 
1008
- @app.route('/api/upload_image', methods=['POST'])
1009
- def upload_image():
1010
- user = get_authenticated_user(request)
1011
- if not user:
1012
- return jsonify({"error": "Authentication required"}), 401
1013
-
1014
- if not HF_TOKEN_WRITE:
1015
- return jsonify({"error": "Image upload service not configured (no write token)."}), 500
1016
-
1017
- if 'images' not in request.files:
1018
- return jsonify({"error": "No image files provided"}), 400
1019
-
1020
- files = request.files.getlist('images')
1021
- if not files or all(f.filename == '' for f in files):
1022
- return jsonify({"error": "No selected files"}), 400
1023
-
1024
- if len(files) > 10: # Max 10 images per upload request
1025
- return jsonify({"error": f"Cannot upload more than 10 images at once."}), 400
1026
-
1027
-
1028
- uploaded_image_paths = []
1029
- api = HfApi(token=HF_TOKEN_WRITE)
1030
-
1031
- for file in files:
1032
- if file:
1033
- filename = secure_filename(file.filename)
1034
- unique_filename = f"{uuid.uuid4()}_{filename}"
1035
- path_in_repo = f"{UPLOAD_FOLDER_HF}/{unique_filename}"
1036
-
1037
- try:
1038
- file.seek(0)
1039
- api.upload_file(
1040
- path_or_fileobj=file,
1041
- path_in_repo=path_in_repo,
1042
- repo_id=REPO_ID,
1043
- repo_type="dataset",
1044
- commit_message=f"Upload image {unique_filename} by user {user.get('id')}"
1045
- )
1046
- uploaded_image_paths.append(path_in_repo)
1047
- logging.info(f"Successfully uploaded {path_in_repo} by user {user.get('id')}")
1048
- except Exception as e:
1049
- logging.error(f"Failed to upload {unique_filename} to HF: {e}")
1050
- return jsonify({"error": f"Failed to upload image {filename}. Error: {str(e)}"}), 500
1051
-
1052
- return jsonify({"message": "Images uploaded successfully", "image_paths": uploaded_image_paths}), 200
1053
-
1054
 
1055
  @app.route('/api/<item_type>', methods=['GET'])
1056
  def get_items(item_type):
@@ -1060,6 +1036,7 @@ def get_items(item_type):
1060
  items = sorted(data.get(item_type, []), key=lambda x: x.get('timestamp', ''), reverse=True)
1061
  return jsonify(items), 200
1062
 
 
1063
  @app.route('/api/<item_type>/<item_id>', methods=['GET'])
1064
  def get_item(item_type, item_id):
1065
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
@@ -1070,6 +1047,7 @@ def get_item(item_type, item_id):
1070
  return jsonify(item), 200
1071
  return jsonify({"error": "Item not found"}), 404
1072
 
 
1073
  @app.route('/api/<item_type>', methods=['POST'])
1074
  def create_item(item_type):
1075
  user = get_authenticated_user(request)
@@ -1079,19 +1057,35 @@ def create_item(item_type):
1079
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1080
  return jsonify({"error": "Invalid item type"}), 400
1081
 
1082
- req_data = request.json
1083
  if not req_data:
1084
  return jsonify({"error": "No data provided"}), 400
1085
 
1086
  new_item = {
1087
  "id": str(uuid.uuid4()),
1088
  "user_id": str(user.get('id')),
1089
- "user_telegram_username": user.get('username', f"id{user.get('id')}"),
1090
  "timestamp": datetime.now().isoformat(),
1091
- "images": req_data.get('images', [])
1092
  }
1093
- if len(new_item["images"]) > 10:
1094
- return jsonify({"error": "Cannot associate more than 10 images."}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1095
 
1096
 
1097
  if item_type == 'resumes':
@@ -1129,6 +1123,7 @@ def create_item(item_type):
1129
  save_data(data)
1130
  return jsonify(new_item), 201
1131
 
 
1132
  @app.route('/api/<item_type>/<item_id>', methods=['PUT'])
1133
  def update_item(item_type, item_id):
1134
  user = get_authenticated_user(request)
@@ -1137,28 +1132,58 @@ def update_item(item_type, item_id):
1137
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1138
  return jsonify({"error": "Invalid item type"}), 400
1139
 
1140
- req_data = request.json
1141
  if not req_data: return jsonify({"error": "No data provided"}), 400
1142
 
1143
  data = load_data()
1144
  items_list = data.get(item_type, [])
1145
  item_index = -1
 
1146
  for idx, i in enumerate(items_list):
1147
  if i['id'] == item_id:
1148
  item_index = idx
 
1149
  break
1150
 
1151
- if item_index == -1: return jsonify({"error": "Item not found"}), 404
1152
 
1153
- original_item = items_list[item_index]
1154
  if str(original_item.get('user_id')) != str(user.get('id')):
1155
  return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
1156
 
1157
  updated_item = original_item.copy()
1158
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1159
- updated_item['images'] = req_data.get('images', original_item.get('images', []))
1160
- if len(updated_item["images"]) > 10:
1161
- return jsonify({"error": "Cannot associate more than 10 images."}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1162
 
1163
 
1164
  if item_type == 'resumes':
@@ -1195,6 +1220,7 @@ def update_item(item_type, item_id):
1195
  save_data(data)
1196
  return jsonify(updated_item), 200
1197
 
 
1198
  @app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
1199
  def delete_item(item_type, item_id):
1200
  user = get_authenticated_user(request)
@@ -1205,24 +1231,27 @@ def delete_item(item_type, item_id):
1205
 
1206
  data = load_data()
1207
  items_list = data.get(item_type, [])
1208
- original_length = len(items_list)
1209
 
1210
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1211
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1212
 
1213
  if str(item_to_delete.get('user_id')) != str(user.get('id')):
1214
- # Allow admin deletion from here in future if needed by checking role
1215
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
1216
-
1217
- # Image deletion from HF is not implemented here to keep it simpler
1218
- # For full cleanup, one would iterate item_to_delete.get('images', []) and call api.delete_file()
1219
 
 
 
1220
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1221
 
1222
- if len(data[item_type]) < original_length:
1223
- save_data(data)
1224
- return jsonify({"message": "Item deleted successfully"}), 200
1225
- return jsonify({"error": "Item not found or deletion failed"}), 404
 
 
 
 
 
 
1226
 
1227
 
1228
  @app.route('/admin', methods=['GET'])
@@ -1231,8 +1260,7 @@ def admin_panel():
1231
  return render_template_string(ADMIN_TEMPLATE,
1232
  resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1233
  vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1234
- freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1235
- UPLOAD_FOLDER_HF=UPLOAD_FOLDER_HF)
1236
 
1237
  @app.route('/admin/delete', methods=['POST'])
1238
  def admin_delete_item():
@@ -1245,25 +1273,22 @@ def admin_delete_item():
1245
 
1246
  data = load_data()
1247
  items_list = data.get(item_type, [])
1248
- original_length = len(items_list)
1249
 
1250
- # Future: If deleting images from HF is desired for admin delete:
1251
- # item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1252
- # if item_to_delete and item_to_delete.get('images') and HF_TOKEN_WRITE:
1253
- # api = HfApi(token=HF_TOKEN_WRITE)
1254
- # for img_path in item_to_delete['images']:
1255
- # try:
1256
- # api.delete_file(path_in_repo=img_path, repo_id=REPO_ID, repo_type="dataset")
1257
- # logging.info(f"Admin deleted image {img_path} from HF.")
1258
- # except Exception as e:
1259
- # logging.error(f"Admin failed to delete image {img_path} from HF: {e}")
1260
-
1261
-
1262
- data[item_type] = [i for i in items_list if i['id'] != item_id]
1263
 
1264
- if len(data[item_type]) < original_length:
 
 
1265
  save_data(data)
1266
- flash(f'{item_type.capitalize().rstrip("s")} deleted successfully.', 'success')
 
 
 
 
 
 
 
 
1267
  else:
1268
  flash('Item not found or already deleted.', 'warning')
1269
  return redirect(url_for('admin_panel'))
@@ -1273,7 +1298,7 @@ def force_upload_admin():
1273
  logging.info("Admin forcing upload to Hugging Face...")
1274
  try:
1275
  upload_db_to_hf()
1276
- flash("Data successfully uploaded to Hugging Face.", 'success')
1277
  except Exception as e:
1278
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1279
  flash(f"Error uploading to Hugging Face: {e}", 'error')
@@ -1284,10 +1309,10 @@ def force_download_admin():
1284
  logging.info("Admin forcing download from Hugging Face...")
1285
  try:
1286
  if download_db_from_hf():
1287
- flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
1288
  load_data()
1289
  else:
1290
- flash("Failed to download data from Hugging Face. Check logs.", 'error')
1291
  except Exception as e:
1292
  logging.error(f"Error during forced download: {e}", exc_info=True)
1293
  flash(f"Error downloading from Hugging Face: {e}", 'error')
@@ -1296,8 +1321,7 @@ def force_download_admin():
1296
 
1297
  if __name__ == '__main__':
1298
  logging.info("Application starting up. Performing initial data load/download...")
1299
- if not os.path.exists(DATA_FILE):
1300
- download_db_from_hf(specific_file=DATA_FILE)
1301
  load_data()
1302
  logging.info("Initial data load complete.")
1303
 
 
1
+ from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
2
  import json
3
  import os
4
  import logging
5
  import threading
6
  import time
7
  from datetime import datetime
8
+ from huggingface_hub import HfApi, hf_hub_download, snapshot_download
9
+ from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError, EntryNotFoundError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
  import uuid
 
19
  app = Flask(__name__)
20
  app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
21
  DATA_FILE = 'tontalent_data.json'
22
+ UPLOAD_DIR = 'uploads'
23
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
24
+
25
+ SYNC_FILES = [DATA_FILE]
26
 
27
  REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
28
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
29
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
30
 
31
+ TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8" # Placeholder, use env var
32
 
33
  DOWNLOAD_RETRIES = 3
34
  DOWNLOAD_DELAY = 5
35
 
36
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
37
 
 
 
 
38
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
39
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
40
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
41
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
42
+ api = HfApi(token=token_to_use)
43
+
44
  files_to_download = [specific_file] if specific_file else SYNC_FILES
45
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
46
  all_successful = True
47
+
48
  for file_name in files_to_download:
49
  success = False
50
  for attempt in range(retries + 1):
 
63
  return False
64
  except HfHubHTTPError as e:
65
  if e.response.status_code == 404:
66
+ logging.warning(f"File {file_name} not found in repo {REPO_ID} (404).")
67
  if attempt == 0 and not os.path.exists(file_name):
68
  try:
69
  if file_name == DATA_FILE:
70
  with open(file_name, 'w', encoding='utf-8') as f:
71
  json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
72
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
73
+ success = True
74
  except Exception as create_e:
75
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
 
76
  break
77
  else:
78
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
 
82
  if not success:
83
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
84
  all_successful = False
85
+
86
+ if all_successful and not specific_file:
87
+ logging.info(f"Attempting to sync remote '{UPLOAD_DIR}' directory to local '{UPLOAD_DIR}'...")
88
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
89
+ try:
90
+ remote_upload_files_info = []
91
+ try:
92
+ repo_files = api.list_files_info(repo_id=REPO_ID, paths=[UPLOAD_DIR], repo_type="dataset", recursive=True)
93
+ remote_upload_files_info = [fi for fi in repo_files if fi.path.startswith(UPLOAD_DIR + '/') and fi.type == 'file']
94
+ except EntryNotFoundError:
95
+ logging.warning(f"Remote directory '{UPLOAD_DIR}' not found in repo {REPO_ID}. Skipping sync of this directory.")
96
+ except HfHubHTTPError as e:
97
+ if e.response.status_code == 404:
98
+ logging.warning(f"Remote '{UPLOAD_DIR}' path not found in repo {REPO_ID} (404). Skipping uploads sync.")
99
+ else:
100
+ logging.error(f"HTTP error listing files in remote '{UPLOAD_DIR}': {e}")
101
+ except Exception as e:
102
+ logging.error(f"Unexpected error listing files in remote '{UPLOAD_DIR}': {e}", exc_info=True)
103
+
104
+ for file_info in remote_upload_files_info:
105
+ local_file_path = os.path.join(os.getcwd(), file_info.path)
106
+ os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
107
+
108
+ needs_download = True
109
+ if os.path.exists(local_file_path) and os.path.getsize(local_file_path) == file_info.size:
110
+ needs_download = False
111
+
112
+ if needs_download:
113
+ logging.info(f"Downloading {file_info.path} from HF uploads...")
114
+ for attempt in range(retries + 1):
115
+ try:
116
+ hf_hub_download(
117
+ repo_id=REPO_ID, filename=file_info.path, repo_type="dataset",
118
+ token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
119
+ force_download=True, resume_download=False
120
+ )
121
+ logging.info(f"Successfully downloaded {file_info.path}.")
122
+ break
123
+ except Exception as e_dl:
124
+ logging.error(f"Error downloading {file_info.path} (Attempt {attempt + 1}): {e_dl}.")
125
+ if attempt < retries: time.sleep(delay)
126
+ else: logging.error(f"Failed to download {file_info.path} after multiple attempts.")
127
+ else:
128
+ logging.info(f"Skipping download for {file_info.path}, already exists locally with correct size.")
129
+ except Exception as e:
130
+ logging.error(f"Error syncing HF '{UPLOAD_DIR}' directory: {e}", exc_info=True)
131
+
132
+ logging.info(f"Download process finished. Overall success for SYNC_FILES: {all_successful}")
133
  return all_successful
134
 
135
+
136
  def upload_db_to_hf(specific_file=None):
137
  if not HF_TOKEN_WRITE:
138
  logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
139
  return
140
+ api = HfApi(token=HF_TOKEN_WRITE)
141
  try:
142
+ api.create_repo(repo_id=REPO_ID, repo_type="dataset", exist_ok=True, token=HF_TOKEN_WRITE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  except Exception as e:
144
+ logging.error(f"Error ensuring Hugging Face repo exists: {e}", exc_info=True)
145
+ return
146
+
147
+ files_to_upload = [specific_file] if specific_file else SYNC_FILES
148
+ logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
149
+ for file_name in files_to_upload:
150
+ if os.path.exists(file_name):
151
+ try:
152
+ api.upload_file(
153
+ path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
154
+ repo_type="dataset", token=HF_TOKEN_WRITE,
155
+ commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
156
+ )
157
+ logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
158
+ except Exception as e:
159
+ logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
160
+ else:
161
+ logging.warning(f"File {file_name} not found locally, skipping upload.")
162
+
163
+ if not specific_file:
164
+ if os.path.exists(UPLOAD_DIR) and os.path.isdir(UPLOAD_DIR) and os.listdir(UPLOAD_DIR):
165
+ logging.info(f"Attempting to upload local '{UPLOAD_DIR}' directory to HF...")
166
+ try:
167
+ api.upload_folder(
168
+ folder_path=UPLOAD_DIR,
169
+ path_in_repo=UPLOAD_DIR,
170
+ repo_id=REPO_ID,
171
+ repo_type="dataset",
172
+ token=HF_TOKEN_WRITE,
173
+ commit_message=f"Sync uploads directory {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
174
+ allow_patterns="*",
175
+ delete_patterns=None
176
+ )
177
+ logging.info(f"Successfully initiated upload of '{UPLOAD_DIR}' directory to Hugging Face.")
178
+ except Exception as e:
179
+ logging.error(f"Error uploading '{UPLOAD_DIR}' directory to Hugging Face: {e}", exc_info=True)
180
+ elif not os.path.exists(UPLOAD_DIR) or not os.listdir(UPLOAD_DIR):
181
+ logging.info(f"Local '{UPLOAD_DIR}' directory not found or empty, skipping upload of this directory.")
182
+
183
+
184
+ logging.info("Finished uploading files to HF.")
185
+
186
 
187
  def periodic_backup():
188
  backup_interval = 1800
 
201
  logging.info(f"Local data loaded successfully from {DATA_FILE}")
202
  if not isinstance(data, dict):
203
  logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
204
+ raise FileNotFoundError
205
  for key in default_data:
206
  if key not in data: data[key] = default_data[key]
207
  return data
 
244
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
245
  json.dump(data, file, ensure_ascii=False, indent=4)
246
  logging.info(f"Data successfully saved to {DATA_FILE}")
247
+ upload_db_to_hf()
248
  except Exception as e:
249
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
250
 
251
  def verify_telegram_auth_data(auth_data_str, bot_token):
252
  if not auth_data_str:
253
  return False, None
 
 
 
254
 
255
  params = dict(urllib.parse.parse_qsl(auth_data_str))
256
  if 'hash' not in params:
 
276
  return False, None
277
  return False, None
278
 
 
279
  MAIN_APP_TEMPLATE = '''
280
  <!DOCTYPE html>
281
  <html lang="en">
 
336
  transition: color 0.2s, border-bottom-color 0.2s;
337
  }
338
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
339
+ .content { flex-grow: 1; padding: 15px; }
340
  .list-item {
341
  background-color: var(--tg-theme-section-bg-color);
342
  border-radius: 8px;
 
345
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
346
  cursor: pointer;
347
  transition: background-color 0.2s;
348
+ display: flex;
349
+ align-items: flex-start;
350
  }
351
  .list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
352
+ .item-image-preview-list {
353
+ width: 60px;
354
+ height: 60px;
355
+ margin-right: 12px;
356
+ flex-shrink: 0;
357
+ background-color: var(--tg-theme-secondary-bg-color);
358
+ border-radius: 4px;
359
+ }
360
+ .item-image-preview-list img {
361
+ width: 100%;
362
+ height: 100%;
363
+ object-fit: cover;
364
+ border-radius: 4px;
365
+ }
366
+ .item-content { flex-grow: 1; }
367
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
368
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
369
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
 
 
 
 
370
  .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
371
  .form-group { margin-bottom: 15px; }
372
  .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; }
373
+ .form-group input, .form-group textarea {
374
  width: 100%;
375
  padding: 10px;
376
  border: 1px solid var(--tg-theme-secondary-bg-color);
 
380
  color: var(--tg-theme-text-color);
381
  box-sizing: border-box;
382
  }
 
383
  .form-group textarea { min-height: 80px; resize: vertical; }
384
+ .image-preview-container { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
385
+ .image-preview-item { position: relative; width: 80px; height: 80px; }
386
+ .image-preview-item img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; border: 1px solid var(--tg-theme-secondary-bg-color); }
 
 
 
 
 
387
  .fab {
388
  position: fixed;
389
  bottom: 20px;
 
406
  .detail-view h2 { margin-top: 0; font-size: 20px; color: var(--tg-theme-text-color); }
407
  .detail-view p { margin-bottom: 8px; line-height: 1.5; font-size: 16px; }
408
  .detail-view strong { font-weight: 600; color: var(--tg-theme-section-header-text-color); }
409
+ .detail-image-gallery { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 15px; }
410
+ .detail-image-gallery img {
411
+ width: calc(50% - 5px); max-width: 150px; height: auto; aspect-ratio: 1 / 1;
412
+ object-fit: cover; border-radius: 6px; border: 1px solid var(--tg-theme-secondary-bg-color);
 
 
 
 
 
 
413
  }
414
+ @media (max-width: 400px) { .detail-image-gallery img { width: calc(100%); max-width: none; } }
415
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
416
  .user-info { padding: 10px 15px; background-color: var(--tg-theme-secondary-bg-color); font-size: 13px; text-align: center; color: var(--tg-theme-hint-color); }
417
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
 
418
  </style>
419
  </head>
420
  <body>
 
434
 
435
  <script>
436
  const tg = window.Telegram.WebApp;
 
 
437
  let currentUser = null;
438
  let currentView = 'resumes';
439
  let currentItem = null;
440
+ let selectedFilesStore = [];
 
441
 
442
  function applyThemeParams() {
443
  document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
 
455
  }
456
 
457
  async function apiCall(endpoint, method = 'GET', body = null) {
458
+ const headers = {};
459
  if (tg.initData) {
460
  headers['X-Telegram-Auth'] = tg.initData;
461
  }
462
  const options = { method, headers };
463
+ if (body instanceof FormData) {
464
+ options.body = body;
465
+ } else if (body) {
466
+ headers['Content-Type'] = 'application/json';
467
+ options.body = JSON.stringify(body);
468
+ }
469
+
470
  try {
471
  const response = await fetch(endpoint, options);
472
  if (!response.ok) {
473
  const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
474
+ throw new Error(errorData.error || \`HTTP error \${response.status}\`);
475
  }
476
  return response.json();
477
  } catch (error) {
 
484
  function renderList(items, type) {
485
  const contentDiv = document.getElementById('mainContent');
486
  if (!items || items.length === 0) {
487
+ contentDiv.innerHTML = \`<div class="empty-state">No \${type} found. Be the first to add one!</div>\`;
488
  return;
489
  }
490
+ contentDiv.innerHTML = items.map(item => \`
491
+ <div class="list-item" onclick="showDetailView('\${type}', '\${item.id}')">
492
+ <div class="item-image-preview-list">
493
+ \${item.images && item.images.length > 0 ? \`<img src="/uploads/\${item.images[0]}" alt="\${item.title || item.name || 'Preview'}">\` : ''}
 
 
 
 
 
 
 
 
 
 
494
  </div>
495
+ <div class="item-content">
496
+ <h3>\${item.title || item.name || 'Untitled'}</h3>
497
+ \${type === 'vacancies' && item.company_name ? \`<p><strong>Company:</strong> \${item.company_name}</p>\` : ''}
498
+ \${type === 'freelance_offers' && item.budget ? \`<p><strong>Budget:</strong> \${item.budget}</p>\` : ''}
499
+ <p class="meta">Posted by: @\${item.user_telegram_username || 'anonymous'} on \${new Date(item.timestamp).toLocaleDateString()}</p>
500
+ </div>
501
+ </div>
502
+ \`).join('');
503
  }
504
 
505
  function showDetailView(type, id) {
506
+ tg.HapticFeedback.impactOccurred('light');
507
  tg.BackButton.show();
508
+ tg.BackButton.onClick(() => { loadView(type); tg.HapticFeedback.impactOccurred('light'); });
 
 
 
509
  tg.MainButton.hide();
510
  document.getElementById('fabButton').style.display = 'none';
511
 
512
+ apiCall(\`/api/\${type}/\${id}\`)
513
  .then(item => {
514
  currentItem = item;
515
  const contentDiv = document.getElementById('mainContent');
516
+ let detailsHtml = \`<div class="detail-view"><h2>\${item.title || item.name}</h2>\`;
517
+
518
  if (item.images && item.images.length > 0) {
519
+ detailsHtml += \`<div class="detail-image-gallery">\`;
520
+ detailsHtml += item.images.map(imgFile => \`<img src="/uploads/\${imgFile}" alt="Image for \${item.title || item.name}">\`).join('');
521
+ detailsHtml += \`</div>\`;
 
 
 
522
  }
523
 
 
 
524
  if (type === 'resumes') {
525
+ detailsHtml += \`
526
+ <p><strong>Skills:</strong> \${item.skills || 'N/A'}</p>
527
+ <p><strong>Experience:</strong><br>\${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
528
+ <p><strong>Education:</strong><br>\${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
529
+ <p><strong>Contact:</strong> \${item.contact || \`@\${item.user_telegram_username}\`}</p>
530
+ \${item.portfolio_link ? \`<p><strong>Portfolio:</strong> <a href="\${item.portfolio_link}" target="_blank">\${item.portfolio_link}</a></p>\` : ''}
531
+ \`;
532
  } else if (type === 'vacancies') {
533
+ detailsHtml += \`
534
+ <p><strong>Company:</strong> \${item.company_name || 'N/A'}</p>
535
+ <p><strong>Description:</strong><br>\${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
536
+ <p><strong>Requirements:</strong><br>\${item.requirements ? item.requirements.replace(/\\n/g, '<br>') : 'N/A'}</p>
537
+ <p><strong>Salary:</strong> \${item.salary || 'N/A'}</p>
538
+ <p><strong>Location:</strong> \${item.location || 'N/A'}</p>
539
+ <p><strong>Contact/Apply:</strong> \${item.contact || \`@\${item.user_telegram_username}\`}</p>
540
+ \`;
541
  } else if (type === 'freelance_offers') {
542
+ detailsHtml += \`
543
+ <p><strong>Description:</strong><br>\${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
544
+ <p><strong>Budget:</strong> \${item.budget || 'N/A'}</p>
545
+ <p><strong>Deadline:</strong> \${item.deadline || 'N/A'}</p>
546
+ <p><strong>Skills Needed:</strong> \${item.skills_needed || 'N/A'}</p>
547
+ <p><strong>Contact:</strong> \${item.contact || \`@\${item.user_telegram_username}\`}</p>
548
+ \`;
549
  }
550
+ detailsHtml += \`<p class="meta">Posted by: @\${item.user_telegram_username || 'anonymous'} on \${new Date(item.timestamp).toLocaleDateString()}</p></div>\`;
551
  contentDiv.innerHTML = detailsHtml;
552
 
553
  if (currentUser && item.user_id === currentUser.id) {
554
  tg.MainButton.setText('Edit My Post');
555
+ tg.MainButton.onClick(() => { showForm(type, item); tg.HapticFeedback.impactOccurred('light'); });
556
  tg.MainButton.show();
557
  }
558
  })
559
  .catch(err => {
560
+ document.getElementById('mainContent').innerHTML = \`<div class="empty-state">Error loading details.</div>\`;
561
  });
562
  }
563
 
564
+ function previewUploadedImages(files) {
565
+ const previewContainer = document.getElementById('imagePreviewContainer');
566
+ previewContainer.innerHTML = '';
567
+ selectedFilesStore = Array.from(files).slice(0, 10);
568
+
569
+ if (files.length > 10) {
570
+ tg.showAlert('You can upload a maximum of 10 images. Only the first 10 will be processed.');
571
+ tg.HapticFeedback.notificationOccurred('warning');
572
+ }
573
+
574
+ selectedFilesStore.forEach(file => {
575
+ const reader = new FileReader();
576
+ reader.onload = function(e) {
577
+ const div = document.createElement('div');
578
+ div.classList.add('image-preview-item');
579
+ const img = document.createElement('img');
580
+ img.src = e.target.result;
581
+ div.appendChild(img);
582
+ previewContainer.appendChild(div);
583
+ }
584
+ reader.readAsDataURL(file);
585
+ });
586
+ }
587
+
588
  function showForm(type, itemToEdit = null) {
589
+ tg.HapticFeedback.impactOccurred('light');
590
  currentItem = itemToEdit;
591
+ selectedFilesStore = [];
 
 
592
  tg.BackButton.show();
593
  tg.BackButton.onClick(() => {
594
+ tg.HapticFeedback.impactOccurred('light');
595
  if (itemToEdit) showDetailView(type, itemToEdit.id);
596
  else loadView(type);
597
  });
598
  document.getElementById('fabButton').style.display = 'none';
599
 
600
  const contentDiv = document.getElementById('mainContent');
601
+ let formHtml = \`<div class="form-container"><h2>\${itemToEdit ? 'Edit' : 'New'} \${type.slice(0, -1)}</h2>\`;
602
 
603
  if (type === 'resumes') {
604
+ formHtml += \`
605
+ <div class="form-group">
606
+ <label for="name">Full Name</label>
607
+ <input type="text" id="name" value="\${itemToEdit?.name || ''}" required>
608
+ </div>
609
+ <div class="form-group">
610
+ <label for="title">Job Title / Desired Position</label>
611
+ <input type="text" id="title" value="\${itemToEdit?.title || ''}" required>
612
+ </div>
613
+ <div class="form-group">
614
+ <label for="skills">Skills (comma separated)</label>
615
+ <textarea id="skills">\${itemToEdit?.skills || ''}</textarea>
616
+ </div>
617
+ <div class="form-group">
618
+ <label for="experience">Experience</label>
619
+ <textarea id="experience">\${itemToEdit?.experience || ''}</textarea>
620
+ </div>
621
+ <div class="form-group">
622
+ <label for="education">Education</label>
623
+ <textarea id="education">\${itemToEdit?.education || ''}</textarea>
624
+ </div>
625
+ <div class="form-group">
626
+ <label for="contact">Contact Info (e.g., email, or leave blank to use Telegram)</label>
627
+ <input type="text" id="contact" value="\${itemToEdit?.contact || ''}">
628
+ </div>
629
+ <div class="form-group">
630
+ <label for="portfolio_link">Portfolio Link (optional)</label>
631
+ <input type="url" id="portfolio_link" value="\${itemToEdit?.portfolio_link || ''}">
632
+ </div>
633
+ \`;
634
  } else if (type === 'vacancies') {
635
+ formHtml += \`
636
+ <div class="form-group">
637
+ <label for="company_name">Company Name</label>
638
+ <input type="text" id="company_name" value="\${itemToEdit?.company_name || ''}" required>
639
+ </div>
640
+ <div class="form-group">
641
+ <label for="title">Job Title</label>
642
+ <input type="text" id="title" value="\${itemToEdit?.title || ''}" required>
643
+ </div>
644
+ <div class="form-group">
645
+ <label for="description">Description</label>
646
+ <textarea id="description">\${itemToEdit?.description || ''}</textarea>
647
+ </div>
648
+ <div class="form-group">
649
+ <label for="requirements">Requirements</label>
650
+ <textarea id="requirements">\${itemToEdit?.requirements || ''}</textarea>
651
+ </div>
652
+ <div class="form-group">
653
+ <label for="salary">Salary/Compensation</label>
654
+ <input type="text" id="salary" value="\${itemToEdit?.salary || ''}">
655
+ </div>
656
+ <div class="form-group">
657
+ <label for="location">Location (e.g., Remote, City)</label>
658
+ <input type="text" id="location" value="\${itemToEdit?.location || ''}">
659
+ </div>
660
+ <div class="form-group">
661
+ <label for="contact">Contact Info / How to Apply</label>
662
+ <textarea id="contact">\${itemToEdit?.contact || ''}</textarea>
663
+ </div>
664
+ \`;
665
  } else if (type === 'freelance_offers') {
666
+ formHtml += \`
667
+ <div class="form-group">
668
+ <label for="title">Project Title</label>
669
+ <input type="text" id="title" value="\${itemToEdit?.title || ''}" required>
670
+ </div>
671
+ <div class="form-group">
672
+ <label for="description">Description of Work</label>
673
+ <textarea id="description">\${itemToEdit?.description || ''}</textarea>
674
+ </div>
675
+ <div class="form-group">
676
+ <label for="budget">Budget</label>
677
+ <input type="text" id="budget" value="\${itemToEdit?.budget || ''}">
678
+ </div>
679
+ <div class="form-group">
680
+ <label for="deadline">Expected Deadline</label>
681
+ <input type="text" id="deadline" value="\${itemToEdit?.deadline || ''}">
682
+ </div>
683
+ <div class="form-group">
684
+ <label for="skills_needed">Skills Needed (comma separated)</label>
685
+ <textarea id="skills_needed">\${itemToEdit?.skills_needed || ''}</textarea>
686
+ </div>
687
+ <div class="form-group">
688
+ <label for="contact">Contact Info (or leave blank to use Telegram)</label>
689
+ <input type="text" id="contact" value="\${itemToEdit?.contact || ''}">
690
+ </div>
691
+ \`;
692
  }
693
+ formHtml += \`
694
  <div class="form-group">
695
+ <label for="images">Images (up to 10)</label>
696
+ <input type="file" id="images" multiple accept="image/*" onchange="previewUploadedImages(this.files)">
697
+ <div id="imagePreviewContainer" class="image-preview-container"></div>
 
698
  </div>
699
+ \${itemToEdit && itemToEdit.images && itemToEdit.images.length > 0 ? \`
700
+ <div class="form-group">
701
+ <label>Current Images</label>
702
+ <div class="detail-image-gallery">
703
+ \${itemToEdit.images.map(imgFile => \`<img src="/uploads/\${imgFile}" alt="Current image"> \`).join('')}
704
+ </div>
705
+ <small>\${selectedFilesStore.length > 0 ? 'Uploading new images will replace these.' : 'Current images will be kept unless new ones are uploaded.'}</small>
706
+ </div>\` : ''}
707
+ \`;
708
+ formHtml += \`<div id="formError" class="error-message"></div></div>\`;
709
  contentDiv.innerHTML = formHtml;
 
710
 
711
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
712
  tg.MainButton.show();
713
  tg.MainButton.onClick(() => handleSubmit(type, itemToEdit ? itemToEdit.id : null));
714
  }
715
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  function handleSubmit(type, itemId = null) {
717
  const payload = {};
718
  let isValid = true;
719
+ document.getElementById('formError').textContent = '';
 
720
 
721
  if (type === 'resumes') {
722
  payload.name = document.getElementById('name').value.trim();
 
747
  }
748
 
749
  if (!isValid) {
750
+ document.getElementById('formError').textContent = 'Please fill in all required fields.';
751
  tg.HapticFeedback.notificationOccurred('error');
752
  return;
753
  }
754
 
755
  tg.MainButton.showProgress();
756
 
757
+ const formData = new FormData();
758
+ for (const key in payload) {
759
+ formData.append(key, payload[key]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
760
  }
761
+ if (selectedFilesStore.length > 0) {
762
+ selectedFilesStore.forEach(file => formData.append('images', file, file.name));
763
+ }
764
+
765
 
 
766
  const method = itemId ? 'PUT' : 'POST';
767
+ const endpoint = itemId ? \`/api/\${type}/\${itemId}\` : \`/api/\${type}\`;
 
768
 
769
+ apiCall(endpoint, method, formData)
770
  .then(response => {
771
  tg.HapticFeedback.notificationOccurred('success');
772
  tg.MainButton.hideProgress();
 
782
  function loadView(tabName) {
783
  currentView = tabName;
784
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
785
+ document.querySelector(\`.tab-button[data-tab="\${tabName}"]\`).classList.add('active');
786
 
787
+ document.getElementById('mainContent').innerHTML = \`<div class="loading">Loading \${tabName}...</div>\`;
788
  tg.BackButton.hide();
789
  tg.MainButton.hide();
790
  document.getElementById('fabButton').style.display = 'block';
791
 
792
+ apiCall(\`/api/\${tabName}\`)
793
  .then(data => renderList(data, tabName))
794
  .catch(err => {
795
+ document.getElementById('mainContent').innerHTML = \`<div class="empty-state">Error loading \${tabName}.</div>\`;
796
  });
797
  }
798
 
799
+ let touchstartX = 0;
800
+ let touchendX = 0;
801
+ const swipeThreshold = 75;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
 
803
+ function handleSwipeGesture() {
804
+ const swipeDiff = touchendX - touchstartX;
805
+ if (Math.abs(swipeDiff) < swipeThreshold) return;
806
 
807
+ const tabs = ['resumes', 'vacancies', 'freelance_offers'];
808
+ let currentIndex = tabs.indexOf(currentView);
 
 
 
 
809
 
810
+ if (swipeDiff < 0) {
811
+ currentIndex = (currentIndex + 1) % tabs.length;
812
+ } else {
813
+ currentIndex = (currentIndex - 1 + tabs.length) % tabs.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  }
815
+ tg.HapticFeedback.impactOccurred('medium');
816
+ loadView(tabs[currentIndex]);
817
  }
818
 
819
  async function init() {
 
822
  tg.expand();
823
  tg.enableClosingConfirmation();
824
 
825
+ document.getElementById('userInfo').textContent = \`Welcome, \${tg.initDataUnsafe.user?.first_name || 'User'}! (@\${tg.initDataUnsafe.user?.username || 'anonymous'})\`;
826
 
827
  try {
828
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
829
  currentUser = authResponse.user;
830
  if (currentUser) {
831
+ document.getElementById('userInfo').textContent = \`Logged in as: \${currentUser.first_name} (@\${currentUser.username})\`;
832
  }
833
  } catch (error) {
834
  console.error("Auth error:", error);
835
+ document.getElementById('userInfo').textContent = \`Auth failed. Limited functionality.\`;
836
+ tg.showAlert("Authentication with the server failed. Some features might not work correctly.");
837
  }
838
 
839
+
840
  document.querySelectorAll('.tab-button').forEach(button => {
841
  button.addEventListener('click', () => {
842
+ tg.HapticFeedback.impactOccurred('light');
843
  loadView(button.dataset.tab);
844
  });
845
  });
846
  document.getElementById('fabButton').addEventListener('click', () => {
847
+ tg.HapticFeedback.impactOccurred('heavy');
848
  showForm(currentView);
849
  });
850
 
851
+ const mainContentEl = document.getElementById('mainContent');
852
+ mainContentEl.addEventListener('touchstart', e => { touchstartX = e.changedTouches[0].screenX; }, {passive: true});
853
+ mainContentEl.addEventListener('touchend', e => { touchendX = e.changedTouches[0].screenX; handleSwipeGesture(); }, {passive: true});
854
+
855
  loadView('resumes');
856
  }
857
 
 
904
  <h2>Data Synchronization with Hugging Face</h2>
905
  <div class="sync-buttons">
906
  <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
907
+ <button type="submit" class="button button-primary">Upload DB & Uploads to HF</button>
908
  </form>
909
  <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
910
+ <button type="submit" class="button button-secondary">Download DB & Uploads from HF</button>
911
  </form>
912
  </div>
913
  <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
 
914
  </div>
915
 
916
  <div class="section">
 
919
  <div class="item">
920
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
921
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
922
+ <p>Posted: {{ resume.timestamp }}</p>
923
  <p>Images: {{ resume.images|length if resume.images else 0 }}</p>
 
924
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
925
  <input type="hidden" name="item_type" value="resumes">
926
  <input type="hidden" name="item_id" value="{{ resume.id }}">
 
938
  <div class="item">
939
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
940
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
941
+ <p>Posted: {{ vacancy.timestamp }}</p>
942
  <p>Images: {{ vacancy.images|length if vacancy.images else 0 }}</p>
 
943
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
944
  <input type="hidden" name="item_type" value="vacancies">
945
  <input type="hidden" name="item_id" value="{{ vacancy.id }}">
 
958
  <h3>{{ offer.title }}</h3>
959
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
960
  <p>Budget: {{ offer.budget }}</p>
961
+ <p>Posted: {{ offer.timestamp }}</p>
962
  <p>Images: {{ offer.images|length if offer.images else 0 }}</p>
 
963
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');">
964
  <input type="hidden" name="item_type" value="freelance_offers">
965
  <input type="hidden" name="item_id" value="{{ offer.id }}">
 
975
  </html>
976
  '''
977
 
 
 
 
 
 
 
 
 
 
 
 
978
  @app.route('/')
979
  def main_app_view():
980
+ return render_template_string(MAIN_APP_TEMPLATE)
981
+
982
+ @app.route('/uploads/<path:filename>')
983
+ def serve_upload(filename):
984
+ return send_from_directory(UPLOAD_DIR, filename)
985
 
986
  @app.route('/api/auth_user', methods=['POST'])
987
  def auth_user():
 
993
  else:
994
  return jsonify({"error": "Authentication data not provided"}), 401
995
 
996
+ is_valid, user_data_dict = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
997
 
998
+ if not is_valid or not user_data_dict:
 
999
  return jsonify({"error": "Invalid authentication data"}), 403
1000
 
1001
  data = load_data()
1002
  users = data.get('users', {})
1003
+ user_id_str = str(user_data_dict.get('id'))
1004
 
1005
  if user_id_str not in users:
1006
  users[user_id_str] = {
1007
+ 'id': user_data_dict.get('id'),
1008
+ 'first_name': user_data_dict.get('first_name'),
1009
+ 'last_name': user_data_dict.get('last_name'),
1010
+ 'username': user_data_dict.get('username'),
1011
+ 'language_code': user_data_dict.get('language_code'),
1012
+ 'photo_url': user_data_dict.get('photo_url'),
1013
  'first_seen': datetime.now().isoformat()
1014
  }
1015
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
 
1022
  auth_data_str = request_obj.headers.get('X-Telegram-Auth')
1023
  if not auth_data_str:
1024
  return None
1025
+ is_valid, user_data_dict = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1026
+ if is_valid and user_data_dict:
1027
+ return user_data_dict
 
1028
  return None
1029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
 
1031
  @app.route('/api/<item_type>', methods=['GET'])
1032
  def get_items(item_type):
 
1036
  items = sorted(data.get(item_type, []), key=lambda x: x.get('timestamp', ''), reverse=True)
1037
  return jsonify(items), 200
1038
 
1039
+
1040
  @app.route('/api/<item_type>/<item_id>', methods=['GET'])
1041
  def get_item(item_type, item_id):
1042
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
1047
  return jsonify(item), 200
1048
  return jsonify({"error": "Item not found"}), 404
1049
 
1050
+
1051
  @app.route('/api/<item_type>', methods=['POST'])
1052
  def create_item(item_type):
1053
  user = get_authenticated_user(request)
 
1057
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1058
  return jsonify({"error": "Invalid item type"}), 400
1059
 
1060
+ req_data = request.form
1061
  if not req_data:
1062
  return jsonify({"error": "No data provided"}), 400
1063
 
1064
  new_item = {
1065
  "id": str(uuid.uuid4()),
1066
  "user_id": str(user.get('id')),
1067
+ "user_telegram_username": user.get('username', 'unknown'),
1068
  "timestamp": datetime.now().isoformat(),
1069
+ "images": []
1070
  }
1071
+
1072
+ uploaded_files = request.files.getlist("images")
1073
+ if len(uploaded_files) > 10:
1074
+ return jsonify({"error": "Maximum 10 images allowed"}), 400
1075
+
1076
+ for file_storage in uploaded_files:
1077
+ if file_storage and file_storage.filename:
1078
+ original_filename = secure_filename(file_storage.filename)
1079
+ extension = os.path.splitext(original_filename)[1].lower()
1080
+ if extension not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
1081
+ return jsonify({"error": f"Invalid image file type: {extension}"}), 400
1082
+ unique_filename = f"{uuid.uuid4()}{extension}"
1083
+ try:
1084
+ file_storage.save(os.path.join(UPLOAD_DIR, unique_filename))
1085
+ new_item["images"].append(unique_filename)
1086
+ except Exception as e:
1087
+ logging.error(f"Failed to save uploaded file {unique_filename}: {e}")
1088
+ return jsonify({"error": "Failed to save image"}), 500
1089
 
1090
 
1091
  if item_type == 'resumes':
 
1123
  save_data(data)
1124
  return jsonify(new_item), 201
1125
 
1126
+
1127
  @app.route('/api/<item_type>/<item_id>', methods=['PUT'])
1128
  def update_item(item_type, item_id):
1129
  user = get_authenticated_user(request)
 
1132
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1133
  return jsonify({"error": "Invalid item type"}), 400
1134
 
1135
+ req_data = request.form
1136
  if not req_data: return jsonify({"error": "No data provided"}), 400
1137
 
1138
  data = load_data()
1139
  items_list = data.get(item_type, [])
1140
  item_index = -1
1141
+ original_item = None
1142
  for idx, i in enumerate(items_list):
1143
  if i['id'] == item_id:
1144
  item_index = idx
1145
+ original_item = i
1146
  break
1147
 
1148
+ if item_index == -1 or original_item is None: return jsonify({"error": "Item not found"}), 404
1149
 
 
1150
  if str(original_item.get('user_id')) != str(user.get('id')):
1151
  return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
1152
 
1153
  updated_item = original_item.copy()
1154
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1155
+
1156
+ uploaded_files = request.files.getlist("images")
1157
+ if uploaded_files and any(f.filename for f in uploaded_files):
1158
+ if len(uploaded_files) > 10:
1159
+ return jsonify({"error": "Maximum 10 images allowed"}), 400
1160
+
1161
+ new_image_filenames = []
1162
+ for file_storage in uploaded_files:
1163
+ if file_storage and file_storage.filename:
1164
+ original_filename = secure_filename(file_storage.filename)
1165
+ extension = os.path.splitext(original_filename)[1].lower()
1166
+ if extension not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
1167
+ return jsonify({"error": f"Invalid image file type: {extension}"}), 400
1168
+ unique_filename = f"{uuid.uuid4()}{extension}"
1169
+ try:
1170
+ file_storage.save(os.path.join(UPLOAD_DIR, unique_filename))
1171
+ new_image_filenames.append(unique_filename)
1172
+ except Exception as e:
1173
+ logging.error(f"Failed to save updated file {unique_filename}: {e}")
1174
+ return jsonify({"error": "Failed to save image"}), 500
1175
+
1176
+ if new_image_filenames:
1177
+ old_images_to_remove = original_item.get("images", [])
1178
+ updated_item['images'] = new_image_filenames
1179
+ for old_img_name in old_images_to_remove:
1180
+ if old_img_name not in new_image_filenames: # Avoid deleting if re-uploaded with same name (unlikely with UUID)
1181
+ try:
1182
+ os.remove(os.path.join(UPLOAD_DIR, old_img_name))
1183
+ except OSError:
1184
+ logging.warning(f"Could not delete old image {old_img_name} during update.")
1185
+ else:
1186
+ updated_item['images'] = original_item.get('images', [])
1187
 
1188
 
1189
  if item_type == 'resumes':
 
1220
  save_data(data)
1221
  return jsonify(updated_item), 200
1222
 
1223
+
1224
  @app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
1225
  def delete_item(item_type, item_id):
1226
  user = get_authenticated_user(request)
 
1231
 
1232
  data = load_data()
1233
  items_list = data.get(item_type, [])
 
1234
 
1235
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1236
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1237
 
1238
  if str(item_to_delete.get('user_id')) != str(user.get('id')):
 
1239
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
 
 
 
1240
 
1241
+ images_to_remove = item_to_delete.get("images", [])
1242
+
1243
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1244
 
1245
+ save_data(data)
1246
+
1247
+ for img_name in images_to_remove:
1248
+ try:
1249
+ os.remove(os.path.join(UPLOAD_DIR, img_name))
1250
+ logging.info(f"Deleted image file {img_name} for item {item_id}")
1251
+ except OSError as e:
1252
+ logging.warning(f"Could not delete image file {img_name}: {e}")
1253
+
1254
+ return jsonify({"message": "Item deleted successfully"}), 200
1255
 
1256
 
1257
  @app.route('/admin', methods=['GET'])
 
1260
  return render_template_string(ADMIN_TEMPLATE,
1261
  resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1262
  vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1263
+ freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True))
 
1264
 
1265
  @app.route('/admin/delete', methods=['POST'])
1266
  def admin_delete_item():
 
1273
 
1274
  data = load_data()
1275
  items_list = data.get(item_type, [])
 
1276
 
1277
+ item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
 
 
 
 
 
 
 
 
 
 
 
 
1278
 
1279
+ if item_to_delete:
1280
+ images_to_remove = item_to_delete.get("images", [])
1281
+ data[item_type] = [i for i in items_list if i['id'] != item_id]
1282
  save_data(data)
1283
+
1284
+ for img_name in images_to_remove:
1285
+ try:
1286
+ os.remove(os.path.join(UPLOAD_DIR, img_name))
1287
+ logging.info(f"Admin deleted image file {img_name} for item {item_id}")
1288
+ except OSError as e:
1289
+ logging.warning(f"Admin could not delete image file {img_name}: {e}")
1290
+
1291
+ flash(f'{item_type.capitalize()[:-1]} deleted successfully.', 'success')
1292
  else:
1293
  flash('Item not found or already deleted.', 'warning')
1294
  return redirect(url_for('admin_panel'))
 
1298
  logging.info("Admin forcing upload to Hugging Face...")
1299
  try:
1300
  upload_db_to_hf()
1301
+ flash("Data and uploads successfully uploaded to Hugging Face.", 'success')
1302
  except Exception as e:
1303
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1304
  flash(f"Error uploading to Hugging Face: {e}", 'error')
 
1309
  logging.info("Admin forcing download from Hugging Face...")
1310
  try:
1311
  if download_db_from_hf():
1312
+ flash("Data and uploads successfully downloaded from Hugging Face. Local files updated.", 'success')
1313
  load_data()
1314
  else:
1315
+ flash("Failed to download data/uploads from Hugging Face. Check logs.", 'error')
1316
  except Exception as e:
1317
  logging.error(f"Error during forced download: {e}", exc_info=True)
1318
  flash(f"Error downloading from Hugging Face: {e}", 'error')
 
1321
 
1322
  if __name__ == '__main__':
1323
  logging.info("Application starting up. Performing initial data load/download...")
1324
+ download_db_from_hf()
 
1325
  load_data()
1326
  logging.info("Initial data load complete.")
1327