Shveiauto commited on
Commit
e62320e
·
verified ·
1 Parent(s): 8bf5835

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +580 -610
app.py CHANGED
@@ -5,8 +5,8 @@ import logging
5
  import threading
6
  import time
7
  from datetime import datetime
8
- from huggingface_hub import HfApi, hf_hub_download, delete_file as hf_delete_file
9
- from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError, EntryNotFoundError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
  import uuid
@@ -19,197 +19,103 @@ load_dotenv()
19
  app = Flask(__name__)
20
  app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
21
  DATA_FILE = 'tontalent_data.json'
22
- LOCAL_IMAGES_FOLDER = "uploaded_images"
23
- app.config['UPLOAD_FOLDER'] = LOCAL_IMAGES_FOLDER
24
- HF_IMAGES_FOLDER_PATH_IN_REPO = "uploaded_images"
25
-
 
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" # Replace with your actual bot token
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 ensure_images_folder_exists():
39
- os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
 
 
 
 
 
 
40
 
41
- def download_data_file_from_hf(retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
42
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
43
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
44
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
45
- logging.info(f"Attempting download for {DATA_FILE} from {REPO_ID}...")
46
- success = False
47
- for attempt in range(retries + 1):
48
- try:
49
- logging.info(f"Downloading {DATA_FILE} (Attempt {attempt + 1}/{retries + 1})...")
50
- local_path = hf_hub_download(
51
- repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset",
52
- token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
53
- force_download=True, resume_download=False
54
- )
55
- logging.info(f"Successfully downloaded {DATA_FILE} to {local_path}.")
56
- success = True
57
- break
58
- except RepositoryNotFoundError:
59
- logging.error(f"Repository {REPO_ID} not found. Download cancelled for {DATA_FILE}.")
60
- return False
61
- except HfHubHTTPError as e:
62
- if e.response.status_code == 404:
63
- logging.warning(f"File {DATA_FILE} not found in repo {REPO_ID} (404).")
64
- if attempt == 0 and not os.path.exists(DATA_FILE):
65
- try:
66
- with open(DATA_FILE, 'w', encoding='utf-8') as f:
67
- json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
68
- logging.info(f"Created empty local file {DATA_FILE} because it was not found on HF.")
69
- except Exception as create_e:
70
- logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
71
- success = False
72
- break
73
- else:
74
- logging.error(f"HTTP error downloading {DATA_FILE} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
75
- except Exception as e:
76
- logging.error(f"Unexpected error downloading {DATA_FILE} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
77
- if attempt < retries: time.sleep(delay)
78
- if not success:
79
- logging.error(f"Failed to download {DATA_FILE} after {retries + 1} attempts.")
80
- return success
81
-
82
- def download_images_folder_from_hf(retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
83
- ensure_images_folder_exists()
84
- if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
85
- logging.warning("HF_TOKEN_READ/WRITE not set, skipping images folder download from HF.")
86
- return False
87
- token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
88
- logging.info(f"Attempting download for images folder '{HF_IMAGES_FOLDER_PATH_IN_REPO}' from {REPO_ID}...")
89
- success = False
90
- for attempt in range(retries + 1):
91
- try:
92
- logging.info(f"Downloading images folder (Attempt {attempt + 1}/{retries + 1})...")
93
- hf_hub_download(
94
- repo_id=REPO_ID,
95
- filename=HF_IMAGES_FOLDER_PATH_IN_REPO,
96
- repo_type="dataset",
97
- token=token_to_use,
98
- local_dir=".", # This will download to ./<HF_IMAGES_FOLDER_PATH_IN_REPO>
99
- local_dir_use_symlinks=False,
100
- force_download=True, # Consider if this is always needed
101
- resume_download=False,
102
- repo_revision=None, # Or specify a branch/commit
103
- allow_patterns= ["**/*"], # Ensure all files in folder are downloaded
104
- ignore_patterns=None,
105
- )
106
- logging.info(f"Successfully downloaded/updated images folder to '{app.config['UPLOAD_FOLDER']}'.")
107
- success = True
108
- break
109
- except RepositoryNotFoundError:
110
- logging.error(f"Repository {REPO_ID} not found. Cannot download images folder.")
111
- return False
112
- except HfHubHTTPError as e:
113
- if e.response.status_code == 404:
114
- logging.warning(f"Images folder '{HF_IMAGES_FOLDER_PATH_IN_REPO}' not found in repo (404). Assuming no images or new setup.")
115
- success = True # Not an error if folder doesn't exist yet
116
  break
117
- else:
118
- logging.error(f"HTTP error downloading images folder (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
119
- except Exception as e:
120
- logging.error(f"Unexpected error downloading images folder (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
121
- if attempt < retries: time.sleep(delay)
122
- if not success:
123
- logging.error(f"Failed to download images folder after {retries + 1} attempts.")
124
- return success
125
-
126
- def upload_data_file_to_hf():
127
- if not HF_TOKEN_WRITE:
128
- logging.warning("HF_TOKEN_WRITE not set. Skipping data file upload to Hugging Face.")
129
- return
130
- if not os.path.exists(DATA_FILE):
131
- logging.warning(f"Data file {DATA_FILE} not found locally, skipping upload.")
132
- return
133
- try:
134
- api = HfApi()
135
- logging.info(f"Starting upload of {DATA_FILE} to HF repo {REPO_ID}...")
136
- api.upload_file(
137
- path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, repo_id=REPO_ID,
138
- repo_type="dataset", token=HF_TOKEN_WRITE,
139
- commit_message=f"Sync {DATA_FILE} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
140
- )
141
- logging.info(f"File {DATA_FILE} successfully uploaded to Hugging Face.")
142
- except Exception as e:
143
- logging.error(f"Error uploading file {DATA_FILE} to Hugging Face: {e}", exc_info=True)
144
-
145
- def upload_images_folder_to_hf():
146
- ensure_images_folder_exists()
147
- if not HF_TOKEN_WRITE:
148
- logging.warning("HF_TOKEN_WRITE not set. Skipping images folder upload to Hugging Face.")
149
- return
150
- if not os.path.exists(app.config['UPLOAD_FOLDER']):
151
- logging.info(f"Local images folder '{app.config['UPLOAD_FOLDER']}' does not exist. Skipping upload.")
152
- return
153
- try:
154
- api = HfApi()
155
- logging.info(f"Starting upload of images folder '{app.config['UPLOAD_FOLDER']}' to HF repo {REPO_ID} path '{HF_IMAGES_FOLDER_PATH_IN_REPO}'...")
156
- api.upload_folder(
157
- folder_path=app.config['UPLOAD_FOLDER'],
158
- path_in_repo=HF_IMAGES_FOLDER_PATH_IN_REPO,
159
- repo_id=REPO_ID,
160
- repo_type="dataset",
161
- token=HF_TOKEN_WRITE,
162
- commit_message=f"Sync images folder {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
163
- allow_patterns="*", # Upload all files in the folder
164
- )
165
- logging.info(f"Images folder '{app.config['UPLOAD_FOLDER']}' successfully uploaded to Hugging Face.")
166
- except Exception as e:
167
- logging.error(f"Error uploading images folder to Hugging Face: {e}", exc_info=True)
168
-
169
- def upload_single_image_to_hf(local_image_path, image_filename_in_repo):
170
  if not HF_TOKEN_WRITE:
171
- logging.warning("HF_TOKEN_WRITE not set. Skipping single image upload.")
172
- return
173
- if not os.path.exists(local_image_path):
174
- logging.warning(f"Local image {local_image_path} not found. Skipping upload.")
175
  return
176
  try:
177
  api = HfApi()
178
- path_in_repo = f"{HF_IMAGES_FOLDER_PATH_IN_REPO}/{image_filename_in_repo}"
179
- logging.info(f"Uploading single image {local_image_path} to {path_in_repo} in {REPO_ID}...")
180
- api.upload_file(
181
- path_or_fileobj=local_image_path,
182
- path_in_repo=path_in_repo,
183
- repo_id=REPO_ID,
184
- repo_type="dataset",
185
- token=HF_TOKEN_WRITE,
186
- commit_message=f"Upload image {image_filename_in_repo} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
187
- )
188
- logging.info(f"Image {image_filename_in_repo} successfully uploaded.")
189
- except Exception as e:
190
- logging.error(f"Error uploading single image {image_filename_in_repo} to HF: {e}", exc_info=True)
191
-
192
- def delete_image_from_hf(image_filename_in_repo):
193
- if not HF_TOKEN_WRITE:
194
- logging.warning("HF_TOKEN_WRITE not set. Skipping image deletion from HF.")
195
- return
196
- try:
197
- api = HfApi() # Use HfApi() directly for delete_file
198
- path_in_repo = f"{HF_IMAGES_FOLDER_PATH_IN_REPO}/{image_filename_in_repo}"
199
- logging.info(f"Deleting image {path_in_repo} from HF repo {REPO_ID}...")
200
- hf_delete_file( # Use the direct import
201
- repo_id=REPO_ID,
202
- path_in_repo=path_in_repo,
203
- repo_type="dataset",
204
- token=HF_TOKEN_WRITE,
205
- commit_message=f"Delete image {image_filename_in_repo} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
206
- )
207
- logging.info(f"Image {path_in_repo} successfully deleted from HF.")
208
- except EntryNotFoundError:
209
- logging.warning(f"Image {path_in_repo} not found in HF repo, skipping deletion.")
210
  except Exception as e:
211
- logging.error(f"Error deleting image {image_filename_in_repo} from HF: {e}", exc_info=True)
212
-
213
 
214
  def periodic_backup():
215
  backup_interval = 1800
@@ -217,8 +123,7 @@ def periodic_backup():
217
  while True:
218
  time.sleep(backup_interval)
219
  logging.info("Starting periodic backup...")
220
- upload_data_file_to_hf()
221
- upload_images_folder_to_hf()
222
  logging.info("Periodic backup finished.")
223
 
224
  def load_data():
@@ -236,7 +141,7 @@ def load_data():
236
  except (FileNotFoundError, json.JSONDecodeError) as e:
237
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
238
 
239
- if download_data_file_from_hf():
240
  try:
241
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
242
  data = json.load(file)
@@ -272,7 +177,7 @@ def save_data(data):
272
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
273
  json.dump(data, file, ensure_ascii=False, indent=4)
274
  logging.info(f"Data successfully saved to {DATA_FILE}")
275
- upload_data_file_to_hf()
276
  except Exception as e:
277
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
278
 
@@ -304,13 +209,12 @@ def verify_telegram_auth_data(auth_data_str, bot_token):
304
  return False, None
305
  return False, None
306
 
307
-
308
  MAIN_APP_TEMPLATE = '''
309
  <!DOCTYPE html>
310
  <html lang="en">
311
  <head>
312
  <meta charset="UTF-8">
313
- <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
314
  <title>TonTalent</title>
315
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
316
  <style>
@@ -327,9 +231,17 @@ MAIN_APP_TEMPLATE = '''
327
  --tg-theme-section-header-text-color: #8e8e93;
328
  --tg-theme-destructive-text-color: #ff3b30;
329
  --tg-theme-accent-text-color: #007aff;
 
 
 
 
 
 
 
 
330
  }
331
  body {
332
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
333
  margin: 0;
334
  padding: 0;
335
  background-color: var(--tg-theme-bg-color);
@@ -337,40 +249,46 @@ MAIN_APP_TEMPLATE = '''
337
  overscroll-behavior-y: none;
338
  -webkit-font-smoothing: antialiased;
339
  -moz-osx-font-smoothing: grayscale;
340
- padding-top: env(safe-area-inset-top);
341
- padding-bottom: env(safe-area-inset-bottom);
342
  }
343
  .app-container { display: flex; flex-direction: column; min-height: 100vh; }
344
  .header {
345
  background-color: var(--tg-theme-header-bg-color);
346
- padding: 12px 15px;
347
  text-align: center;
348
  font-weight: 600;
349
- font-size: 17px;
350
- border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
351
  position: sticky;
352
  top: 0;
353
  z-index: 100;
354
  }
355
- .user-info {
356
- padding: 15px;
357
- background-color: var(--tg-theme-secondary-bg-color);
358
- font-size: 14px;
359
- text-align: left;
360
- color: var(--tg-theme-text-color);
361
- display: flex;
362
  align-items: center;
363
- border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
 
 
364
  }
365
- .user-info img {
366
- width: 40px; height: 40px; border-radius: 50%;
367
- margin-right: 12px;
368
- background-color: var(--tg-theme-hint-color); /* Placeholder bg */
 
 
 
369
  }
370
- .tabs { display: flex; background-color: var(--tg-theme-section-bg-color); padding: 5px 10px; border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color); }
 
 
 
 
 
 
 
371
  .tab-button {
372
  flex: 1;
373
- padding: 12px 5px;
374
  text-align: center;
375
  cursor: pointer;
376
  background: none;
@@ -378,93 +296,94 @@ MAIN_APP_TEMPLATE = '''
378
  color: var(--tg-theme-hint-color);
379
  font-size: 15px;
380
  font-weight: 500;
381
- border-bottom: 2.5px solid transparent;
382
- transition: color 0.2s ease-in-out, border-bottom-color 0.2s ease-in-out;
383
- -webkit-tap-highlight-color: transparent;
384
  }
385
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); font-weight: 600; }
386
- .content {
387
- flex-grow: 1; padding: 15px;
388
- opacity: 0;
389
- transition: opacity 0.2s ease-in-out;
390
- }
391
- .content.content-visible { opacity: 1; }
 
 
392
  .list-item {
393
  background-color: var(--tg-theme-section-bg-color);
394
- border-radius: 10px;
395
- padding: 12px 15px;
396
- margin-bottom: 12px;
397
- box-shadow: 0 2px 8px rgba(0,0,0,0.06);
398
  cursor: pointer;
399
- transition: background-color 0.15s ease-out, transform 0.15s ease-out;
400
- display: flex;
401
- align-items: center;
402
  }
403
- .list-item:active { background-color: var(--tg-theme-secondary-bg-color); transform: scale(0.98); }
404
- .list-item-image { width: 60px; height: 60px; border-radius: 8px; margin-right: 12px; object-fit: cover; background-color: var(--tg-theme-secondary-bg-color); }
405
- .list-item-content h3 { margin: 0 0 4px 0; font-size: 17px; font-weight: 600; color: var(--tg-theme-text-color); }
406
- .list-item-content p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
407
- .list-item-content .meta { font-size: 13px; color: var(--tg-theme-hint-color); }
 
408
 
409
- .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
410
- .form-group { margin-bottom: 18px; }
411
- .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 6px; font-weight: 500; }
412
  .form-group input, .form-group textarea, .form-group input[type="file"] {
413
  width: 100%;
414
- padding: 12px;
415
  border: 1px solid var(--tg-theme-secondary-bg-color);
416
- border-radius: 8px;
417
  font-size: 16px;
418
  background-color: var(--tg-theme-bg-color);
419
  color: var(--tg-theme-text-color);
420
  box-sizing: border-box;
421
- transition: border-color 0.2s, box-shadow 0.2s;
422
- }
423
- .form-group input:focus, .form-group textarea:focus {
424
- border-color: var(--tg-theme-link-color);
425
- box-shadow: 0 0 0 2px var(--tg-theme-link-color_0_3); /* Assuming a way to get transparent link color */
426
  }
 
427
  .form-group textarea { min-height: 100px; resize: vertical; }
428
- .form-group input[type="file"] { padding: 8px; }
429
- #image_preview_container img { max-width: 120px; max-height: 120px; margin-top: 10px; border-radius: 6px; border: 1px solid var(--tg-theme-secondary-bg-color); }
430
-
431
  .fab {
432
  position: fixed;
433
- bottom: calc(20px + env(safe-area-inset-bottom)); /* Adjust for safe area */
434
- right: 20px;
435
  width: 56px;
436
  height: 56px;
437
  background-color: var(--tg-theme-button-color);
438
  color: var(--tg-theme-button-text-color);
439
- border-radius: 28px; /* iOS like rounded */
440
  display: flex;
441
  align-items: center;
442
  justify-content: center;
443
  font-size: 28px;
444
  line-height: 1;
445
- font-weight: 300;
446
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
447
  cursor: pointer;
448
  z-index: 1000;
449
  border: none;
450
- transition: transform 0.15s ease-out;
451
  }
452
- .fab:active { transform: scale(0.92); }
453
- .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
454
- .detail-view img.detail-image { width: 100%; max-height: 300px; object-fit: cover; border-radius: 10px; margin-bottom: 15px; background-color: var(--tg-theme-secondary-bg-color); }
455
- .detail-view h2 { margin-top: 0; font-size: 22px; font-weight: 600; color: var(--tg-theme-text-color); }
456
- .detail-view p { margin-bottom: 10px; line-height: 1.6; font-size: 16px; }
 
457
  .detail-view strong { font-weight: 500; color: var(--tg-theme-section-header-text-color); }
458
- .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
459
- .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 8px; }
 
 
 
 
 
460
  </style>
461
  </head>
462
  <body>
463
- <div class="app-container" id="appContainer">
464
  <div class="header">TonTalent</div>
465
- <div class="user-info" id="userInfo">
466
- <img src="" alt="avatar" id="userAvatar" style="display:none;">
467
- <span id="userGreeting">Loading user...</span>
468
  </div>
469
  <div class="tabs">
470
  <button class="tab-button active" data-tab="resumes">Resumes</button>
@@ -485,100 +404,103 @@ MAIN_APP_TEMPLATE = '''
485
  const mainContentEl = document.getElementById('mainContent');
486
 
487
  function applyThemeParams() {
488
- const rootStyle = document.documentElement.style;
489
- rootStyle.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
490
- rootStyle.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
491
- rootStyle.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
492
- rootStyle.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
493
- rootStyle.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
494
- rootStyle.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
495
- rootStyle.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
496
- rootStyle.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
497
- rootStyle.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
498
- rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
499
- rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
500
- rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
501
-
502
- if(tg.themeParams.link_color) {
503
- const lc = tg.themeParams.link_color;
504
- const r = parseInt(lc.slice(1, 3), 16);
505
- const g = parseInt(lc.slice(3, 5), 16);
506
- const b = parseInt(lc.slice(5, 7), 16);
507
- rootStyle.setProperty('--tg-theme-link-color_0_3', `rgba(${r}, ${g}, ${b}, 0.3)`);
508
- } else {
509
- rootStyle.setProperty('--tg-theme-link-color_0_3', `rgba(0, 122, 255, 0.3)`);
510
- }
511
  }
512
 
513
- async function apiCall(endpoint, method = 'GET', body = null, isFormData = false) {
514
  const headers = {};
515
- if (!isFormData) {
516
- headers['Content-Type'] = 'application/json';
517
  }
518
  if (tg.initData) {
519
  headers['X-Telegram-Auth'] = tg.initData;
520
  }
521
  const options = { method, headers };
522
- if (body) options.body = isFormData ? body : JSON.stringify(body);
523
-
 
 
 
524
  try {
525
  const response = await fetch(endpoint, options);
526
  if (!response.ok) {
527
- const errorData = await response.json().catch(() => ({ error: 'Request failed without JSON body' }));
528
  throw new Error(errorData.error || `HTTP error ${response.status}`);
529
  }
530
  return response.json();
531
  } catch (error) {
532
  console.error('API Call Error:', error);
533
  tg.showAlert(error.message || 'An API error occurred.');
 
534
  throw error;
 
 
535
  }
536
  }
537
 
538
  function renderList(items, type) {
539
- mainContentEl.classList.remove('content-visible');
540
- setTimeout(() => {
541
- if (!items || items.length === 0) {
542
- mainContentEl.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
543
- } else {
544
- mainContentEl.innerHTML = items.map(item => `
545
- <div class="list-item" onclick="showDetailView('${type}', '${item.id}')">
546
- ${item.image_filename ? `<img src="/uploads/${item.image_filename}" class="list-item-image" alt="${item.title || item.name || 'Item image'}">` : `<div class="list-item-image"></div>`}
547
- <div class="list-item-content">
548
- <h3>${item.title || item.name || 'Untitled'}</h3>
549
- ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
550
- ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
551
- <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
552
- </div>
553
  </div>
554
- `).join('');
555
- }
556
- mainContentEl.classList.add('content-visible');
557
- }, 150);
 
558
  }
559
 
560
  function showDetailView(type, id) {
561
  tg.BackButton.show();
562
- tg.BackButton.onClick(() => { loadView(type); tg.HapticFeedback.impactOccurred('light'); });
 
 
 
563
  tg.MainButton.hide();
564
  document.getElementById('fabButton').style.display = 'none';
565
- mainContentEl.classList.remove('content-visible');
 
566
 
567
  apiCall(`/api/${type}/${id}`)
568
  .then(item => {
569
  currentItem = item;
570
  let detailsHtml = `<div class="detail-view">`;
571
  if (item.image_filename) {
572
- detailsHtml += `<img src="/uploads/${item.image_filename}" class="detail-image" alt="${item.title || item.name || 'Detail image'}">`;
573
  }
574
  detailsHtml += `<h2>${item.title || item.name}</h2>`;
 
575
  if (type === 'resumes') {
576
  detailsHtml += `
577
  <p><strong>Skills:</strong> ${item.skills || 'N/A'}</p>
578
  <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
579
  <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
580
  <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
581
- ${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>` : ''}
582
  `;
583
  } else if (type === 'vacancies') {
584
  detailsHtml += `
@@ -599,23 +521,20 @@ MAIN_APP_TEMPLATE = '''
599
  `;
600
  }
601
  detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
602
-
603
- setTimeout(() => {
604
- mainContentEl.innerHTML = detailsHtml;
605
- mainContentEl.classList.add('content-visible');
606
- },150);
607
 
608
- if (currentUser && item.user_id === currentUser.id) {
609
  tg.MainButton.setText('Edit My Post');
610
- tg.MainButton.onClick(() => { showForm(type, item); tg.HapticFeedback.impactOccurred('light'); });
 
 
 
611
  tg.MainButton.show();
612
  }
613
  })
614
  .catch(err => {
615
- setTimeout(() => {
616
- mainContentEl.innerHTML = `<div class="empty-state">Error loading details.</div>`;
617
- mainContentEl.classList.add('content-visible');
618
- }, 150);
619
  });
620
  }
621
 
@@ -628,72 +547,115 @@ MAIN_APP_TEMPLATE = '''
628
  tg.HapticFeedback.impactOccurred('light');
629
  });
630
  document.getElementById('fabButton').style.display = 'none';
631
- mainContentEl.classList.remove('content-visible');
 
632
 
633
- let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
 
634
  const commonFields = `
635
  <div class="form-group">
636
- <label for="item_image">Image (optional)</label>
637
- <input type="file" id="item_image" accept="image/*">
638
- <div id="image_preview_container">
639
- ${itemToEdit && itemToEdit.image_filename ? `<img src="/uploads/${itemToEdit.image_filename}" alt="Current image">` : ''}
640
- </div>
641
  </div>
642
  `;
 
643
  if (type === 'resumes') {
644
  formHtml += `
645
- <div class="form-group"><label for="name">Full Name</label><input type="text" id="name" value="${itemToEdit?.name || ''}" required></div>
646
- <div class="form-group"><label for="title">Job Title / Desired Position</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
647
  ${commonFields}
648
- <div class="form-group"><label for="skills">Skills (comma separated)</label><textarea id="skills">${itemToEdit?.skills || ''}</textarea></div>
649
- <div class="form-group"><label for="experience">Experience</label><textarea id="experience">${itemToEdit?.experience || ''}</textarea></div>
650
- <div class="form-group"><label for="education">Education</label><textarea id="education">${itemToEdit?.education || ''}</textarea></div>
651
- <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
652
- <div class="form-group"><label for="portfolio_link">Portfolio Link</label><input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}"></div>
653
  `;
654
  } else if (type === 'vacancies') {
655
  formHtml += `
656
- <div class="form-group"><label for="company_name">Company Name</label><input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required></div>
657
- <div class="form-group"><label for="title">Job Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  ${commonFields}
659
- <div class="form-group"><label for="description">Description</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
660
- <div class="form-group"><label for="requirements">Requirements</label><textarea id="requirements">${itemToEdit?.requirements || ''}</textarea></div>
661
- <div class="form-group"><label for="salary">Salary/Compensation</label><input type="text" id="salary" value="${itemToEdit?.salary || ''}"></div>
662
- <div class="form-group"><label for="location">Location</label><input type="text" id="location" value="${itemToEdit?.location || ''}"></div>
663
- <div class="form-group"><label for="contact">Contact Info / How to Apply</label><textarea id="contact">${itemToEdit?.contact || ''}</textarea></div>
664
  `;
665
  } else if (type === 'freelance_offers') {
666
  formHtml += `
667
- <div class="form-group"><label for="title">Project Title</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  ${commonFields}
669
- <div class="form-group"><label for="description">Description of Work</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div>
670
- <div class="form-group"><label for="budget">Budget</label><input type="text" id="budget" value="${itemToEdit?.budget || ''}"></div>
671
- <div class="form-group"><label for="deadline">Expected Deadline</label><input type="text" id="deadline" value="${itemToEdit?.deadline || ''}"></div>
672
- <div class="form-group"><label for="skills_needed">Skills Needed</label><textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea></div>
673
- <div class="form-group"><label for="contact">Contact Info</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>
674
  `;
675
  }
676
  formHtml += `<div id="formError" class="error-message"></div></div>`;
677
-
678
- setTimeout(() => {
679
- mainContentEl.innerHTML = formHtml;
680
- mainContentEl.classList.add('content-visible');
681
- const fileInput = document.getElementById('item_image');
682
- const imagePreview = document.getElementById('image_preview_container');
683
- if (fileInput) {
684
- fileInput.addEventListener('change', function(event) {
685
- if (event.target.files && event.target.files[0]) {
686
- const reader = new FileReader();
687
- reader.onload = function(e) {
688
- imagePreview.innerHTML = `<img src="${e.target.result}" alt="Preview">`;
689
- }
690
- reader.readAsDataURL(event.target.files[0]);
691
- } else {
692
- imagePreview.innerHTML = itemToEdit && itemToEdit.image_filename ? `<img src="/uploads/${itemToEdit.image_filename}" alt="Current image">` : '';
693
- }
694
- });
695
- }
696
- }, 150);
697
 
698
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
699
  tg.MainButton.show();
@@ -705,12 +667,6 @@ MAIN_APP_TEMPLATE = '''
705
  let isValid = true;
706
  document.getElementById('formError').textContent = '';
707
 
708
- const formData = new FormData();
709
- const imageFile = document.getElementById('item_image').files[0];
710
- if (imageFile) {
711
- formData.append('image', imageFile);
712
- }
713
-
714
  if (type === 'resumes') {
715
  payload.name = document.getElementById('name').value.trim();
716
  payload.title = document.getElementById('title').value.trim();
@@ -740,150 +696,144 @@ MAIN_APP_TEMPLATE = '''
740
  }
741
 
742
  if (!isValid) {
743
- document.getElementById('formError').textContent = 'Please fill in all required fields.';
744
  tg.HapticFeedback.notificationOccurred('error');
745
  return;
746
  }
747
 
 
748
  for (const key in payload) {
749
  formData.append(key, payload[key]);
750
  }
751
-
752
- tg.MainButton.showProgress();
 
 
753
 
754
  const method = itemId ? 'PUT' : 'POST';
755
  const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
756
 
757
- apiCall(endpoint, method, formData, true)
758
  .then(response => {
759
  tg.HapticFeedback.notificationOccurred('success');
760
- tg.MainButton.hideProgress();
761
  loadView(type);
762
  })
763
  .catch(err => {
764
  tg.HapticFeedback.notificationOccurred('error');
765
- tg.MainButton.hideProgress();
766
- document.getElementById('formError').textContent = err.message || 'Failed to submit.';
767
  });
768
  }
769
 
770
  function loadView(tabName) {
771
- if (currentView === tabName && mainContentEl.innerHTML !== '' && !mainContentEl.querySelector('.loading')) {
772
- // If trying to load the same tab and it's already loaded, do nothing or maybe scroll to top
773
- // For now, allow reload by not returning early.
774
- }
775
-
776
  currentView = tabName;
777
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
778
  document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
779
 
780
- mainContentEl.classList.remove('content-visible');
781
- setTimeout(() => {
782
- mainContentEl.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
783
- mainContentEl.classList.add('content-visible'); // Show loading indicator
784
- }, 0); // Start transition immediately for loading indicator
785
-
786
  tg.BackButton.hide();
787
  tg.MainButton.hide();
788
  document.getElementById('fabButton').style.display = 'block';
789
 
790
  apiCall(`/api/${tabName}`)
791
- .then(data => renderList(data, tabName)) // renderList handles its own fade-in
792
  .catch(err => {
793
- mainContentEl.classList.remove('content-visible');
794
- setTimeout(() => {
795
- mainContentEl.innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`;
796
- mainContentEl.classList.add('content-visible');
797
- }, 150);
798
  });
799
  }
800
-
801
- function setupSwipeGestures() {
802
- const appContainer = document.getElementById('appContainer');
803
- let touchstartX = 0;
804
- let touchendX = 0;
805
- let touchstartY = 0;
806
- let touchendY = 0;
807
- const swipeThreshold = 75;
808
-
809
- appContainer.addEventListener('touchstart', function(event) {
810
- if (event.target.closest('.form-container textarea, .form-container input, .detail-view')) {
811
- // Don't interfere with form inputs or scrollable detail views
812
- return;
813
- }
814
- touchstartX = event.changedTouches[0].screenX;
815
- touchstartY = event.changedTouches[0].screenY;
816
- }, {passive: true});
817
-
818
- appContainer.addEventListener('touchend', function(event) {
819
- if (event.target.closest('.form-container textarea, .form-container input, .detail-view')) {
820
- return;
 
 
 
 
 
 
 
 
 
821
  }
822
- touchendX = event.changedTouches[0].screenX;
823
- touchendY = event.changedTouches[0].screenY;
824
- handleSwipe();
825
- }, {passive: true});
826
-
827
- function handleSwipe() {
828
- const deltaX = touchendX - touchstartX;
829
- const deltaY = touchendY - touchstartY;
830
-
831
- if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) {
832
- const tabs = ['resumes', 'vacancies', 'freelance_offers'];
833
- const currentIndex = tabs.indexOf(currentView);
834
-
835
- if (deltaX < 0) { // Swiped left
836
- if (currentIndex < tabs.length - 1) {
837
- loadView(tabs[currentIndex + 1]);
838
- tg.HapticFeedback.selectionChanged();
839
- }
840
- } else { // Swiped right
841
- if (currentIndex > 0) {
842
- loadView(tabs[currentIndex - 1]);
843
- tg.HapticFeedback.selectionChanged();
844
- }
845
- }
846
  }
847
- touchstartX = 0; touchendX = 0; touchstartY = 0; touchendY = 0; // Reset
848
  }
849
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
850
 
851
  async function init() {
852
  tg.ready();
853
  applyThemeParams();
854
  tg.expand();
855
  tg.enableClosingConfirmation();
856
- tg.setHeaderColor(tg.themeParams.secondary_bg_color || '#f0f0f0'); // Match user info bg
857
-
858
- const greetingEl = document.getElementById('userGreeting');
859
- const avatarEl = document.getElementById('userAvatar');
860
- greetingEl.textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}! (@${tg.initDataUnsafe.user?.username || 'anonymous'})`;
 
 
 
 
 
861
 
862
  try {
863
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
864
  currentUser = authResponse.user;
865
  if (currentUser) {
866
- greetingEl.textContent = `${currentUser.first_name || ''} ${currentUser.last_name || ''} (@${currentUser.username || 'anon'})`;
867
- if (currentUser.photo_url) {
868
- avatarEl.src = currentUser.photo_url;
869
- avatarEl.style.display = 'inline-block';
870
- } else {
871
- avatarEl.style.display = 'none';
872
- }
873
  }
874
  } catch (error) {
875
  console.error("Auth error:", error);
876
- greetingEl.textContent = `Auth failed. Limited functionality.`;
877
  tg.showAlert("Authentication with the server failed. Some features might not work correctly.");
878
  }
879
 
880
  document.querySelectorAll('.tab-button').forEach(button => {
881
- button.addEventListener('click', () => { loadView(button.dataset.tab); tg.HapticFeedback.selectionChanged(); });
 
 
 
 
 
 
 
882
  });
883
- document.getElementById('fabButton').addEventListener('click', () => { showForm(currentView); tg.HapticFeedback.impactOccurred('medium'); });
884
 
885
- setupSwipeGestures();
886
- loadView('resumes'); // Load initial view
887
  }
888
 
889
  init();
@@ -904,11 +854,11 @@ ADMIN_TEMPLATE = '''
904
  .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
905
  h1, h2 { color: #333; }
906
  .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
907
- .item { border-bottom: 1px solid #eee; padding: 10px 0; display: flex; align-items: flex-start; }
908
  .item:last-child { border-bottom: none; }
909
- .item-image { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; margin-right: 10px; background-color: #e0e0e0; }
910
- .item-details h3 { margin: 0 0 5px 0; }
911
- .item-details p { margin: 3px 0; font-size: 0.9em; color: #555; }
912
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
913
  .button-primary { background-color: #007bff; color: white; }
914
  .button-danger { background-color: #dc3545; color: white; }
@@ -935,43 +885,79 @@ ADMIN_TEMPLATE = '''
935
  <div class="section">
936
  <h2>Data Synchronization with Hugging Face</h2>
937
  <div class="sync-buttons">
938
- <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data AND images to Hugging Face? This will overwrite server data.');">
939
- <button type="submit" class="button button-primary">Upload All to HF</button>
940
  </form>
941
- <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data AND images from Hugging Face? This will overwrite local data.');">
942
- <button type="submit" class="button button-secondary">Download All from HF</button>
943
  </form>
944
  </div>
945
- <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
946
  </div>
947
 
948
- {% for item_type_plural, items_list in [('Resumes', resumes), ('Vacancies', vacancies), ('Freelance Offers', freelance_offers)] %}
949
  <div class="section">
950
- <h2>{{ item_type_plural }} ({{ items_list|length }})</h2>
951
- {% for item in items_list %}
952
  <div class="item">
953
- {% if item.image_filename %}
954
- <img src="{{ url_for('serve_uploaded_image', filename=item.image_filename) }}" class="item-image" alt="Image">
955
- {% else %}
956
- <div class="item-image"></div>
 
957
  {% endif %}
958
- <div class="item-details">
959
- <h3>{{ item.title or item.name }} {% if item.company_name %}- {{ item.company_name }}{% endif %}</h3>
960
- <p>ID: {{ item.id }}</p>
961
- <p>User ID: {{ item.user_id }} (@{{ item.user_telegram_username }})</p>
962
- <p>Posted: {{ item.timestamp }}</p>
963
- <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this item?');">
964
- <input type="hidden" name="item_type" value="{{ item_type_plural.lower().replace(' ', '_') }}">
965
- <input type="hidden" name="item_id" value="{{ item.id }}">
966
- <button type="submit" class="button button-danger">Delete</button>
967
- </form>
968
- </div>
969
  </div>
970
  {% else %}
971
- <p>No {{ item_type_plural.lower() }} found.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  {% endfor %}
973
  </div>
974
- {% endfor %}
975
  </div>
976
  </body>
977
  </html>
@@ -981,8 +967,8 @@ ADMIN_TEMPLATE = '''
981
  def main_app_view():
982
  return render_template_string(MAIN_APP_TEMPLATE)
983
 
984
- @app.route('/uploads/<filename>')
985
- def serve_uploaded_image(filename):
986
  return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
987
 
988
  @app.route('/api/auth_user', methods=['POST'])
@@ -1014,8 +1000,16 @@ def auth_user():
1014
  'photo_url': user_data_from_tg.get('photo_url'),
1015
  'first_seen': datetime.now().isoformat()
1016
  }
 
 
 
 
 
 
 
 
 
1017
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
1018
- users[user_id_str]['photo_url'] = user_data_from_tg.get('photo_url', users[user_id_str].get('photo_url')) # Update photo_url if changed
1019
  data['users'] = users
1020
  save_data(data)
1021
 
@@ -1025,9 +1019,12 @@ def get_authenticated_user(request_obj):
1025
  auth_data_str = request_obj.headers.get('X-Telegram-Auth')
1026
  if not auth_data_str:
1027
  return None
1028
- is_valid, user_data_from_tg = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1029
- if is_valid and user_data_from_tg:
1030
- return user_data_from_tg
 
 
 
1031
  return None
1032
 
1033
  @app.route('/api/<item_type>', methods=['GET'])
@@ -1048,19 +1045,6 @@ def get_item(item_type, item_id):
1048
  return jsonify(item), 200
1049
  return jsonify({"error": "Item not found"}), 404
1050
 
1051
- def process_and_save_item_image(request_files, item_data):
1052
- if 'image' in request_files:
1053
- file = request_files['image']
1054
- if file and file.filename != '':
1055
- ensure_images_folder_exists()
1056
- filename = secure_filename(f"{uuid.uuid4()}_{file.filename}")
1057
- filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
1058
- file.save(filepath)
1059
- item_data['image_filename'] = filename
1060
- upload_single_image_to_hf(filepath, filename)
1061
- return filename
1062
- return None
1063
-
1064
  @app.route('/api/<item_type>', methods=['POST'])
1065
  def create_item(item_type):
1066
  user = get_authenticated_user(request)
@@ -1070,9 +1054,8 @@ def create_item(item_type):
1070
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1071
  return jsonify({"error": "Invalid item type"}), 400
1072
 
1073
- req_data_form = request.form
1074
- if not req_data_form:
1075
- return jsonify({"error": "No data provided"}), 400
1076
 
1077
  new_item = {
1078
  "id": str(uuid.uuid4()),
@@ -1081,37 +1064,43 @@ def create_item(item_type):
1081
  "timestamp": datetime.now().isoformat(),
1082
  "image_filename": None
1083
  }
 
 
 
 
 
 
1084
 
1085
- process_and_save_item_image(request.files, new_item)
1086
 
1087
  if item_type == 'resumes':
1088
  required_fields = ['name', 'title']
1089
  for field in required_fields:
1090
- if not req_data_form.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1091
  new_item.update({
1092
- "name": req_data_form.get('name'), "title": req_data_form.get('title'),
1093
- "skills": req_data_form.get('skills', ''), "experience": req_data_form.get('experience', ''),
1094
- "education": req_data_form.get('education', ''), "contact": req_data_form.get('contact', ''),
1095
- "portfolio_link": req_data_form.get('portfolio_link', '')
1096
  })
1097
  elif item_type == 'vacancies':
1098
  required_fields = ['company_name', 'title']
1099
  for field in required_fields:
1100
- if not req_data_form.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1101
  new_item.update({
1102
- "company_name": req_data_form.get('company_name'), "title": req_data_form.get('title'),
1103
- "description": req_data_form.get('description', ''), "requirements": req_data_form.get('requirements', ''),
1104
- "salary": req_data_form.get('salary', ''), "location": req_data_form.get('location', ''),
1105
- "contact": req_data_form.get('contact', '')
1106
  })
1107
  elif item_type == 'freelance_offers':
1108
  required_fields = ['title']
1109
  for field in required_fields:
1110
- if not req_data_form.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1111
  new_item.update({
1112
- "title": req_data_form.get('title'), "description": req_data_form.get('description', ''),
1113
- "budget": req_data_form.get('budget', ''), "deadline": req_data_form.get('deadline', ''),
1114
- "skills_needed": req_data_form.get('skills_needed', ''), "contact": req_data_form.get('contact', '')
1115
  })
1116
 
1117
  data = load_data()
@@ -1127,8 +1116,7 @@ def update_item(item_type, item_id):
1127
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1128
  return jsonify({"error": "Invalid item type"}), 400
1129
 
1130
- req_data_form = request.form
1131
- if not req_data_form: return jsonify({"error": "No data provided"}), 400
1132
 
1133
  data = load_data()
1134
  items_list = data.get(item_type, [])
@@ -1146,45 +1134,50 @@ def update_item(item_type, item_id):
1146
 
1147
  updated_item = original_item.copy()
1148
  updated_item['updated_timestamp'] = datetime.now().isoformat()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1149
 
1150
- old_image_filename = original_item.get('image_filename')
1151
- new_image_filename = process_and_save_item_image(request.files, updated_item)
1152
 
1153
- if new_image_filename and old_image_filename and new_image_filename != old_image_filename:
1154
- try:
1155
- os.remove(os.path.join(app.config['UPLOAD_FOLDER'], old_image_filename))
1156
- delete_image_from_hf(old_image_filename)
1157
- except OSError as e:
1158
- logging.error(f"Error deleting old local image {old_image_filename}: {e}")
1159
-
1160
  if item_type == 'resumes':
1161
  updated_item.update({
1162
- "name": req_data_form.get('name', original_item.get('name')),
1163
- "title": req_data_form.get('title', original_item.get('title')),
1164
- "skills": req_data_form.get('skills', original_item.get('skills')),
1165
- "experience": req_data_form.get('experience', original_item.get('experience')),
1166
- "education": req_data_form.get('education', original_item.get('education')),
1167
- "contact": req_data_form.get('contact', original_item.get('contact')),
1168
- "portfolio_link": req_data_form.get('portfolio_link', original_item.get('portfolio_link'))
1169
  })
1170
  elif item_type == 'vacancies':
1171
- updated_item.update({
1172
- "company_name": req_data_form.get('company_name', original_item.get('company_name')),
1173
- "title": req_data_form.get('title', original_item.get('title')),
1174
- "description": req_data_form.get('description', original_item.get('description')),
1175
- "requirements": req_data_form.get('requirements', original_item.get('requirements')),
1176
- "salary": req_data_form.get('salary', original_item.get('salary')),
1177
- "location": req_data_form.get('location', original_item.get('location')),
1178
- "contact": req_data_form.get('contact', original_item.get('contact'))
1179
  })
1180
  elif item_type == 'freelance_offers':
1181
  updated_item.update({
1182
- "title": req_data_form.get('title', original_item.get('title')),
1183
- "description": req_data_form.get('description', original_item.get('description')),
1184
- "budget": req_data_form.get('budget', original_item.get('budget')),
1185
- "deadline": req_data_form.get('deadline', original_item.get('deadline')),
1186
- "skills_needed": req_data_form.get('skills_needed', original_item.get('skills_needed')),
1187
- "contact": req_data_form.get('contact', original_item.get('contact'))
1188
  })
1189
 
1190
  data[item_type][item_index] = updated_item
@@ -1201,26 +1194,23 @@ def delete_item(item_type, item_id):
1201
 
1202
  data = load_data()
1203
  items_list = data.get(item_type, [])
1204
- original_length = len(items_list)
1205
 
1206
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1207
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1208
 
1209
  if str(item_to_delete.get('user_id')) != str(user.get('id')):
1210
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
1211
-
1212
- image_filename_to_delete = item_to_delete.get('image_filename')
1213
- if image_filename_to_delete:
1214
- try:
1215
- os.remove(os.path.join(app.config['UPLOAD_FOLDER'], image_filename_to_delete))
1216
- logging.info(f"Deleted local image: {image_filename_to_delete}")
1217
- except OSError as e:
1218
- logging.error(f"Error deleting local image {image_filename_to_delete}: {e}")
1219
- delete_image_from_hf(image_filename_to_delete)
1220
-
1221
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1222
 
1223
  if len(data[item_type]) < original_length:
 
 
 
 
 
 
1224
  save_data(data)
1225
  return jsonify({"message": "Item deleted successfully"}), 200
1226
  return jsonify({"error": "Item not found or deletion failed"}), 404
@@ -1229,63 +1219,50 @@ def delete_item(item_type, item_id):
1229
  @app.route('/admin', methods=['GET'])
1230
  def admin_panel():
1231
  data = load_data()
1232
- # Map item_type_plural to the correct key in data
1233
- item_type_map = {
1234
- 'resumes': 'resumes',
1235
- 'vacancies': 'vacancies',
1236
- 'freelance_offers': 'freelance_offers' # Key for freelance_offers in data
1237
- }
1238
  return render_template_string(ADMIN_TEMPLATE,
1239
- resumes=sorted(data.get(item_type_map['resumes'], []), key=lambda x: x.get('timestamp', ''), reverse=True),
1240
- vacancies=sorted(data.get(item_type_map['vacancies'], []), key=lambda x: x.get('timestamp', ''), reverse=True),
1241
- freelance_offers=sorted(data.get(item_type_map['freelance_offers'], []), key=lambda x: x.get('timestamp', ''), reverse=True))
1242
-
1243
 
1244
  @app.route('/admin/delete', methods=['POST'])
1245
  def admin_delete_item():
1246
- item_type_form_value = request.form.get('item_type') # e.g., "resumes", "vacancies", "freelance_offers"
1247
  item_id = request.form.get('item_id')
1248
 
1249
- # Ensure item_type_form_value is one of the valid keys for data dictionary
1250
- valid_item_types = ['resumes', 'vacancies', 'freelance_offers']
1251
- if not item_type_form_value or not item_id or item_type_form_value not in valid_item_types:
1252
  flash('Invalid item type or ID for deletion.', 'error')
1253
  return redirect(url_for('admin_panel'))
1254
 
1255
  data = load_data()
1256
- items_list = data.get(item_type_form_value, [])
1257
- original_length = len(items_list)
1258
 
1259
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1260
-
1261
- if item_to_delete:
1262
- image_filename_to_delete = item_to_delete.get('image_filename')
1263
- if image_filename_to_delete:
 
 
 
 
 
1264
  try:
1265
- os.remove(os.path.join(app.config['UPLOAD_FOLDER'], image_filename_to_delete))
1266
- logging.info(f"Admin deleted local image: {image_filename_to_delete}")
1267
  except OSError as e:
1268
- logging.error(f"Admin error deleting local image {image_filename_to_delete}: {e}")
1269
- delete_image_from_hf(image_filename_to_delete)
1270
-
1271
- data[item_type_form_value] = [i for i in items_list if i['id'] != item_id]
1272
- if len(data[item_type_form_value]) < original_length:
1273
- save_data(data)
1274
- flash(f'{item_type_form_value.capitalize().replace("_", " ")[:-1]} deleted successfully.', 'success')
1275
- else:
1276
- flash('Item not found or already deleted.', 'warning') # Should not happen if item_to_delete was found
1277
  else:
1278
- flash('Item not found.', 'warning')
1279
-
1280
  return redirect(url_for('admin_panel'))
1281
 
1282
  @app.route('/admin/force_upload', methods=['POST'])
1283
  def force_upload_admin():
1284
  logging.info("Admin forcing upload to Hugging Face...")
1285
  try:
1286
- upload_data_file_to_hf()
1287
- upload_images_folder_to_hf()
1288
- flash("Data and images successfully uploaded to Hugging Face.", 'success')
1289
  except Exception as e:
1290
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1291
  flash(f"Error uploading to Hugging Face: {e}", 'error')
@@ -1295,17 +1272,11 @@ def force_upload_admin():
1295
  def force_download_admin():
1296
  logging.info("Admin forcing download from Hugging Face...")
1297
  try:
1298
- data_download_ok = download_data_file_from_hf()
1299
- images_download_ok = download_images_folder_from_hf() # Call new function
1300
- if data_download_ok : # Check if data file download was specifically ok
1301
- flash("Data file successfully downloaded from Hugging Face. Local files updated.", 'success')
1302
- if images_download_ok:
1303
- flash("Images folder also successfully downloaded/updated from Hugging Face.", 'success')
1304
- else:
1305
- flash("Images folder download/update failed or was skipped. Check logs.", 'warning')
1306
  load_data()
1307
  else:
1308
- flash("Failed to download data file from Hugging Face. Check logs.", 'error')
1309
  except Exception as e:
1310
  logging.error(f"Error during forced download: {e}", exc_info=True)
1311
  flash(f"Error downloading from Hugging Face: {e}", 'error')
@@ -1313,12 +1284,11 @@ def force_download_admin():
1313
 
1314
 
1315
  if __name__ == '__main__':
1316
- ensure_images_folder_exists()
1317
- logging.info("Application starting up. Performing initial data and image sync...")
1318
- download_data_file_from_hf()
1319
- download_images_folder_from_hf()
1320
- load_data() # This will load the data file, potentially creating a default if download failed
1321
- logging.info("Initial data load and image sync complete.")
1322
 
1323
  if HF_TOKEN_WRITE:
1324
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
 
5
  import threading
6
  import time
7
  from datetime import datetime
8
+ from huggingface_hub import HfApi, hf_hub_download
9
+ from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from werkzeug.utils import secure_filename
11
  from dotenv import load_dotenv
12
  import uuid
 
19
  app = Flask(__name__)
20
  app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
21
  DATA_FILE = 'tontalent_data.json'
22
+ SYNC_FILES = [DATA_FILE]
23
+ UPLOAD_FOLDER = 'tontalent_uploads'
24
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
25
+ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
26
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
27
 
28
  REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
29
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
30
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
31
 
32
+ TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8"
33
 
34
  DOWNLOAD_RETRIES = 3
35
  DOWNLOAD_DELAY = 5
36
 
37
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
38
 
39
+ def allowed_file(filename):
40
+ return '.' in filename and \
41
+ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
42
+
43
+ def ensure_upload_folder():
44
+ if not os.path.exists(UPLOAD_FOLDER):
45
+ os.makedirs(UPLOAD_FOLDER)
46
+ logging.info(f"Created upload folder: {UPLOAD_FOLDER}")
47
 
48
+ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
49
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
50
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
51
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
52
+ files_to_download = [specific_file] if specific_file else SYNC_FILES
53
+ logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
54
+ all_successful = True
55
+ for file_name in files_to_download:
56
+ success = False
57
+ for attempt in range(retries + 1):
58
+ try:
59
+ logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
60
+ local_path = hf_hub_download(
61
+ repo_id=REPO_ID, filename=file_name, repo_type="dataset",
62
+ token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
63
+ force_download=True, resume_download=False
64
+ )
65
+ logging.info(f"Successfully downloaded {file_name} to {local_path}.")
66
+ success = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  break
68
+ except RepositoryNotFoundError:
69
+ logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
70
+ return False
71
+ except HfHubHTTPError as e:
72
+ if e.response.status_code == 404:
73
+ logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
74
+ if attempt == 0 and not os.path.exists(file_name):
75
+ try:
76
+ if file_name == DATA_FILE:
77
+ with open(file_name, 'w', encoding='utf-8') as f:
78
+ json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
79
+ logging.info(f"Created empty local file {file_name} because it was not found on HF.")
80
+ except Exception as create_e:
81
+ logging.error(f"Failed to create empty local file {file_name}: {create_e}")
82
+ success = False
83
+ break
84
+ else:
85
+ logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
86
+ except Exception as e:
87
+ logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
88
+ if attempt < retries: time.sleep(delay)
89
+ if not success:
90
+ logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
91
+ all_successful = False
92
+ logging.info(f"Download process finished. Overall success: {all_successful}")
93
+ return all_successful
94
+
95
+ def upload_db_to_hf(specific_file=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  if not HF_TOKEN_WRITE:
97
+ logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
 
 
 
98
  return
99
  try:
100
  api = HfApi()
101
+ files_to_upload = [specific_file] if specific_file else SYNC_FILES
102
+ logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
103
+ for file_name in files_to_upload:
104
+ if os.path.exists(file_name):
105
+ try:
106
+ api.upload_file(
107
+ path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
108
+ repo_type="dataset", token=HF_TOKEN_WRITE,
109
+ commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
110
+ )
111
+ logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
112
+ except Exception as e:
113
+ logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
114
+ else:
115
+ logging.warning(f"File {file_name} not found locally, skipping upload.")
116
+ logging.info("Finished uploading files to HF.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  except Exception as e:
118
+ logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
 
119
 
120
  def periodic_backup():
121
  backup_interval = 1800
 
123
  while True:
124
  time.sleep(backup_interval)
125
  logging.info("Starting periodic backup...")
126
+ upload_db_to_hf()
 
127
  logging.info("Periodic backup finished.")
128
 
129
  def load_data():
 
141
  except (FileNotFoundError, json.JSONDecodeError) as e:
142
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
143
 
144
+ if download_db_from_hf(specific_file=DATA_FILE):
145
  try:
146
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
147
  data = json.load(file)
 
177
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
178
  json.dump(data, file, ensure_ascii=False, indent=4)
179
  logging.info(f"Data successfully saved to {DATA_FILE}")
180
+ upload_db_to_hf(specific_file=DATA_FILE)
181
  except Exception as e:
182
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
183
 
 
209
  return False, None
210
  return False, None
211
 
 
212
  MAIN_APP_TEMPLATE = '''
213
  <!DOCTYPE html>
214
  <html lang="en">
215
  <head>
216
  <meta charset="UTF-8">
217
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
218
  <title>TonTalent</title>
219
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
220
  <style>
 
231
  --tg-theme-section-header-text-color: #8e8e93;
232
  --tg-theme-destructive-text-color: #ff3b30;
233
  --tg-theme-accent-text-color: #007aff;
234
+ --system-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
235
+ --border-radius-s: 8px;
236
+ --border-radius-m: 12px;
237
+ --spacing-xs: 4px;
238
+ --spacing-s: 8px;
239
+ --spacing-m: 12px;
240
+ --spacing-l: 16px;
241
+ --spacing-xl: 20px;
242
  }
243
  body {
244
+ font-family: var(--system-font);
245
  margin: 0;
246
  padding: 0;
247
  background-color: var(--tg-theme-bg-color);
 
249
  overscroll-behavior-y: none;
250
  -webkit-font-smoothing: antialiased;
251
  -moz-osx-font-smoothing: grayscale;
252
+ line-height: 1.4;
 
253
  }
254
  .app-container { display: flex; flex-direction: column; min-height: 100vh; }
255
  .header {
256
  background-color: var(--tg-theme-header-bg-color);
257
+ padding: var(--spacing-m) var(--spacing-l);
258
  text-align: center;
259
  font-weight: 600;
260
+ font-size: 18px;
261
+ border-bottom: 1px solid var(--tg-theme-secondary-bg-color);
262
  position: sticky;
263
  top: 0;
264
  z-index: 100;
265
  }
266
+ .user-info-bar {
267
+ display: flex;
 
 
 
 
 
268
  align-items: center;
269
+ padding: var(--spacing-s) var(--spacing-l);
270
+ background-color: var(--tg-theme-secondary-bg-color);
271
+ border-bottom: 1px solid var(--tg-theme-secondary-bg-color);
272
  }
273
+ .user-avatar {
274
+ width: 36px;
275
+ height: 36px;
276
+ border-radius: 50%;
277
+ margin-right: var(--spacing-m);
278
+ background-color: var(--tg-theme-hint-color); /* Placeholder */
279
+ object-fit: cover;
280
  }
281
+ .user-details {
282
+ font-size: 14px;
283
+ color: var(--tg-theme-text-color);
284
+ }
285
+ .user-details strong { font-weight: 500; }
286
+ .user-details span { color: var(--tg-theme-hint-color); font-size: 12px; }
287
+
288
+ .tabs { display: flex; background-color: var(--tg-theme-bg-color); padding: var(--spacing-s); border-bottom: 1px solid var(--tg-theme-secondary-bg-color); }
289
  .tab-button {
290
  flex: 1;
291
+ padding: var(--spacing-m);
292
  text-align: center;
293
  cursor: pointer;
294
  background: none;
 
296
  color: var(--tg-theme-hint-color);
297
  font-size: 15px;
298
  font-weight: 500;
299
+ border-bottom: 3px solid transparent;
300
+ transition: color 0.2s ease, border-bottom-color 0.2s ease;
 
301
  }
302
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); font-weight: 600; }
303
+ .content { flex-grow: 1; padding: var(--spacing-l); overflow-x: hidden; }
304
+ .content.entering { animation: fadeIn 0.3s ease-out; }
305
+ .content.detail-view-entering { animation: slideInFromRight 0.3s ease-out; }
306
+ .content.form-view-entering { animation: slideInFromRight 0.3s ease-out; }
307
+
308
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
309
+ @keyframes slideInFromRight { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
310
+
311
  .list-item {
312
  background-color: var(--tg-theme-section-bg-color);
313
+ border-radius: var(--border-radius-m);
314
+ padding: var(--spacing-m) var(--spacing-l);
315
+ margin-bottom: var(--spacing-m);
316
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
317
  cursor: pointer;
318
+ transition: transform 0.15s ease-out, background-color 0.15s ease-out;
 
 
319
  }
320
+ .list-item:active { transform: scale(0.98); background-color: var(--tg-theme-secondary-bg-color); }
321
+ .list-item-header { display: flex; align-items: center; margin-bottom: var(--spacing-s); }
322
+ .list-item-image { width: 50px; height: 50px; border-radius: var(--border-radius-s); object-fit: cover; margin-right: var(--spacing-m); background-color: var(--tg-theme-secondary-bg-color); }
323
+ .list-item-text h3 { margin: 0 0 var(--spacing-xs) 0; font-size: 17px; font-weight: 600; color: var(--tg-theme-text-color); }
324
+ .list-item-text p { margin: 0 0 var(--spacing-xs) 0; font-size: 14px; color: var(--tg-theme-hint-color); }
325
+ .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); margin-top: var(--spacing-s); }
326
 
327
+ .form-container { padding: var(--spacing-l); background-color: var(--tg-theme-section-bg-color); }
328
+ .form-group { margin-bottom: var(--spacing-l); }
329
+ .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: var(--spacing-s); font-weight: 500; }
330
  .form-group input, .form-group textarea, .form-group input[type="file"] {
331
  width: 100%;
332
+ padding: var(--spacing-m);
333
  border: 1px solid var(--tg-theme-secondary-bg-color);
334
+ border-radius: var(--border-radius-s);
335
  font-size: 16px;
336
  background-color: var(--tg-theme-bg-color);
337
  color: var(--tg-theme-text-color);
338
  box-sizing: border-box;
339
+ font-family: var(--system-font);
 
 
 
 
340
  }
341
+ .form-group input[type="file"] { padding: var(--spacing-s); }
342
  .form-group textarea { min-height: 100px; resize: vertical; }
343
+ .form-group input:focus, .form-group textarea:focus { border-color: var(--tg-theme-link-color); box-shadow: 0 0 0 2px var(--tg-theme-link-color-alpha, rgba(0,122,255,0.2)); outline: none; }
344
+
 
345
  .fab {
346
  position: fixed;
347
+ bottom: var(--spacing-xl);
348
+ right: var(--spacing-xl);
349
  width: 56px;
350
  height: 56px;
351
  background-color: var(--tg-theme-button-color);
352
  color: var(--tg-theme-button-text-color);
353
+ border-radius: 50%;
354
  display: flex;
355
  align-items: center;
356
  justify-content: center;
357
  font-size: 28px;
358
  line-height: 1;
 
359
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
360
  cursor: pointer;
361
  z-index: 1000;
362
  border: none;
363
+ transition: transform 0.2s ease;
364
  }
365
+ .fab:active { transform: scale(0.95); }
366
+
367
+ .detail-view { padding: var(--spacing-l); background-color: var(--tg-theme-section-bg-color); }
368
+ .detail-view img.item-image-large { width: 100%; max-height: 300px; object-fit: cover; border-radius: var(--border-radius-m); margin-bottom: var(--spacing-l); background-color: var(--tg-theme-secondary-bg-color); }
369
+ .detail-view h2 { margin-top: 0; font-size: 22px; font-weight: 600; color: var(--tg-theme-text-color); margin-bottom: var(--spacing-s); }
370
+ .detail-view p { margin-bottom: var(--spacing-m); line-height: 1.6; font-size: 16px; }
371
  .detail-view strong { font-weight: 500; color: var(--tg-theme-section-header-text-color); }
372
+ .detail-view .meta { font-size: 13px; color: var(--tg-theme-hint-color); margin-top: var(--spacing-l); }
373
+ .detail-view a { color: var(--tg-theme-link-color); text-decoration: none; }
374
+ .detail-view a:hover { text-decoration: underline; }
375
+
376
+ .loading, .empty-state { text-align: center; padding: 50px var(--spacing-l); color: var(--tg-theme-hint-color); font-size: 16px; }
377
+ .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: var(--spacing-s); }
378
+ .image-preview { max-width: 100px; max-height: 100px; margin-top: var(--spacing-s); border-radius: var(--border-radius-s); }
379
  </style>
380
  </head>
381
  <body>
382
+ <div class="app-container">
383
  <div class="header">TonTalent</div>
384
+ <div class="user-info-bar" id="userInfoContainer">
385
+ <img src="" alt="User Avatar" class="user-avatar" id="userAvatar" style="display:none;">
386
+ <div class="user-details" id="userInfo">Loading user...</div>
387
  </div>
388
  <div class="tabs">
389
  <button class="tab-button active" data-tab="resumes">Resumes</button>
 
404
  const mainContentEl = document.getElementById('mainContent');
405
 
406
  function applyThemeParams() {
407
+ const root = document.documentElement;
408
+ root.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
409
+ root.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
410
+ root.style.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
411
+ root.style.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
412
+ root.style.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
413
+ root.style.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
414
+ root.style.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
415
+ root.style.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
416
+ root.style.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
417
+ root.style.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
418
+ root.style.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
419
+ root.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
420
+ root.style.setProperty('--tg-theme-link-color-alpha', (tg.themeParams.link_color || '#007aff') + '33');
 
 
 
 
 
 
 
 
 
421
  }
422
 
423
+ async function apiCall(endpoint, method = 'GET', body = null) {
424
  const headers = {};
425
+ if (!(body instanceof FormData)) {
426
+ headers['Content-Type'] = 'application/json';
427
  }
428
  if (tg.initData) {
429
  headers['X-Telegram-Auth'] = tg.initData;
430
  }
431
  const options = { method, headers };
432
+ if (body) {
433
+ options.body = (body instanceof FormData) ? body : JSON.stringify(body);
434
+ }
435
+
436
+ tg.MainButton.showProgress();
437
  try {
438
  const response = await fetch(endpoint, options);
439
  if (!response.ok) {
440
+ const errorData = await response.json().catch(() => ({ error: 'Request failed, server returned non-JSON response' }));
441
  throw new Error(errorData.error || `HTTP error ${response.status}`);
442
  }
443
  return response.json();
444
  } catch (error) {
445
  console.error('API Call Error:', error);
446
  tg.showAlert(error.message || 'An API error occurred.');
447
+ tg.HapticFeedback.notificationOccurred('error');
448
  throw error;
449
+ } finally {
450
+ tg.MainButton.hideProgress();
451
  }
452
  }
453
 
454
  function renderList(items, type) {
455
+ mainContentEl.classList.remove('detail-view-entering', 'form-view-entering');
456
+ mainContentEl.classList.add('entering');
457
+ if (!items || items.length === 0) {
458
+ mainContentEl.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
459
+ return;
460
+ }
461
+ mainContentEl.innerHTML = items.map(item => `
462
+ <div class="list-item" onclick="showDetailView('${type}', '${item.id}')">
463
+ <div class="list-item-header">
464
+ ${item.image_filename ? `<img src="/uploads/${item.image_filename}" class="list-item-image" alt="${item.title || item.name || 'Item image'}">` : `<div class="list-item-image"></div>`}
465
+ <div class="list-item-text">
466
+ <h3>${item.title || item.name || 'Untitled'}</h3>
467
+ ${type === 'vacancies' && item.company_name ? `<p>${item.company_name}</p>` : ''}
468
+ ${type === 'freelance_offers' && item.budget ? `<p>Budget: ${item.budget}</p>` : ''}
469
  </div>
470
+ </div>
471
+ <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
472
+ </div>
473
+ `).join('');
474
+ setTimeout(() => mainContentEl.classList.remove('entering'), 300);
475
  }
476
 
477
  function showDetailView(type, id) {
478
  tg.BackButton.show();
479
+ tg.BackButton.onClick(() => {
480
+ loadView(type);
481
+ tg.HapticFeedback.impactOccurred('light');
482
+ });
483
  tg.MainButton.hide();
484
  document.getElementById('fabButton').style.display = 'none';
485
+ mainContentEl.classList.remove('entering', 'form-view-entering');
486
+ mainContentEl.classList.add('detail-view-entering');
487
 
488
  apiCall(`/api/${type}/${id}`)
489
  .then(item => {
490
  currentItem = item;
491
  let detailsHtml = `<div class="detail-view">`;
492
  if (item.image_filename) {
493
+ detailsHtml += `<img src="/uploads/${item.image_filename}" class="item-image-large" alt="${item.title || item.name}">`;
494
  }
495
  detailsHtml += `<h2>${item.title || item.name}</h2>`;
496
+
497
  if (type === 'resumes') {
498
  detailsHtml += `
499
  <p><strong>Skills:</strong> ${item.skills || 'N/A'}</p>
500
  <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
501
  <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
502
  <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
503
+ ${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank" rel="noopener noreferrer">${item.portfolio_link}</a></p>` : ''}
504
  `;
505
  } else if (type === 'vacancies') {
506
  detailsHtml += `
 
521
  `;
522
  }
523
  detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
524
+ mainContentEl.innerHTML = detailsHtml;
525
+ setTimeout(() => mainContentEl.classList.remove('detail-view-entering'), 300);
 
 
 
526
 
527
+ if (currentUser && item.user_id === String(currentUser.id)) {
528
  tg.MainButton.setText('Edit My Post');
529
+ tg.MainButton.onClick(() => {
530
+ showForm(type, item);
531
+ tg.HapticFeedback.impactOccurred('light');
532
+ });
533
  tg.MainButton.show();
534
  }
535
  })
536
  .catch(err => {
537
+ mainContentEl.innerHTML = `<div class="empty-state">Error loading details. Please try again.</div>`;
 
 
 
538
  });
539
  }
540
 
 
547
  tg.HapticFeedback.impactOccurred('light');
548
  });
549
  document.getElementById('fabButton').style.display = 'none';
550
+ mainContentEl.classList.remove('entering', 'detail-view-entering');
551
+ mainContentEl.classList.add('form-view-entering');
552
 
553
+ let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.charAt(0).toUpperCase() + type.slice(1, -1)}</h2>`;
554
+
555
  const commonFields = `
556
  <div class="form-group">
557
+ <label for="item_image">Image (Optional)</label>
558
+ <input type="file" id="item_image" name="item_image" accept="image/png, image/jpeg, image/gif">
559
+ ${itemToEdit && itemToEdit.image_filename ? `<img src="/uploads/${itemToEdit.image_filename}" class="image-preview" alt="Current image">` : ''}
 
 
560
  </div>
561
  `;
562
+
563
  if (type === 'resumes') {
564
  formHtml += `
565
+ <div class="form-group">
566
+ <label for="name">Full Name*</label>
567
+ <input type="text" id="name" value="${itemToEdit?.name || ''}" required>
568
+ </div>
569
+ <div class="form-group">
570
+ <label for="title">Job Title / Desired Position*</label>
571
+ <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
572
+ </div>
573
+ <div class="form-group">
574
+ <label for="skills">Skills (comma separated)</label>
575
+ <textarea id="skills">${itemToEdit?.skills || ''}</textarea>
576
+ </div>
577
+ <div class="form-group">
578
+ <label for="experience">Experience</label>
579
+ <textarea id="experience">${itemToEdit?.experience || ''}</textarea>
580
+ </div>
581
+ <div class="form-group">
582
+ <label for="education">Education</label>
583
+ <textarea id="education">${itemToEdit?.education || ''}</textarea>
584
+ </div>
585
+ <div class="form-group">
586
+ <label for="contact">Contact Info (e.g., email, or blank for Telegram)</label>
587
+ <input type="text" id="contact" value="${itemToEdit?.contact || ''}">
588
+ </div>
589
+ <div class="form-group">
590
+ <label for="portfolio_link">Portfolio Link (optional)</label>
591
+ <input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}">
592
+ </div>
593
  ${commonFields}
 
 
 
 
 
594
  `;
595
  } else if (type === 'vacancies') {
596
  formHtml += `
597
+ <div class="form-group">
598
+ <label for="company_name">Company Name*</label>
599
+ <input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required>
600
+ </div>
601
+ <div class="form-group">
602
+ <label for="title">Job Title*</label>
603
+ <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
604
+ </div>
605
+ <div class="form-group">
606
+ <label for="description">Description</label>
607
+ <textarea id="description">${itemToEdit?.description || ''}</textarea>
608
+ </div>
609
+ <div class="form-group">
610
+ <label for="requirements">Requirements</label>
611
+ <textarea id="requirements">${itemToEdit?.requirements || ''}</textarea>
612
+ </div>
613
+ <div class="form-group">
614
+ <label for="salary">Salary/Compensation</label>
615
+ <input type="text" id="salary" value="${itemToEdit?.salary || ''}">
616
+ </div>
617
+ <div class="form-group">
618
+ <label for="location">Location (e.g., Remote, City)</label>
619
+ <input type="text" id="location" value="${itemToEdit?.location || ''}">
620
+ </div>
621
+ <div class="form-group">
622
+ <label for="contact">Contact Info / How to Apply</label>
623
+ <textarea id="contact">${itemToEdit?.contact || ''}</textarea>
624
+ </div>
625
  ${commonFields}
 
 
 
 
 
626
  `;
627
  } else if (type === 'freelance_offers') {
628
  formHtml += `
629
+ <div class="form-group">
630
+ <label for="title">Project Title*</label>
631
+ <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
632
+ </div>
633
+ <div class="form-group">
634
+ <label for="description">Description of Work</label>
635
+ <textarea id="description">${itemToEdit?.description || ''}</textarea>
636
+ </div>
637
+ <div class="form-group">
638
+ <label for="budget">Budget</label>
639
+ <input type="text" id="budget" value="${itemToEdit?.budget || ''}">
640
+ </div>
641
+ <div class="form-group">
642
+ <label for="deadline">Expected Deadline</label>
643
+ <input type="text" id="deadline" value="${itemToEdit?.deadline || ''}">
644
+ </div>
645
+ <div class="form-group">
646
+ <label for="skills_needed">Skills Needed (comma separated)</label>
647
+ <textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea>
648
+ </div>
649
+ <div class="form-group">
650
+ <label for="contact">Contact Info (or blank for Telegram)</label>
651
+ <input type="text" id="contact" value="${itemToEdit?.contact || ''}">
652
+ </div>
653
  ${commonFields}
 
 
 
 
 
654
  `;
655
  }
656
  formHtml += `<div id="formError" class="error-message"></div></div>`;
657
+ mainContentEl.innerHTML = formHtml;
658
+ setTimeout(() => mainContentEl.classList.remove('form-view-entering'), 300);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
 
660
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
661
  tg.MainButton.show();
 
667
  let isValid = true;
668
  document.getElementById('formError').textContent = '';
669
 
 
 
 
 
 
 
670
  if (type === 'resumes') {
671
  payload.name = document.getElementById('name').value.trim();
672
  payload.title = document.getElementById('title').value.trim();
 
696
  }
697
 
698
  if (!isValid) {
699
+ document.getElementById('formError').textContent = 'Please fill in all required fields (marked with *).';
700
  tg.HapticFeedback.notificationOccurred('error');
701
  return;
702
  }
703
 
704
+ const formData = new FormData();
705
  for (const key in payload) {
706
  formData.append(key, payload[key]);
707
  }
708
+ const imageInput = document.getElementById('item_image');
709
+ if (imageInput && imageInput.files[0]) {
710
+ formData.append('item_image', imageInput.files[0]);
711
+ }
712
 
713
  const method = itemId ? 'PUT' : 'POST';
714
  const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
715
 
716
+ apiCall(endpoint, method, formData)
717
  .then(response => {
718
  tg.HapticFeedback.notificationOccurred('success');
 
719
  loadView(type);
720
  })
721
  .catch(err => {
722
  tg.HapticFeedback.notificationOccurred('error');
723
+ document.getElementById('formError').textContent = err.message || 'Failed to submit. Please try again.';
 
724
  });
725
  }
726
 
727
  function loadView(tabName) {
 
 
 
 
 
728
  currentView = tabName;
729
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
730
  document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
731
 
732
+ mainContentEl.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
 
 
 
 
 
733
  tg.BackButton.hide();
734
  tg.MainButton.hide();
735
  document.getElementById('fabButton').style.display = 'block';
736
 
737
  apiCall(`/api/${tabName}`)
738
+ .then(data => renderList(data, tabName))
739
  .catch(err => {
740
+ mainContentEl.innerHTML = `<div class="empty-state">Error loading ${tabName}. Please check your connection.</div>`;
 
 
 
 
741
  });
742
  }
743
+
744
+ let touchstartX = 0;
745
+ let touchendX = 0;
746
+ let touchstartY = 0;
747
+ let touchendY = 0;
748
+ const swipeThreshold = 75;
749
+ const swipeArea = mainContentEl;
750
+
751
+ swipeArea.addEventListener('touchstart', function(event) {
752
+ touchstartX = event.changedTouches[0].screenX;
753
+ touchstartY = event.changedTouches[0].screenY;
754
+ }, { passive: true });
755
+
756
+ swipeArea.addEventListener('touchend', function(event) {
757
+ touchendX = event.changedTouches[0].screenX;
758
+ touchendY = event.changedTouches[0].screenY;
759
+ handleSwipe();
760
+ }, { passive: true });
761
+
762
+ function handleSwipe() {
763
+ const deltaX = touchendX - touchstartX;
764
+ const deltaY = Math.abs(touchendY - touchstartY);
765
+
766
+ if (Math.abs(deltaX) > swipeThreshold && deltaY < Math.abs(deltaX) / 2) { // Horizontal swipe, not too vertical
767
+ const tabs = ['resumes', 'vacancies', 'freelance_offers'];
768
+ let currentIndex = tabs.indexOf(currentView);
769
+ if (deltaX < 0) { // Swiped left
770
+ currentIndex = (currentIndex + 1) % tabs.length;
771
+ } else { // Swiped right
772
+ currentIndex = (currentIndex - 1 + tabs.length) % tabs.length;
773
  }
774
+ if (tabs[currentIndex] !== currentView) {
775
+ loadView(tabs[currentIndex]);
776
+ tg.HapticFeedback.selectionChanged();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  }
 
778
  }
779
  }
780
+
781
+ function displayUserInfo(user) {
782
+ const userInfoDiv = document.getElementById('userInfo');
783
+ const userAvatarEl = document.getElementById('userAvatar');
784
+ let displayName = user.first_name || 'User';
785
+ if (user.last_name) displayName += ' ' + user.last_name;
786
+ let usernameString = user.username ? `(@${user.username})` : '(anonymous)';
787
+
788
+ if (user.photo_url) {
789
+ userAvatarEl.src = user.photo_url;
790
+ userAvatarEl.style.display = 'inline-block';
791
+ } else {
792
+ userAvatarEl.style.display = 'none';
793
+ }
794
+ userInfoDiv.innerHTML = `<strong>${displayName}</strong><br><span>${usernameString}</span>`;
795
+ }
796
 
797
  async function init() {
798
  tg.ready();
799
  applyThemeParams();
800
  tg.expand();
801
  tg.enableClosingConfirmation();
802
+
803
+ const tgUser = tg.initDataUnsafe.user;
804
+ if (tgUser) {
805
+ displayUserInfo({
806
+ first_name: tgUser.first_name,
807
+ last_name: tgUser.last_name,
808
+ username: tgUser.username,
809
+ photo_url: tgUser.photo_url
810
+ });
811
+ }
812
 
813
  try {
814
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
815
  currentUser = authResponse.user;
816
  if (currentUser) {
817
+ displayUserInfo(currentUser);
 
 
 
 
 
 
818
  }
819
  } catch (error) {
820
  console.error("Auth error:", error);
821
+ document.getElementById('userInfo').innerHTML = `<strong>Authentication Failed</strong><br><span>Limited functionality</span>`;
822
  tg.showAlert("Authentication with the server failed. Some features might not work correctly.");
823
  }
824
 
825
  document.querySelectorAll('.tab-button').forEach(button => {
826
+ button.addEventListener('click', () => {
827
+ loadView(button.dataset.tab);
828
+ tg.HapticFeedback.selectionChanged();
829
+ });
830
+ });
831
+ document.getElementById('fabButton').addEventListener('click', () => {
832
+ showForm(currentView);
833
+ tg.HapticFeedback.impactOccurred('medium');
834
  });
 
835
 
836
+ loadView('resumes');
 
837
  }
838
 
839
  init();
 
854
  .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
855
  h1, h2 { color: #333; }
856
  .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
857
+ .item { border-bottom: 1px solid #eee; padding: 10px 0; }
858
  .item:last-child { border-bottom: none; }
859
+ .item h3 { margin: 0 0 5px 0; }
860
+ .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
861
+ .item img { max-width: 100px; max-height: 100px; border-radius: 4px; margin-top: 5px;}
862
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
863
  .button-primary { background-color: #007bff; color: white; }
864
  .button-danger { background-color: #dc3545; color: white; }
 
885
  <div class="section">
886
  <h2>Data Synchronization with Hugging Face</h2>
887
  <div class="sync-buttons">
888
+ <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
889
+ <button type="submit" class="button button-primary">Upload DB to HF</button>
890
  </form>
891
+ <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
892
+ <button type="submit" class="button button-secondary">Download DB from HF</button>
893
  </form>
894
  </div>
895
+ <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set. Images are NOT synced to HF, only their references in the database.</p>
896
  </div>
897
 
 
898
  <div class="section">
899
+ <h2>Resumes ({{ resumes|length }})</h2>
900
+ {% for resume in resumes %}
901
  <div class="item">
902
+ <h3>{{ resume.name }} - {{ resume.title }}</h3>
903
+ <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
904
+ <p>Posted: {{ resume.timestamp }}</p>
905
+ {% if resume.image_filename %}
906
+ <img src="{{ url_for('uploaded_file', filename=resume.image_filename) }}" alt="Resume image">
907
  {% endif %}
908
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
909
+ <input type="hidden" name="item_type" value="resumes">
910
+ <input type="hidden" name="item_id" value="{{ resume.id }}">
911
+ <button type="submit" class="button button-danger">Delete</button>
912
+ </form>
 
 
 
 
 
 
913
  </div>
914
  {% else %}
915
+ <p>No resumes found.</p>
916
+ {% endfor %}
917
+ </div>
918
+
919
+ <div class="section">
920
+ <h2>Vacancies ({{ vacancies|length }})</h2>
921
+ {% for vacancy in vacancies %}
922
+ <div class="item">
923
+ <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
924
+ <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
925
+ <p>Posted: {{ vacancy.timestamp }}</p>
926
+ {% if vacancy.image_filename %}
927
+ <img src="{{ url_for('uploaded_file', filename=vacancy.image_filename) }}" alt="Vacancy image">
928
+ {% endif %}
929
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
930
+ <input type="hidden" name="item_type" value="vacancies">
931
+ <input type="hidden" name="item_id" value="{{ vacancy.id }}">
932
+ <button type="submit" class="button button-danger">Delete</button>
933
+ </form>
934
+ </div>
935
+ {% else %}
936
+ <p>No vacancies found.</p>
937
+ {% endfor %}
938
+ </div>
939
+
940
+ <div class="section">
941
+ <h2>Freelance Offers ({{ freelance_offers|length }})</h2>
942
+ {% for offer in freelance_offers %}
943
+ <div class="item">
944
+ <h3>{{ offer.title }}</h3>
945
+ <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
946
+ <p>Budget: {{ offer.budget }}</p>
947
+ <p>Posted: {{ offer.timestamp }}</p>
948
+ {% if offer.image_filename %}
949
+ <img src="{{ url_for('uploaded_file', filename=offer.image_filename) }}" alt="Offer image">
950
+ {% endif %}
951
+ <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');">
952
+ <input type="hidden" name="item_type" value="freelance_offers">
953
+ <input type="hidden" name="item_id" value="{{ offer.id }}">
954
+ <button type="submit" class="button button-danger">Delete</button>
955
+ </form>
956
+ </div>
957
+ {% else %}
958
+ <p>No freelance offers found.</p>
959
  {% endfor %}
960
  </div>
 
961
  </div>
962
  </body>
963
  </html>
 
967
  def main_app_view():
968
  return render_template_string(MAIN_APP_TEMPLATE)
969
 
970
+ @app.route('/uploads/<path:filename>')
971
+ def uploaded_file(filename):
972
  return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
973
 
974
  @app.route('/api/auth_user', methods=['POST'])
 
1000
  'photo_url': user_data_from_tg.get('photo_url'),
1001
  'first_seen': datetime.now().isoformat()
1002
  }
1003
+ else: # Update user data if it changed in TG
1004
+ users[user_id_str].update({
1005
+ 'first_name': user_data_from_tg.get('first_name'),
1006
+ 'last_name': user_data_from_tg.get('last_name'),
1007
+ 'username': user_data_from_tg.get('username'),
1008
+ 'language_code': user_data_from_tg.get('language_code'),
1009
+ 'photo_url': user_data_from_tg.get('photo_url'),
1010
+ })
1011
+
1012
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
 
1013
  data['users'] = users
1014
  save_data(data)
1015
 
 
1019
  auth_data_str = request_obj.headers.get('X-Telegram-Auth')
1020
  if not auth_data_str:
1021
  return None
1022
+ is_valid, user_data = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1023
+ if is_valid and user_data:
1024
+ data = load_data()
1025
+ # Return the user data stored in our system, which might have more/different info
1026
+ # or fallback to TG provided data if user not in our DB (should not happen after auth_user)
1027
+ return data.get('users', {}).get(str(user_data.get('id')), user_data)
1028
  return None
1029
 
1030
  @app.route('/api/<item_type>', methods=['GET'])
 
1045
  return jsonify(item), 200
1046
  return jsonify({"error": "Item not found"}), 404
1047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
  @app.route('/api/<item_type>', methods=['POST'])
1049
  def create_item(item_type):
1050
  user = get_authenticated_user(request)
 
1054
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1055
  return jsonify({"error": "Invalid item type"}), 400
1056
 
1057
+ if not request.form:
1058
+ return jsonify({"error": "No form data provided"}), 400
 
1059
 
1060
  new_item = {
1061
  "id": str(uuid.uuid4()),
 
1064
  "timestamp": datetime.now().isoformat(),
1065
  "image_filename": None
1066
  }
1067
+
1068
+ image_file = request.files.get('item_image')
1069
+ if image_file and allowed_file(image_file.filename):
1070
+ filename = secure_filename(f"{new_item['id']}_{image_file.filename}")
1071
+ image_file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
1072
+ new_item["image_filename"] = filename
1073
 
1074
+ req_data = request.form
1075
 
1076
  if item_type == 'resumes':
1077
  required_fields = ['name', 'title']
1078
  for field in required_fields:
1079
+ if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1080
  new_item.update({
1081
+ "name": req_data.get('name'), "title": req_data.get('title'),
1082
+ "skills": req_data.get('skills', ''), "experience": req_data.get('experience', ''),
1083
+ "education": req_data.get('education', ''), "contact": req_data.get('contact', ''),
1084
+ "portfolio_link": req_data.get('portfolio_link', '')
1085
  })
1086
  elif item_type == 'vacancies':
1087
  required_fields = ['company_name', 'title']
1088
  for field in required_fields:
1089
+ if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1090
  new_item.update({
1091
+ "company_name": req_data.get('company_name'), "title": req_data.get('title'),
1092
+ "description": req_data.get('description', ''), "requirements": req_data.get('requirements', ''),
1093
+ "salary": req_data.get('salary', ''), "location": req_data.get('location', ''),
1094
+ "contact": req_data.get('contact', '')
1095
  })
1096
  elif item_type == 'freelance_offers':
1097
  required_fields = ['title']
1098
  for field in required_fields:
1099
+ if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1100
  new_item.update({
1101
+ "title": req_data.get('title'), "description": req_data.get('description', ''),
1102
+ "budget": req_data.get('budget', ''), "deadline": req_data.get('deadline', ''),
1103
+ "skills_needed": req_data.get('skills_needed', ''), "contact": req_data.get('contact', '')
1104
  })
1105
 
1106
  data = load_data()
 
1116
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1117
  return jsonify({"error": "Invalid item type"}), 400
1118
 
1119
+ if not request.form: return jsonify({"error": "No form data provided"}), 400
 
1120
 
1121
  data = load_data()
1122
  items_list = data.get(item_type, [])
 
1134
 
1135
  updated_item = original_item.copy()
1136
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1137
+
1138
+ req_data = request.form
1139
+
1140
+ image_file = request.files.get('item_image')
1141
+ if image_file and allowed_file(image_file.filename):
1142
+ if original_item.get("image_filename"):
1143
+ try:
1144
+ os.remove(os.path.join(app.config['UPLOAD_FOLDER'], original_item["image_filename"]))
1145
+ except OSError as e:
1146
+ logging.error(f"Error deleting old image {original_item['image_filename']}: {e}")
1147
+
1148
+ filename = secure_filename(f"{item_id}_{image_file.filename}")
1149
+ image_file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
1150
+ updated_item["image_filename"] = filename
1151
 
 
 
1152
 
 
 
 
 
 
 
 
1153
  if item_type == 'resumes':
1154
  updated_item.update({
1155
+ "name": req_data.get('name', original_item.get('name')),
1156
+ "title": req_data.get('title', original_item.get('title')),
1157
+ "skills": req_data.get('skills', original_item.get('skills')),
1158
+ "experience": req_data.get('experience', original_item.get('experience')),
1159
+ "education": req_data.get('education', original_item.get('education')),
1160
+ "contact": req_data.get('contact', original_item.get('contact')),
1161
+ "portfolio_link": req_data.get('portfolio_link', original_item.get('portfolio_link'))
1162
  })
1163
  elif item_type == 'vacancies':
1164
+ updated_item.update({
1165
+ "company_name": req_data.get('company_name', original_item.get('company_name')),
1166
+ "title": req_data.get('title', original_item.get('title')),
1167
+ "description": req_data.get('description', original_item.get('description')),
1168
+ "requirements": req_data.get('requirements', original_item.get('requirements')),
1169
+ "salary": req_data.get('salary', original_item.get('salary')),
1170
+ "location": req_data.get('location', original_item.get('location')),
1171
+ "contact": req_data.get('contact', original_item.get('contact'))
1172
  })
1173
  elif item_type == 'freelance_offers':
1174
  updated_item.update({
1175
+ "title": req_data.get('title', original_item.get('title')),
1176
+ "description": req_data.get('description', original_item.get('description')),
1177
+ "budget": req_data.get('budget', original_item.get('budget')),
1178
+ "deadline": req_data.get('deadline', original_item.get('deadline')),
1179
+ "skills_needed": req_data.get('skills_needed', original_item.get('skills_needed')),
1180
+ "contact": req_data.get('contact', original_item.get('contact'))
1181
  })
1182
 
1183
  data[item_type][item_index] = updated_item
 
1194
 
1195
  data = load_data()
1196
  items_list = data.get(item_type, [])
 
1197
 
1198
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1199
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1200
 
1201
  if str(item_to_delete.get('user_id')) != str(user.get('id')):
1202
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
1203
+
1204
+ original_length = len(items_list)
 
 
 
 
 
 
 
 
1205
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1206
 
1207
  if len(data[item_type]) < original_length:
1208
+ if item_to_delete.get("image_filename"):
1209
+ try:
1210
+ os.remove(os.path.join(app.config['UPLOAD_FOLDER'], item_to_delete["image_filename"]))
1211
+ logging.info(f"Deleted image {item_to_delete['image_filename']} for item {item_id}")
1212
+ except OSError as e:
1213
+ logging.error(f"Error deleting image {item_to_delete['image_filename']}: {e}")
1214
  save_data(data)
1215
  return jsonify({"message": "Item deleted successfully"}), 200
1216
  return jsonify({"error": "Item not found or deletion failed"}), 404
 
1219
  @app.route('/admin', methods=['GET'])
1220
  def admin_panel():
1221
  data = load_data()
 
 
 
 
 
 
1222
  return render_template_string(ADMIN_TEMPLATE,
1223
+ resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1224
+ vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True),
1225
+ freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True))
 
1226
 
1227
  @app.route('/admin/delete', methods=['POST'])
1228
  def admin_delete_item():
1229
+ item_type = request.form.get('item_type')
1230
  item_id = request.form.get('item_id')
1231
 
1232
+ if not item_type or not item_id or item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
 
1233
  flash('Invalid item type or ID for deletion.', 'error')
1234
  return redirect(url_for('admin_panel'))
1235
 
1236
  data = load_data()
1237
+ items_list = data.get(item_type, [])
 
1238
 
1239
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1240
+ if not item_to_delete:
1241
+ flash('Item not found or already deleted.', 'warning')
1242
+ return redirect(url_for('admin_panel'))
1243
+
1244
+ original_length = len(items_list)
1245
+ data[item_type] = [i for i in items_list if i['id'] != item_id]
1246
+
1247
+ if len(data[item_type]) < original_length:
1248
+ if item_to_delete.get("image_filename"):
1249
  try:
1250
+ os.remove(os.path.join(app.config['UPLOAD_FOLDER'], item_to_delete["image_filename"]))
1251
+ logging.info(f"Admin deleted image {item_to_delete['image_filename']} for item {item_id}")
1252
  except OSError as e:
1253
+ logging.error(f"Admin error deleting image {item_to_delete['image_filename']}: {e}")
1254
+ save_data(data)
1255
+ flash(f'{item_type.capitalize()[:-1]} deleted successfully.', 'success')
 
 
 
 
 
 
1256
  else:
1257
+ flash('Item not found or already deleted.', 'warning')
 
1258
  return redirect(url_for('admin_panel'))
1259
 
1260
  @app.route('/admin/force_upload', methods=['POST'])
1261
  def force_upload_admin():
1262
  logging.info("Admin forcing upload to Hugging Face...")
1263
  try:
1264
+ upload_db_to_hf()
1265
+ flash("Data successfully uploaded to Hugging Face.", 'success')
 
1266
  except Exception as e:
1267
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1268
  flash(f"Error uploading to Hugging Face: {e}", 'error')
 
1272
  def force_download_admin():
1273
  logging.info("Admin forcing download from Hugging Face...")
1274
  try:
1275
+ if download_db_from_hf():
1276
+ flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
 
 
 
 
 
 
1277
  load_data()
1278
  else:
1279
+ flash("Failed to download data from Hugging Face. Check logs.", 'error')
1280
  except Exception as e:
1281
  logging.error(f"Error during forced download: {e}", exc_info=True)
1282
  flash(f"Error downloading from Hugging Face: {e}", 'error')
 
1284
 
1285
 
1286
  if __name__ == '__main__':
1287
+ ensure_upload_folder()
1288
+ logging.info("Application starting up. Performing initial data load/download...")
1289
+ download_db_from_hf()
1290
+ load_data()
1291
+ logging.info("Initial data load complete.")
 
1292
 
1293
  if HF_TOKEN_WRITE:
1294
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)