Shveiauto commited on
Commit
8ed7259
·
verified ·
1 Parent(s): cb2a433

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +393 -561
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,34 +19,57 @@ 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
- UPLOAD_DIR = 'uploads' # Directory to store uploaded images
23
- os.makedirs(UPLOAD_DIR, exist_ok=True)
24
 
25
- # SYNC_FILES now primarily refers to the main JSON data file. Images are handled separately.
26
- SYNC_FILES = [DATA_FILE]
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") # Can be same as HF_TOKEN_WRITE
31
 
32
- TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8" # Replace with your actual bot token
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 download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
40
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
41
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
42
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
43
-
44
  files_to_download = [specific_file] if specific_file else SYNC_FILES
45
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
46
  all_successful = True
47
-
48
- data_file_downloaded_this_run = False
49
-
50
  for file_name in files_to_download:
51
  success = False
52
  for attempt in range(retries + 1):
@@ -58,8 +81,6 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
58
  force_download=True, resume_download=False
59
  )
60
  logging.info(f"Successfully downloaded {file_name} to {local_path}.")
61
- if file_name == DATA_FILE:
62
- data_file_downloaded_this_run = True
63
  success = True
64
  break
65
  except RepositoryNotFoundError:
@@ -68,15 +89,16 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
68
  except HfHubHTTPError as e:
69
  if e.response.status_code == 404:
70
  logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
71
- if file_name == DATA_FILE and attempt == 0 and not os.path.exists(file_name):
72
  try:
73
- with open(file_name, 'w', encoding='utf-8') as f:
74
- json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
75
- logging.info(f"Created empty local file {file_name} because it was not found on HF.")
 
76
  except Exception as create_e:
77
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
78
- success = False
79
- break
80
  else:
81
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
82
  except Exception as e:
@@ -85,133 +107,33 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
85
  if not success:
86
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
87
  all_successful = False
88
-
89
- if data_file_downloaded_this_run and all_successful: # Download associated images only if DATA_FILE was successfully downloaded
90
- logging.info(f"Data file {DATA_FILE} downloaded. Attempting to download associated images.")
91
- try:
92
- with open(DATA_FILE, 'r', encoding='utf-8') as f:
93
- data = json.load(f)
94
-
95
- os.makedirs(UPLOAD_DIR, exist_ok=True)
96
- image_filenames = set()
97
- for item_type in ['resumes', 'vacancies', 'freelance_offers']:
98
- for item in data.get(item_type, []):
99
- if item.get('image_filename'):
100
- image_filenames.add(item['image_filename'])
101
-
102
- logging.info(f"Found {len(image_filenames)} unique image references in {DATA_FILE}.")
103
- for img_filename in image_filenames:
104
- repo_img_path = f"{UPLOAD_DIR}/{img_filename}"
105
- local_img_path = os.path.join(UPLOAD_DIR, img_filename)
106
- img_success = False
107
- for attempt in range(retries + 1):
108
- try:
109
- logging.info(f"Downloading image {repo_img_path} (Attempt {attempt + 1})...")
110
- # This will save to UPLOAD_DIR/img_filename if filename in repo is UPLOAD_DIR/img_filename
111
- # Correct way is to download 'uploads/image.jpg' to local './uploads/image.jpg'
112
- downloaded_path = hf_hub_download(
113
- repo_id=REPO_ID, filename=repo_img_path, repo_type="dataset",
114
- token=token_to_use, local_dir=".", local_dir_use_symlinks=False, # Download relative to project root
115
- force_download=True, resume_download=False
116
- )
117
- # Ensure the downloaded file is where we expect it
118
- expected_local_path = os.path.abspath(os.path.join(".", repo_img_path)) # e.g. /app/uploads/image.jpg
119
- if os.path.abspath(downloaded_path) == expected_local_path:
120
- logging.info(f"Successfully downloaded image {repo_img_path} to {downloaded_path}.")
121
- else:
122
- # This case should ideally not happen if paths are constructed correctly.
123
- # If it does, we might need to move the file.
124
- os.makedirs(os.path.dirname(local_img_path), exist_ok=True)
125
- if os.path.exists(local_img_path): os.remove(local_img_path) # remove if it exists from previous failed attempt
126
- os.rename(downloaded_path, local_img_path)
127
- logging.info(f"Downloaded image {repo_img_path} and moved to {local_img_path}.")
128
-
129
- img_success = True
130
- break
131
- except HfHubHTTPError as e_img:
132
- if e_img.response.status_code == 404:
133
- logging.warning(f"Image {repo_img_path} not found in repo {REPO_ID} (404). Skipping.")
134
- break
135
- else:
136
- logging.error(f"HTTP error downloading image {repo_img_path} (Attempt {attempt + 1}): {e_img}. Retrying in {delay}s...")
137
- except Exception as e_img:
138
- logging.error(f"Unexpected error downloading image {repo_img_path} (Attempt {attempt + 1}): {e_img}. Retrying in {delay}s...", exc_info=True)
139
- if attempt < retries: time.sleep(delay)
140
- if not img_success:
141
- logging.error(f"Failed to download image {repo_img_path} after {retries + 1} attempts.")
142
- # Not setting all_successful to False for image failures, as core data might be usable.
143
- except Exception as e:
144
- logging.error(f"Error processing images after downloading {DATA_FILE}: {e}", exc_info=True)
145
-
146
- logging.info(f"Download process finished. Overall success for core files: {all_successful}")
147
  return all_successful
148
 
149
-
150
  def upload_db_to_hf(specific_file=None):
151
  if not HF_TOKEN_WRITE:
152
  logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
153
  return
154
-
155
- api = HfApi()
156
-
157
- # Handle main data file
158
- files_to_upload_main = [specific_file] if specific_file and specific_file == DATA_FILE else ([DATA_FILE] if not specific_file else [])
159
-
160
- for file_name in files_to_upload_main:
161
- if os.path.exists(file_name):
162
- try:
163
- logging.info(f"Uploading main data file {file_name} to HF repo {REPO_ID}...")
164
- api.upload_file(
165
- path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
166
- repo_type="dataset", token=HF_TOKEN_WRITE,
167
- commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
168
- )
169
- logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
170
- except Exception as e:
171
- logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
172
- else:
173
- logging.warning(f"File {file_name} not found locally, skipping upload.")
174
-
175
- # Handle associated image files if DATA_FILE was part of the upload scope
176
- if DATA_FILE in files_to_upload_main or not specific_file:
177
- logging.info(f"Checking for images to upload based on {DATA_FILE} content...")
178
- try:
179
- if not os.path.exists(DATA_FILE):
180
- logging.warning(f"{DATA_FILE} not found locally. Cannot determine images to upload.")
181
- return
182
-
183
- with open(DATA_FILE, 'r', encoding='utf-8') as f:
184
- data = json.load(f)
185
-
186
- image_filenames = set()
187
- for item_type in ['resumes', 'vacancies', 'freelance_offers']:
188
- for item in data.get(item_type, []):
189
- if item.get('image_filename'):
190
- image_filenames.add(item['image_filename'])
191
-
192
- logging.info(f"Found {len(image_filenames)} unique image references in {DATA_FILE} for potential upload.")
193
- for img_filename in image_filenames:
194
- local_img_path = os.path.join(UPLOAD_DIR, img_filename)
195
- repo_img_path = f"{UPLOAD_DIR}/{img_filename}" # Path in HF repo, e.g., "uploads/image.jpg"
196
-
197
- if os.path.exists(local_img_path):
198
- try:
199
- logging.info(f"Uploading image {local_img_path} to {repo_img_path} in HF repo {REPO_ID}...")
200
- api.upload_file(
201
- path_or_fileobj=local_img_path, path_in_repo=repo_img_path, repo_id=REPO_ID,
202
- repo_type="dataset", token=HF_TOKEN_WRITE,
203
- commit_message=f"Sync image {img_filename} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
204
- )
205
- logging.info(f"Image {img_filename} successfully uploaded to Hugging Face.")
206
- except Exception as e:
207
- logging.error(f"Error uploading image {img_filename} to Hugging Face: {e}")
208
- else:
209
- logging.warning(f"Image {local_img_path} referenced in {DATA_FILE} but not found locally. Skipping upload.")
210
- except Exception as e:
211
- logging.error(f"Error processing images for upload: {e}", exc_info=True)
212
-
213
- logging.info("Finished Hugging Face upload process.")
214
-
215
 
216
  def periodic_backup():
217
  backup_interval = 1800
@@ -219,7 +141,7 @@ def periodic_backup():
219
  while True:
220
  time.sleep(backup_interval)
221
  logging.info("Starting periodic backup...")
222
- upload_db_to_hf() # This will now also handle images based on DATA_FILE
223
  logging.info("Periodic backup finished.")
224
 
225
  def load_data():
@@ -230,14 +152,13 @@ def load_data():
230
  logging.info(f"Local data loaded successfully from {DATA_FILE}")
231
  if not isinstance(data, dict):
232
  logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
233
- raise FileNotFoundError # Trigger download logic
234
  for key in default_data:
235
  if key not in data: data[key] = default_data[key]
236
  return data
237
  except (FileNotFoundError, json.JSONDecodeError) as e:
238
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
239
 
240
- # This download_db_from_hf will also handle images if DATA_FILE is downloaded
241
  if download_db_from_hf(specific_file=DATA_FILE):
242
  try:
243
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
@@ -274,8 +195,7 @@ def save_data(data):
274
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
275
  json.dump(data, file, ensure_ascii=False, indent=4)
276
  logging.info(f"Data successfully saved to {DATA_FILE}")
277
- # Upload data and any referenced images
278
- upload_db_to_hf(specific_file=DATA_FILE)
279
  except Exception as e:
280
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
281
 
@@ -340,9 +260,9 @@ MAIN_APP_TEMPLATE = '''
340
  overscroll-behavior-y: none;
341
  -webkit-font-smoothing: antialiased;
342
  -moz-osx-font-smoothing: grayscale;
343
- overflow-x: hidden; /* Prevent horizontal scroll from swipe */
344
  }
345
- .app-container { display: flex; flex-direction: column; min-height: 100vh; width: 100%; overflow: hidden;}
346
  .header {
347
  background-color: var(--tg-theme-header-bg-color);
348
  padding: 10px 15px;
@@ -353,8 +273,9 @@ MAIN_APP_TEMPLATE = '''
353
  position: sticky;
354
  top: 0;
355
  z-index: 100;
 
356
  }
357
- .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; }
358
  .tab-button {
359
  flex: 1;
360
  padding: 10px;
@@ -369,38 +290,38 @@ MAIN_APP_TEMPLATE = '''
369
  transition: color 0.2s, border-bottom-color 0.2s;
370
  }
371
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
372
- .content-wrapper { flex-grow: 1; display: flex; width: 300%; /* For 3 tabs */ transition: transform 0.3s ease-in-out; }
373
- .content-pane { width: calc(100% / 3); padding: 15px; box-sizing: border-box; overflow-y: auto;}
374
  .list-item {
375
  background-color: var(--tg-theme-section-bg-color);
376
- border-radius: 8px;
377
  padding: 12px 15px;
378
  margin-bottom: 10px;
379
- box-shadow: 0 1px 3px rgba(0,0,0,0.05);
380
  cursor: pointer;
381
- transition: background-color 0.2s;
382
  }
383
- .list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
384
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
385
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
386
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
387
- .list-item img.item-image-thumbnail { max-width: 100px; max-height: 100px; border-radius: 4px; margin-top: 5px; object-fit: cover;}
388
- .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
 
 
389
  .form-group { margin-bottom: 15px; }
390
- .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; }
391
  .form-group input, .form-group textarea, .form-group input[type="file"] {
392
  width: 100%;
393
  padding: 10px;
394
  border: 1px solid var(--tg-theme-secondary-bg-color);
395
- border-radius: 6px;
396
  font-size: 16px;
397
  background-color: var(--tg-theme-bg-color);
398
  color: var(--tg-theme-text-color);
399
  box-sizing: border-box;
400
  }
401
- .form-group input[type="file"] { padding: 3px;}
402
  .form-group textarea { min-height: 80px; resize: vertical; }
403
- .image-preview { max-width: 200px; max-height: 200px; margin-top: 10px; border-radius: 4px; border: 1px solid var(--tg-theme-secondary-bg-color); }
404
  .fab {
405
  position: fixed;
406
  bottom: 20px;
@@ -413,168 +334,137 @@ MAIN_APP_TEMPLATE = '''
413
  display: flex;
414
  align-items: center;
415
  justify-content: center;
416
- font-size: 24px;
 
417
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
418
  cursor: pointer;
419
  z-index: 1000;
420
  border: none;
 
421
  }
422
- .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); }
423
- .detail-view h2 { margin-top: 0; font-size: 20px; color: var(--tg-theme-text-color); }
424
- .detail-view p { margin-bottom: 8px; line-height: 1.5; font-size: 16px; }
425
- .detail-view strong { font-weight: 600; color: var(--tg-theme-section-header-text-color); }
426
- .detail-view img.item-image-detail { max-width: 100%; height: auto; border-radius: 8px; margin-bottom: 10px; border: 1px solid var(--tg-theme-secondary-bg-color); }
 
427
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
428
- .user-info { padding: 10px 15px; background-color: var(--tg-theme-secondary-bg-color); font-size: 13px; text-align: center; color: var(--tg-theme-hint-color); }
 
429
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
430
- #mainContent { overflow-x: hidden; } /* Used for single view content like form/detail */
431
- .content-pane-container { /* Parent for panes when in list view */
432
- flex-grow: 1; display: flex; width: 100%; overflow: hidden;
433
- }
434
- .content-panes-wrapper { /* The sliding div */
435
- display: flex; width: 300%; transition: transform 0.3s ease-in-out; height: 100%;
436
- }
437
- .pane {
438
- width: calc(100% / 3); box-sizing: border-box; padding: 15px; overflow-y: auto; height: 100%;
439
- }
440
-
441
  </style>
442
  </head>
443
  <body>
444
  <div class="app-container">
445
  <div class="header">TonTalent</div>
446
- <div class="user-info" id="userInfo">Loading user...</div>
 
 
 
447
  <div class="tabs">
448
- <button class="tab-button active" data-tab="resumes" data-index="0">Resumes</button>
449
- <button class="tab-button" data-tab="vacancies" data-index="1">Vacancies</button>
450
- <button class="tab-button" data-tab="freelance_offers" data-index="2">Freelance</button>
451
  </div>
452
-
453
- <div id="viewContainer" class="content-pane-container">
454
- <!-- This div will hold either mainContent (for forms/details) or contentPanesWrapper (for lists) -->
455
  </div>
456
-
457
  <button class="fab" id="fabButton" title="Add New Item">+</button>
458
  </div>
459
 
460
  <script>
461
  const tg = window.Telegram.WebApp;
462
  let currentUser = null;
463
- let currentView = 'resumes'; // The string name of the tab
464
- let currentItem = null; // For detail or edit
465
- let currentDisplayMode = 'list'; // 'list', 'form', 'detail'
466
-
467
  const tabOrder = ['resumes', 'vacancies', 'freelance_offers'];
468
- let currentTabIndex = 0;
469
-
470
- const viewContainer = document.getElementById('viewContainer');
471
- let contentPanesWrapper = null; // Will be created for list view
472
- let mainContentDiv = null; // Will be created for form/detail view
473
-
474
 
475
  function applyThemeParams() {
476
- document.documentElement.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
477
- document.documentElement.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
478
- document.documentElement.style.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
479
- document.documentElement.style.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
480
- document.documentElement.style.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
481
- document.documentElement.style.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
482
- document.documentElement.style.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
483
- document.documentElement.style.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
484
- document.documentElement.style.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
485
- document.documentElement.style.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
486
- document.documentElement.style.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
487
- document.documentElement.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
 
488
  }
489
 
490
- async function apiCall(endpoint, method = 'GET', body = null, isFormData = false) {
491
  const headers = {};
492
- if (!isFormData) {
493
- headers['Content-Type'] = 'application/json';
494
- }
495
  if (tg.initData) {
496
  headers['X-Telegram-Auth'] = tg.initData;
497
  }
498
  const options = { method, headers };
499
- if (body) options.body = isFormData ? body : JSON.stringify(body);
500
-
 
 
 
 
 
 
501
  try {
502
  const response = await fetch(endpoint, options);
503
  if (!response.ok) {
504
- const errorData = await response.json().catch(() => ({ error: 'Request failed' }));
505
  throw new Error(errorData.error || `HTTP error ${response.status}`);
506
  }
507
  return response.json();
508
  } catch (error) {
509
  console.error('API Call Error:', error);
510
  tg.showAlert(error.message || 'An API error occurred.');
511
- tg.HapticFeedback.notificationOccurred('error');
512
  throw error;
513
  }
514
  }
515
 
516
- function setupListViewStructure() {
517
- viewContainer.innerHTML = ''; // Clear previous structure
518
- contentPanesWrapper = document.createElement('div');
519
- contentPanesWrapper.className = 'content-panes-wrapper';
520
- tabOrder.forEach(tabName => {
521
- const pane = document.createElement('div');
522
- pane.className = 'pane';
523
- pane.id = `pane-${tabName}`;
524
- pane.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
525
- contentPanesWrapper.appendChild(pane);
526
- });
527
- viewContainer.appendChild(contentPanesWrapper);
528
- setupSwipeGestures();
529
- currentDisplayMode = 'list';
530
- }
531
-
532
- function setupSingleViewStructure() {
533
- viewContainer.innerHTML = ''; // Clear previous structure
534
- mainContentDiv = document.createElement('div');
535
- mainContentDiv.className = 'content'; // Use existing 'content' class styling for padding etc.
536
- mainContentDiv.id = 'mainContent';
537
- viewContainer.appendChild(mainContentDiv);
538
- currentDisplayMode = 'formOrDetail'; // Could be 'form' or 'detail'
539
- }
540
-
541
-
542
  function renderList(items, type) {
543
- const paneId = `pane-${type}`;
544
- const paneDiv = document.getElementById(paneId);
545
- if (!paneDiv) {
546
- console.error(`Pane ${paneId} not found for rendering list.`);
547
- return;
548
- }
549
- if (!items || items.length === 0) {
550
- paneDiv.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
551
- return;
552
- }
553
- paneDiv.innerHTML = items.map(item => `
554
- <div class="list-item" onclick="showDetailView('${type}', '${item.id}')">
555
- ${item.image_filename ? `<img src="/uploads/${item.image_filename}" class="item-image-thumbnail" alt="${item.title || item.name || 'Item image'}">` : ''}
556
- <h3>${item.title || item.name || 'Untitled'}</h3>
557
- ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
558
- ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
559
- <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
560
- </div>
561
- `).join('');
 
 
 
 
562
  }
563
 
564
  function showDetailView(type, id) {
565
- setupSingleViewStructure();
566
- currentDisplayMode = 'detail';
567
  tg.BackButton.show();
568
- tg.BackButton.onClick(() => loadListView(type, false)); // false: don't force data reload
569
  tg.MainButton.hide();
570
  document.getElementById('fabButton').style.display = 'none';
 
 
571
 
572
  apiCall(`/api/${type}/${id}`)
573
  .then(item => {
574
  currentItem = item;
575
  let detailsHtml = `<div class="detail-view">`;
576
- if (item.image_filename) {
577
- detailsHtml += `<img src="/uploads/${item.image_filename}" class="item-image-detail" alt="${item.title || item.name || 'Item image'}">`;
578
  }
579
  detailsHtml += `<h2>${item.title || item.name}</h2>`;
580
 
@@ -584,7 +474,7 @@ MAIN_APP_TEMPLATE = '''
584
  <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
585
  <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
586
  <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
587
- ${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>` : ''}
588
  `;
589
  } else if (type === 'vacancies') {
590
  detailsHtml += `
@@ -605,37 +495,45 @@ MAIN_APP_TEMPLATE = '''
605
  `;
606
  }
607
  detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
608
- mainContentDiv.innerHTML = detailsHtml;
 
 
 
 
609
 
610
  if (currentUser && item.user_id === currentUser.id) {
611
  tg.MainButton.setText('Edit My Post');
612
- tg.MainButton.onClick(() => showForm(type, item));
613
  tg.MainButton.show();
614
  }
615
  })
616
  .catch(err => {
617
- mainContentDiv.innerHTML = `<div class="empty-state">Error loading details.</div>`;
 
 
 
618
  });
619
  }
620
 
621
  function showForm(type, itemToEdit = null) {
622
- setupSingleViewStructure();
623
- currentDisplayMode = 'form';
624
  currentItem = itemToEdit;
625
  tg.BackButton.show();
626
  tg.BackButton.onClick(() => {
627
  if (itemToEdit) showDetailView(type, itemToEdit.id);
628
- else loadListView(type, false);
 
629
  });
630
  document.getElementById('fabButton').style.display = 'none';
 
 
631
 
632
- let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`;
633
 
634
- formHtml += `
635
  <div class="form-group">
636
- <label for="image">Image (optional)</label>
637
  <input type="file" id="image" accept="image/*">
638
- ${itemToEdit && itemToEdit.image_filename ? `<img src="/uploads/${itemToEdit.image_filename}" class="image-preview" id="imagePreview">` : '<img src="#" class="image-preview" id="imagePreview" style="display:none;">'}
639
  </div>
640
  `;
641
 
@@ -649,6 +547,7 @@ MAIN_APP_TEMPLATE = '''
649
  <label for="title">Job Title / Desired Position</label>
650
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
651
  </div>
 
652
  <div class="form-group">
653
  <label for="skills">Skills (comma separated)</label>
654
  <textarea id="skills">${itemToEdit?.skills || ''}</textarea>
@@ -680,6 +579,7 @@ MAIN_APP_TEMPLATE = '''
680
  <label for="title">Job Title</label>
681
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
682
  </div>
 
683
  <div class="form-group">
684
  <label for="description">Description</label>
685
  <textarea id="description">${itemToEdit?.description || ''}</textarea>
@@ -707,6 +607,7 @@ MAIN_APP_TEMPLATE = '''
707
  <label for="title">Project Title</label>
708
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
709
  </div>
 
710
  <div class="form-group">
711
  <label for="description">Description of Work</label>
712
  <textarea id="description">${itemToEdit?.description || ''}</textarea>
@@ -730,22 +631,11 @@ MAIN_APP_TEMPLATE = '''
730
  `;
731
  }
732
  formHtml += `<div id="formError" class="error-message"></div></div>`;
733
- mainContentDiv.innerHTML = formHtml;
734
-
735
- document.getElementById('image').addEventListener('change', function(event) {
736
- const preview = document.getElementById('imagePreview');
737
- if (event.target.files && event.target.files[0]) {
738
- const reader = new FileReader();
739
- reader.onload = function(e) {
740
- preview.src = e.target.result;
741
- preview.style.display = 'block';
742
- }
743
- reader.readAsDataURL(event.target.files[0]);
744
- } else {
745
- preview.style.display = 'none';
746
- preview.src = '#';
747
- }
748
- });
749
 
750
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
751
  tg.MainButton.show();
@@ -757,11 +647,6 @@ MAIN_APP_TEMPLATE = '''
757
  let isValid = true;
758
  document.getElementById('formError').textContent = '';
759
 
760
- const imageFile = document.getElementById('image').files[0];
761
- if (imageFile) {
762
- formData.append('image', imageFile);
763
- }
764
-
765
  if (type === 'resumes') {
766
  const name = document.getElementById('name').value.trim();
767
  const title = document.getElementById('title').value.trim();
@@ -795,6 +680,11 @@ MAIN_APP_TEMPLATE = '''
795
  formData.append('contact', document.getElementById('contact').value.trim());
796
  }
797
 
 
 
 
 
 
798
  if (!isValid) {
799
  document.getElementById('formError').textContent = 'Please fill in all required fields.';
800
  tg.HapticFeedback.notificationOccurred('error');
@@ -806,11 +696,11 @@ MAIN_APP_TEMPLATE = '''
806
  const method = itemId ? 'PUT' : 'POST';
807
  const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
808
 
809
- apiCall(endpoint, method, formData, true) // true for FormData
810
  .then(response => {
811
  tg.HapticFeedback.notificationOccurred('success');
812
  tg.MainButton.hideProgress();
813
- loadListView(type, true); // Force reload data for the list
814
  })
815
  .catch(err => {
816
  tg.HapticFeedback.notificationOccurred('error');
@@ -818,119 +708,120 @@ MAIN_APP_TEMPLATE = '''
818
  document.getElementById('formError').textContent = err.message || 'Failed to submit.';
819
  });
820
  }
821
-
822
- function switchToTab(newTabIndex, fromSwipe = false) {
823
- if (newTabIndex < 0 || newTabIndex >= tabOrder.length) return;
824
-
825
- currentTabIndex = newTabIndex;
826
- currentView = tabOrder[currentTabIndex];
827
 
 
 
828
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
829
- document.querySelector(`.tab-button[data-tab="${currentView}"]`).classList.add('active');
 
 
 
 
830
 
831
- if (contentPanesWrapper) {
832
- contentPanesWrapper.style.transform = `translateX(-${currentTabIndex * (100 / tabOrder.length)}%)`;
833
- }
834
- if (fromSwipe) {
835
- tg.HapticFeedback.impactOccurred('light');
836
- }
837
-
838
- // Load data for the new tab if its pane is empty or marked for reload
839
- const pane = document.getElementById(`pane-${currentView}`);
840
- if (pane && (pane.innerHTML.includes('class="loading"') || pane.innerHTML.includes('class="empty-state"') || pane.dataset.needsReload === 'true')) {
841
- pane.innerHTML = `<div class="loading">Loading ${currentView}...</div>`; // Show loading again
842
- apiCall(`/api/${currentView}`)
843
- .then(data => {
844
- renderList(data, currentView);
845
- pane.dataset.needsReload = 'false';
846
- })
847
- .catch(err => {
848
- pane.innerHTML = `<div class="empty-state">Error loading ${currentView}.</div>`;
849
- });
850
- }
851
- }
852
-
853
- // Called when clicking a tab or after form submission/detail view back
854
- function loadListView(tabName, forceDataReload = true) {
855
- if (currentDisplayMode !== 'list') { // Transitioning from form/detail to list
856
- setupListViewStructure();
857
- // Load all tabs' initial data or mark them for reload
858
- tabOrder.forEach((name, index) => {
859
- const pane = document.getElementById(`pane-${name}`);
860
- if (pane) pane.dataset.needsReload = 'true'; // Mark for reload
861
- });
862
- }
863
- currentView = tabName;
864
- currentTabIndex = tabOrder.indexOf(tabName);
865
-
866
- switchToTab(currentTabIndex); // This will handle rendering and data loading if needed
867
-
868
  tg.BackButton.hide();
869
  tg.MainButton.hide();
870
  document.getElementById('fabButton').style.display = 'block';
871
- }
872
-
873
 
874
- function setupSwipeGestures() {
875
- if (!contentPanesWrapper) return;
876
- let touchStartX = 0;
877
- let touchEndX = 0;
878
- const swipeThreshold = 50; // Minimum distance for a swipe
879
-
880
- contentPanesWrapper.addEventListener('touchstart', (event) => {
881
- touchStartX = event.changedTouches[0].screenX;
882
- }, { passive: true });
883
-
884
- contentPanesWrapper.addEventListener('touchend', (event) => {
885
- touchEndX = event.changedTouches[0].screenX;
886
- handleSwipe();
887
- }, { passive: true });
888
-
889
- function handleSwipe() {
890
- const deltaX = touchEndX - touchStartX;
891
- if (Math.abs(deltaX) > swipeThreshold) {
892
- if (deltaX < 0) { // Swiped left (next tab)
893
- switchToTab(currentTabIndex + 1, true);
894
- } else { // Swiped right (previous tab)
895
- switchToTab(currentTabIndex - 1, true);
896
- }
897
- }
898
- }
899
  }
900
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
901
  async function init() {
902
  tg.ready();
903
  applyThemeParams();
 
904
  tg.expand();
905
  tg.enableClosingConfirmation();
906
 
907
- document.getElementById('userInfo').textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}! (@${tg.initDataUnsafe.user?.username || 'anonymous'})`;
908
 
909
  try {
910
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
911
  currentUser = authResponse.user;
912
  if (currentUser) {
913
- document.getElementById('userInfo').textContent = `Logged in as: ${currentUser.first_name} (@${currentUser.username})`;
914
  }
915
  } catch (error) {
916
  console.error("Auth error:", error);
917
- document.getElementById('userInfo').textContent = `Auth failed. Limited functionality.`;
 
918
  }
919
 
920
  document.querySelectorAll('.tab-button').forEach(button => {
921
  button.addEventListener('click', () => {
 
922
  tg.HapticFeedback.impactOccurred('light');
923
- loadListView(button.dataset.tab);
924
  });
925
  });
926
  document.getElementById('fabButton').addEventListener('click', () => {
927
- tg.HapticFeedback.impactOccurred('medium');
928
  showForm(currentView);
 
929
  });
930
 
931
- loadListView('resumes'); // Initial load, will set up panes
 
932
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
933
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
  init();
935
  </script>
936
  </body>
@@ -953,7 +844,7 @@ ADMIN_TEMPLATE = '''
953
  .item:last-child { border-bottom: none; }
954
  .item h3 { margin: 0 0 5px 0; }
955
  .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
956
- .item img.admin-thumb { max-width: 80px; max-height: 80px; border-radius: 4px; float: left; margin-right: 10px; }
957
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
958
  .button-primary { background-color: #007bff; color: white; }
959
  .button-danger { background-color: #dc3545; color: white; }
@@ -963,7 +854,6 @@ ADMIN_TEMPLATE = '''
963
  .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
964
  .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
965
  .sync-buttons form { display: inline-block; margin-right: 10px; }
966
- .clearfix::after { content: ""; clear: both; display: table; }
967
  </style>
968
  </head>
969
  <body>
@@ -981,24 +871,22 @@ ADMIN_TEMPLATE = '''
981
  <div class="section">
982
  <h2>Data Synchronization with Hugging Face</h2>
983
  <div class="sync-buttons">
984
- <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data & images to Hugging Face? This will overwrite server data.');">
985
- <button type="submit" class="button button-primary">Upload DB & Images to HF</button>
986
  </form>
987
- <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data & images from Hugging Face? This will overwrite local data.');">
988
- <button type="submit" class="button button-secondary">Download DB & Images from HF</button>
989
  </form>
990
  </div>
991
- <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
992
  </div>
993
 
994
  <div class="section">
995
  <h2>Resumes ({{ resumes|length }})</h2>
996
  {% for resume in resumes %}
997
- <div class="item clearfix">
998
- {% if resume.image_filename %}
999
- <img src="{{ url_for('serve_uploaded_file', filename=resume.image_filename) }}" alt="Resume Image" class="admin-thumb">
1000
- {% endif %}
1001
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
 
1002
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
1003
  <p>Posted: {{ resume.timestamp }}</p>
1004
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
@@ -1015,11 +903,9 @@ ADMIN_TEMPLATE = '''
1015
  <div class="section">
1016
  <h2>Vacancies ({{ vacancies|length }})</h2>
1017
  {% for vacancy in vacancies %}
1018
- <div class="item clearfix">
1019
- {% if vacancy.image_filename %}
1020
- <img src="{{ url_for('serve_uploaded_file', filename=vacancy.image_filename) }}" alt="Vacancy Image" class="admin-thumb">
1021
- {% endif %}
1022
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
 
1023
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
1024
  <p>Posted: {{ vacancy.timestamp }}</p>
1025
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
@@ -1036,11 +922,9 @@ ADMIN_TEMPLATE = '''
1036
  <div class="section">
1037
  <h2>Freelance Offers ({{ freelance_offers|length }})</h2>
1038
  {% for offer in freelance_offers %}
1039
- <div class="item clearfix">
1040
- {% if offer.image_filename %}
1041
- <img src="{{ url_for('serve_uploaded_file', filename=offer.image_filename) }}" alt="Offer Image" class="admin-thumb">
1042
- {% endif %}
1043
  <h3>{{ offer.title }}</h3>
 
1044
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
1045
  <p>Budget: {{ offer.budget }}</p>
1046
  <p>Posted: {{ offer.timestamp }}</p>
@@ -1063,10 +947,18 @@ ADMIN_TEMPLATE = '''
1063
  def main_app_view():
1064
  return render_template_string(MAIN_APP_TEMPLATE)
1065
 
1066
- @app.route('/uploads/<filename>')
1067
- def serve_uploaded_file(filename):
1068
- return send_from_directory(UPLOAD_DIR, filename)
 
 
 
 
 
 
 
1069
 
 
1070
 
1071
  @app.route('/api/auth_user', methods=['POST'])
1072
  def auth_user():
@@ -1078,26 +970,28 @@ def auth_user():
1078
  else:
1079
  return jsonify({"error": "Authentication data not provided"}), 401
1080
 
1081
- is_valid, user_data = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
1082
 
1083
- if not is_valid or not user_data:
1084
  return jsonify({"error": "Invalid authentication data"}), 403
1085
 
1086
  data = load_data()
1087
  users = data.get('users', {})
1088
- user_id_str = str(user_data.get('id'))
1089
 
1090
  if user_id_str not in users:
1091
  users[user_id_str] = {
1092
- 'id': user_data.get('id'),
1093
- 'first_name': user_data.get('first_name'),
1094
- 'last_name': user_data.get('last_name'),
1095
- 'username': user_data.get('username'),
1096
- 'language_code': user_data.get('language_code'),
1097
- 'photo_url': user_data.get('photo_url'),
1098
  'first_seen': datetime.now().isoformat()
1099
  }
1100
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
 
 
1101
  data['users'] = users
1102
  save_data(data)
1103
 
@@ -1112,7 +1006,6 @@ def get_authenticated_user(request_headers):
1112
  return user_data
1113
  return None
1114
 
1115
- # Generic GET all items
1116
  @app.route('/api/<item_type>', methods=['GET'])
1117
  def get_items(item_type):
1118
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
@@ -1121,7 +1014,6 @@ def get_items(item_type):
1121
  items = sorted(data.get(item_type, []), key=lambda x: x.get('timestamp', ''), reverse=True)
1122
  return jsonify(items), 200
1123
 
1124
- # Generic GET single item
1125
  @app.route('/api/<item_type>/<item_id>', methods=['GET'])
1126
  def get_item(item_type, item_id):
1127
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
@@ -1132,35 +1024,6 @@ def get_item(item_type, item_id):
1132
  return jsonify(item), 200
1133
  return jsonify({"error": "Item not found"}), 404
1134
 
1135
- def _handle_image_upload(request_files, current_item_data=None):
1136
- image_filename = current_item_data.get('image_filename') if current_item_data else None
1137
- old_image_filename = image_filename # Keep track if it's an update with a new image
1138
-
1139
- if 'image' in request_files:
1140
- file = request_files['image']
1141
- if file and file.filename != '':
1142
- # Delete old image if it exists and a new one is uploaded
1143
- if old_image_filename and os.path.exists(os.path.join(UPLOAD_DIR, old_image_filename)):
1144
- try:
1145
- os.remove(os.path.join(UPLOAD_DIR, old_image_filename))
1146
- logging.info(f"Deleted old local image: {old_image_filename}")
1147
- if HF_TOKEN_WRITE:
1148
- hf_delete_file(path_in_repo=f"{UPLOAD_DIR}/{old_image_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
1149
- logging.info(f"Attempted deletion of old image {old_image_filename} from HF.")
1150
- except EntryNotFoundError:
1151
- logging.warning(f"Old image {old_image_filename} not found on HF for deletion.")
1152
- except Exception as e_del_img:
1153
- logging.error(f"Error deleting old image {old_image_filename} from local or HF: {e_del_img}")
1154
-
1155
- filename = secure_filename(file.filename)
1156
- unique_filename = f"{uuid.uuid4()}_{filename}"
1157
- file.save(os.path.join(UPLOAD_DIR, unique_filename))
1158
- image_filename = unique_filename
1159
- logging.info(f"New image saved: {image_filename}")
1160
- return image_filename, old_image_filename # Return new and potentially old (if replaced)
1161
-
1162
-
1163
- # Generic POST new item
1164
  @app.route('/api/<item_type>', methods=['POST'])
1165
  def create_item(item_type):
1166
  user = get_authenticated_user(request.headers)
@@ -1170,58 +1033,66 @@ def create_item(item_type):
1170
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1171
  return jsonify({"error": "Invalid item type"}), 400
1172
 
1173
- # request.form for text fields, request.files for files (multipart/form-data)
1174
- req_data = request.form
1175
- if not req_data:
1176
  return jsonify({"error": "No data provided"}), 400
1177
 
 
1178
  new_item = {
1179
- "id": str(uuid.uuid4()),
1180
  "user_id": str(user.get('id')),
1181
  "user_telegram_username": user.get('username', 'unknown'),
1182
  "timestamp": datetime.now().isoformat(),
 
1183
  }
 
 
 
 
 
 
 
 
 
 
 
1184
 
1185
- image_filename, _ = _handle_image_upload(request.files)
1186
- if image_filename:
1187
- new_item['image_filename'] = image_filename
1188
 
1189
  if item_type == 'resumes':
1190
  required_fields = ['name', 'title']
1191
  for field in required_fields:
1192
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1193
  new_item.update({
1194
- "name": req_data.get('name'), "title": req_data.get('title'),
1195
- "skills": req_data.get('skills', ''), "experience": req_data.get('experience', ''),
1196
- "education": req_data.get('education', ''), "contact": req_data.get('contact', ''),
1197
- "portfolio_link": req_data.get('portfolio_link', '')
1198
  })
1199
  elif item_type == 'vacancies':
1200
  required_fields = ['company_name', 'title']
1201
  for field in required_fields:
1202
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1203
  new_item.update({
1204
- "company_name": req_data.get('company_name'), "title": req_data.get('title'),
1205
- "description": req_data.get('description', ''), "requirements": req_data.get('requirements', ''),
1206
- "salary": req_data.get('salary', ''), "location": req_data.get('location', ''),
1207
- "contact": req_data.get('contact', '')
1208
  })
1209
  elif item_type == 'freelance_offers':
1210
  required_fields = ['title']
1211
  for field in required_fields:
1212
- if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1213
  new_item.update({
1214
- "title": req_data.get('title'), "description": req_data.get('description', ''),
1215
- "budget": req_data.get('budget', ''), "deadline": req_data.get('deadline', ''),
1216
- "skills_needed": req_data.get('skills_needed', ''), "contact": req_data.get('contact', '')
1217
  })
1218
 
1219
  data = load_data()
1220
  data[item_type].append(new_item)
1221
- save_data(data) # This will also trigger image upload to HF if HF_TOKEN_WRITE is set
1222
  return jsonify(new_item), 201
1223
 
1224
- # Generic PUT update item
1225
  @app.route('/api/<item_type>/<item_id>', methods=['PUT'])
1226
  def update_item(item_type, item_id):
1227
  user = get_authenticated_user(request.headers)
@@ -1230,67 +1101,73 @@ def update_item(item_type, item_id):
1230
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1231
  return jsonify({"error": "Invalid item type"}), 400
1232
 
1233
- req_data = request.form
1234
- if not req_data: return jsonify({"error": "No data provided"}), 400
1235
 
1236
  data = load_data()
1237
  items_list = data.get(item_type, [])
1238
  item_index = -1
1239
- original_item = None
1240
  for idx, i in enumerate(items_list):
1241
  if i['id'] == item_id:
1242
  item_index = idx
1243
- original_item = i
1244
  break
1245
 
1246
- if item_index == -1 or not original_item: return jsonify({"error": "Item not found"}), 404
1247
 
 
1248
  if str(original_item.get('user_id')) != str(user.get('id')):
1249
  return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
1250
 
1251
  updated_item = original_item.copy()
1252
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1253
 
1254
- new_image_filename, old_image_filename_if_replaced = _handle_image_upload(request.files, original_item)
1255
- if new_image_filename: # This means a new image was uploaded OR existing one was kept (if no new file)
1256
- updated_item['image_filename'] = new_image_filename
1257
- # If no new image was uploaded, image_filename remains as is or is None if it wasn't there.
 
 
 
 
 
 
 
 
1258
 
1259
  if item_type == 'resumes':
1260
  updated_item.update({
1261
- "name": req_data.get('name', original_item.get('name')),
1262
- "title": req_data.get('title', original_item.get('title')),
1263
- "skills": req_data.get('skills', original_item.get('skills')),
1264
- "experience": req_data.get('experience', original_item.get('experience')),
1265
- "education": req_data.get('education', original_item.get('education')),
1266
- "contact": req_data.get('contact', original_item.get('contact')),
1267
- "portfolio_link": req_data.get('portfolio_link', original_item.get('portfolio_link'))
1268
  })
1269
  elif item_type == 'vacancies':
1270
  updated_item.update({
1271
- "company_name": req_data.get('company_name', original_item.get('company_name')),
1272
- "title": req_data.get('title', original_item.get('title')),
1273
- "description": req_data.get('description', original_item.get('description')),
1274
- "requirements": req_data.get('requirements', original_item.get('requirements')),
1275
- "salary": req_data.get('salary', original_item.get('salary')),
1276
- "location": req_data.get('location', original_item.get('location')),
1277
- "contact": req_data.get('contact', original_item.get('contact'))
1278
  })
1279
  elif item_type == 'freelance_offers':
1280
  updated_item.update({
1281
- "title": req_data.get('title', original_item.get('title')),
1282
- "description": req_data.get('description', original_item.get('description')),
1283
- "budget": req_data.get('budget', original_item.get('budget')),
1284
- "deadline": req_data.get('deadline', original_item.get('deadline')),
1285
- "skills_needed": req_data.get('skills_needed', original_item.get('skills_needed')),
1286
- "contact": req_data.get('contact', original_item.get('contact'))
1287
  })
1288
 
1289
  data[item_type][item_index] = updated_item
1290
  save_data(data)
1291
  return jsonify(updated_item), 200
1292
 
1293
- # Generic DELETE item (can be restricted to admin or item owner)
1294
  @app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
1295
  def delete_item(item_type, item_id):
1296
  user = get_authenticated_user(request.headers)
@@ -1301,48 +1178,25 @@ def delete_item(item_type, item_id):
1301
 
1302
  data = load_data()
1303
  items_list = data.get(item_type, [])
 
1304
 
1305
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1306
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1307
 
1308
- if str(item_to_delete.get('user_id')) != str(user.get('id')): # Add admin check here if needed
1309
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
1310
-
1311
- image_filename_to_delete = item_to_delete.get('image_filename')
1312
 
 
1313
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1314
 
1315
- save_data(data) # Save JSON first
1316
-
1317
- if image_filename_to_delete:
1318
- local_image_path = os.path.join(UPLOAD_DIR, image_filename_to_delete)
1319
- if os.path.exists(local_image_path):
1320
- try:
1321
- os.remove(local_image_path)
1322
- logging.info(f"Deleted local image: {image_filename_to_delete}")
1323
- except Exception as e:
1324
- logging.error(f"Error deleting local image {image_filename_to_delete}: {e}")
1325
-
1326
- if HF_TOKEN_WRITE:
1327
- try:
1328
- hf_delete_file(
1329
- path_in_repo=f"{UPLOAD_DIR}/{image_filename_to_delete}",
1330
- repo_id=REPO_ID,
1331
- repo_type="dataset",
1332
- token=HF_TOKEN_WRITE
1333
- )
1334
- logging.info(f"Attempted deletion of image {image_filename_to_delete} from HF.")
1335
- except EntryNotFoundError:
1336
- logging.warning(f"Image {image_filename_to_delete} not found on HF for deletion.")
1337
- except Exception as e:
1338
- logging.error(f"Error deleting image {image_filename_to_delete} from HF: {e}")
1339
-
1340
- return jsonify({"message": "Item deleted successfully"}), 200
1341
 
1342
 
1343
  @app.route('/admin', methods=['GET'])
1344
  def admin_panel():
1345
- # Add authentication for admin here if needed
1346
  data = load_data()
1347
  return render_template_string(ADMIN_TEMPLATE,
1348
  resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
@@ -1351,7 +1205,6 @@ def admin_panel():
1351
 
1352
  @app.route('/admin/delete', methods=['POST'])
1353
  def admin_delete_item():
1354
- # Add authentication for admin here if needed
1355
  item_type = request.form.get('item_type')
1356
  item_id = request.form.get('item_id')
1357
 
@@ -1361,48 +1214,30 @@ def admin_delete_item():
1361
 
1362
  data = load_data()
1363
  items_list = data.get(item_type, [])
1364
-
1365
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1366
- image_filename_to_delete = None
1367
- if item_to_delete:
1368
- image_filename_to_delete = item_to_delete.get('image_filename')
1369
-
1370
  original_length = len(items_list)
1371
- data[item_type] = [i for i in items_list if i['id'] != item_id]
1372
-
1373
- if len(data[item_type]) < original_length:
1374
- save_data(data) # Save JSON first
 
 
 
1375
 
1376
- if image_filename_to_delete:
1377
- local_image_path = os.path.join(UPLOAD_DIR, image_filename_to_delete)
1378
- if os.path.exists(local_image_path):
1379
- try:
1380
- os.remove(local_image_path)
1381
- logging.info(f"Admin deleted local image: {image_filename_to_delete}")
1382
- except Exception as e:
1383
- logging.error(f"Admin: Error deleting local image {image_filename_to_delete}: {e}")
1384
-
1385
- if HF_TOKEN_WRITE:
1386
- try:
1387
- hf_delete_file(path_in_repo=f"{UPLOAD_DIR}/{image_filename_to_delete}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
1388
- logging.info(f"Admin: Attempted deletion of image {image_filename_to_delete} from HF.")
1389
- except EntryNotFoundError:
1390
- logging.warning(f"Admin: Image {image_filename_to_delete} not found on HF for deletion.")
1391
- except Exception as e:
1392
- logging.error(f"Admin: Error deleting image {image_filename_to_delete} from HF: {e}")
1393
 
1394
- flash(f'{item_type.capitalize()[:-1]} deleted successfully.', 'success')
 
 
1395
  else:
1396
  flash('Item not found or already deleted.', 'warning')
1397
  return redirect(url_for('admin_panel'))
1398
 
1399
  @app.route('/admin/force_upload', methods=['POST'])
1400
  def force_upload_admin():
1401
- # Add authentication for admin here
1402
  logging.info("Admin forcing upload to Hugging Face...")
1403
  try:
1404
- upload_db_to_hf() # This now handles both DATA_FILE and associated images
1405
- flash("Data and referenced images successfully uploaded to Hugging Face.", 'success')
1406
  except Exception as e:
1407
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1408
  flash(f"Error uploading to Hugging Face: {e}", 'error')
@@ -1410,15 +1245,13 @@ def force_upload_admin():
1410
 
1411
  @app.route('/admin/force_download', methods=['POST'])
1412
  def force_download_admin():
1413
- # Add authentication for admin here
1414
  logging.info("Admin forcing download from Hugging Face...")
1415
  try:
1416
- # This now handles both DATA_FILE and associated images
1417
- if download_db_from_hf():
1418
- flash("Data and referenced images successfully downloaded from Hugging Face. Local files updated.", 'success')
1419
  load_data()
1420
  else:
1421
- flash("Failed to download data/images from Hugging Face. Check logs.", 'error')
1422
  except Exception as e:
1423
  logging.error(f"Error during forced download: {e}", exc_info=True)
1424
  flash(f"Error downloading from Hugging Face: {e}", 'error')
@@ -1427,9 +1260,8 @@ def force_download_admin():
1427
 
1428
  if __name__ == '__main__':
1429
  logging.info("Application starting up. Performing initial data load/download...")
1430
- # Initial download will also try to fetch images referenced in DATA_FILE
1431
  download_db_from_hf()
1432
- load_data() # Load data into memory after potential download
1433
  logging.info("Initial data load complete.")
1434
 
1435
  if HF_TOKEN_WRITE:
 
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
+ UPLOADS_DIR_NAME = 'uploads'
23
+ app.config['UPLOADS_DIR'] = os.path.join(app.root_path, UPLOADS_DIR_NAME)
24
 
25
+ SYNC_FILES = [DATA_FILE]
 
26
 
27
  REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
28
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
29
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
30
 
31
+ TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8" # Placeholder, use actual token
32
 
33
  DOWNLOAD_RETRIES = 3
34
  DOWNLOAD_DELAY = 5
35
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
36
 
37
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
38
 
39
+ if not os.path.exists(app.config['UPLOADS_DIR']):
40
+ os.makedirs(app.config['UPLOADS_DIR'], exist_ok=True)
41
+ for item_type_subdir in ['resumes', 'vacancies', 'freelance_offers']:
42
+ path = os.path.join(app.config['UPLOADS_DIR'], item_type_subdir)
43
+ os.makedirs(path, exist_ok=True)
44
+
45
+ def allowed_file(filename):
46
+ return '.' in filename and \
47
+ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
48
+
49
+ def delete_existing_image(image_url):
50
+ if not image_url:
51
+ return
52
+ try:
53
+ image_path_parts = image_url.split('/')
54
+ if len(image_path_parts) == 4 and image_path_parts[1] == UPLOADS_DIR_NAME:
55
+ # ['', 'uploads', 'item_type_subdir', 'filename.ext']
56
+ item_type_subdir = image_path_parts[2]
57
+ img_filename = image_path_parts[3]
58
+ local_image_path = os.path.join(app.config['UPLOADS_DIR'], item_type_subdir, img_filename)
59
+ if os.path.exists(local_image_path):
60
+ os.remove(local_image_path)
61
+ logging.info(f"Deleted old image: {local_image_path}")
62
+ except Exception as e:
63
+ logging.error(f"Error deleting image from URL {image_url}: {e}")
64
+
65
+
66
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
67
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
68
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
69
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
 
70
  files_to_download = [specific_file] if specific_file else SYNC_FILES
71
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
72
  all_successful = True
 
 
 
73
  for file_name in files_to_download:
74
  success = False
75
  for attempt in range(retries + 1):
 
81
  force_download=True, resume_download=False
82
  )
83
  logging.info(f"Successfully downloaded {file_name} to {local_path}.")
 
 
84
  success = True
85
  break
86
  except RepositoryNotFoundError:
 
89
  except HfHubHTTPError as e:
90
  if e.response.status_code == 404:
91
  logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
92
+ if attempt == 0 and not os.path.exists(file_name):
93
  try:
94
+ if file_name == DATA_FILE:
95
+ with open(file_name, 'w', encoding='utf-8') as f:
96
+ json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f)
97
+ logging.info(f"Created empty local file {file_name} because it was not found on HF.")
98
  except Exception as create_e:
99
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
100
+ success = False
101
+ break
102
  else:
103
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
104
  except Exception as e:
 
107
  if not success:
108
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
109
  all_successful = False
110
+ logging.info(f"Download process finished. Overall success: {all_successful}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  return all_successful
112
 
 
113
  def upload_db_to_hf(specific_file=None):
114
  if not HF_TOKEN_WRITE:
115
  logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
116
  return
117
+ try:
118
+ api = HfApi()
119
+ files_to_upload = [specific_file] if specific_file else SYNC_FILES
120
+ logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
121
+ for file_name in files_to_upload:
122
+ if os.path.exists(file_name):
123
+ try:
124
+ api.upload_file(
125
+ path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
126
+ repo_type="dataset", token=HF_TOKEN_WRITE,
127
+ commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
128
+ )
129
+ logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
130
+ except Exception as e:
131
+ logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
132
+ else:
133
+ logging.warning(f"File {file_name} not found locally, skipping upload.")
134
+ logging.info("Finished uploading files to HF.")
135
+ except Exception as e:
136
+ logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  def periodic_backup():
139
  backup_interval = 1800
 
141
  while True:
142
  time.sleep(backup_interval)
143
  logging.info("Starting periodic backup...")
144
+ upload_db_to_hf()
145
  logging.info("Periodic backup finished.")
146
 
147
  def load_data():
 
152
  logging.info(f"Local data loaded successfully from {DATA_FILE}")
153
  if not isinstance(data, dict):
154
  logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
155
+ raise FileNotFoundError
156
  for key in default_data:
157
  if key not in data: data[key] = default_data[key]
158
  return data
159
  except (FileNotFoundError, json.JSONDecodeError) as e:
160
  logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
161
 
 
162
  if download_db_from_hf(specific_file=DATA_FILE):
163
  try:
164
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
 
195
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
196
  json.dump(data, file, ensure_ascii=False, indent=4)
197
  logging.info(f"Data successfully saved to {DATA_FILE}")
198
+ upload_db_to_hf(specific_file=DATA_FILE)
 
199
  except Exception as e:
200
  logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
201
 
 
260
  overscroll-behavior-y: none;
261
  -webkit-font-smoothing: antialiased;
262
  -moz-osx-font-smoothing: grayscale;
263
+ transition: background-color 0.3s, color 0.3s;
264
  }
265
+ .app-container { display: flex; flex-direction: column; min-height: 100vh; }
266
  .header {
267
  background-color: var(--tg-theme-header-bg-color);
268
  padding: 10px 15px;
 
273
  position: sticky;
274
  top: 0;
275
  z-index: 100;
276
+ transition: background-color 0.3s, border-bottom-color 0.3s;
277
  }
278
+ .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; transition: background-color 0.3s; }
279
  .tab-button {
280
  flex: 1;
281
  padding: 10px;
 
290
  transition: color 0.2s, border-bottom-color 0.2s;
291
  }
292
  .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }
293
+ .content { flex-grow: 1; padding: 15px; transition: opacity 0.3s ease-in-out; }
 
294
  .list-item {
295
  background-color: var(--tg-theme-section-bg-color);
296
+ border-radius: 12px;
297
  padding: 12px 15px;
298
  margin-bottom: 10px;
299
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
300
  cursor: pointer;
301
+ transition: background-color 0.2s, transform 0.1s;
302
  }
303
+ .list-item:active { background-color: var(--tg-theme-secondary-bg-color); transform: scale(0.98); }
304
  .list-item h3 { margin: 0 0 5px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
305
  .list-item p { margin: 0 0 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
306
  .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); }
307
+ .list-item-image { width: 60px; height: 60px; border-radius: 8px; object-fit: cover; margin-right: 12px; background-color: var(--tg-theme-secondary-bg-color); }
308
+ .list-item-content { display: flex; align-items: center; }
309
+
310
+ .form-container { padding: 15px; background-color: var(--tg-theme-section-bg-color); border-radius: 12px; margin-bottom: 15px;}
311
  .form-group { margin-bottom: 15px; }
312
+ .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; font-weight: 500;}
313
  .form-group input, .form-group textarea, .form-group input[type="file"] {
314
  width: 100%;
315
  padding: 10px;
316
  border: 1px solid var(--tg-theme-secondary-bg-color);
317
+ border-radius: 8px;
318
  font-size: 16px;
319
  background-color: var(--tg-theme-bg-color);
320
  color: var(--tg-theme-text-color);
321
  box-sizing: border-box;
322
  }
323
+ .form-group input[type="file"] { padding: 5px; }
324
  .form-group textarea { min-height: 80px; resize: vertical; }
 
325
  .fab {
326
  position: fixed;
327
  bottom: 20px;
 
334
  display: flex;
335
  align-items: center;
336
  justify-content: center;
337
+ font-size: 28px;
338
+ line-height: 28px;
339
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
340
  cursor: pointer;
341
  z-index: 1000;
342
  border: none;
343
+ transition: background-color 0.3s, transform 0.2s;
344
  }
345
+ .fab:active { transform: scale(0.9); }
346
+ .detail-view { padding: 15px; background-color: var(--tg-theme-section-bg-color); border-radius: 12px; }
347
+ .detail-view h2 { margin-top: 0; font-size: 22px; font-weight: 600; color: var(--tg-theme-text-color); }
348
+ .detail-view p { margin-bottom: 10px; line-height: 1.6; font-size: 16px; }
349
+ .detail-view strong { font-weight: 500; color: var(--tg-theme-section-header-text-color); }
350
+ .detail-image { max-width: 100%; border-radius: 8px; margin-bottom: 15px; background-color: var(--tg-theme-secondary-bg-color); }
351
  .loading, .empty-state { text-align: center; padding: 40px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
352
+ .user-info { padding: 10px 15px; background-color: var(--tg-theme-secondary-bg-color); font-size: 14px; text-align: center; color: var(--tg-theme-hint-color); display: flex; align-items: center; justify-content: center; transition: background-color 0.3s, color 0.3s; }
353
+ .user-info img { width: 28px; height: 28px; border-radius: 50%; vertical-align: middle; margin-right: 10px; object-fit: cover; }
354
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 5px; }
355
+ .view-transition { opacity: 0; transform: translateY(10px); transition: opacity 0.3s ease-out, transform 0.3s ease-out; }
356
+ .view-transition.visible { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
 
 
 
357
  </style>
358
  </head>
359
  <body>
360
  <div class="app-container">
361
  <div class="header">TonTalent</div>
362
+ <div class="user-info" id="userInfo">
363
+ <img id="userAvatar" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="User Avatar" style="display:none;">
364
+ <span id="userText">Loading user...</span>
365
+ </div>
366
  <div class="tabs">
367
+ <button class="tab-button active" data-tab="resumes">Resumes</button>
368
+ <button class="tab-button" data-tab="vacancies">Vacancies</button>
369
+ <button class="tab-button" data-tab="freelance_offers">Freelance</button>
370
  </div>
371
+ <div class="content" id="mainContent">
372
+ <div class="loading">Loading content...</div>
 
373
  </div>
 
374
  <button class="fab" id="fabButton" title="Add New Item">+</button>
375
  </div>
376
 
377
  <script>
378
  const tg = window.Telegram.WebApp;
379
  let currentUser = null;
380
+ let currentView = 'resumes';
381
+ let currentItem = null;
 
 
382
  const tabOrder = ['resumes', 'vacancies', 'freelance_offers'];
 
 
 
 
 
 
383
 
384
  function applyThemeParams() {
385
+ const root = document.documentElement;
386
+ root.style.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
387
+ root.style.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
388
+ root.style.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
389
+ root.style.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
390
+ root.style.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
391
+ root.style.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
392
+ root.style.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
393
+ root.style.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
394
+ root.style.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
395
+ root.style.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
396
+ root.style.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
397
+ root.style.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
398
  }
399
 
400
+ async function apiCall(endpoint, method = 'GET', body = null) {
401
  const headers = {};
 
 
 
402
  if (tg.initData) {
403
  headers['X-Telegram-Auth'] = tg.initData;
404
  }
405
  const options = { method, headers };
406
+ if (body) {
407
+ if (body instanceof FormData) {
408
+ options.body = body;
409
+ } else {
410
+ headers['Content-Type'] = 'application/json';
411
+ options.body = JSON.stringify(body);
412
+ }
413
+ }
414
  try {
415
  const response = await fetch(endpoint, options);
416
  if (!response.ok) {
417
+ const errorData = await response.json().catch(() => ({ error: 'Request failed with status ' + response.status }));
418
  throw new Error(errorData.error || `HTTP error ${response.status}`);
419
  }
420
  return response.json();
421
  } catch (error) {
422
  console.error('API Call Error:', error);
423
  tg.showAlert(error.message || 'An API error occurred.');
 
424
  throw error;
425
  }
426
  }
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  function renderList(items, type) {
429
+ const contentDiv = document.getElementById('mainContent');
430
+ contentDiv.classList.remove('visible');
431
+
432
+ setTimeout(() => {
433
+ if (!items || items.length === 0) {
434
+ contentDiv.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`;
435
+ } else {
436
+ contentDiv.innerHTML = items.map(item => `
437
+ <div class="list-item" onclick="showDetailView('${type}', '${item.id}')">
438
+ <div class="list-item-content">
439
+ ${item.image_url ? `<img src="${item.image_url}" alt="Image" class="list-item-image">` : `<div class="list-item-image" style="display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--tg-theme-hint-color);">🖼️</div>`}
440
+ <div>
441
+ <h3>${item.title || item.name || 'Untitled'}</h3>
442
+ ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''}
443
+ ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''}
444
+ <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p>
445
+ </div>
446
+ </div>
447
+ </div>
448
+ `).join('');
449
+ }
450
+ contentDiv.classList.add('visible');
451
+ }, 100);
452
  }
453
 
454
  function showDetailView(type, id) {
 
 
455
  tg.BackButton.show();
456
+ tg.BackButton.onClick(() => { loadView(type); tg.HapticFeedback.impactOccurred('light'); });
457
  tg.MainButton.hide();
458
  document.getElementById('fabButton').style.display = 'none';
459
+ const contentDiv = document.getElementById('mainContent');
460
+ contentDiv.classList.remove('visible');
461
 
462
  apiCall(`/api/${type}/${id}`)
463
  .then(item => {
464
  currentItem = item;
465
  let detailsHtml = `<div class="detail-view">`;
466
+ if (item.image_url) {
467
+ detailsHtml += `<img src="${item.image_url}" alt="${item.title || item.name}" class="detail-image">`;
468
  }
469
  detailsHtml += `<h2>${item.title || item.name}</h2>`;
470
 
 
474
  <p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p>
475
  <p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p>
476
  <p><strong>Contact:</strong> ${item.contact || `@${item.user_telegram_username}`}</p>
477
+ ${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>` : ''}
478
  `;
479
  } else if (type === 'vacancies') {
480
  detailsHtml += `
 
495
  `;
496
  }
497
  detailsHtml += `<p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p></div>`;
498
+
499
+ setTimeout(() => {
500
+ contentDiv.innerHTML = detailsHtml;
501
+ contentDiv.classList.add('visible');
502
+ },100);
503
 
504
  if (currentUser && item.user_id === currentUser.id) {
505
  tg.MainButton.setText('Edit My Post');
506
+ tg.MainButton.onClick(() => { showForm(type, item); tg.HapticFeedback.impactOccurred('light'); });
507
  tg.MainButton.show();
508
  }
509
  })
510
  .catch(err => {
511
+ setTimeout(() => {
512
+ contentDiv.innerHTML = `<div class="empty-state">Error loading details.</div>`;
513
+ contentDiv.classList.add('visible');
514
+ }, 100);
515
  });
516
  }
517
 
518
  function showForm(type, itemToEdit = null) {
 
 
519
  currentItem = itemToEdit;
520
  tg.BackButton.show();
521
  tg.BackButton.onClick(() => {
522
  if (itemToEdit) showDetailView(type, itemToEdit.id);
523
+ else loadView(type);
524
+ tg.HapticFeedback.impactOccurred('light');
525
  });
526
  document.getElementById('fabButton').style.display = 'none';
527
+ const contentDiv = document.getElementById('mainContent');
528
+ contentDiv.classList.remove('visible');
529
 
530
+ let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1).replace('_',' ')}</h2>`;
531
 
532
+ const commonFields = `
533
  <div class="form-group">
534
+ <label for="image">Image (Optional)</label>
535
  <input type="file" id="image" accept="image/*">
536
+ ${itemToEdit && itemToEdit.image_url ? `<p style="font-size:0.8em; margin-top:5px;">Current image: <a href="${itemToEdit.image_url}" target="_blank" style="color:var(--tg-theme-link-color);">View</a>. Upload new to replace.</p>` : ''}
537
  </div>
538
  `;
539
 
 
547
  <label for="title">Job Title / Desired Position</label>
548
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
549
  </div>
550
+ ${commonFields}
551
  <div class="form-group">
552
  <label for="skills">Skills (comma separated)</label>
553
  <textarea id="skills">${itemToEdit?.skills || ''}</textarea>
 
579
  <label for="title">Job Title</label>
580
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
581
  </div>
582
+ ${commonFields}
583
  <div class="form-group">
584
  <label for="description">Description</label>
585
  <textarea id="description">${itemToEdit?.description || ''}</textarea>
 
607
  <label for="title">Project Title</label>
608
  <input type="text" id="title" value="${itemToEdit?.title || ''}" required>
609
  </div>
610
+ ${commonFields}
611
  <div class="form-group">
612
  <label for="description">Description of Work</label>
613
  <textarea id="description">${itemToEdit?.description || ''}</textarea>
 
631
  `;
632
  }
633
  formHtml += `<div id="formError" class="error-message"></div></div>`;
634
+
635
+ setTimeout(() => {
636
+ contentDiv.innerHTML = formHtml;
637
+ contentDiv.classList.add('visible');
638
+ }, 100);
 
 
 
 
 
 
 
 
 
 
 
639
 
640
  tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post');
641
  tg.MainButton.show();
 
647
  let isValid = true;
648
  document.getElementById('formError').textContent = '';
649
 
 
 
 
 
 
650
  if (type === 'resumes') {
651
  const name = document.getElementById('name').value.trim();
652
  const title = document.getElementById('title').value.trim();
 
680
  formData.append('contact', document.getElementById('contact').value.trim());
681
  }
682
 
683
+ const imageInput = document.getElementById('image');
684
+ if (imageInput && imageInput.files[0]) {
685
+ formData.append('image', imageInput.files[0]);
686
+ }
687
+
688
  if (!isValid) {
689
  document.getElementById('formError').textContent = 'Please fill in all required fields.';
690
  tg.HapticFeedback.notificationOccurred('error');
 
696
  const method = itemId ? 'PUT' : 'POST';
697
  const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`;
698
 
699
+ apiCall(endpoint, method, formData)
700
  .then(response => {
701
  tg.HapticFeedback.notificationOccurred('success');
702
  tg.MainButton.hideProgress();
703
+ loadView(type);
704
  })
705
  .catch(err => {
706
  tg.HapticFeedback.notificationOccurred('error');
 
708
  document.getElementById('formError').textContent = err.message || 'Failed to submit.';
709
  });
710
  }
 
 
 
 
 
 
711
 
712
+ function loadView(tabName) {
713
+ currentView = tabName;
714
  document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
715
+ document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active');
716
+
717
+ const contentDiv = document.getElementById('mainContent');
718
+ contentDiv.classList.remove('visible');
719
+ contentDiv.innerHTML = `<div class="loading">Loading ${tabName}...</div>`;
720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
  tg.BackButton.hide();
722
  tg.MainButton.hide();
723
  document.getElementById('fabButton').style.display = 'block';
 
 
724
 
725
+ apiCall(`/api/${tabName}`)
726
+ .then(data => {
727
+ renderList(data, tabName)
728
+ })
729
+ .catch(err => {
730
+ setTimeout(() => {
731
+ contentDiv.innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`;
732
+ contentDiv.classList.add('visible');
733
+ }, 100);
734
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  }
736
 
737
+ function updateUserDisplay(user) {
738
+ const userAvatar = document.getElementById('userAvatar');
739
+ const userText = document.getElementById('userText');
740
+ if (user && user.photo_url) {
741
+ userAvatar.src = user.photo_url;
742
+ userAvatar.style.display = 'inline-block';
743
+ } else if (tg.initDataUnsafe.user?.photo_url) {
744
+ userAvatar.src = tg.initDataUnsafe.user.photo_url;
745
+ userAvatar.style.display = 'inline-block';
746
+ }
747
+ else {
748
+ userAvatar.style.display = 'none';
749
+ }
750
+
751
+ if (user) {
752
+ userText.textContent = `Logged in as: ${user.first_name} (@${user.username})`;
753
+ } else {
754
+ userText.textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}! (@${tg.initDataUnsafe.user?.username || 'anonymous'})`;
755
+ }
756
+ }
757
+
758
  async function init() {
759
  tg.ready();
760
  applyThemeParams();
761
+ tg.onEvent('themeChanged', applyThemeParams);
762
  tg.expand();
763
  tg.enableClosingConfirmation();
764
 
765
+ updateUserDisplay(null); // Initial display with unsafe data
766
 
767
  try {
768
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
769
  currentUser = authResponse.user;
770
  if (currentUser) {
771
+ updateUserDisplay(currentUser);
772
  }
773
  } catch (error) {
774
  console.error("Auth error:", error);
775
+ document.getElementById('userText').textContent = `Auth failed. Limited functionality.`;
776
+ tg.showAlert("Authentication with the server failed. Some features might not work correctly.");
777
  }
778
 
779
  document.querySelectorAll('.tab-button').forEach(button => {
780
  button.addEventListener('click', () => {
781
+ loadView(button.dataset.tab);
782
  tg.HapticFeedback.impactOccurred('light');
 
783
  });
784
  });
785
  document.getElementById('fabButton').addEventListener('click', () => {
 
786
  showForm(currentView);
787
+ tg.HapticFeedback.impactOccurred('medium');
788
  });
789
 
790
+ initSwipeNavigation();
791
+ loadView('resumes');
792
  }
793
+
794
+ function initSwipeNavigation() {
795
+ let touchstartX = 0;
796
+ let touchendX = 0;
797
+ const swipeThreshold = 75;
798
+ const mainContentEl = document.getElementById('mainContent');
799
+
800
+ mainContentEl.addEventListener('touchstart', e => {
801
+ touchstartX = e.changedTouches[0].screenX;
802
+ }, {passive: true});
803
+
804
+ mainContentEl.addEventListener('touchend', e => {
805
+ touchendX = e.changedTouches[0].screenX;
806
+ handleSwipe();
807
+ });
808
 
809
+ function handleSwipe() {
810
+ const currentTabIndex = tabOrder.indexOf(currentView);
811
+ if (touchendX < touchstartX - swipeThreshold) {
812
+ if (currentTabIndex < tabOrder.length - 1) {
813
+ loadView(tabOrder[currentTabIndex + 1]);
814
+ tg.HapticFeedback.impactOccurred('light');
815
+ }
816
+ }
817
+ if (touchendX > touchstartX + swipeThreshold) {
818
+ if (currentTabIndex > 0) {
819
+ loadView(tabOrder[currentTabIndex - 1]);
820
+ tg.HapticFeedback.impactOccurred('light');
821
+ }
822
+ }
823
+ }
824
+ }
825
  init();
826
  </script>
827
  </body>
 
844
  .item:last-child { border-bottom: none; }
845
  .item h3 { margin: 0 0 5px 0; }
846
  .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
847
+ .item img { max-width: 100px; max-height: 100px; border-radius: 4px; margin-top: 5px;}
848
  .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
849
  .button-primary { background-color: #007bff; color: white; }
850
  .button-danger { background-color: #dc3545; color: white; }
 
854
  .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
855
  .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
856
  .sync-buttons form { display: inline-block; margin-right: 10px; }
 
857
  </style>
858
  </head>
859
  <body>
 
871
  <div class="section">
872
  <h2>Data Synchronization with Hugging Face</h2>
873
  <div class="sync-buttons">
874
+ <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
875
+ <button type="submit" class="button button-primary">Upload DB to HF</button>
876
  </form>
877
+ <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
878
+ <button type="submit" class="button button-secondary">Download DB from HF</button>
879
  </form>
880
  </div>
881
+ <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set. Image files are not currently synced via this mechanism.</p>
882
  </div>
883
 
884
  <div class="section">
885
  <h2>Resumes ({{ resumes|length }})</h2>
886
  {% for resume in resumes %}
887
+ <div class="item">
 
 
 
888
  <h3>{{ resume.name }} - {{ resume.title }}</h3>
889
+ {% if resume.image_url %}<img src="{{ resume.image_url }}" alt="Resume Image">{% endif %}
890
  <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p>
891
  <p>Posted: {{ resume.timestamp }}</p>
892
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');">
 
903
  <div class="section">
904
  <h2>Vacancies ({{ vacancies|length }})</h2>
905
  {% for vacancy in vacancies %}
906
+ <div class="item">
 
 
 
907
  <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3>
908
+ {% if vacancy.image_url %}<img src="{{ vacancy.image_url }}" alt="Vacancy Image">{% endif %}
909
  <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p>
910
  <p>Posted: {{ vacancy.timestamp }}</p>
911
  <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');">
 
922
  <div class="section">
923
  <h2>Freelance Offers ({{ freelance_offers|length }})</h2>
924
  {% for offer in freelance_offers %}
925
+ <div class="item">
 
 
 
926
  <h3>{{ offer.title }}</h3>
927
+ {% if offer.image_url %}<img src="{{ offer.image_url }}" alt="Freelance Offer Image">{% endif %}
928
  <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p>
929
  <p>Budget: {{ offer.budget }}</p>
930
  <p>Posted: {{ offer.timestamp }}</p>
 
947
  def main_app_view():
948
  return render_template_string(MAIN_APP_TEMPLATE)
949
 
950
+ @app.route('/uploads/<item_type_subdir>/<filename>')
951
+ def uploaded_file_typed(item_type_subdir, filename):
952
+ if item_type_subdir not in ['resumes', 'vacancies', 'freelance_offers']:
953
+ return jsonify({"error": "Invalid category"}), 404
954
+
955
+ directory = os.path.join(app.config['UPLOADS_DIR'], item_type_subdir)
956
+ # Security: Ensure filename is safe and does not try to escape the directory
957
+ safe_filename = secure_filename(filename)
958
+ if safe_filename != filename: # Check if secure_filename changed it (e.g. '../' was present)
959
+ return jsonify({"error": "Invalid filename"}), 400
960
 
961
+ return send_from_directory(directory, safe_filename)
962
 
963
  @app.route('/api/auth_user', methods=['POST'])
964
  def auth_user():
 
970
  else:
971
  return jsonify({"error": "Authentication data not provided"}), 401
972
 
973
+ is_valid, user_data_dict = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
974
 
975
+ if not is_valid or not user_data_dict:
976
  return jsonify({"error": "Invalid authentication data"}), 403
977
 
978
  data = load_data()
979
  users = data.get('users', {})
980
+ user_id_str = str(user_data_dict.get('id'))
981
 
982
  if user_id_str not in users:
983
  users[user_id_str] = {
984
+ 'id': user_data_dict.get('id'),
985
+ 'first_name': user_data_dict.get('first_name'),
986
+ 'last_name': user_data_dict.get('last_name'),
987
+ 'username': user_data_dict.get('username'),
988
+ 'language_code': user_data_dict.get('language_code'),
989
+ 'photo_url': user_data_dict.get('photo_url'),
990
  'first_seen': datetime.now().isoformat()
991
  }
992
  users[user_id_str]['last_seen'] = datetime.now().isoformat()
993
+ if user_data_dict.get('photo_url'): # Update photo_url if it changed
994
+ users[user_id_str]['photo_url'] = user_data_dict.get('photo_url')
995
  data['users'] = users
996
  save_data(data)
997
 
 
1006
  return user_data
1007
  return None
1008
 
 
1009
  @app.route('/api/<item_type>', methods=['GET'])
1010
  def get_items(item_type):
1011
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
1014
  items = sorted(data.get(item_type, []), key=lambda x: x.get('timestamp', ''), reverse=True)
1015
  return jsonify(items), 200
1016
 
 
1017
  @app.route('/api/<item_type>/<item_id>', methods=['GET'])
1018
  def get_item(item_type, item_id):
1019
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
 
1024
  return jsonify(item), 200
1025
  return jsonify({"error": "Item not found"}), 404
1026
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1027
  @app.route('/api/<item_type>', methods=['POST'])
1028
  def create_item(item_type):
1029
  user = get_authenticated_user(request.headers)
 
1033
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1034
  return jsonify({"error": "Invalid item type"}), 400
1035
 
1036
+ form_data = request.form
1037
+ if not form_data:
 
1038
  return jsonify({"error": "No data provided"}), 400
1039
 
1040
+ new_item_id = str(uuid.uuid4())
1041
  new_item = {
1042
+ "id": new_item_id,
1043
  "user_id": str(user.get('id')),
1044
  "user_telegram_username": user.get('username', 'unknown'),
1045
  "timestamp": datetime.now().isoformat(),
1046
+ "image_url": None
1047
  }
1048
+
1049
+ image_file = request.files.get('image')
1050
+ if image_file and allowed_file(image_file.filename):
1051
+ try:
1052
+ file_ext = os.path.splitext(image_file.filename)[1].lower()
1053
+ image_filename = f"{new_item_id}{file_ext}"
1054
+ image_save_path = os.path.join(app.config['UPLOADS_DIR'], item_type, image_filename)
1055
+ image_file.save(image_save_path)
1056
+ new_item['image_url'] = f"/{UPLOADS_DIR_NAME}/{item_type}/{image_filename}"
1057
+ except Exception as e:
1058
+ logging.error(f"Error saving image for new item: {e}")
1059
 
 
 
 
1060
 
1061
  if item_type == 'resumes':
1062
  required_fields = ['name', 'title']
1063
  for field in required_fields:
1064
+ if not form_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1065
  new_item.update({
1066
+ "name": form_data.get('name'), "title": form_data.get('title'),
1067
+ "skills": form_data.get('skills', ''), "experience": form_data.get('experience', ''),
1068
+ "education": form_data.get('education', ''), "contact": form_data.get('contact', ''),
1069
+ "portfolio_link": form_data.get('portfolio_link', '')
1070
  })
1071
  elif item_type == 'vacancies':
1072
  required_fields = ['company_name', 'title']
1073
  for field in required_fields:
1074
+ if not form_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1075
  new_item.update({
1076
+ "company_name": form_data.get('company_name'), "title": form_data.get('title'),
1077
+ "description": form_data.get('description', ''), "requirements": form_data.get('requirements', ''),
1078
+ "salary": form_data.get('salary', ''), "location": form_data.get('location', ''),
1079
+ "contact": form_data.get('contact', '')
1080
  })
1081
  elif item_type == 'freelance_offers':
1082
  required_fields = ['title']
1083
  for field in required_fields:
1084
+ if not form_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400
1085
  new_item.update({
1086
+ "title": form_data.get('title'), "description": form_data.get('description', ''),
1087
+ "budget": form_data.get('budget', ''), "deadline": form_data.get('deadline', ''),
1088
+ "skills_needed": form_data.get('skills_needed', ''), "contact": form_data.get('contact', '')
1089
  })
1090
 
1091
  data = load_data()
1092
  data[item_type].append(new_item)
1093
+ save_data(data)
1094
  return jsonify(new_item), 201
1095
 
 
1096
  @app.route('/api/<item_type>/<item_id>', methods=['PUT'])
1097
  def update_item(item_type, item_id):
1098
  user = get_authenticated_user(request.headers)
 
1101
  if item_type not in ['resumes', 'vacancies', 'freelance_offers']:
1102
  return jsonify({"error": "Invalid item type"}), 400
1103
 
1104
+ form_data = request.form
1105
+ if not form_data: return jsonify({"error": "No data provided"}), 400
1106
 
1107
  data = load_data()
1108
  items_list = data.get(item_type, [])
1109
  item_index = -1
 
1110
  for idx, i in enumerate(items_list):
1111
  if i['id'] == item_id:
1112
  item_index = idx
 
1113
  break
1114
 
1115
+ if item_index == -1: return jsonify({"error": "Item not found"}), 404
1116
 
1117
+ original_item = items_list[item_index]
1118
  if str(original_item.get('user_id')) != str(user.get('id')):
1119
  return jsonify({"error": "Forbidden: You can only edit your own items"}), 403
1120
 
1121
  updated_item = original_item.copy()
1122
  updated_item['updated_timestamp'] = datetime.now().isoformat()
1123
 
1124
+ image_file = request.files.get('image')
1125
+ if image_file and allowed_file(image_file.filename):
1126
+ delete_existing_image(original_item.get('image_url'))
1127
+ try:
1128
+ file_ext = os.path.splitext(image_file.filename)[1].lower()
1129
+ image_filename = f"{item_id}{file_ext}"
1130
+ image_save_path = os.path.join(app.config['UPLOADS_DIR'], item_type, image_filename)
1131
+ image_file.save(image_save_path)
1132
+ updated_item['image_url'] = f"/{UPLOADS_DIR_NAME}/{item_type}/{image_filename}"
1133
+ except Exception as e:
1134
+ logging.error(f"Error saving image for updated item {item_id}: {e}")
1135
+
1136
 
1137
  if item_type == 'resumes':
1138
  updated_item.update({
1139
+ "name": form_data.get('name', original_item.get('name')),
1140
+ "title": form_data.get('title', original_item.get('title')),
1141
+ "skills": form_data.get('skills', original_item.get('skills')),
1142
+ "experience": form_data.get('experience', original_item.get('experience')),
1143
+ "education": form_data.get('education', original_item.get('education')),
1144
+ "contact": form_data.get('contact', original_item.get('contact')),
1145
+ "portfolio_link": form_data.get('portfolio_link', original_item.get('portfolio_link'))
1146
  })
1147
  elif item_type == 'vacancies':
1148
  updated_item.update({
1149
+ "company_name": form_data.get('company_name', original_item.get('company_name')),
1150
+ "title": form_data.get('title', original_item.get('title')),
1151
+ "description": form_data.get('description', original_item.get('description')),
1152
+ "requirements": form_data.get('requirements', original_item.get('requirements')),
1153
+ "salary": form_data.get('salary', original_item.get('salary')),
1154
+ "location": form_data.get('location', original_item.get('location')),
1155
+ "contact": form_data.get('contact', original_item.get('contact'))
1156
  })
1157
  elif item_type == 'freelance_offers':
1158
  updated_item.update({
1159
+ "title": form_data.get('title', original_item.get('title')),
1160
+ "description": form_data.get('description', original_item.get('description')),
1161
+ "budget": form_data.get('budget', original_item.get('budget')),
1162
+ "deadline": form_data.get('deadline', original_item.get('deadline')),
1163
+ "skills_needed": form_data.get('skills_needed', original_item.get('skills_needed')),
1164
+ "contact": form_data.get('contact', original_item.get('contact'))
1165
  })
1166
 
1167
  data[item_type][item_index] = updated_item
1168
  save_data(data)
1169
  return jsonify(updated_item), 200
1170
 
 
1171
  @app.route('/api/<item_type>/<item_id>', methods=['DELETE'])
1172
  def delete_item(item_type, item_id):
1173
  user = get_authenticated_user(request.headers)
 
1178
 
1179
  data = load_data()
1180
  items_list = data.get(item_type, [])
1181
+ original_length = len(items_list)
1182
 
1183
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
1184
  if not item_to_delete: return jsonify({"error": "Item not found"}), 404
1185
 
1186
+ if str(item_to_delete.get('user_id')) != str(user.get('id')):
1187
  return jsonify({"error": "Forbidden: You can only delete your own items"}), 403
 
 
1188
 
1189
+ delete_existing_image(item_to_delete.get('image_url'))
1190
  data[item_type] = [i for i in items_list if i['id'] != item_id]
1191
 
1192
+ if len(data[item_type]) < original_length:
1193
+ save_data(data)
1194
+ return jsonify({"message": "Item deleted successfully"}), 200
1195
+ return jsonify({"error": "Item not found or deletion failed"}), 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1196
 
1197
 
1198
  @app.route('/admin', methods=['GET'])
1199
  def admin_panel():
 
1200
  data = load_data()
1201
  return render_template_string(ADMIN_TEMPLATE,
1202
  resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True),
 
1205
 
1206
  @app.route('/admin/delete', methods=['POST'])
1207
  def admin_delete_item():
 
1208
  item_type = request.form.get('item_type')
1209
  item_id = request.form.get('item_id')
1210
 
 
1214
 
1215
  data = load_data()
1216
  items_list = data.get(item_type, [])
 
1217
  item_to_delete = next((i for i in items_list if i['id'] == item_id), None)
 
 
 
 
1218
  original_length = len(items_list)
1219
+
1220
+ if item_to_delete:
1221
+ delete_existing_image(item_to_delete.get('image_url'))
1222
+ data[item_type] = [i for i in items_list if i['id'] != item_id]
1223
+ else:
1224
+ flash('Item not found.', 'warning')
1225
+ return redirect(url_for('admin_panel'))
1226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1227
 
1228
+ if len(data[item_type]) < original_length:
1229
+ save_data(data)
1230
+ flash(f'{item_type.capitalize().replace("_", " ")[:-1]} deleted successfully.', 'success')
1231
  else:
1232
  flash('Item not found or already deleted.', 'warning')
1233
  return redirect(url_for('admin_panel'))
1234
 
1235
  @app.route('/admin/force_upload', methods=['POST'])
1236
  def force_upload_admin():
 
1237
  logging.info("Admin forcing upload to Hugging Face...")
1238
  try:
1239
+ upload_db_to_hf()
1240
+ flash("Data successfully uploaded to Hugging Face.", 'success')
1241
  except Exception as e:
1242
  logging.error(f"Error during forced upload: {e}", exc_info=True)
1243
  flash(f"Error uploading to Hugging Face: {e}", 'error')
 
1245
 
1246
  @app.route('/admin/force_download', methods=['POST'])
1247
  def force_download_admin():
 
1248
  logging.info("Admin forcing download from Hugging Face...")
1249
  try:
1250
+ if download_db_from_hf():
1251
+ flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
 
1252
  load_data()
1253
  else:
1254
+ flash("Failed to download data from Hugging Face. Check logs.", 'error')
1255
  except Exception as e:
1256
  logging.error(f"Error during forced download: {e}", exc_info=True)
1257
  flash(f"Error downloading from Hugging Face: {e}", 'error')
 
1260
 
1261
  if __name__ == '__main__':
1262
  logging.info("Application starting up. Performing initial data load/download...")
 
1263
  download_db_from_hf()
1264
+ load_data()
1265
  logging.info("Initial data load complete.")
1266
 
1267
  if HF_TOKEN_WRITE: