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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +438 -557
app.py CHANGED
@@ -5,8 +5,8 @@ import logging
5
  import threading
6
  import time
7
  from datetime import datetime
8
- from huggingface_hub import HfApi, hf_hub_download, login
9
- from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError, HfFolder
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
  import uuid
@@ -19,50 +19,28 @@ 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
- UPLOADS_FOLDER_NAME = 'uploads'
23
- MAX_IMAGES = 10
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") # Replace with your actual token or ensure it's 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 HF_TOKEN_WRITE:
37
- try:
38
- login(token=HF_TOKEN_WRITE)
39
- logging.info("Successfully logged in to Hugging Face Hub with write token.")
40
- except Exception as e:
41
- logging.error(f"Failed to login to Hugging Face Hub with write token: {e}")
42
- elif HF_TOKEN_READ:
43
- try:
44
- login(token=HF_TOKEN_READ)
45
- logging.info("Successfully logged in to Hugging Face Hub with read token (write operations will fail).")
46
- except Exception as e:
47
- logging.error(f"Failed to login to Hugging Face Hub with read token: {e}")
48
- else:
49
- logging.warning("No Hugging Face token provided. HF Hub operations might fail for private repos or be anonymous.")
50
-
51
-
52
- def ensure_uploads_folder_exists():
53
- path = os.path.join(app.root_path, UPLOADS_FOLDER_NAME)
54
- if not os.path.exists(path):
55
- os.makedirs(path)
56
- logging.info(f"Created local uploads folder: {path}")
57
-
58
- ensure_uploads_folder_exists()
59
 
60
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
 
 
61
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
62
- if not token_to_use and "HF_TOKEN" not in os.environ and not HfFolder.get_token(): # Check if anonymous
63
- logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set, and no global HF login found. Download might fail for private repos.")
64
-
65
- files_to_download = [specific_file] if specific_file else [DATA_FILE]
66
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
67
  all_successful = True
68
  for file_name in files_to_download:
@@ -90,11 +68,9 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
90
  with open(file_name, 'w', encoding='utf-8') as f:
91
  json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
92
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
93
- success = True # If we create it, it's a form of success for this step
94
  except Exception as create_e:
95
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
96
- else: # if it's not the first attempt or file already exists locally, a 404 is a failure for this file.
97
- success = False
98
  break
99
  else:
100
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
@@ -107,41 +83,38 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
107
  logging.info(f"Download process finished. Overall success: {all_successful}")
108
  return all_successful
109
 
110
- def upload_file_to_hf_direct(local_file_path, path_in_repo, commit_msg_prefix="Upload"):
111
  if not HF_TOKEN_WRITE:
112
- logging.warning(f"HF_TOKEN_WRITE not set. Skipping upload of {local_file_path} to Hugging Face.")
113
- return False
114
  try:
115
- api = HfApi()
116
- logging.info(f"Starting upload of {local_file_path} to {path_in_repo} in HF repo {REPO_ID}...")
117
- api.upload_file(
118
- path_or_fileobj=local_file_path,
119
- path_in_repo=path_in_repo,
120
- repo_id=REPO_ID,
121
- repo_type="dataset",
122
- token=HF_TOKEN_WRITE,
123
- commit_message=f"{commit_msg_prefix} {os.path.basename(path_in_repo)} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
124
- )
125
- logging.info(f"File {local_file_path} successfully uploaded to {path_in_repo} in Hugging Face.")
126
- return True
 
 
 
 
 
127
  except Exception as e:
128
- logging.error(f"Error uploading file {local_file_path} to Hugging Face: {e}", exc_info=True)
129
- return False
130
-
131
- def upload_data_file_to_hf():
132
- if not os.path.exists(DATA_FILE):
133
- logging.warning(f"Data file {DATA_FILE} not found locally, skipping upload.")
134
- return
135
- return upload_file_to_hf_direct(DATA_FILE, DATA_FILE, commit_msg_prefix="Sync data_file")
136
-
137
 
138
  def periodic_backup():
139
- backup_interval = 1800
140
  logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
141
  while True:
142
  time.sleep(backup_interval)
143
  logging.info("Starting periodic backup...")
144
- upload_data_file_to_hf()
145
  logging.info("Periodic backup finished.")
146
 
147
  def load_data():
@@ -195,13 +168,16 @@ def save_data(data):
195
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
196
  json.dump(data, file, ensure_ascii=False, indent=4)
197
  logging.info(f"Data successfully saved to {DATA_FILE}")
198
- upload_data_file_to_hf()
199
  except Exception as e:
200
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
201
 
202
  def verify_telegram_auth_data(auth_data_str, bot_token):
203
  if not auth_data_str:
204
  return False, None
 
 
 
205
 
206
  params = dict(urllib.parse.parse_qsl(auth_data_str))
207
  if 'hash' not in params:
@@ -236,8 +212,6 @@ MAIN_APP_TEMPLATE = '''
236
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
237
  <title>TonTalent</title>
238
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
239
- <link rel="stylesheet" href="https://unpkg.com/swiper/swiper-bundle.min.css">
240
- <script src="https://unpkg.com/swiper/swiper-bundle.min.js"></script>
241
  <style>
242
  :root {
243
  --tg-theme-bg-color: #ffffff;
@@ -262,12 +236,8 @@ MAIN_APP_TEMPLATE = '''
262
  overscroll-behavior-y: none;
263
  -webkit-font-smoothing: antialiased;
264
  -moz-osx-font-smoothing: grayscale;
265
- display: flex;
266
- flex-direction: column;
267
- height: 100vh; /* Use vh for full viewport height */
268
- overflow: hidden; /* Prevent body scroll, manage scroll in content */
269
  }
270
- .app-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; /* For flexbox scroll */ }
271
  .header {
272
  background-color: var(--tg-theme-header-bg-color);
273
  padding: 10px 15px;
@@ -279,7 +249,7 @@ MAIN_APP_TEMPLATE = '''
279
  top: 0;
280
  z-index: 100;
281
  }
282
- .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; flex-shrink: 0; }
283
  .tab-button {
284
  flex: 1;
285
  padding: 10px;
@@ -294,18 +264,7 @@ MAIN_APP_TEMPLATE = '''
294
  transition: color 0.2s, border-bottom-color 0.2s;
295
  }
296
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
297
-
298
- .content-swiper-container {
299
- flex-grow: 1;
300
- width: 100%;
301
- overflow: hidden; /* Swiper handles its own overflow */
302
- }
303
- .swiper-slide {
304
- overflow-y: auto; /* Allow vertical scroll within each slide */
305
- -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */
306
- padding: 15px;
307
- box-sizing: border-box;
308
- }
309
  .list-item {
310
  background-color: var(--tg-theme-section-bg-color);
311
  border-radius: 8px;
@@ -314,14 +273,19 @@ MAIN_APP_TEMPLATE = '''
314
  box-shadow: 0 1px 3px rgba(0,0,0,0.05);
315
  cursor: pointer;
316
  transition: background-color 0.2s;
 
 
317
  }
318
  .list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
 
319
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
320
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
321
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
322
- .list-item-image-preview { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin-right: 10px; float: left; }
323
-
324
- .form-container { padding: 0; /* Padding handled by swiper-slide */ background-color: var(--tg-theme-section-bg-color); }
 
 
325
  .form-group { margin-bottom: 15px; }
326
  .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; }
327
  .form-group input, .form-group textarea, .form-group input[type="file"] {
@@ -334,17 +298,16 @@ MAIN_APP_TEMPLATE = '''
334
  color: var(--tg-theme-text-color);
335
  box-sizing: border-box;
336
  }
337
- .form-group input[type="file"] { padding: 3px; }
338
  .form-group textarea { min-height: 80px; resize: vertical; }
339
- .image-preview-container { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
340
- .image-preview-item { position: relative; }
341
- .image-preview-item img { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--tg-theme-secondary-bg-color); }
342
- .remove-image-btn {
343
  position: absolute; top: -5px; right: -5px; background: var(--tg-theme-destructive-text-color); color: white;
344
- border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
345
- font-size: 12px; cursor: pointer; border: none;
346
  }
347
-
348
  .fab {
349
  position: fixed;
350
  bottom: 20px;
@@ -363,17 +326,25 @@ MAIN_APP_TEMPLATE = '''
363
  z-index: 1000;
364
  border: none;
365
  }
366
- .detail-view { padding: 0; /* Padding handled by swiper-slide */ background-color: var(--tg-theme-section-bg-color); }
367
  .detail-view h2 { margin-top: 0; font-size: 20px; color: var(--tg-theme-text-color); }
368
  .detail-view p { margin-bottom: 8px; line-height: 1.5; font-size: 16px; }
369
  .detail-view strong { font-weight: 600; color: var(--tg-theme-section-header-text-color); }
370
- .detail-images-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; margin-bottom: 15px; }
371
- .detail-image-item img { width: 100%; height: auto; max-height: 200px; object-fit: cover; border-radius: 6px; }
372
-
 
 
 
 
 
 
 
 
373
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
374
- .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); flex-shrink: 0; }
375
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
376
- .main-content-area { flex-grow: 1; overflow: hidden; position: relative; /* for fab */}
377
  </style>
378
  </head>
379
  <body>
@@ -381,42 +352,25 @@ MAIN_APP_TEMPLATE = '''
381
  <div class="header">TonTalent</div>
382
  <div class="user-info" id="userInfo">Loading user...</div>
383
  <div class="tabs">
384
- <button class="tab-button active" data-tab-id="resumes" data-tab-index="0">Resumes</button>
385
- <button class="tab-button" data-tab-id="vacancies" data-tab-index="1">Vacancies</button>
386
- <button class="tab-button" data-tab-id="freelance_offers" data-tab-index="2">Freelance</button>
387
  </div>
388
- <div class="main-content-area">
389
- <div class="content-swiper-container" id="contentSwiper">
390
- <div class="swiper-wrapper">
391
- <div class="swiper-slide" id="resumesContent"><div class="loading">Loading resumes...</div></div>
392
- <div class="swiper-slide" id="vacanciesContent"><div class="loading">Loading vacancies...</div></div>
393
- <div class="swiper-slide" id="freelance_offersContent"><div class="loading">Loading freelance offers...</div></div>
394
- </div>
395
- </div>
396
- <button class="fab" id="fabButton" title="Add New Item">+</button>
397
  </div>
 
398
  </div>
399
 
400
  <script>
401
  const tg = window.Telegram.WebApp;
402
- const HF_REPO_ID = "{{ HF_REPO_ID_JS }}";
403
- const MAX_IMAGES_ALLOWED = {{ MAX_IMAGES }};
404
  let currentUser = null;
405
- let currentViewType = 'resumes';
406
- let currentItem = null;
407
- let contentSwiper;
408
- let fabButton;
409
-
410
- const tabConfig = {
411
- 0: 'resumes',
412
- 1: 'vacancies',
413
- 2: 'freelance_offers'
414
- };
415
- const tabNameToIndex = {
416
- 'resumes': 0,
417
- 'vacancies': 1,
418
- 'freelance_offers': 2
419
- };
420
 
421
  function applyThemeParams() {
422
  document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
@@ -433,16 +387,13 @@ MAIN_APP_TEMPLATE = '''
433
  document.documentElement.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
434
  }
435
 
436
- async function apiCall(endpoint, method = 'GET', body = null, isFormData = false) {
437
- const headers = {};
438
- if (!isFormData) {
439
- headers['Content-Type'] = 'application/json';
440
- }
441
  if (tg.initData) {
442
  headers['X-Telegram-Auth'] = tg.initData;
443
  }
444
  const options = { method, headers };
445
- if (body) options.body = isFormData ? body : JSON.stringify(body);
446
  try {
447
  const response = await fetch(endpoint, options);
448
  if (!response.ok) {
@@ -457,64 +408,68 @@ MAIN_APP_TEMPLATE = '''
457
  }
458
  }
459
 
460
- function getHfImageUrl(pathInRepo) {
461
- if (!pathInRepo) return '';
462
- return `https://huggingface.co/datasets/${HF_REPO_ID}/resolve/main/${pathInRepo}`;
463
- }
464
-
465
  function renderList(items, type) {
466
- const contentDiv = document.getElementById(`${type}Content`);
467
  if (!items || items.length === 0) {
468
  contentDiv.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
469
  return;
470
  }
471
- contentDiv.innerHTML = items.map(item => `
472
- <div class="list-item" onclick="showDetailViewWrapper('${type}', '${item.id}')">
473
- ${item.images && item.images.length > 0 ? `<img src="${getHfImageUrl(item.images[0])}" class="list-item-image-preview" alt="Preview">` : ''}
474
- <h3>${item.title || item.name || 'Untitled'}</h3>
475
- ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
476
- ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
477
- <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
478
- <div style="clear:both;"></div>
479
- </div>
480
- `).join('');
 
 
 
 
 
 
 
481
  }
482
-
483
- function showDetailViewWrapper(type, id) {
484
  tg.HapticFeedback.impactOccurred('light');
485
- currentViewType = type;
486
  showDetailView(type, id);
487
  }
488
 
489
-
490
  function showDetailView(type, id) {
491
  tg.BackButton.show();
492
- tg.BackButton.onClick(() => loadView(type, false));
 
 
 
493
  tg.MainButton.hide();
494
- fabButton.style.display = 'none';
495
- contentSwiper.enabled = false;
496
-
497
- const targetSlide = document.getElementById(`${type}Content`);
498
- targetSlide.innerHTML = `<div class="loading">Loading details...</div>`;
499
 
500
  apiCall(`/api/${type}/${id}`)
501
  .then(item => {
502
  currentItem = item;
503
- let imagesHtml = '';
 
504
  if (item.images && item.images.length > 0) {
505
- imagesHtml = `<div class="detail-images-container">
506
- ${item.images.map(imgPath => `<div class="detail-image-item"><img src="${getHfImageUrl(imgPath)}" alt="Image"></div>`).join('')}
507
- </div>`;
 
 
 
508
  }
509
 
510
- let detailsHtml = `<div class="detail-view"><h2>${item.title || item.name}</h2>${imagesHtml}`;
 
511
  if (type === 'resumes') {
512
  detailsHtml += `
513
  <p><strong>Skills:</strong> ${item.skills || 'N/A'}</p>
514
  <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
515
  <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
516
  <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
517
- ${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank">${item.portfolio_link}</a></p>` : ''}
518
  `;
519
  } else if (type === 'vacancies') {
520
  detailsHtml += `
@@ -535,222 +490,147 @@ MAIN_APP_TEMPLATE = '''
535
  `;
536
  }
537
  detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
538
- targetSlide.innerHTML = detailsHtml;
539
 
540
  if (currentUser && item.user_id === currentUser.id) {
541
  tg.MainButton.setText('Edit My Post');
542
- tg.MainButton.onClick(() => {
543
- tg.HapticFeedback.impactOccurred('medium');
544
- showForm(type, item);
545
- });
546
  tg.MainButton.show();
547
  }
548
  })
549
  .catch(err => {
550
- targetSlide.innerHTML = `<div class="empty-state">Error loading details.</div>`;
551
  });
552
  }
553
 
554
- let currentFiles = [];
555
- let existingImageUrls = [];
556
-
557
- function renderImagePreviews() {
558
- const previewContainer = document.getElementById('imagePreviewContainer');
559
- if (!previewContainer) return;
560
- previewContainer.innerHTML = '';
561
-
562
- existingImageUrls.forEach((url, index) => {
563
- const itemDiv = document.createElement('div');
564
- itemDiv.className = 'image-preview-item';
565
- const img = document.createElement('img');
566
- img.src = getHfImageUrl(url);
567
- const removeBtn = document.createElement('button');
568
- removeBtn.className = 'remove-image-btn';
569
- removeBtn.innerHTML = '×';
570
- removeBtn.onclick = () => {
571
- existingImageUrls.splice(index, 1);
572
- renderImagePreviews();
573
- };
574
- itemDiv.appendChild(img);
575
- itemDiv.appendChild(removeBtn);
576
- previewContainer.appendChild(itemDiv);
577
- });
578
-
579
- currentFiles.forEach((file, index) => {
580
- const itemDiv = document.createElement('div');
581
- itemDiv.className = 'image-preview-item';
582
- const img = document.createElement('img');
583
- img.src = URL.createObjectURL(file);
584
- img.onload = () => URL.revokeObjectURL(img.src);
585
- const removeBtn = document.createElement('button');
586
- removeBtn.className = 'remove-image-btn';
587
- removeBtn.innerHTML = '×';
588
- removeBtn.onclick = () => {
589
- currentFiles.splice(index, 1);
590
- document.getElementById('images').files = new FileListItems(currentFiles); // Update file input
591
- renderImagePreviews();
592
- };
593
- itemDiv.appendChild(img);
594
- itemDiv.appendChild(removeBtn);
595
- previewContainer.appendChild(itemDiv);
596
- });
597
- }
598
-
599
- function FileListItems (files) {
600
- var b = new ClipboardEvent("").clipboardData || new DataTransfer()
601
- for (var i = 0, len = files.length; i<len; i++) b.items.add(files[i])
602
- return b.files
603
- }
604
-
605
- function handleFileSelect(event) {
606
- const files = Array.from(event.target.files);
607
- const totalImages = existingImageUrls.length + currentFiles.length + files.length;
608
- if (totalImages > MAX_IMAGES_ALLOWED) {
609
- tg.showAlert(`You can upload a maximum of ${MAX_IMAGES_ALLOWED} images.`);
610
- // Reset file input to avoid issues with re-selecting same files after error
611
- event.target.value = null;
612
- return;
613
- }
614
- currentFiles.push(...files.slice(0, MAX_IMAGES_ALLOWED - (existingImageUrls.length + currentFiles.length)));
615
- renderImagePreviews();
616
- }
617
-
618
  function showForm(type, itemToEdit = null) {
619
- tg.HapticFeedback.impactOccurred('light');
620
  currentItem = itemToEdit;
621
- currentFiles = [];
622
- existingImageUrls = itemToEdit && itemToEdit.images ? [...itemToEdit.images] : [];
623
 
624
  tg.BackButton.show();
625
  tg.BackButton.onClick(() => {
 
626
  if (itemToEdit) showDetailView(type, itemToEdit.id);
627
- else loadView(type, false);
628
  });
629
- fabButton.style.display = 'none';
630
- contentSwiper.enabled = false;
631
 
632
- const targetSlide = document.getElementById(`${type}Content`);
633
  let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
634
 
635
- const commonImageFields = `
636
- <div class="form-group">
637
- <label for="images">Images (up to ${MAX_IMAGES_ALLOWED})</label>
638
- <input type="file" id="images" multiple accept="image/*">
639
- <div id="imagePreviewContainer" class="image-preview-container"></div>
640
- </div>
641
- `;
642
-
643
  if (type === 'resumes') {
644
  formHtml += `
645
- <div class="form-group">
646
- <label for="name">Full Name</label>
647
- <input type="text" id="name" value="${itemToEdit?.name || ''}" required>
648
- </div>
649
- <div class="form-group">
650
- <label for="title">Job Title / Desired Position</label>
651
- <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
652
- </div>
653
- ${commonImageFields}
654
- <div class="form-group">
655
- <label for="skills">Skills (comma separated)</label>
656
- <textarea id="skills">${itemToEdit?.skills || ''}</textarea>
657
- </div>
658
- <div class="form-group">
659
- <label for="experience">Experience</label>
660
- <textarea id="experience">${itemToEdit?.experience || ''}</textarea>
661
- </div>
662
- <div class="form-group">
663
- <label for="education">Education</label>
664
- <textarea id="education">${itemToEdit?.education || ''}</textarea>
665
- </div>
666
- <div class="form-group">
667
- <label for="contact">Contact Info (e.g., email, or leave blank to use Telegram)</label>
668
- <input type="text" id="contact" value="${itemToEdit?.contact || ''}">
669
- </div>
670
- <div class="form-group">
671
- <label for="portfolio_link">Portfolio Link (optional)</label>
672
- <input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}">
673
- </div>
674
  `;
675
  } else if (type === 'vacancies') {
676
  formHtml += `
677
- <div class="form-group">
678
- <label for="company_name">Company Name</label>
679
- <input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required>
680
- </div>
681
- <div class="form-group">
682
- <label for="title">Job Title</label>
683
- <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
684
- </div>
685
- ${commonImageFields}
686
- <div class="form-group">
687
- <label for="description">Description</label>
688
- <textarea id="description">${itemToEdit?.description || ''}</textarea>
689
- </div>
690
- <div class="form-group">
691
- <label for="requirements">Requirements</label>
692
- <textarea id="requirements">${itemToEdit?.requirements || ''}</textarea>
693
- </div>
694
- <div class="form-group">
695
- <label for="salary">Salary/Compensation</label>
696
- <input type="text" id="salary" value="${itemToEdit?.salary || ''}">
697
- </div>
698
- <div class="form-group">
699
- <label for="location">Location (e.g., Remote, City)</label>
700
- <input type="text" id="location" value="${itemToEdit?.location || ''}">
701
- </div>
702
- <div class="form-group">
703
- <label for="contact">Contact Info / How to Apply</label>
704
- <textarea id="contact">${itemToEdit?.contact || ''}</textarea>
705
- </div>
706
  `;
707
  } else if (type === 'freelance_offers') {
708
  formHtml += `
709
- <div class="form-group">
710
- <label for="title">Project Title</label>
711
- <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
712
- </div>
713
- ${commonImageFields}
714
- <div class="form-group">
715
- <label for="description">Description of Work</label>
716
- <textarea id="description">${itemToEdit?.description || ''}</textarea>
717
- </div>
718
- <div class="form-group">
719
- <label for="budget">Budget</label>
720
- <input type="text" id="budget" value="${itemToEdit?.budget || ''}">
721
- </div>
722
- <div class="form-group">
723
- <label for="deadline">Expected Deadline</label>
724
- <input type="text" id="deadline" value="${itemToEdit?.deadline || ''}">
725
- </div>
726
- <div class="form-group">
727
- <label for="skills_needed">Skills Needed (comma separated)</label>
728
- <textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea>
729
- </div>
730
- <div class="form-group">
731
- <label for="contact">Contact Info (or leave blank to use Telegram)</label>
732
- <input type="text" id="contact" value="${itemToEdit?.contact || ''}">
733
- </div>
734
  `;
735
  }
 
 
 
 
 
 
 
 
736
  formHtml += `<div id="formError" class="error-message"></div></div>`;
737
- targetSlide.innerHTML = formHtml;
738
- document.getElementById('images').addEventListener('change', handleFileSelect);
739
  renderImagePreviews();
740
 
741
-
742
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
743
  tg.MainButton.show();
744
- tg.MainButton.onClick(() => {
745
- tg.HapticFeedback.impactOccurred('medium');
746
- handleSubmit(type, itemToEdit ? itemToEdit.id : null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  });
 
748
  }
749
 
750
- async function handleSubmit(type, itemId = null) {
751
  const payload = {};
752
  let isValid = true;
753
- document.getElementById('formError').textContent = '';
 
754
 
755
  if (type === 'resumes') {
756
  payload.name = document.getElementById('name').value.trim();
@@ -781,41 +661,54 @@ MAIN_APP_TEMPLATE = '''
781
  }
782
 
783
  if (!isValid) {
784
- document.getElementById('formError').textContent = 'Please fill in all required fields.';
785
  tg.HapticFeedback.notificationOccurred('error');
786
  return;
787
  }
788
 
789
  tg.MainButton.showProgress();
790
-
791
- const uploadedImageUrls = [...existingImageUrls];
792
- if (currentFiles.length > 0) {
793
- document.getElementById('formError').textContent = 'Uploading images...';
794
- for (const file of currentFiles) {
795
- const formData = new FormData();
796
- formData.append('file', file);
797
- try {
798
- const uploadResponse = await apiCall('/api/upload_image', 'POST', formData, true);
799
- uploadedImageUrls.push(uploadResponse.url);
800
- } catch (uploadError) {
801
- tg.HapticFeedback.notificationOccurred('error');
802
- tg.MainButton.hideProgress();
803
- document.getElementById('formError').textContent = `Image upload failed: ${uploadError.message}`;
804
- return;
805
  }
806
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  }
808
- payload.images = uploadedImageUrls;
809
-
810
 
 
811
  const method = itemId ? 'PUT' : 'POST';
812
  const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
 
813
 
814
  apiCall(endpoint, method, payload)
815
  .then(response => {
816
  tg.HapticFeedback.notificationOccurred('success');
817
  tg.MainButton.hideProgress();
818
- loadView(type, false);
819
  })
820
  .catch(err => {
821
  tg.HapticFeedback.notificationOccurred('error');
@@ -824,44 +717,83 @@ MAIN_APP_TEMPLATE = '''
824
  });
825
  }
826
 
827
- function updateActiveTab(tabName) {
 
828
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
829
- const activeButton = document.querySelector(`.tab-button[data-tab-id="${tabName}"]`);
830
- if (activeButton) {
831
- activeButton.classList.add('active');
832
- }
833
- }
834
-
835
- function loadView(tabName, fromUserInteraction = true) {
836
- currentViewType = tabName;
837
- if(fromUserInteraction) tg.HapticFeedback.selectionChanged();
838
- updateActiveTab(tabName);
839
-
840
- const targetSlide = document.getElementById(`${tabName}Content`);
841
- targetSlide.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
842
 
 
843
  tg.BackButton.hide();
844
  tg.MainButton.hide();
845
- fabButton.style.display = 'block';
846
- contentSwiper.enabled = true;
847
- if (fromUserInteraction) {
848
- contentSwiper.slideTo(tabNameToIndex[tabName]);
849
- }
850
-
851
 
852
  apiCall(`/api/${tabName}`)
853
  .then(data => renderList(data, tabName))
854
  .catch(err => {
855
- targetSlide.innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`;
856
  });
857
  }
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  async function init() {
860
  tg.ready();
861
  applyThemeParams();
862
  tg.expand();
863
  tg.enableClosingConfirmation();
864
- fabButton = document.getElementById('fabButton');
865
 
866
  document.getElementById('userInfo').textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}! (@${tg.initDataUnsafe.user?.username || 'anonymous'})`;
867
 
@@ -869,55 +801,26 @@ MAIN_APP_TEMPLATE = '''
869
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
870
  currentUser = authResponse.user;
871
  if (currentUser) {
872
- document.getElementById('userInfo').textContent = `Logged in as: ${currentUser.first_name} (@${currentUser.username})`;
873
  }
874
  } catch (error) {
875
  console.error("Auth error:", error);
876
  document.getElementById('userInfo').textContent = `Auth failed. Limited functionality.`;
877
- tg.showAlert("Authentication with the server failed. Some features might not work correctly.");
878
  }
879
 
880
- contentSwiper = new Swiper('#contentSwiper', {
881
- initialSlide: 0,
882
- autoHeight: false, // Important: slides manage their own height and scroll
883
- on: {
884
- slideChange: function () {
885
- const newTabIndex = this.activeIndex;
886
- const newTabName = tabConfig[newTabIndex];
887
- if (newTabName && currentViewType !== newTabName) {
888
- // Only call loadView if the content for this tab isn't already being displayed or hasn't been loaded yet.
889
- // For simplicity, we might just always call it or check if the slide is empty.
890
- const targetSlide = document.getElementById(`${newTabName}Content`);
891
- if (targetSlide.querySelector('.loading') || targetSlide.querySelector('.empty-state') || currentViewType !== newTabName) {
892
- loadView(newTabName, false); // fromUserInteraction is false as swiper initiated it
893
- } else {
894
- // If content already loaded, just update tab state
895
- currentViewType = newTabName;
896
- updateActiveTab(newTabName);
897
- tg.BackButton.hide();
898
- tg.MainButton.hide();
899
- fabButton.style.display = 'block';
900
- contentSwiper.enabled = true;
901
- }
902
- }
903
- },
904
- },
905
- });
906
-
907
-
908
  document.querySelectorAll('.tab-button').forEach(button => {
909
  button.addEventListener('click', () => {
910
- const tabId = button.dataset.tabId;
911
- contentSwiper.slideTo(parseInt(button.dataset.tabIndex));
912
- // Swiper's slideChange event will handle calling loadView or updating state
913
  });
914
  });
915
- fabButton.addEventListener('click', () => {
916
  tg.HapticFeedback.impactOccurred('medium');
917
- showForm(currentViewType);
918
  });
919
 
920
- loadView('resumes', false); // Initial load
 
921
  }
922
 
923
  init();
@@ -942,8 +845,6 @@ ADMIN_TEMPLATE = '''
942
  .item:last-child { border-bottom: none; }
943
  .item h3 { margin: 0 0 5px 0; }
944
  .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
945
- .item-images { margin-top: 5px; }
946
- .item-images img { max-width: 50px; max-height: 50px; margin-right: 5px; border-radius: 3px; border: 1px solid #ccc; }
947
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
948
  .button-primary { background-color: #007bff; color: white; }
949
  .button-danger { background-color: #dc3545; color: white; }
@@ -969,16 +870,16 @@ ADMIN_TEMPLATE = '''
969
 
970
  <div class="section">
971
  <h2>Data Synchronization with Hugging Face</h2>
972
- <p><strong>Repo ID:</strong> {{ HF_REPO_ID_JS }}</p>
973
  <div class="sync-buttons">
974
- <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data file ({{ DATA_FILE_JS }}) to Hugging Face? This will overwrite the remote data file.');">
975
- <button type="submit" class="button button-primary">Upload {{ DATA_FILE_JS }} to HF</button>
976
  </form>
977
- <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data file ({{ DATA_FILE_JS }}) from Hugging Face? This will overwrite the local data file.');">
978
- <button type="submit" class="button button-secondary">Download {{ DATA_FILE_JS }} from HF</button>
979
  </form>
980
  </div>
981
- <p style="font-size: 0.8em; color: #666;">Image files are uploaded individually when items are created/edited. The buttons above only sync the main JSON data file. Automatic backup of {{ DATA_FILE_JS }} runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
 
982
  </div>
983
 
984
  <div class="section">
@@ -987,16 +888,8 @@ ADMIN_TEMPLATE = '''
987
  <div class="item">
988
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
989
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
990
- <p>Posted: {{ resume.timestamp }}</p>
991
- {% if resume.images %}
992
- <div class="item-images">
993
- {% for img_path in resume.images %}
994
- <a href="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" target="_blank">
995
- <img src="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" alt="Image">
996
- </a>
997
- {% endfor %}
998
- </div>
999
- {% endif %}
1000
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
1001
  <input type="hidden" name="item_type" value="resumes">
1002
  <input type="hidden" name="item_id" value="{{ resume.id }}">
@@ -1014,16 +907,8 @@ ADMIN_TEMPLATE = '''
1014
  <div class="item">
1015
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
1016
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
1017
- <p>Posted: {{ vacancy.timestamp }}</p>
1018
- {% if vacancy.images %}
1019
- <div class="item-images">
1020
- {% for img_path in vacancy.images %}
1021
- <a href="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" target="_blank">
1022
- <img src="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" alt="Image">
1023
- </a>
1024
- {% endfor %}
1025
- </div>
1026
- {% endif %}
1027
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
1028
  <input type="hidden" name="item_type" value="vacancies">
1029
  <input type="hidden" name="item_id" value="{{ vacancy.id }}">
@@ -1042,16 +927,8 @@ ADMIN_TEMPLATE = '''
1042
  <h3>{{ offer.title }}</h3>
1043
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
1044
  <p>Budget: {{ offer.budget }}</p>
1045
- <p>Posted: {{ offer.timestamp }}</p>
1046
- {% if offer.images %}
1047
- <div class="item-images">
1048
- {% for img_path in offer.images %}
1049
- <a href="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" target="_blank">
1050
- <img src="https://huggingface.co/datasets/{{ HF_REPO_ID_JS }}/resolve/main/{{ img_path }}" alt="Image">
1051
- </a>
1052
- {% endfor %}
1053
- </div>
1054
- {% endif %}
1055
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');">
1056
  <input type="hidden" name="item_type" value="freelance_offers">
1057
  <input type="hidden" name="item_id" value="{{ offer.id }}">
@@ -1067,9 +944,20 @@ ADMIN_TEMPLATE = '''
1067
  </html>
1068
  '''
1069
 
 
 
 
 
 
 
 
 
 
 
 
1070
  @app.route('/')
1071
  def main_app_view():
1072
- return render_template_string(MAIN_APP_TEMPLATE, HF_REPO_ID_JS=REPO_ID, MAX_IMAGES=MAX_IMAGES)
1073
 
1074
  @app.route('/api/auth_user', methods=['POST'])
1075
  def auth_user():
@@ -1081,42 +969,40 @@ def auth_user():
1081
  else:
1082
  return jsonify({"error": "Authentication data not provided"}), 401
1083
 
1084
- is_valid, user_data_tg = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1085
 
1086
- if not is_valid or not user_data_tg:
 
1087
  return jsonify({"error": "Invalid authentication data"}), 403
1088
 
1089
  data = load_data()
1090
  users = data.get('users', {})
1091
- user_id_str = str(user_data_tg.get('id'))
1092
 
1093
  if user_id_str not in users:
1094
  users[user_id_str] = {
1095
- 'id': user_data_tg.get('id'),
1096
- 'first_name': user_data_tg.get('first_name'),
1097
- 'last_name': user_data_tg.get('last_name'),
1098
- 'username': user_data_tg.get('username'),
1099
- 'language_code': user_data_tg.get('language_code'),
1100
- 'photo_url': user_data_tg.get('photo_url'),
1101
  'first_seen': datetime.now().isoformat()
1102
  }
1103
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
1104
- if user_data_tg.get('username'): users[user_id_str]['username'] = user_data_tg.get('username') # Update username if changed
1105
-
1106
  data['users'] = users
1107
  save_data(data)
1108
 
1109
  return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
1110
 
1111
- def get_authenticated_user(request_obj): # Changed to pass request object
1112
  auth_data_str = request_obj.headers.get('X-Telegram-Auth')
1113
  if not auth_data_str:
1114
  return None
1115
- is_valid, user_data_tg = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1116
- if is_valid and user_data_tg:
1117
- data = load_data() # Load current user data from our DB
1118
- user_id_str = str(user_data_tg.get('id'))
1119
- return data.get('users', {}).get(user_id_str) # Return our stored user object
1120
  return None
1121
 
1122
  @app.route('/api/upload_image', methods=['POST'])
@@ -1125,42 +1011,45 @@ def upload_image():
1125
  if not user:
1126
  return jsonify({"error": "Authentication required"}), 401
1127
 
1128
- if 'file' not in request.files:
1129
- return jsonify({"error": "No file part"}), 400
 
 
 
 
 
 
 
1130
 
1131
- file = request.files['file']
1132
- if file.filename == '':
1133
- return jsonify({"error": "No selected file"}), 400
1134
-
1135
- if file:
1136
- original_filename = secure_filename(file.filename)
1137
- file_extension = os.path.splitext(original_filename)[1]
1138
- unique_filename = f"{uuid.uuid4()}{file_extension}"
1139
-
1140
- local_folder_path = os.path.join(app.root_path, UPLOADS_FOLDER_NAME)
1141
- ensure_uploads_folder_exists() # Make sure it exists
1142
- local_file_path = os.path.join(local_folder_path, unique_filename)
1143
-
1144
- try:
1145
- file.save(local_file_path)
1146
-
1147
- path_in_repo = f"{UPLOADS_FOLDER_NAME}/{unique_filename}"
1148
 
1149
- if upload_file_to_hf_direct(local_file_path, path_in_repo, commit_msg_prefix="Upload image"):
1150
- os.remove(local_file_path) # Clean up local file after successful HF upload
1151
- return jsonify({"message": "File uploaded successfully to HF", "url": path_in_repo}), 201
1152
- else:
1153
- # If HF upload fails, still try to clean up local file, but report error
1154
- if os.path.exists(local_file_path):
1155
- os.remove(local_file_path)
1156
- return jsonify({"error": "Failed to upload file to Hugging Face"}), 500
1157
- except Exception as e:
1158
- logging.error(f"Error during image upload process: {e}", exc_info=True)
1159
- if os.path.exists(local_file_path): # Cleanup if error occurred after save but before/during HF upload
1160
- os.remove(local_file_path)
1161
- return jsonify({"error": f"An error occurred: {str(e)}"}), 500
1162
-
1163
- return jsonify({"error": "File processing failed"}), 500
 
1164
 
1165
 
1166
  @app.route('/api/<item_type>', methods=['GET'])
@@ -1194,21 +1083,16 @@ def create_item(item_type):
1194
  if not req_data:
1195
  return jsonify({"error": "No data provided"}), 400
1196
 
1197
- image_urls = req_data.get('images', [])
1198
- if not isinstance(image_urls, list) or len(image_urls) > MAX_IMAGES:
1199
- return jsonify({"error": f"Invalid image data or too many images (max {MAX_IMAGES})"}), 400
1200
-
1201
- # Sanitize image URLs (basic check for now)
1202
- valid_image_urls = [url for url in image_urls if isinstance(url, str) and url.startswith(UPLOADS_FOLDER_NAME + "/")]
1203
-
1204
-
1205
  new_item = {
1206
  "id": str(uuid.uuid4()),
1207
- "user_id": user.get('id'),
1208
- "user_telegram_username": user.get('username', 'unknown'),
1209
  "timestamp": datetime.now().isoformat(),
1210
- "images": valid_image_urls
1211
  }
 
 
 
1212
 
1213
  if item_type == 'resumes':
1214
  required_fields = ['name', 'title']
@@ -1256,12 +1140,6 @@ def update_item(item_type, item_id):
1256
  req_data = request.json
1257
  if not req_data: return jsonify({"error": "No data provided"}), 400
1258
 
1259
- image_urls = req_data.get('images', [])
1260
- if not isinstance(image_urls, list) or len(image_urls) > MAX_IMAGES:
1261
- return jsonify({"error": f"Invalid image data or too many images (max {MAX_IMAGES})"}), 400
1262
- valid_image_urls = [url for url in image_urls if isinstance(url, str) and url.startswith(UPLOADS_FOLDER_NAME + "/")]
1263
-
1264
-
1265
  data = load_data()
1266
  items_list = data.get(item_type, [])
1267
  item_index = -1
@@ -1278,7 +1156,10 @@ def update_item(item_type, item_id):
1278
 
1279
  updated_item = original_item.copy()
1280
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1281
- updated_item['images'] = valid_image_urls # Update images
 
 
 
1282
 
1283
  if item_type == 'resumes':
1284
  updated_item.update({
@@ -1330,11 +1211,11 @@ def delete_item(item_type, item_id):
1330
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1331
 
1332
  if str(item_to_delete.get('user_id')) != str(user.get('id')):
1333
- # Allow admin to delete later if needed by checking a role, for now, only owner.
1334
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
1335
-
1336
- # Note: Image files on HF are not deleted here. This would require more complex logic.
1337
- # For now, they will be orphaned.
1338
 
1339
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1340
 
@@ -1351,9 +1232,7 @@ def admin_panel():
1351
  resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1352
  vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1353
  freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1354
- HF_REPO_ID_JS=REPO_ID,
1355
- DATA_FILE_JS=DATA_FILE
1356
- )
1357
 
1358
  @app.route('/admin/delete', methods=['POST'])
1359
  def admin_delete_item():
@@ -1368,9 +1247,18 @@ def admin_delete_item():
1368
  items_list = data.get(item_type, [])
1369
  original_length = len(items_list)
1370
 
 
1371
  # item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1372
- # Images associated with this item are NOT deleted from HF in this admin action.
1373
-
 
 
 
 
 
 
 
 
1374
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1375
 
1376
  if len(data[item_type]) < original_length:
@@ -1382,12 +1270,10 @@ def admin_delete_item():
1382
 
1383
  @app.route('/admin/force_upload', methods=['POST'])
1384
  def force_upload_admin():
1385
- logging.info("Admin forcing upload of data file to Hugging Face...")
1386
  try:
1387
- if upload_data_file_to_hf():
1388
- flash(f"Data file {DATA_FILE} successfully uploaded to Hugging Face.", 'success')
1389
- else:
1390
- flash(f"Failed to upload {DATA_FILE} to Hugging Face. Check logs.", 'error')
1391
  except Exception as e:
1392
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1393
  flash(f"Error uploading to Hugging Face: {e}", 'error')
@@ -1395,13 +1281,13 @@ def force_upload_admin():
1395
 
1396
  @app.route('/admin/force_download', methods=['POST'])
1397
  def force_download_admin():
1398
- logging.info("Admin forcing download of data file from Hugging Face...")
1399
  try:
1400
- if download_db_from_hf(specific_file=DATA_FILE):
1401
- flash(f"Data file {DATA_FILE} successfully downloaded from Hugging Face. Local file updated.", 'success')
1402
  load_data()
1403
  else:
1404
- flash(f"Failed to download {DATA_FILE} from Hugging Face. Check logs.", 'error')
1405
  except Exception as e:
1406
  logging.error(f"Error during forced download: {e}", exc_info=True)
1407
  flash(f"Error downloading from Hugging Face: {e}", 'error')
@@ -1409,23 +1295,18 @@ def force_download_admin():
1409
 
1410
 
1411
  if __name__ == '__main__':
1412
- logging.info("Application starting up...")
1413
-
1414
- ensure_uploads_folder_exists()
1415
-
1416
- logging.info("Performing initial data file download (if available on HF)...")
1417
- download_db_from_hf(specific_file=DATA_FILE)
1418
-
1419
- logging.info("Loading initial data...")
1420
  load_data()
1421
- logging.info("Initial data load sequence complete.")
1422
 
1423
  if HF_TOKEN_WRITE:
1424
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1425
  backup_thread.start()
1426
- logging.info("Periodic backup thread for data file started.")
1427
  else:
1428
- logging.warning(f"Periodic backup of {DATA_FILE} will NOT run (HF_TOKEN_WRITE not set).")
1429
 
1430
  port = int(os.environ.get('PORT', 7860))
1431
  logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
 
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
  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:
 
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...")
 
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
113
  logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
114
  while True:
115
  time.sleep(backup_interval)
116
  logging.info("Starting periodic backup...")
117
+ upload_db_to_hf()
118
  logging.info("Periodic backup finished.")
119
 
120
  def load_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:
 
212
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
213
  <title>TonTalent</title>
214
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
 
 
215
  <style>
216
  :root {
217
  --tg-theme-bg-color: #ffffff;
 
236
  overscroll-behavior-y: none;
237
  -webkit-font-smoothing: antialiased;
238
  -moz-osx-font-smoothing: grayscale;
 
 
 
 
239
  }
240
+ .app-container { display: flex; flex-direction: column; min-height: 100vh; }
241
  .header {
242
  background-color: var(--tg-theme-header-bg-color);
243
  padding: 10px 15px;
 
249
  top: 0;
250
  z-index: 100;
251
  }
252
+ .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; }
253
  .tab-button {
254
  flex: 1;
255
  padding: 10px;
 
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
  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"] {
 
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;
 
326
  z-index: 1000;
327
  border: none;
328
  }
329
+ .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
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>
 
352
  <div class="header">TonTalent</div>
353
  <div class="user-info" id="userInfo">Loading user...</div>
354
  <div class="tabs">
355
+ <button class="tab-button active" data-tab="resumes">Resumes</button>
356
+ <button class="tab-button" data-tab="vacancies">Vacancies</button>
357
+ <button class="tab-button" data-tab="freelance_offers">Freelance</button>
358
  </div>
359
+ <div class="content" id="mainContent">
360
+ <div class="loading">Loading content...</div>
 
 
 
 
 
 
 
361
  </div>
362
+ <button class="fab" id="fabButton" title="Add New Item">+</button>
363
  </div>
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');
 
387
  document.documentElement.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
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) {
 
408
  }
409
  }
410
 
 
 
 
 
 
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 += `
 
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
  }
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();
711
+ loadView(type);
712
  })
713
  .catch(err => {
714
  tg.HapticFeedback.notificationOccurred('error');
 
717
  });
718
  }
719
 
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() {
793
  tg.ready();
794
  applyThemeParams();
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
 
 
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
 
826
  init();
 
845
  .item:last-child { border-bottom: none; }
846
  .item h3 { margin: 0 0 5px 0; }
847
  .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
 
 
848
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
849
  .button-primary { background-color: #007bff; color: white; }
850
  .button-danger { background-color: #dc3545; color: white; }
 
870
 
871
  <div class="section">
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
  <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
  <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
  <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
  </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
  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()
 
 
993
  data['users'] = users
994
  save_data(data)
995
 
996
  return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
997
 
998
+ 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'])
 
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'])
 
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':
1098
  required_fields = ['name', 'title']
 
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
 
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':
1165
  updated_item.update({
 
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
 
 
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():
 
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:
 
1270
 
1271
  @app.route('/admin/force_upload', methods=['POST'])
1272
  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')
 
1281
 
1282
  @app.route('/admin/force_download', methods=['POST'])
1283
  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')
 
1295
 
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
 
1304
  if HF_TOKEN_WRITE:
1305
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1306
  backup_thread.start()
1307
+ logging.info("Periodic backup thread started.")
1308
  else:
1309
+ logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
1310
 
1311
  port = int(os.environ.get('PORT', 7860))
1312
  logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")