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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +550 -624
app.py CHANGED
@@ -5,7 +5,7 @@ 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
@@ -18,31 +18,47 @@ load_dotenv()
18
 
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:
@@ -59,129 +75,172 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
59
  success = True
60
  break
61
  except RepositoryNotFoundError:
62
- logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
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...")
79
  except Exception as e:
80
- logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
81
  if attempt < retries: time.sleep(delay)
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():
@@ -190,7 +249,7 @@ def periodic_backup():
190
  while True:
191
  time.sleep(backup_interval)
192
  logging.info("Starting periodic backup...")
193
- upload_db_to_hf()
194
  logging.info("Periodic backup finished.")
195
 
196
  def load_data():
@@ -208,7 +267,7 @@ def load_data():
208
  except (FileNotFoundError, json.JSONDecodeError) as e:
209
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
210
 
211
- if download_db_from_hf(specific_file=DATA_FILE):
212
  try:
213
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
214
  data = json.load(file)
@@ -244,7 +303,14 @@ def save_data(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
 
@@ -252,7 +318,12 @@ 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:
257
  return False, None
258
 
@@ -276,6 +347,7 @@ def verify_telegram_auth_data(auth_data_str, bot_token):
276
  return False, None
277
  return False, None
278
 
 
279
  MAIN_APP_TEMPLATE = '''
280
  <!DOCTYPE html>
281
  <html lang="en">
@@ -286,132 +358,44 @@ MAIN_APP_TEMPLATE = '''
286
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
287
  <style>
288
  :root {
289
- --tg-theme-bg-color: #ffffff;
290
- --tg-theme-text-color: #000000;
291
- --tg-theme-hint-color: #999999;
292
- --tg-theme-link-color: #007aff;
293
- --tg-theme-button-color: #007aff;
294
- --tg-theme-button-text-color: #ffffff;
295
- --tg-theme-secondary-bg-color: #f0f0f0;
296
- --tg-theme-header-bg-color: #efeff4;
297
- --tg-theme-section-bg-color: #ffffff;
298
- --tg-theme-section-header-text-color: #8e8e93;
299
- --tg-theme-destructive-text-color: #ff3b30;
300
- --tg-theme-accent-text-color: #007aff;
301
- }
302
- body {
303
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
304
- margin: 0;
305
- padding: 0;
306
- background-color: var(--tg-theme-bg-color);
307
- color: var(--tg-theme-text-color);
308
- overscroll-behavior-y: none;
309
- -webkit-font-smoothing: antialiased;
310
- -moz-osx-font-smoothing: grayscale;
311
  }
 
312
  .app-container { display: flex; flex-direction: column; min-height: 100vh; }
313
- .header {
314
- background-color: var(--tg-theme-header-bg-color);
315
- padding: 10px 15px;
316
- text-align: center;
317
- font-weight: 600;
318
- font-size: 17px;
319
- border-bottom: 1px solid var(--tg-theme-secondary-bg-color);
320
- position: sticky;
321
- top: 0;
322
- z-index: 100;
323
- }
324
  .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; }
325
- .tab-button {
326
- flex: 1;
327
- padding: 10px;
328
- text-align: center;
329
- cursor: pointer;
330
- background: none;
331
- border: none;
332
- color: var(--tg-theme-hint-color);
333
- font-size: 15px;
334
- font-weight: 500;
335
- border-bottom: 2px solid transparent;
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;
343
- padding: 12px 15px;
344
- margin-bottom: 10px;
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);
377
- border-radius: 6px;
378
- font-size: 16px;
379
- background-color: var(--tg-theme-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;
390
- right: 20px;
391
- width: 56px;
392
- height: 56px;
393
- background-color: var(--tg-theme-button-color);
394
- color: var(--tg-theme-button-text-color);
395
- border-radius: 50%;
396
- display: flex;
397
- align-items: center;
398
- justify-content: center;
399
- font-size: 24px;
400
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
401
- cursor: pointer;
402
- z-index: 1000;
403
- border: none;
404
- }
405
  .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
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; }
@@ -437,41 +421,39 @@ MAIN_APP_TEMPLATE = '''
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');
444
- document.documentElement.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
445
- document.documentElement.style.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
446
- document.documentElement.style.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
447
- document.documentElement.style.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
448
- document.documentElement.style.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
449
- document.documentElement.style.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
450
- document.documentElement.style.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
451
- document.documentElement.style.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
452
- document.documentElement.style.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
453
- document.documentElement.style.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
454
- document.documentElement.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
 
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,111 +466,98 @@ MAIN_APP_TEMPLATE = '''
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');
@@ -598,120 +567,98 @@ MAIN_APP_TEMPLATE = '''
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 = {};
@@ -755,18 +702,21 @@ MAIN_APP_TEMPLATE = '''
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,14 +732,15 @@ MAIN_APP_TEMPLATE = '''
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>\`;
@@ -800,25 +751,37 @@ MAIN_APP_TEMPLATE = '''
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() {
820
  tg.ready();
821
  applyThemeParams();
 
822
  tg.expand();
823
  tg.enableClosingConfirmation();
824
 
@@ -833,24 +796,19 @@ MAIN_APP_TEMPLATE = '''
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
  }
@@ -903,14 +861,14 @@ ADMIN_TEMPLATE = '''
903
  <div class="section">
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,9 +877,9 @@ ADMIN_TEMPLATE = '''
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 }}">
927
  <button type="submit" class="button button-danger">Delete</button>
@@ -938,9 +896,9 @@ ADMIN_TEMPLATE = '''
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 }}">
946
  <button type="submit" class="button button-danger">Delete</button>
@@ -958,9 +916,9 @@ ADMIN_TEMPLATE = '''
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 }}">
966
  <button type="submit" class="button button-danger">Delete</button>
@@ -980,8 +938,9 @@ 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,41 +952,40 @@ 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()
1016
  data['users'] = users
1017
- save_data(data)
1018
 
1019
  return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
1020
 
1021
- def get_authenticated_user(request_obj):
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):
1033
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
@@ -1036,7 +994,6 @@ 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,19 +1004,21 @@ def get_item(item_type, item_id):
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)
1054
- if not user:
1055
- return jsonify({"error": "Authentication required"}), 401
1056
 
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()),
@@ -1069,189 +1028,170 @@ def create_item(item_type):
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':
1092
- required_fields = ['name', 'title']
1093
- for field in required_fields:
1094
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1095
- new_item.update({
1096
- "name": req_data.get('name'), "title": req_data.get('title'),
1097
- "skills": req_data.get('skills', ''), "experience": req_data.get('experience', ''),
1098
- "education": req_data.get('education', ''), "contact": req_data.get('contact', ''),
1099
- "portfolio_link": req_data.get('portfolio_link', '')
1100
- })
1101
  elif item_type == 'vacancies':
1102
- required_fields = ['company_name', 'title']
1103
- for field in required_fields:
1104
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1105
- new_item.update({
1106
- "company_name": req_data.get('company_name'), "title": req_data.get('title'),
1107
- "description": req_data.get('description', ''), "requirements": req_data.get('requirements', ''),
1108
- "salary": req_data.get('salary', ''), "location": req_data.get('location', ''),
1109
- "contact": req_data.get('contact', '')
1110
- })
1111
  elif item_type == 'freelance_offers':
1112
- required_fields = ['title']
1113
- for field in required_fields:
1114
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1115
- new_item.update({
1116
- "title": req_data.get('title'), "description": req_data.get('description', ''),
1117
- "budget": req_data.get('budget', ''), "deadline": req_data.get('deadline', ''),
1118
- "skills_needed": req_data.get('skills_needed', ''), "contact": req_data.get('contact', '')
1119
- })
1120
 
1121
  data = load_data()
1122
  data[item_type].append(new_item)
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)
1130
  if not user: return jsonify({"error": "Authentication required"}), 401
1131
 
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':
1190
- updated_item.update({
1191
- "name": req_data.get('name', original_item.get('name')),
1192
- "title": req_data.get('title', original_item.get('title')),
1193
- "skills": req_data.get('skills', original_item.get('skills')),
1194
- "experience": req_data.get('experience', original_item.get('experience')),
1195
- "education": req_data.get('education', original_item.get('education')),
1196
- "contact": req_data.get('contact', original_item.get('contact')),
1197
- "portfolio_link": req_data.get('portfolio_link', original_item.get('portfolio_link'))
1198
- })
1199
  elif item_type == 'vacancies':
1200
- updated_item.update({
1201
- "company_name": req_data.get('company_name', original_item.get('company_name')),
1202
- "title": req_data.get('title', original_item.get('title')),
1203
- "description": req_data.get('description', original_item.get('description')),
1204
- "requirements": req_data.get('requirements', original_item.get('requirements')),
1205
- "salary": req_data.get('salary', original_item.get('salary')),
1206
- "location": req_data.get('location', original_item.get('location')),
1207
- "contact": req_data.get('contact', original_item.get('contact'))
1208
- })
1209
  elif item_type == 'freelance_offers':
1210
- updated_item.update({
1211
- "title": req_data.get('title', original_item.get('title')),
1212
- "description": req_data.get('description', original_item.get('description')),
1213
- "budget": req_data.get('budget', original_item.get('budget')),
1214
- "deadline": req_data.get('deadline', original_item.get('deadline')),
1215
- "skills_needed": req_data.get('skills_needed', original_item.get('skills_needed')),
1216
- "contact": req_data.get('contact', original_item.get('contact'))
1217
- })
1218
 
1219
  data[item_type][item_index] = updated_item
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)
1227
- if not user: return jsonify({"error": "Authentication required"}), 401
1228
-
1229
- if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1230
- return jsonify({"error": "Invalid item type"}), 400
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'])
@@ -1271,34 +1211,20 @@ def admin_delete_item():
1271
  flash('Invalid item type or ID for deletion.', 'error')
1272
  return redirect(url_for('admin_panel'))
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'))
1295
 
1296
  @app.route('/admin/force_upload', methods=['POST'])
1297
  def force_upload_admin():
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')
@@ -1306,13 +1232,13 @@ def force_upload_admin():
1306
 
1307
  @app.route('/admin/force_download', methods=['POST'])
1308
  def force_download_admin():
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')
 
5
  import threading
6
  import time
7
  from datetime import datetime
8
+ from huggingface_hub import HfApi, hf_hub_download, list_repo_files
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError, EntryNotFoundError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
 
18
 
19
  app = Flask(__name__)
20
  app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
21
+
22
  DATA_FILE = 'tontalent_data.json'
23
+ UPLOADS_DIR_NAME = 'uploads' # Relative to project root
24
+ app.config['UPLOAD_FOLDER'] = UPLOADS_DIR_NAME
25
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
26
+
27
+ MAX_IMAGE_FILES = 10
28
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
29
 
30
+ SYNC_FILES = [DATA_FILE] # Main data file, images handled separately based on this file
31
 
32
  REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
33
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
34
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
35
 
36
+ TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8" # Replace with your actual bot token
37
 
38
  DOWNLOAD_RETRIES = 3
39
  DOWNLOAD_DELAY = 5
40
 
41
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
42
 
43
+ def allowed_file(filename):
44
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
45
+
46
+ def _get_all_image_paths_from_data(data_dict):
47
+ image_paths = set()
48
+ for item_type_key in ['resumes', 'vacancies', 'freelance_offers']:
49
+ for item in data_dict.get(item_type_key, []):
50
+ for img_path in item.get('images', []):
51
+ if img_path.startswith(UPLOADS_DIR_NAME + "/"): # Ensure it's a path we manage
52
+ image_paths.add(img_path)
53
+ return list(image_paths)
54
+
55
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
56
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
57
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
58
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
59
+
 
60
  files_to_download = [specific_file] if specific_file else SYNC_FILES
61
+ logging.info(f"Attempting download for primary files {files_to_download} from {REPO_ID}...")
62
  all_successful = True
63
 
64
  for file_name in files_to_download:
 
75
  success = True
76
  break
77
  except RepositoryNotFoundError:
78
+ logging.error(f"Repository {REPO_ID} not found. Download cancelled.")
79
+ return False
80
+ except (HfHubHTTPError, EntryNotFoundError) as e:
81
+ is_404 = isinstance(e, EntryNotFoundError) or (isinstance(e, HfHubHTTPError) and e.response.status_code == 404)
82
+ if is_404:
83
+ logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
84
  if attempt == 0 and not os.path.exists(file_name):
85
  try:
86
  if file_name == DATA_FILE:
87
  with open(file_name, 'w', encoding='utf-8') as f:
88
  json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
89
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
90
+ success = True # Created locally, treat as success for this file
91
  except Exception as create_e:
92
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
93
  break
94
  else:
95
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
96
  except Exception as e:
97
+ logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
98
  if attempt < retries: time.sleep(delay)
99
  if not success:
100
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
101
  all_successful = False
102
+
103
+ if not all_successful:
104
+ logging.error("Not all primary files downloaded successfully. Image download might be skipped or incomplete.")
105
+ return False
106
+
107
+ # Download images if DATA_FILE was part of the download or no specific file was requested (full sync)
108
+ if (specific_file is None or specific_file == DATA_FILE) and os.path.exists(DATA_FILE):
109
+ logging.info("Downloading referenced image files...")
110
  try:
111
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
112
+ data_content = json.load(f)
113
+ image_paths_in_data = _get_all_image_paths_from_data(data_content)
114
+
115
+ for img_repo_path in image_paths_in_data:
116
+ local_img_full_path = os.path.join(app.root_path, img_repo_path)
117
+ os.makedirs(os.path.dirname(local_img_full_path), exist_ok=True)
118
+ img_success = False
119
+ for attempt in range(retries + 1):
120
+ try:
121
+ logging.info(f"Downloading image {img_repo_path} (Attempt {attempt+1})")
122
+ hf_hub_download(
123
+ repo_id=REPO_ID, filename=img_repo_path, repo_type="dataset",
124
+ token=token_to_use, local_dir=".", local_dir_use_symlinks=False, # local_dir="." means files go into UPLOADS_DIR_NAME/
125
+ force_download=True, resume_download=False
126
+ )
127
+ logging.info(f"Successfully downloaded image {img_repo_path}.")
128
+ img_success = True
129
+ break
130
+ except (HfHubHTTPError, EntryNotFoundError) as e_img:
131
+ is_404_img = isinstance(e_img, EntryNotFoundError) or (isinstance(e_img, HfHubHTTPError) and e_img.response.status_code == 404)
132
+ if is_404_img:
133
+ logging.warning(f"Image {img_repo_path} not found on HF (404). Skipping.")
 
 
 
 
 
 
 
 
 
134
  break
135
+ else:
136
+ logging.error(f"HTTP error downloading image {img_repo_path}: {e_img}. Retrying...")
137
+ except Exception as e_img:
138
+ logging.error(f"Unexpected error downloading image {img_repo_path}: {e_img}. Retrying...", exc_info=True)
139
+ if attempt < retries: time.sleep(delay)
140
+ if not img_success:
141
+ logging.error(f"Failed to download image {img_repo_path} after multiple attempts.")
142
+ all_successful = False # Mark overall sync as potentially incomplete
143
  except Exception as e:
144
+ logging.error(f"Error processing or downloading images: {e}", exc_info=True)
145
+ all_successful = False
146
+
147
+ logging.info(f"Download process finished. Overall success: {all_successful}")
148
  return all_successful
149
 
150
 
151
+ def upload_db_to_hf(specific_file_local_path=None, specific_file_repo_path=None):
152
  if not HF_TOKEN_WRITE:
153
  logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
154
  return
155
+
156
  try:
157
+ api = HfApi()
158
+
159
+ files_to_upload_map = {} # {local_path: repo_path}
160
+
161
+ if specific_file_local_path and specific_file_repo_path:
162
+ if os.path.exists(specific_file_local_path):
163
+ files_to_upload_map[specific_file_local_path] = specific_file_repo_path
164
+ else:
165
+ logging.warning(f"Specific file {specific_file_local_path} not found locally for upload.")
166
+ else: # Full sync
167
+ for file_name in SYNC_FILES: # DATA_FILE
168
+ if os.path.exists(file_name):
169
+ files_to_upload_map[file_name] = file_name
170
+
171
+ # Add images referenced in DATA_FILE for full sync
172
+ if os.path.exists(DATA_FILE):
173
+ try:
174
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
175
+ data_content = json.load(f)
176
+ image_paths_in_data = _get_all_image_paths_from_data(data_content)
177
+ for img_repo_path in image_paths_in_data: # img_repo_path is like 'uploads/file.jpg'
178
+ local_img_path = os.path.join(app.root_path, img_repo_path)
179
+ if os.path.exists(local_img_path):
180
+ files_to_upload_map[local_img_path] = img_repo_path
181
+ else:
182
+ logging.warning(f"Image {local_img_path} (referenced in data) not found locally, skipping upload.")
183
+ except Exception as e:
184
+ logging.error(f"Error reading DATA_FILE for image paths during upload: {e}")
185
+
186
+ if not files_to_upload_map:
187
+ logging.info("No files to upload.")
188
+ return
189
 
190
+ logging.info(f"Starting upload of {len(files_to_upload_map)} file(s) to HF repo {REPO_ID}...")
191
+ for local_path, repo_path_in_repo in files_to_upload_map.items():
 
 
192
  try:
193
+ logging.info(f"Uploading {local_path} to {repo_path_in_repo}...")
194
  api.upload_file(
195
+ path_or_fileobj=local_path, path_in_repo=repo_path_in_repo, repo_id=REPO_ID,
196
  repo_type="dataset", token=HF_TOKEN_WRITE,
197
+ commit_message=f"Sync {os.path.basename(local_path)} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  )
199
+ logging.info(f"File {local_path} successfully uploaded to {repo_path_in_repo}.")
200
  except Exception as e:
201
+ logging.error(f"Error uploading file {local_path} to Hugging Face: {e}")
202
+ logging.info("Finished uploading files to HF.")
 
203
 
204
+ except Exception as e:
205
+ logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
206
 
207
+
208
+ def delete_files_from_hf(file_paths_in_repo):
209
+ if not HF_TOKEN_WRITE:
210
+ logging.warning("HF_TOKEN_WRITE not set. Skipping deletion from Hugging Face.")
211
+ return False
212
+ if not file_paths_in_repo:
213
+ return True
214
+
215
+ api = HfApi()
216
+ try:
217
+ logging.info(f"Attempting to delete files from HF: {file_paths_in_repo}")
218
+ # HfApi().delete_files expects list of strings, not string
219
+ paths_to_delete = file_paths_in_repo if isinstance(file_paths_in_repo, list) else [file_paths_in_repo]
220
+ api.delete_files(
221
+ repo_id=REPO_ID,
222
+ paths_in_repo=paths_to_delete,
223
+ repo_type="dataset",
224
+ token=HF_TOKEN_WRITE,
225
+ commit_message=f"Deleted files: {', '.join(paths_to_delete)}"
226
+ )
227
+ logging.info(f"Successfully deleted files from HF: {paths_to_delete}")
228
+ return True
229
+ except EntryNotFoundError:
230
+ logging.warning(f"Some files not found on HF during deletion: {paths_to_delete}. Considered as success.")
231
+ return True # If not found, it's effectively deleted or was never there.
232
+ except Exception as e:
233
+ logging.error(f"Error deleting files from Hugging Face: {e}", exc_info=True)
234
+ return False
235
+
236
+ def delete_local_files(local_file_paths):
237
+ for local_path in local_file_paths:
238
+ try:
239
+ if os.path.exists(local_path):
240
+ os.remove(local_path)
241
+ logging.info(f"Locally deleted {local_path}")
242
+ except Exception as e:
243
+ logging.error(f"Error deleting local file {local_path}: {e}")
244
 
245
 
246
  def periodic_backup():
 
249
  while True:
250
  time.sleep(backup_interval)
251
  logging.info("Starting periodic backup...")
252
+ upload_db_to_hf()
253
  logging.info("Periodic backup finished.")
254
 
255
  def load_data():
 
267
  except (FileNotFoundError, json.JSONDecodeError) as e:
268
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
269
 
270
+ if download_db_from_hf(specific_file=DATA_FILE): # This will also attempt to download images referenced in DATA_FILE
271
  try:
272
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
273
  data = json.load(file)
 
303
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
304
  json.dump(data, file, ensure_ascii=False, indent=4)
305
  logging.info(f"Data successfully saved to {DATA_FILE}")
306
+ upload_db_to_hf(specific_file_local_path=DATA_FILE, specific_file_repo_path=DATA_FILE) # Upload DATA_FILE first
307
+ # Then upload all images referenced in it.
308
+ # The general upload_db_to_hf() without args will handle both DATA_FILE and its referenced images.
309
+ # For fine-grained control, could call: upload_db_to_hf() here without args
310
+ # to ensure images are also synced after DATA_FILE is updated.
311
+ # Current `upload_db_to_hf` will try to upload all referenced images if no specific_file args are given.
312
+ # So for just saving data, it's enough to upload DATA_FILE.
313
+ # The periodic backup or admin force upload will do a more thorough sync.
314
  except Exception as e:
315
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
316
 
 
318
  if not auth_data_str:
319
  return False, None
320
 
321
+ params = {}
322
+ try:
323
+ params = dict(urllib.parse.parse_qsl(auth_data_str))
324
+ except Exception: # Broad exception for parsing issues
325
+ return False, None
326
+
327
  if 'hash' not in params:
328
  return False, None
329
 
 
347
  return False, None
348
  return False, None
349
 
350
+
351
  MAIN_APP_TEMPLATE = '''
352
  <!DOCTYPE html>
353
  <html lang="en">
 
358
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
359
  <style>
360
  :root {
361
+ --tg-theme-bg-color: #ffffff; --tg-theme-text-color: #000000; --tg-theme-hint-color: #999999;
362
+ --tg-theme-link-color: #007aff; --tg-theme-button-color: #007aff; --tg-theme-button-text-color: #ffffff;
363
+ --tg-theme-secondary-bg-color: #f0f0f0; --tg-theme-header-bg-color: #efeff4;
364
+ --tg-theme-section-bg-color: #ffffff; --tg-theme-section-header-text-color: #8e8e93;
365
+ --tg-theme-destructive-text-color: #ff3b30; --tg-theme-accent-text-color: #007aff;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  }
367
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; margin: 0; padding: 0; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); overscroll-behavior-y: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
368
  .app-container { display: flex; flex-direction: column; min-height: 100vh; }
369
+ .header { background-color: var(--tg-theme-header-bg-color); padding: 10px 15px; text-align: center; font-weight: 600; font-size: 17px; border-bottom: 1px solid var(--tg-theme-secondary-bg-color); position: sticky; top: 0; z-index: 100; }
 
 
 
 
 
 
 
 
 
 
370
  .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; }
371
+ .tab-button { flex: 1; padding: 10px; text-align: center; cursor: pointer; background: none; border: none; color: var(--tg-theme-hint-color); font-size: 15px; font-weight: 500; border-bottom: 2px solid transparent; transition: color 0.2s, border-bottom-color 0.2s; }
 
 
 
 
 
 
 
 
 
 
 
 
372
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
373
+ .content { flex-grow: 1; padding: 15px; overflow-x: hidden; } /* overflow-x hidden for swipe */
374
+ .list-item { background-color: var(--tg-theme-section-bg-color); border-radius: 8px; padding: 12px 15px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); cursor: pointer; transition: background-color 0.2s; }
 
 
 
 
 
 
 
 
 
 
375
  .list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
377
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
378
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
379
+ .item-images-preview { display: flex; align-items: center; margin-top: 8px; }
380
+ .item-images-preview img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; margin-right: 8px; border: 1px solid var(--tg-theme-secondary-bg-color); }
381
+ .item-images-preview .image-count { font-size: 12px; color: var(--tg-theme-hint-color); background-color: var(--tg-theme-secondary-bg-color); padding: 2px 6px; border-radius: 4px; }
382
  .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
383
  .form-group { margin-bottom: 15px; }
384
  .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; }
385
+ .form-group input, .form-group textarea, .form-group input[type="file"] { width: 100%; padding: 10px; border: 1px solid var(--tg-theme-secondary-bg-color); border-radius: 6px; font-size: 16px; background-color: var(--tg-theme-bg-color); color: var(--tg-theme-text-color); box-sizing: border-box; }
386
+ .form-group input[type="file"] { padding: 5px; }
 
 
 
 
 
 
 
 
387
  .form-group textarea { min-height: 80px; resize: vertical; }
388
  .image-preview-container { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
389
+ .image-preview-item { position: relative; }
390
+ .image-preview-item img { width: 80px; height: 80px; object-fit: cover; border-radius: 4px; border: 1px solid var(--tg-theme-secondary-bg-color); }
391
+ .image-preview-item .delete-img-btn { position: absolute; top: -5px; right: -5px; background: var(--tg-theme-destructive-text-color); color: white; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
392
+ .fab { position: fixed; bottom: 20px; right: 20px; width: 56px; height: 56px; background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); cursor: pointer; z-index: 1000; border: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
394
  .detail-view h2 { margin-top: 0; font-size: 20px; color: var(--tg-theme-text-color); }
395
  .detail-view p { margin-bottom: 8px; line-height: 1.5; font-size: 16px; }
396
  .detail-view strong { font-weight: 600; color: var(--tg-theme-section-header-text-color); }
397
+ .image-gallery { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 15px; }
398
+ .image-gallery img { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; border: 1px solid var(--tg-theme-secondary-bg-color); }
 
 
 
 
399
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
400
  .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); }
401
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
 
421
  let currentUser = null;
422
  let currentView = 'resumes';
423
  let currentItem = null;
424
+ let currentItemImages = []; // For managing images in form
425
 
426
  function applyThemeParams() {
427
+ const theme = tg.themeParams;
428
+ document.documentElement.style.setProperty('--tg-theme-bg-color', theme.bg_color || '#ffffff');
429
+ document.documentElement.style.setProperty('--tg-theme-text-color', theme.text_color || '#000000');
430
+ document.documentElement.style.setProperty('--tg-theme-hint-color', theme.hint_color || '#999999');
431
+ document.documentElement.style.setProperty('--tg-theme-link-color', theme.link_color || '#007aff');
432
+ document.documentElement.style.setProperty('--tg-theme-button-color', theme.button_color || '#007aff');
433
+ document.documentElement.style.setProperty('--tg-theme-button-text-color', theme.button_text_color || '#ffffff');
434
+ document.documentElement.style.setProperty('--tg-theme-secondary-bg-color', theme.secondary_bg_color || '#f0f0f0');
435
+ document.documentElement.style.setProperty('--tg-theme-header-bg-color', theme.header_bg_color || theme.secondary_bg_color || '#efeff4');
436
+ document.documentElement.style.setProperty('--tg-theme-section-bg-color', theme.section_bg_color || theme.bg_color || '#ffffff');
437
+ document.documentElement.style.setProperty('--tg-theme-section-header-text-color', theme.section_header_text_color || theme.hint_color || '#8e8e93');
438
+ document.documentElement.style.setProperty('--tg-theme-destructive-text-color', theme.destructive_text_color || '#ff3b30');
439
+ document.documentElement.style.setProperty('--tg-theme-accent-text-color', theme.accent_text_color || theme.link_color || '#007aff');
440
  }
441
 
442
+ async function apiCall(endpoint, method = 'GET', body = null, isFormData = false) {
443
  const headers = {};
444
+ if (!isFormData) headers['Content-Type'] = 'application/json';
445
+ if (tg.initData) headers['X-Telegram-Auth'] = tg.initData;
446
+
447
  const options = { method, headers };
448
+ if (body) {
449
+ if (isFormData) options.body = body;
450
+ else options.body = JSON.stringify(body);
 
 
451
  }
 
452
  try {
453
  const response = await fetch(endpoint, options);
454
  if (!response.ok) {
455
+ const errorData = await response.json().catch(() => ({ error: 'Request failed, server returned non-JSON error.' }));
456
+ throw new Error(errorData.error || `HTTP error ${response.status}`);
457
  }
458
  return response.json();
459
  } catch (error) {
 
466
  function renderList(items, type) {
467
  const contentDiv = document.getElementById('mainContent');
468
  if (!items || items.length === 0) {
469
+ contentDiv.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
470
  return;
471
  }
472
+ contentDiv.innerHTML = items.map(item => `
473
+ <div class="list-item" onclick="showDetailViewWrapper('${type}', '${item.id}')">
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
+ <div class="item-images-preview">
478
+ ${item.images && item.images.length > 0 ? `<img src="/${item.images[0]}" alt="Preview">` : ''}
479
+ ${item.images && item.images.length > 1 ? `<span class="image-count">+${item.images.length - 1}</span>` : ''}
480
+ ${item.images && item.images.length === 1 && !item.images[0] ? '' : ''}
 
481
  </div>
482
+ <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
483
  </div>
484
+ `).join('');
485
+ }
486
+
487
+ function showDetailViewWrapper(type, id) {
488
+ tg.HapticFeedback.impactOccurred('light');
489
+ showDetailView(type, id);
490
+ }
491
+
492
+ function showFormWrapper(type, itemToEdit = null) {
493
+ tg.HapticFeedback.impactOccurred('light');
494
+ showForm(type, itemToEdit);
495
  }
496
 
497
  function showDetailView(type, id) {
 
498
  tg.BackButton.show();
499
  tg.BackButton.onClick(() => { loadView(type); tg.HapticFeedback.impactOccurred('light'); });
500
  tg.MainButton.hide();
501
  document.getElementById('fabButton').style.display = 'none';
502
 
503
+ apiCall(`/api/${type}/${id}`)
504
  .then(item => {
505
  currentItem = item;
506
  const contentDiv = document.getElementById('mainContent');
507
+ let detailsHtml = `<div class="detail-view"><h2>${item.title || item.name}</h2>`;
508
 
509
  if (item.images && item.images.length > 0) {
510
+ detailsHtml += '<div class="image-gallery">';
511
+ item.images.forEach(imgPath => {
512
+ detailsHtml += `<img src="/${imgPath}" alt="Image for ${item.title || item.name}">`;
513
+ });
514
+ detailsHtml += '</div>';
515
  }
516
 
517
  if (type === 'resumes') {
518
+ detailsHtml += `
519
+ <p><strong>Skills:</strong> ${item.skills || 'N/A'}</p>
520
+ <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
521
+ <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
522
+ <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
523
+ ${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank" style="color: var(--tg-theme-link-color);">${item.portfolio_link}</a></p>` : ''}
524
+ `;
525
  } else if (type === 'vacancies') {
526
+ detailsHtml += `
527
+ <p><strong>Company:</strong> ${item.company_name || 'N/A'}</p>
528
+ <p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
529
+ <p><strong>Requirements:</strong><br>${item.requirements ? item.requirements.replace(/\\n/g, '<br>') : 'N/A'}</p>
530
+ <p><strong>Salary:</strong> ${item.salary || 'N/A'}</p>
531
+ <p><strong>Location:</strong> ${item.location || 'N/A'}</p>
532
+ <p><strong>Contact/Apply:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
533
+ `;
534
  } else if (type === 'freelance_offers') {
535
+ detailsHtml += `
536
+ <p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p>
537
+ <p><strong>Budget:</strong> ${item.budget || 'N/A'}</p>
538
+ <p><strong>Deadline:</strong> ${item.deadline || 'N/A'}</p>
539
+ <p><strong>Skills Needed:</strong> ${item.skills_needed || 'N/A'}</p>
540
+ <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
541
+ `;
542
  }
543
+ detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
544
  contentDiv.innerHTML = detailsHtml;
545
 
546
  if (currentUser && item.user_id === currentUser.id) {
547
  tg.MainButton.setText('Edit My Post');
548
+ tg.MainButton.onClick(() => showFormWrapper(type, item));
549
  tg.MainButton.show();
550
  }
551
  })
552
  .catch(err => {
553
+ document.getElementById('mainContent').innerHTML = `<div class="empty-state">Error loading details.</div>`;
554
  });
555
  }
556
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  function showForm(type, itemToEdit = null) {
 
558
  currentItem = itemToEdit;
559
+ currentItemImages = itemToEdit && itemToEdit.images ? [...itemToEdit.images] : [];
560
+
561
  tg.BackButton.show();
562
  tg.BackButton.onClick(() => {
563
  tg.HapticFeedback.impactOccurred('light');
 
567
  document.getElementById('fabButton').style.display = 'none';
568
 
569
  const contentDiv = document.getElementById('mainContent');
570
+ let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
571
 
572
  if (type === 'resumes') {
573
+ formHtml += `
574
+ <div class="form-group"><label for="name">Full Name</label><input type="text" id="name" value="${itemToEdit?.name || ''}" required></div>
575
+ <div class="form-group"><label for="title">Job Title / Desired Position</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
576
+ <div class="form-group"><label for="skills">Skills (comma separated)</label><textarea id="skills">${itemToEdit?.skills || ''}</textarea></div>
577
+ <div class="form-group"><label for="experience">Experience</label><textarea id="experience">${itemToEdit?.experience || ''}</textarea></div>
578
+ <div class="form-group"><label for="education">Education</label><textarea id="education">${itemToEdit?.education || ''}</textarea></div>
579
+ <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
580
+ <div class="form-group"><label for="portfolio_link">Portfolio Link</label><input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}"></div>
581
+ `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  } else if (type === 'vacancies') {
583
+ formHtml += `
584
+ <div class="form-group"><label for="company_name">Company Name</label><input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required></div>
585
+ <div class="form-group"><label for="title">Job Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
586
+ <div class="form-group"><label for="description">Description</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
587
+ <div class="form-group"><label for="requirements">Requirements</label><textarea id="requirements">${itemToEdit?.requirements || ''}</textarea></div>
588
+ <div class="form-group"><label for="salary">Salary/Compensation</label><input type="text" id="salary" value="${itemToEdit?.salary || ''}"></div>
589
+ <div class="form-group"><label for="location">Location</label><input type="text" id="location" value="${itemToEdit?.location || ''}"></div>
590
+ <div class="form-group"><label for="contact">Contact Info / How to Apply</label><textarea id="contact">${itemToEdit?.contact || ''}</textarea></div>
591
+ `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  } else if (type === 'freelance_offers') {
593
+ formHtml += `
594
+ <div class="form-group"><label for="title">Project Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
595
+ <div class="form-group"><label for="description">Description of Work</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
596
+ <div class="form-group"><label for="budget">Budget</label><input type="text" id="budget" value="${itemToEdit?.budget || ''}"></div>
597
+ <div class="form-group"><label for="deadline">Expected Deadline</label><input type="text" id="deadline" value="${itemToEdit?.deadline || ''}"></div>
598
+ <div class="form-group"><label for="skills_needed">Skills Needed</label><textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea></div>
599
+ <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
600
+ `;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  }
602
+ formHtml += `
603
  <div class="form-group">
604
+ <label for="item_images">Images (up to 10)</label>
605
+ <input type="file" id="item_images" multiple accept="image/*" onchange="previewImages(event)">
606
+ <div id="image_previews_container" class="image-preview-container"></div>
607
  </div>
608
+ `;
609
+ formHtml += `<div id="formError" class="error-message"></div></div>`;
 
 
 
 
 
 
 
 
610
  contentDiv.innerHTML = formHtml;
611
+ renderImagePreviews();
612
+
613
 
614
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
615
  tg.MainButton.show();
616
  tg.MainButton.onClick(() => handleSubmit(type, itemToEdit ? itemToEdit.id : null));
617
  }
618
+
619
+ function previewImages(event) {
620
+ const files = event.target.files;
621
+ const remainingSlots = 10 - currentItemImages.length;
622
+
623
+ for (let i = 0; i < Math.min(files.length, remainingSlots); i++) {
624
+ const file = files[i];
625
+ const reader = new FileReader();
626
+ reader.onload = (e) => {
627
+ // Store as object to differentiate new files (File object) from existing (string path)
628
+ currentItemImages.push({ file: file, previewUrl: e.target.result });
629
+ renderImagePreviews();
630
+ }
631
+ reader.readAsDataURL(file);
632
+ }
633
+ if (currentItemImages.length >= 10) {
634
+ tg.showAlert('Maximum 10 images allowed.');
635
+ }
636
+ // Clear the input value so the same file can be re-added if removed.
637
+ event.target.value = null;
638
+ }
639
+
640
+ function renderImagePreviews() {
641
+ const container = document.getElementById('image_previews_container');
642
+ if (!container) return;
643
+ container.innerHTML = '';
644
+ currentItemImages.forEach((img, index) => {
645
+ const imgSrc = typeof img === 'string' ? '/' + img : img.previewUrl;
646
+ const previewItem = document.createElement('div');
647
+ previewItem.classList.add('image-preview-item');
648
+ previewItem.innerHTML = \`
649
+ <img src="\${imgSrc}" alt="Preview">
650
+ <button class="delete-img-btn" onclick="removeImage(\${index})">×</button>
651
+ \`;
652
+ container.appendChild(previewItem);
653
+ });
654
+ }
655
+
656
+ function removeImage(index) {
657
+ currentItemImages.splice(index, 1);
658
+ renderImagePreviews();
659
+ tg.HapticFeedback.impactOccurred('light');
660
+ }
661
+
662
 
663
  function handleSubmit(type, itemId = null) {
664
  const payload = {};
 
702
  tg.MainButton.showProgress();
703
 
704
  const formData = new FormData();
705
+ // Existing images (paths)
706
+ payload.images = currentItemImages.filter(img => typeof img === 'string');
707
+ formData.append('payload', JSON.stringify(payload));
708
+
709
+ // New images (File objects)
710
+ currentItemImages.forEach(img => {
711
+ if (typeof img !== 'string' && img.file) { // It's a new file object
712
+ formData.append('images', img.file);
713
+ }
714
+ });
715
 
716
  const method = itemId ? 'PUT' : 'POST';
717
+ const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
718
 
719
+ apiCall(endpoint, method, formData, true)
720
  .then(response => {
721
  tg.HapticFeedback.notificationOccurred('success');
722
  tg.MainButton.hideProgress();
 
732
  function loadView(tabName) {
733
  currentView = tabName;
734
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
735
+ const activeTabButton = document.querySelector(\`.tab-button[data-tab="\${tabName}"]\`);
736
+ if (activeTabButton) activeTabButton.classList.add('active');
737
 
738
  document.getElementById('mainContent').innerHTML = \`<div class="loading">Loading \${tabName}...</div>\`;
739
  tg.BackButton.hide();
740
  tg.MainButton.hide();
741
  document.getElementById('fabButton').style.display = 'block';
742
 
743
+ apiCall(`/api/\${tabName}`)
744
  .then(data => renderList(data, tabName))
745
  .catch(err => {
746
  document.getElementById('mainContent').innerHTML = \`<div class="empty-state">Error loading \${tabName}.</div>\`;
 
751
  let touchendX = 0;
752
  const swipeThreshold = 75;
753
 
754
+ function handleTouchStart(event) {
755
+ touchstartX = event.changedTouches[0].screenX;
756
+ }
757
+
758
+ function handleTouchEnd(event) {
759
+ touchendX = event.changedTouches[0].screenX;
760
+ handleSwipeGesture();
761
+ }
762
+
763
  function handleSwipeGesture() {
764
+ const deltaX = touchendX - touchstartX;
765
+ if (Math.abs(deltaX) < swipeThreshold) return;
766
 
767
  const tabs = ['resumes', 'vacancies', 'freelance_offers'];
768
  let currentIndex = tabs.indexOf(currentView);
769
 
770
+ if (deltaX < 0) {
771
  currentIndex = (currentIndex + 1) % tabs.length;
772
  } else {
773
  currentIndex = (currentIndex - 1 + tabs.length) % tabs.length;
774
  }
775
+ if (tabs[currentIndex] !== currentView) {
776
+ loadView(tabs[currentIndex]);
777
+ tg.HapticFeedback.selectionChanged();
778
+ }
779
  }
780
 
781
  async function init() {
782
  tg.ready();
783
  applyThemeParams();
784
+ tg.onEvent('themeChanged', applyThemeParams);
785
  tg.expand();
786
  tg.enableClosingConfirmation();
787
 
 
796
  } catch (error) {
797
  console.error("Auth error:", error);
798
  document.getElementById('userInfo').textContent = \`Auth failed. Limited functionality.\`;
 
799
  }
800
 
 
801
  document.querySelectorAll('.tab-button').forEach(button => {
802
  button.addEventListener('click', () => {
 
803
  loadView(button.dataset.tab);
804
+ tg.HapticFeedback.impactOccurred('light');
805
  });
806
  });
807
+ document.getElementById('fabButton').addEventListener('click', () => showFormWrapper(currentView));
 
 
 
808
 
809
  const mainContentEl = document.getElementById('mainContent');
810
+ mainContentEl.addEventListener('touchstart', handleTouchStart, { passive: true });
811
+ mainContentEl.addEventListener('touchend', handleTouchEnd, { passive: true });
812
 
813
  loadView('resumes');
814
  }
 
861
  <div class="section">
862
  <h2>Data Synchronization with Hugging Face</h2>
863
  <div class="sync-buttons">
864
+ <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data (including images) to Hugging Face? This will overwrite server data.');">
865
+ <button type="submit" class="button button-primary">Upload All to HF</button>
866
  </form>
867
+ <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data (including images) from Hugging Face? This will overwrite local data.');">
868
+ <button type="submit" class="button button-secondary">Download All from HF</button>
869
  </form>
870
  </div>
871
+ <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set (uploads DB file and referenced images).</p>
872
  </div>
873
 
874
  <div class="section">
 
877
  <div class="item">
878
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
879
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
 
880
  <p>Images: {{ resume.images|length if resume.images else 0 }}</p>
881
+ <p>Posted: {{ resume.timestamp|truncate(19, True, '') }}</p>
882
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume and its images?');">
883
  <input type="hidden" name="item_type" value="resumes">
884
  <input type="hidden" name="item_id" value="{{ resume.id }}">
885
  <button type="submit" class="button button-danger">Delete</button>
 
896
  <div class="item">
897
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
898
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
899
+ <p>Images: {{ vacancy.images|length if vacancy.images else 0 }}</p>
900
+ <p>Posted: {{ vacancy.timestamp|truncate(19, True, '') }}</p>
901
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy and its images?');">
902
  <input type="hidden" name="item_type" value="vacancies">
903
  <input type="hidden" name="item_id" value="{{ vacancy.id }}">
904
  <button type="submit" class="button button-danger">Delete</button>
 
916
  <h3>{{ offer.title }}</h3>
917
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
918
  <p>Budget: {{ offer.budget }}</p>
 
919
  <p>Images: {{ offer.images|length if offer.images else 0 }}</p>
920
+ <p>Posted: {{ offer.timestamp|truncate(19, True, '') }}</p>
921
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer and its images?');">
922
  <input type="hidden" name="item_type" value="freelance_offers">
923
  <input type="hidden" name="item_id" value="{{ offer.id }}">
924
  <button type="submit" class="button button-danger">Delete</button>
 
938
  return render_template_string(MAIN_APP_TEMPLATE)
939
 
940
  @app.route('/uploads/<path:filename>')
941
+ def uploaded_file(filename):
942
+ return send_from_directory(os.path.join(app.root_path, app.config['UPLOAD_FOLDER']), filename)
943
+
944
 
945
  @app.route('/api/auth_user', methods=['POST'])
946
  def auth_user():
 
952
  else:
953
  return jsonify({"error": "Authentication data not provided"}), 401
954
 
955
+ is_valid, user_data_tg = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
956
 
957
+ if not is_valid or not user_data_tg:
958
  return jsonify({"error": "Invalid authentication data"}), 403
959
 
960
  data = load_data()
961
  users = data.get('users', {})
962
+ user_id_str = str(user_data_tg.get('id'))
963
 
964
  if user_id_str not in users:
965
  users[user_id_str] = {
966
+ 'id': user_data_tg.get('id'),
967
+ 'first_name': user_data_tg.get('first_name'),
968
+ 'last_name': user_data_tg.get('last_name'),
969
+ 'username': user_data_tg.get('username'),
970
+ 'language_code': user_data_tg.get('language_code'),
971
+ 'photo_url': user_data_tg.get('photo_url'),
972
  'first_seen': datetime.now().isoformat()
973
  }
974
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
975
  data['users'] = users
976
+ save_data(data) # Only saves DATA_FILE, image sync happens elsewhere or periodically
977
 
978
  return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
979
 
980
+ def get_authenticated_user(request_headers):
981
+ auth_data_str = request_headers.get('X-Telegram-Auth')
982
  if not auth_data_str:
983
  return None
984
+ is_valid, user_data = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
985
+ if is_valid and user_data:
986
+ return user_data
987
  return None
988
 
 
989
  @app.route('/api/<item_type>', methods=['GET'])
990
  def get_items(item_type):
991
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
994
  items = sorted(data.get(item_type, []), key=lambda x: x.get('timestamp', ''), reverse=True)
995
  return jsonify(items), 200
996
 
 
997
  @app.route('/api/<item_type>/<item_id>', methods=['GET'])
998
  def get_item(item_type, item_id):
999
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
1004
  return jsonify(item), 200
1005
  return jsonify({"error": "Item not found"}), 404
1006
 
 
1007
  @app.route('/api/<item_type>', methods=['POST'])
1008
  def create_item(item_type):
1009
+ user = get_authenticated_user(request.headers)
1010
+ if not user: return jsonify({"error": "Authentication required"}), 401
 
1011
 
1012
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1013
  return jsonify({"error": "Invalid item type"}), 400
1014
 
1015
+ if 'payload' not in request.form:
1016
+ return jsonify({"error": "Missing payload in form data"}), 400
1017
+
1018
+ try:
1019
+ req_data = json.loads(request.form.get('payload'))
1020
+ except json.JSONDecodeError:
1021
+ return jsonify({"error": "Invalid JSON payload"}), 400
1022
 
1023
  new_item = {
1024
  "id": str(uuid.uuid4()),
 
1028
  "images": []
1029
  }
1030
 
1031
+ uploaded_image_paths = []
1032
+ uploaded_files = request.files.getlist('images')
1033
+ if len(uploaded_files) > MAX_IMAGE_FILES:
1034
+ return jsonify({"error": f"Maximum {MAX_IMAGE_FILES} images allowed."}), 400
1035
+
1036
+ for file in uploaded_files:
1037
+ if file and allowed_file(file.filename):
1038
+ filename = secure_filename(file.filename)
1039
+ unique_filename = str(uuid.uuid4()) + os.path.splitext(filename)[1]
1040
+ local_save_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
1041
+
1042
  try:
1043
+ file.save(local_save_path)
1044
+ # Relative path for storage in JSON and for HF repo
1045
+ repo_image_path = os.path.join(UPLOADS_DIR_NAME, unique_filename).replace("\\", "/")
1046
+ uploaded_image_paths.append(repo_image_path)
1047
+ # Upload this single image to HF immediately
1048
+ upload_db_to_hf(specific_file_local_path=local_save_path, specific_file_repo_path=repo_image_path)
1049
  except Exception as e:
1050
+ logging.error(f"Failed to save or upload image {unique_filename}: {e}")
1051
+ # Potentially cleanup already saved file if upload fails, or handle later
1052
+ # For now, if save fails, it won't be added to new_item['images']
1053
+ elif file: # File present but not allowed type
1054
+ logging.warning(f"Skipped file with disallowed extension: {file.filename}")
1055
 
1056
 
1057
+ new_item['images'] = uploaded_image_paths
1058
+
1059
  if item_type == 'resumes':
1060
+ new_item.update({k: req_data.get(k, '') for k in ['name', 'title', 'skills', 'experience', 'education', 'contact', 'portfolio_link']})
1061
+ if not new_item['name'] or not new_item['title']: return jsonify({"error": "Missing name or title"}), 400
 
 
 
 
 
 
 
1062
  elif item_type == 'vacancies':
1063
+ new_item.update({k: req_data.get(k, '') for k in ['company_name', 'title', 'description', 'requirements', 'salary', 'location', 'contact']})
1064
+ if not new_item['company_name'] or not new_item['title']: return jsonify({"error": "Missing company name or title"}), 400
 
 
 
 
 
 
 
1065
  elif item_type == 'freelance_offers':
1066
+ new_item.update({k: req_data.get(k, '') for k in ['title', 'description', 'budget', 'deadline', 'skills_needed', 'contact']})
1067
+ if not new_item['title']: return jsonify({"error": "Missing title"}), 400
 
 
 
 
 
 
1068
 
1069
  data = load_data()
1070
  data[item_type].append(new_item)
1071
+ save_data(data) # This saves DATA_FILE and uploads it. Images already uploaded one by one.
1072
  return jsonify(new_item), 201
1073
 
1074
 
1075
  @app.route('/api/<item_type>/<item_id>', methods=['PUT'])
1076
  def update_item(item_type, item_id):
1077
+ user = get_authenticated_user(request.headers)
1078
  if not user: return jsonify({"error": "Authentication required"}), 401
1079
 
1080
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1081
  return jsonify({"error": "Invalid item type"}), 400
1082
 
1083
+ if 'payload' not in request.form: return jsonify({"error": "Missing payload"}), 400
1084
+ try:
1085
+ req_data = json.loads(request.form.get('payload'))
1086
+ except json.JSONDecodeError: return jsonify({"error": "Invalid JSON payload"}), 400
1087
 
1088
  data = load_data()
1089
  items_list = data.get(item_type, [])
1090
+ item_index = next((idx for idx, i in enumerate(items_list) if i['id'] == item_id), -1)
 
 
 
 
 
 
1091
 
1092
+ if item_index == -1: return jsonify({"error": "Item not found"}), 404
1093
 
1094
+ original_item = items_list[item_index]
1095
  if str(original_item.get('user_id')) != str(user.get('id')):
1096
  return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
1097
 
1098
  updated_item = original_item.copy()
1099
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1100
 
1101
+ # Image handling:
1102
+ # `req_data['images']` contains paths of existing images to keep.
1103
+ # `request.files.getlist('images')` contains newly uploaded files.
1104
+
1105
+ existing_images_to_keep = set(req_data.get('images', []))
1106
+ current_images_in_item = set(original_item.get('images', []))
1107
+
1108
+ images_to_delete_paths = list(current_images_in_item - existing_images_to_keep)
1109
+
1110
+ # Delete images from HF and local that are no longer needed
1111
+ if images_to_delete_paths:
1112
+ delete_files_from_hf(images_to_delete_paths)
1113
+ local_paths_to_delete = [os.path.join(app.root_path, p) for p in images_to_delete_paths]
1114
+ delete_local_files(local_paths_to_delete)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1115
 
1116
+ final_image_list = list(existing_images_to_keep)
1117
+
1118
+ newly_uploaded_files = request.files.getlist('images')
1119
+ if len(final_image_list) + len(newly_uploaded_files) > MAX_IMAGE_FILES:
1120
+ return jsonify({"error": f"Total images cannot exceed {MAX_IMAGE_FILES}"}), 400
1121
+
1122
+ for file in newly_uploaded_files:
1123
+ if file and allowed_file(file.filename):
1124
+ filename = secure_filename(file.filename)
1125
+ unique_filename = str(uuid.uuid4()) + os.path.splitext(filename)[1]
1126
+ local_save_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
1127
+ try:
1128
+ file.save(local_save_path)
1129
+ repo_image_path = os.path.join(UPLOADS_DIR_NAME, unique_filename).replace("\\", "/")
1130
+ final_image_list.append(repo_image_path)
1131
+ upload_db_to_hf(specific_file_local_path=local_save_path, specific_file_repo_path=repo_image_path)
1132
+ except Exception as e:
1133
+ logging.error(f"Failed to save or upload new image during update {unique_filename}: {e}")
1134
+ elif file:
1135
+ logging.warning(f"Skipped file with disallowed extension during update: {file.filename}")
1136
+
1137
+ updated_item['images'] = final_image_list
1138
 
1139
+ # Update text fields
1140
  if item_type == 'resumes':
1141
+ for k in ['name', 'title', 'skills', 'experience', 'education', 'contact', 'portfolio_link']:
1142
+ if k in req_data: updated_item[k] = req_data[k]
 
 
 
 
 
 
 
1143
  elif item_type == 'vacancies':
1144
+ for k in ['company_name', 'title', 'description', 'requirements', 'salary', 'location', 'contact']:
1145
+ if k in req_data: updated_item[k] = req_data[k]
 
 
 
 
 
 
 
1146
  elif item_type == 'freelance_offers':
1147
+ for k in ['title', 'description', 'budget', 'deadline', 'skills_needed', 'contact']:
1148
+ if k in req_data: updated_item[k] = req_data[k]
 
 
 
 
 
 
1149
 
1150
  data[item_type][item_index] = updated_item
1151
  save_data(data)
1152
  return jsonify(updated_item), 200
1153
 
1154
 
1155
+ def _delete_item_logic(item_type, item_id, user_id_check=None):
 
 
 
 
 
 
 
1156
  data = load_data()
1157
  items_list = data.get(item_type, [])
1158
 
1159
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1160
+ if not item_to_delete:
1161
+ return False, "Item not found"
1162
 
1163
+ if user_id_check and str(item_to_delete.get('user_id')) != str(user_id_check):
1164
+ return False, "Forbidden: You can only delete your own items"
1165
 
1166
+ images_to_delete_paths = item_to_delete.get('images', [])
1167
 
1168
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1169
 
1170
+ save_data(data) # Save updated DATA_FILE and upload it to HF
1171
 
1172
+ if images_to_delete_paths:
1173
+ delete_files_from_hf(images_to_delete_paths)
1174
+ local_paths_to_delete = [os.path.join(app.root_path, p) for p in images_to_delete_paths]
1175
+ delete_local_files(local_paths_to_delete)
1176
+
1177
+ return True, "Item deleted successfully"
1178
+
1179
+ @app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
1180
+ def delete_item(item_type, item_id):
1181
+ user = get_authenticated_user(request.headers)
1182
+ if not user: return jsonify({"error": "Authentication required"}), 401
1183
+
1184
+ if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1185
+ return jsonify({"error": "Invalid item type"}), 400
1186
+
1187
+ success, message = _delete_item_logic(item_type, item_id, user_id_check=str(user.get('id')))
1188
+
1189
+ if success:
1190
+ return jsonify({"message": message}), 200
1191
+ else:
1192
+ if "Forbidden" in message:
1193
+ return jsonify({"error": message}), 403
1194
+ return jsonify({"error": message}), 404
1195
 
1196
 
1197
  @app.route('/admin', methods=['GET'])
 
1211
  flash('Invalid item type or ID for deletion.', 'error')
1212
  return redirect(url_for('admin_panel'))
1213
 
1214
+ success, message = _delete_item_logic(item_type, item_id) # No user_id_check for admin
 
 
 
1215
 
1216
+ if success:
1217
+ flash(f'{item_type.capitalize()[:-1]} and associated images deleted successfully.', 'success')
 
 
 
 
 
 
 
 
 
 
 
1218
  else:
1219
+ flash(f'Error deleting item: {message}', 'error')
1220
  return redirect(url_for('admin_panel'))
1221
 
1222
  @app.route('/admin/force_upload', methods=['POST'])
1223
  def force_upload_admin():
1224
+ logging.info("Admin forcing full upload to Hugging Face...")
1225
  try:
1226
+ upload_db_to_hf() # This will upload DATA_FILE and all images referenced in it
1227
+ flash("Data and referenced images successfully uploaded to Hugging Face.", 'success')
1228
  except Exception as e:
1229
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1230
  flash(f"Error uploading to Hugging Face: {e}", 'error')
 
1232
 
1233
  @app.route('/admin/force_download', methods=['POST'])
1234
  def force_download_admin():
1235
+ logging.info("Admin forcing full download from Hugging Face...")
1236
  try:
1237
+ if download_db_from_hf(): # This will download DATA_FILE and all images referenced in it
1238
+ flash("Data and referenced images successfully downloaded from Hugging Face. Local files updated.", 'success')
1239
  load_data()
1240
  else:
1241
+ flash("Failed to download data/images from Hugging Face. Check logs.", 'error')
1242
  except Exception as e:
1243
  logging.error(f"Error during forced download: {e}", exc_info=True)
1244
  flash(f"Error downloading from Hugging Face: {e}", 'error')