Shveiauto commited on
Commit
82ad48a
·
verified ·
1 Parent(s): 5a0a6a6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1025 -746
app.py CHANGED
@@ -1,4 +1,4 @@
1
- from flask import Flask, render_template_string, request, jsonify, redirect, url_for
2
  import json
3
  import os
4
  import logging
@@ -13,29 +13,26 @@ import requests
13
  import uuid
14
  import hmac
15
  import hashlib
16
- from urllib.parse import unquote, parse_qs
17
 
18
  load_dotenv()
19
 
20
  app = Flask(__name__)
21
- app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_telegram_mini_app_12345')
22
- DATA_FILE = 'tontalent_data.json'
23
- SYNC_FILES = [DATA_FILE]
24
 
25
- REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent_data_store") # Example, user should set this
26
- HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") # Renamed from HF_TOKEN for clarity
27
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
29
- # Telegram Bot Token for initData verification
30
- TELEGRAM_BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8"
 
 
31
 
32
  DOWNLOAD_RETRIES = 3
33
  DOWNLOAD_DELAY = 5
34
 
35
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
36
 
37
- # --- Hugging Face Sync Functions (largely unchanged, adapted for single data file) ---
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.")
@@ -62,15 +59,15 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
62
  except HfHubHTTPError as e:
63
  if e.response.status_code == 404:
64
  logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
65
- if attempt == 0 and not os.path.exists(file_name) and file_name == DATA_FILE:
66
  try:
67
  with open(file_name, 'w', encoding='utf-8') as f:
68
- json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': []}, f)
69
- logging.info(f"Created empty local file {file_name} as it was not on HF.")
70
  except Exception as create_e:
71
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
72
- success = False # File not found is not a success for the file itself
73
- break
74
  else:
75
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
76
  except requests.exceptions.RequestException as e:
@@ -81,12 +78,12 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
81
  if not success:
82
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
83
  all_successful = False
84
- logging.info(f"Download process finished. Overall success for requested files: {all_successful}")
85
  return all_successful
86
 
87
  def upload_db_to_hf(specific_file=None):
88
  if not HF_TOKEN_WRITE:
89
- logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
90
  return
91
  try:
92
  api = HfApi()
@@ -118,53 +115,43 @@ def periodic_backup():
118
  logging.info("Periodic backup finished.")
119
 
120
  # --- Data Loading and Saving Functions ---
121
-
122
  def load_data():
123
- default_data = {'resumes': [], 'vacancies': [], 'freelance_offers': []}
124
  try:
125
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
126
  data = json.load(file)
127
- logging.info(f"Local data loaded successfully from {DATA_FILE}")
128
- if not isinstance(data, dict):
129
- logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
130
- raise FileNotFoundError # Trigger download
131
  for key in default_data:
132
- if key not in data: data[key] = []
 
 
133
  return data
134
- except FileNotFoundError:
135
- logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
136
- except json.JSONDecodeError:
137
- logging.error(f"Error decoding JSON in local {DATA_FILE}. File might be corrupt. Attempting download.")
138
 
139
- if download_db_from_hf(specific_file=DATA_FILE):
140
  try:
141
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
142
  data = json.load(file)
143
- logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
144
  if not isinstance(data, dict):
145
- logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.")
146
  return default_data
147
  for key in default_data:
148
- if key not in data: data[key] = []
 
 
149
  return data
150
- except FileNotFoundError:
151
- logging.error(f"File {DATA_FILE} still not found even after download reported success. Using default.")
152
- return default_data
153
- except json.JSONDecodeError:
154
- logging.error(f"Error decoding JSON in downloaded {DATA_FILE}. Using default.")
155
- return default_data
156
- except Exception as e:
157
- logging.error(f"Unknown error loading downloaded {DATA_FILE}: {e}. Using default.", exc_info=True)
158
  return default_data
159
  else:
160
- logging.error(f"Failed to download {DATA_FILE} from HF after retries. Using empty default data structure.")
161
- if not os.path.exists(DATA_FILE):
162
  try:
163
- with open(DATA_FILE, 'w', encoding='utf-8') as f:
164
- json.dump(default_data, f)
165
- logging.info(f"Created empty local file {DATA_FILE} after failed download.")
166
  except Exception as create_e:
167
- logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
168
  return default_data
169
 
170
  def save_data(data):
@@ -172,64 +159,41 @@ def save_data(data):
172
  if not isinstance(data, dict):
173
  logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
174
  return
175
- default_keys = ['resumes', 'vacancies', 'freelance_offers']
176
- for key in default_keys:
177
- if key not in data: data[key] = []
178
-
179
- with open(DATA_FILE, 'w', encoding='utf-8') as file:
180
  json.dump(data, file, ensure_ascii=False, indent=4)
181
- logging.info(f"Data successfully saved to {DATA_FILE}")
182
- upload_db_to_hf(specific_file=DATA_FILE)
183
  except Exception as e:
184
- logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
185
-
186
- # --- Telegram InitData Verification ---
187
- def verify_telegram_init_data(init_data_str):
188
- if not init_data_str:
189
- logging.warning("Verification_failed: init_data_str is empty")
190
- return None
191
-
192
- parsed_data = parse_qs(init_data_str)
193
-
194
- received_hash = parsed_data.pop('hash', [None])[0]
195
- if not received_hash:
196
- logging.warning("Verification_failed: hash is missing from init_data")
197
- return None
198
-
199
- data_check_string_parts = []
200
- for key in sorted(parsed_data.keys()):
201
- # Ensure values are strings, especially 'user' which can be JSON
202
- value = parsed_data[key][0]
203
- data_check_string_parts.append(f"{key}={value}")
204
-
205
- data_check_string = "\n".join(data_check_string_parts)
206
 
 
 
207
  try:
208
- secret_key = hmac.new("WebAppData".encode(), TELEGRAM_BOT_TOKEN.encode(), hashlib.sha256).digest()
 
 
 
 
 
 
209
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
 
 
 
 
 
 
 
210
  except Exception as e:
211
- logging.error(f"Error during HMAC calculation: {e}")
212
  return None
213
 
214
- if calculated_hash == received_hash:
215
- user_data_str = parsed_data.get('user', [None])[0]
216
- if user_data_str:
217
- try:
218
- user_obj = json.loads(unquote(user_data_str))
219
- logging.info(f"Telegram user verified: ID {user_obj.get('id')}")
220
- return user_obj
221
- except json.JSONDecodeError as e:
222
- logging.error(f"Failed to decode user data JSON: {e} - Data: {user_data_str}")
223
- return None # Invalid user JSON
224
- logging.warning("Verification_failed: user data is missing though hash is valid")
225
- return {} # Valid hash, but no user data (should not happen with standard initData)
226
-
227
- logging.warning(f"Verification_failed: Hashes do not match. Calculated: {calculated_hash}, Received: {received_hash}")
228
- logging.debug(f"Data check string for failed hash: {data_check_string}")
229
- return None
230
-
231
  # --- Templates ---
232
- MAIN_APP_TEMPLATE = """
 
233
  <!DOCTYPE html>
234
  <html lang="en">
235
  <head>
@@ -237,705 +201,1020 @@ MAIN_APP_TEMPLATE = """
237
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
238
  <title>TonTalent</title>
239
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
 
240
  <style>
241
- :root {
242
- --tg-theme-bg-color: var(--tg-theme-bg-color, #ffffff);
243
- --tg-theme-text-color: var(--tg-theme-text-color, #000000);
244
- --tg-theme-hint-color: var(--tg-theme-hint-color, #999999);
245
- --tg-theme-link-color: var(--tg-theme-link-color, #2481cc);
246
- --tg-theme-button-color: var(--tg-theme-button-color, #2481cc);
247
- --tg-theme-button-text-color: var(--tg-theme-button-text-color, #ffffff);
248
- --tg-theme-secondary-bg-color: var(--tg-theme-secondary-bg-color, #f2f2f2);
249
- --tg-theme-header-bg-color: var(--tg-theme-header-bg-color, #ffffff); /* Added for header */
250
- --tg-theme-accent-text-color: var(--tg-theme-accent-text-color, #2481cc); /* Added for accents */
251
-
252
-
253
- --border-color: rgba(0, 0, 0, 0.1);
254
- --border-radius-s: 8px;
255
- --border-radius-m: 12px;
256
- --padding-s: 8px;
257
- --padding-m: 16px;
258
- --padding-l: 24px;
259
- --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
260
- }
261
- body.dark {
262
- --border-color: rgba(255, 255, 255, 0.15);
263
- }
264
-
265
- * { box-sizing: border-box; margin: 0; padding: 0; }
266
- body {
267
- font-family: var(--font-family);
268
- background-color: var(--tg-theme-bg-color);
269
- color: var(--tg-theme-text-color);
270
- overscroll-behavior-y: none;
271
- display: flex;
272
- flex-direction: column;
273
- height: 100vh;
274
  margin: 0;
275
- padding: 0;
276
- }
277
- .app-container {
278
- display: flex;
279
- flex-direction: column;
280
- flex-grow: 1;
281
- overflow: hidden;
282
- }
283
- .main-header {
284
- background-color: var(--tg-theme-header-bg-color);
285
- padding: 10px var(--padding-m);
286
- border-bottom: 1px solid var(--border-color);
287
- display: flex;
288
- justify-content: space-between;
289
- align-items: center;
290
- position: sticky;
291
- top: 0;
292
- z-index: 100;
293
- }
294
- .main-header h1 {
295
- font-size: 1.5em;
296
- font-weight: 600;
297
  color: var(--tg-theme-text-color);
298
- }
299
- .add-button {
300
- background-color: var(--tg-theme-button-color);
301
- color: var(--tg-theme-button-text-color);
302
- border: none;
303
- padding: 8px 12px;
304
- border-radius: var(--border-radius-s);
305
- font-size: 1.2em;
306
- cursor: pointer;
307
- }
308
- .tab-navigation {
309
- display: flex;
310
- justify-content: space-around;
311
- background-color: var(--tg-theme-secondary-bg-color);
312
- border-top: 1px solid var(--border-color);
313
- padding: var(--padding-s) 0;
314
- position: sticky;
315
- bottom: 0;
316
- z-index: 100;
317
- }
318
- .tab-button {
319
- flex: 1;
320
- padding: 12px 0;
321
- background: none;
322
- border: none;
323
- color: var(--tg-theme-hint-color);
324
- font-size: 0.9em;
325
- font-weight: 500;
326
- cursor: pointer;
327
- transition: color 0.2s ease;
328
- display: flex;
329
- flex-direction: column;
330
- align-items: center;
331
- gap: 4px;
332
- }
333
- .tab-button i { font-size: 1.3em; }
334
- .tab-button.active {
335
- color: var(--tg-theme-button-color); /* Use button color for active tab */
336
- }
337
- .content-area {
338
- flex-grow: 1;
339
- padding: var(--padding-m);
340
- overflow-y: auto;
341
- -webkit-overflow-scrolling: touch;
342
- }
343
- .item-card {
344
  background-color: var(--tg-theme-secondary-bg-color);
345
- border-radius: var(--border-radius-m);
346
- padding: var(--padding-m);
347
- margin-bottom: var(--padding-m);
348
- box-shadow: 0 2px 10px rgba(0,0,0,0.05);
349
  border: 1px solid var(--border-color);
350
- }
351
- body.dark .item-card {
352
- box-shadow: 0 2px 10px rgba(0,0,0,0.2);
353
- }
354
- .item-card h3 {
355
- font-size: 1.2em;
356
- font-weight: 600;
357
- color: var(--tg-theme-text-color);
358
- margin-bottom: var(--padding-s);
359
- }
360
- .item-card p {
361
- font-size: 0.95em;
362
- color: var(--tg-theme-text-color);
363
- margin-bottom: 6px;
364
- line-height: 1.5;
365
- white-space: pre-wrap;
366
- word-break: break-word;
367
- }
368
- .item-card .meta-info {
369
- font-size: 0.8em;
370
- color: var(--tg-theme-hint-color);
371
- margin-top: var(--padding-s);
372
- }
373
- .item-card .meta-info span { margin-right: 10px; }
374
- .item-card strong { font-weight: 500; }
375
-
376
- .modal {
377
- display: none;
378
- position: fixed;
379
- z-index: 1000;
380
- left: 0;
381
- top: 0;
382
- width: 100%;
383
- height: 100%;
384
- overflow: auto;
385
- background-color: rgba(0,0,0,0.5);
386
- backdrop-filter: blur(5px);
387
- }
388
- .modal-content {
389
- background-color: var(--tg-theme-bg-color);
390
- margin: 10% auto;
391
- padding: var(--padding-l);
392
- border-radius: var(--border-radius-m);
393
- width: 90%;
394
- max-width: 500px;
395
- position: relative;
396
- }
397
- .modal-header {
398
- display: flex;
399
- justify-content: space-between;
400
- align-items: center;
401
- margin-bottom: var(--padding-m);
402
- padding-bottom: var(--padding-s);
403
- border-bottom: 1px solid var(--border-color);
404
- }
405
- .modal-header h2 {
406
- font-size: 1.3em;
407
- font-weight: 600;
408
- }
409
- .close-button {
410
- font-size: 1.8em;
411
- font-weight: 300;
412
- color: var(--tg-theme-hint-color);
413
- background: none;
414
  border: none;
 
 
415
  cursor: pointer;
416
- line-height: 1;
417
- }
418
- .form-group {
419
- margin-bottom: var(--padding-m);
420
- }
421
- .form-group label {
422
- display: block;
423
- font-size: 0.9em;
424
- font-weight: 500;
425
- margin-bottom: var(--padding-s);
426
- color: var(--tg-theme-text-color);
427
- }
428
- .form-group input[type="text"],
429
- .form-group input[type="email"],
430
- .form-group input[type="tel"],
431
- .form-group textarea,
432
- .form-group select {
433
  width: 100%;
434
  padding: 12px;
 
435
  border: 1px solid var(--border-color);
436
- border-radius: var(--border-radius-s);
437
- font-size: 1em;
438
- font-family: var(--font-family);
439
  background-color: var(--tg-theme-secondary-bg-color);
440
  color: var(--tg-theme-text-color);
441
- }
442
- .form-group input:focus, .form-group textarea:focus, .form-group select:focus {
443
- border-color: var(--tg-theme-button-color);
444
- outline: none;
445
- box-shadow: 0 0 0 2px var(--tg-theme-button-color-alpha, rgba(36, 129, 204, 0.2));
446
- }
447
- .form-group textarea {
448
- min-height: 100px;
449
- resize: vertical;
450
- }
451
- .submit-button {
452
- background-color: var(--tg-theme-button-color);
453
- color: var(--tg-theme-button-text-color);
454
- border: none;
455
- padding: 12px 20px;
456
- border-radius: var(--border-radius-s);
457
- font-size: 1em;
458
- font-weight: 500;
459
  cursor: pointer;
460
- width: 100%;
461
- transition: background-color 0.2s ease;
462
- }
463
- .submit-button:hover {
464
- opacity: 0.9;
465
- }
466
- .delete-button {
467
- background-color: var(--tg-theme-destructive-text-color, #ff4d4d);
468
- color: var(--tg-theme-button-text-color);
469
  border: none;
470
- padding: 6px 10px;
471
- border-radius: var(--border-radius-s);
472
- font-size: 0.8em;
473
- cursor: pointer;
474
- margin-top: 8px;
475
- float: right;
476
- }
477
- .empty-state {
478
- text-align: center;
479
- padding: 40px 20px;
480
- color: var(--tg-theme-hint-color);
481
- }
482
- .empty-state p { font-size: 1.1em; margin-bottom: 10px; }
483
- .loading-spinner {
484
- border: 4px solid var(--tg-theme-secondary-bg-color);
485
- border-top: 4px solid var(--tg-theme-button-color);
486
- border-radius: 50%;
487
- width: 30px;
488
- height: 30px;
489
- animation: spin 1s linear infinite;
490
- margin: 20px auto;
491
- }
492
- @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
493
- .user-info {
494
- font-size: 0.8em;
495
  color: var(--tg-theme-hint-color);
496
- padding: 0 var(--padding-m) 5px;
497
- text-align: right;
498
- }
499
- /* FontAwesome for icons */
500
- @import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css");
 
 
 
 
 
 
 
 
 
 
 
 
 
501
  </style>
502
  </head>
503
  <body>
504
- <div class="app-container">
505
- <header class="main-header">
506
- <h1>TonTalent</h1>
507
- <button class="add-button" id="openModalBtn"><i class="fas fa-plus"></i></button>
508
- </header>
509
-
510
- <div id="userInfoDisplay" class="user-info"></div>
511
-
512
- <main class="content-area" id="contentArea">
513
- <div class="loading-spinner"></div>
514
- </main>
515
-
516
- <nav class="tab-navigation">
517
- <button class="tab-button active" data-tab="resumes"><i class="fas fa-address-card"></i> Resumes</button>
518
- <button class="tab-button" data-tab="vacancies"><i class="fas fa-briefcase"></i> Vacancies</button>
519
- <button class="tab-button" data-tab="freelance_offers"><i class="fas fa-handshake"></i> Offers</button>
520
- </nav>
521
- </div>
522
-
523
- <div id="formModal" class="modal">
524
- <div class="modal-content">
525
- <div class="modal-header">
526
- <h2 id="modalTitle">Create New</h2>
527
- <button class="close-button" id="closeModalBtn">×</button>
528
- </div>
529
- <form id="itemForm">
530
- <input type="hidden" id="formItemType" name="itemType">
531
- <input type="hidden" id="formItemId" name="itemId"> <!-- For editing -->
532
-
533
- <div id="commonFields">
534
- <!-- Fields will be dynamically added here -->
535
- </div>
536
- <button type="submit" class="submit-button">Submit</button>
537
- </form>
538
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
539
  </div>
540
 
541
  <script>
542
  const tg = window.Telegram.WebApp;
543
- let currentUser = null;
544
- let currentTab = 'resumes';
545
- let allData = { resumes: [], vacancies: [], freelance_offers: [] };
546
-
547
- const formFieldsConfig = {
548
- resumes: [
549
- { name: 'title', label: 'Resume Title (e.g., Software Engineer)', type: 'text', required: true },
550
- { name: 'name', label: 'Your Name', type: 'text', required: true },
551
- { name: 'contact', label: 'Contact Info (e.g., Telegram @username, email)', type: 'text', required: true },
552
- { name: 'skills', label: 'Skills (comma-separated)', type: 'text', required: true },
553
- { name: 'experience_summary', label: 'Experience Summary', type: 'textarea', required: true },
554
- { name: 'looking_for', label: 'Looking For (e.g., Full-time, Remote)', type: 'text', required: false }
555
- ],
556
- vacancies: [
557
- { name: 'job_title', label: 'Job Title', type: 'text', required: true },
558
- { name: 'company_name', label: 'Company Name', type: 'text', required: true },
559
- { name: 'location', label: 'Location (e.g., Remote, City)', type: 'text', required: true },
560
- { name: 'description', label: 'Job Description', type: 'textarea', required: true },
561
- { name: 'requirements', label: 'Requirements (comma-separated)', type: 'text', required: true },
562
- { name: 'employment_type', label: 'Employment Type (e.g., Full-time)', type: 'text', required: false },
563
- { name: 'salary_range', label: 'Salary Range (optional)', type: 'text', required: false }
564
- ],
565
- freelance_offers: [
566
- { name: 'offer_title', label: 'Offer Title', type: 'text', required: true },
567
- { name: 'description', label: 'Offer Description', type: 'textarea', required: true },
568
- { name: 'required_skills', label: 'Required Skills (comma-separated)', type: 'text', required: true },
569
- { name: 'budget', label: 'Budget (e.g., $500, Negotiable)', type: 'text', required: true },
570
- { name: 'timeline', label: 'Timeline (optional)', type: 'text', required: false }
571
- ]
572
- };
573
-
574
- function applyTheme() {
575
- tg.ready();
576
- tg.expand(); // Make the app full height
577
- document.body.style.backgroundColor = tg.themeParams.bg_color || '#ffffff';
578
- document.body.style.color = tg.themeParams.text_color || '#000000';
579
- if (tg.colorScheme === 'dark') {
580
  document.body.classList.add('dark');
581
- } else {
 
 
 
 
 
582
  document.body.classList.remove('dark');
583
- }
584
- // Update CSS variables
585
- const root = document.documentElement;
586
- for (const key in tg.themeParams) {
587
- root.style.setProperty('--tg-theme-' + key.replace(/_/g, '-'), tg.themeParams[key]);
588
- }
589
- // For button focus shadow
590
- if (tg.themeParams.button_color) {
591
- const buttonColor = tg.themeParams.button_color;
592
- // Simple way to make it semi-transparent for shadow, might need a proper RGBA conversion
593
- const alphaColor = buttonColor.startsWith('#') ? buttonColor + '33' : 'rgba(36, 129, 204, 0.2)';
594
- root.style.setProperty('--tg-theme-button-color-alpha', alphaColor);
595
- }
596
- }
597
-
598
- async function fetchAndRenderData(tab) {
599
- const contentArea = document.getElementById('contentArea');
600
- contentArea.innerHTML = '<div class="loading-spinner"></div>';
601
- try {
602
- const response = await fetch(`/api/get_data/${tab}?initData=${encodeURIComponent(tg.initData)}`);
603
- if (!response.ok) {
604
- const errorData = await response.json().catch(() => ({ detail: "Failed to fetch data" }));
605
- throw new Error(errorData.detail || `HTTP error! status: ${response.status}`);
606
- }
607
- const data = await response.json();
608
- allData[tab] = data;
609
- renderItems(tab);
610
- } catch (error) {
611
- console.error('Error fetching data:', error);
612
- contentArea.innerHTML = `<p class="empty-state">Error loading data: ${error.message}. Please try again.</p>`;
613
- tg.showAlert(`Error loading data: ${error.message}`);
614
- }
615
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
- function renderItems(type) {
618
- const contentArea = document.getElementById('contentArea');
619
- const items = allData[type] || [];
620
-
621
- if (items.length === 0) {
622
- contentArea.innerHTML = `<div class="empty-state"><p>No ${type} found.</p><p>Click the '+' button to add one!</p></div>`;
623
- return;
624
- }
625
-
626
- let html = '';
627
- items.slice().reverse().forEach(item => { // Show newest first
628
- html += '<div class="item-card">';
629
- html += `<h3>${escapeHtml(item.title || item.job_title || item.offer_title || 'Untitled')}</h3>`;
630
-
631
- const fields = formFieldsConfig[type];
632
- fields.forEach(field => {
633
- if (item[field.name] && field.name !== 'title' && field.name !== 'job_title' && field.name !== 'offer_title') {
634
- if (field.name === 'name' && type === 'resumes') return; // Handled by user_display_name
635
- html += `<p><strong>${escapeHtml(field.label.replace('(optional)','').replace('(comma-separated)','').replace(' (e.g., Telegram @username, email)',''))}:</strong> ${escapeHtml(String(item[field.name]))}</p>`;
636
- }
637
- });
638
-
639
- html += `<div class="meta-info">`;
640
- html += `<span>Posted by: ${escapeHtml(item.user_display_name || 'Unknown User')}</span>`;
641
- html += `<span>On: ${new Date(item.timestamp).toLocaleDateString()}</span>`;
642
- if (currentUser && item.user_id === currentUser.id) {
643
- html += `<button class="delete-button" onclick="deleteItem('${type}', '${item.id}')">Delete</button>`;
644
- }
645
- html += `</div></div>`;
646
- });
647
- contentArea.innerHTML = html;
648
- }
649
 
650
- function setupTabs() {
651
- const tabButtons = document.querySelectorAll('.tab-button');
652
- tabButtons.forEach(button => {
653
- button.addEventListener('click', () => {
654
- currentTab = button.dataset.tab;
655
- tabButtons.forEach(btn => btn.classList.remove('active'));
656
- button.classList.add('active');
657
- fetchAndRenderData(currentTab);
658
- });
659
- });
660
- }
 
 
 
 
 
 
 
661
 
662
- function setupModal() {
663
- const modal = document.getElementById('formModal');
664
- const openBtn = document.getElementById('openModalBtn');
665
- const closeBtn = document.getElementById('closeModalBtn');
666
- const form = document.getElementById('itemForm');
667
- const commonFieldsContainer = document.getElementById('commonFields');
668
- const modalTitle = document.getElementById('modalTitle');
669
-
670
- openBtn.addEventListener('click', () => {
671
- modalTitle.textContent = `Create New ${capitalizeFirstLetter(currentTab.slice(0, -1))}`;
672
- document.getElementById('formItemType').value = currentTab;
673
- document.getElementById('formItemId').value = ''; // Clear for new item
674
-
675
- commonFieldsContainer.innerHTML = '';
676
- const fields = formFieldsConfig[currentTab];
677
- fields.forEach(field => {
678
- let fieldHtml = `<div class="form-group">
679
- <label for="form_${field.name}">${field.label}${field.required ? ' *' : ''}</label>`;
680
- if (field.type === 'textarea') {
681
- fieldHtml += `<textarea id="form_${field.name}" name="${field.name}" ${field.required ? 'required' : ''}></textarea>`;
682
- } else {
683
- fieldHtml += `<input type="${field.type}" id="form_${field.name}" name="${field.name}" ${field.required ? 'required' : ''}>`;
684
- }
685
- fieldHtml += `</div>`;
686
- commonFieldsContainer.innerHTML += fieldHtml;
687
- });
688
- // Auto-fill name for resumes if user data available
689
- if (currentTab === 'resumes' && currentUser && document.getElementById('form_name')) {
690
- document.getElementById('form_name').value = `${currentUser.first_name || ''} ${currentUser.last_name || ''}`.trim() || currentUser.username || '';
691
- }
692
-
693
-
694
- modal.style.display = 'block';
695
- tg.BackButton.show();
696
- tg.BackButton.onClick(closeModal);
697
- });
698
-
699
- function closeModal() {
700
- modal.style.display = 'none';
701
- tg.BackButton.hide();
702
- }
703
- closeBtn.addEventListener('click', closeModal);
704
-
705
- window.addEventListener('click', (event) => {
706
- if (event.target == modal) closeModal();
707
- });
708
 
709
- form.addEventListener('submit', async (event) => {
710
- event.preventDefault();
711
- const formData = new FormData(form);
712
- const itemData = {};
713
- formData.forEach((value, key) => itemData[key] = value);
714
-
715
- itemData.user_id = currentUser ? currentUser.id : 'anonymous';
716
- itemData.user_display_name = currentUser ? `${currentUser.first_name || ''} ${currentUser.last_name || ''}`.trim() || currentUser.username : 'Anonymous';
717
-
718
- tg.MainButton.showProgress();
719
- try {
720
- const response = await fetch(`/api/create_item/${itemData.itemType}?initData=${encodeURIComponent(tg.initData)}`, {
721
- method: 'POST',
722
- headers: { 'Content-Type': 'application/json' },
723
- body: JSON.stringify(itemData)
724
- });
725
- if (!response.ok) {
726
- const errorText = await response.text();
727
- throw new Error(JSON.parse(errorText).detail || `Server error: ${response.status}`);
728
- }
729
- const newItem = await response.json();
730
- tg.showAlert(`${capitalizeFirstLetter(itemData.itemType.slice(0,-1))} created successfully!`);
731
- closeModal();
732
- fetchAndRenderData(currentTab); // Refresh list
733
- } catch (error) {
734
- console.error('Error submitting form:', error);
735
- tg.showAlert(`Error: ${error.message}`);
736
- } finally {
737
- tg.MainButton.hideProgress();
738
- }
739
- });
740
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
 
742
- async function deleteItem(type, itemId) {
743
- if (!tg.showConfirm(`Are you sure you want to delete this ${type.slice(0,-1)}?`)) return;
744
-
745
- tg.MainButton.showProgress();
746
- try {
747
- const response = await fetch(`/api/delete_item/${type}/${itemId}?initData=${encodeURIComponent(tg.initData)}`, {
748
- method: 'POST' // Using POST for deletion as it needs initData in body/query
749
- });
750
- if (!response.ok) {
751
- const errorText = await response.text();
752
- throw new Error(JSON.parse(errorText).detail || `Server error: ${response.status}`);
753
- }
754
- await response.json();
755
- tg.showAlert('Item deleted successfully.');
756
- fetchAndRenderData(currentTab); // Refresh list
757
- } catch (error) {
758
- console.error('Error deleting item:', error);
759
- tg.showAlert(`Error: ${error.message}`);
760
- } finally {
761
- tg.MainButton.hideProgress();
762
- }
763
- }
764
-
765
- function escapeHtml(unsafe) {
766
- if (unsafe === null || typeof unsafe === 'undefined') return '';
767
- return String(unsafe)
768
- .replace(/&/g, "&")
769
- .replace(/</g, "<")
770
- .replace(/>/g, ">")
771
- .replace(/"/g, """)
772
- .replace(/'/g, "'");
773
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
774
 
775
- function capitalizeFirstLetter(string) {
776
- return string.charAt(0).toUpperCase() + string.slice(1);
777
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
 
779
- function displayUserInfo() {
780
- const userInfoDisplay = document.getElementById('userInfoDisplay');
781
- if (currentUser && currentUser.username) {
782
- userInfoDisplay.textContent = `Logged in as: @${currentUser.username}`;
783
- } else if (currentUser && currentUser.first_name) {
784
- userInfoDisplay.textContent = `Logged in as: ${currentUser.first_name}`;
785
- } else {
786
- userInfoDisplay.textContent = `User not fully identified. Some features might be limited.`;
787
- }
788
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
789
 
790
- async function initApp() {
791
- applyTheme();
792
- tg.onEvent('themeChanged', applyTheme);
793
- tg.onEvent('viewportChanged', () => tg.expand()); // Ensure it's always expanded
794
-
795
- if (!tg.initDataUnsafe || !tg.initDataUnsafe.user) {
796
- if (tg.initData) { // If initData exists, but user part is missing, try to validate it server-side
797
- try {
798
- const response = await fetch(`/api/validate_user?initData=${encodeURIComponent(tg.initData)}`);
799
- if (response.ok) {
800
- currentUser = await response.json();
801
- } else {
802
- console.warn("Server-side user validation failed or user not found in initData.");
803
- }
804
- } catch (e) {
805
- console.error("Error during server-side user validation:", e);
806
- }
807
- }
808
- if (!currentUser) {
809
- console.warn('Telegram user data not available or validation failed. Proceeding with limited functionality.');
810
- tg.showAlert('Could not identify user. Some features might be limited.');
811
- }
812
- } else {
813
- currentUser = tg.initDataUnsafe.user;
814
- }
815
-
816
- if (currentUser) {
817
- console.log("Current User:", currentUser);
818
- }
819
- displayUserInfo();
820
-
821
- setupTabs();
822
- setupModal();
823
- fetchAndRenderData(currentTab); // Initial data load
824
- }
825
-
826
- document.addEventListener('DOMContentLoaded', initApp);
827
 
828
- </script>
829
- </body>
830
- </html>
831
- """
 
 
 
832
 
833
  # --- Flask Routes ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  @app.route('/')
835
  def index():
836
- return render_template_string(MAIN_APP_TEMPLATE)
837
-
838
- @app.route('/api/validate_user', methods=['GET'])
839
- def validate_user_route():
840
- init_data_str = request.args.get('initData')
841
- user = verify_telegram_init_data(init_data_str)
842
- if user:
843
- return jsonify(user), 200
844
- else:
845
- return jsonify({"detail": "Invalid or missing Telegram initData"}), 401
846
-
847
-
848
- @app.route('/api/get_data/<item_type>', methods=['GET'])
849
- def get_data_api(item_type):
850
- init_data_str = request.args.get('initData')
851
- # Basic check, though for GET it's less critical than for POST/state-changing operations
852
- # For sensitive data, GET might also need strict validation
853
- if not verify_telegram_init_data(init_data_str):
854
- logging.warning(f"Unauthorized attempt to get_data for {item_type}")
855
- # Depending on sensitivity, you might allow read without strict auth or deny
856
- # For now, allowing read but logging it. Stricter apps would return 401.
857
- # return jsonify({"detail": "Unauthorized: Invalid Telegram data"}), 401
858
-
859
  data = load_data()
860
- if item_type in data:
861
- return jsonify(data[item_type])
862
- return jsonify({"detail": "Invalid item type"}), 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
 
864
- @app.route('/api/create_item/<item_type>', methods=['POST'])
865
- def create_item_api(item_type):
866
- init_data_str = request.args.get('initData') # Or from request.json.get('initData') if sent in body
867
- user = verify_telegram_init_data(init_data_str)
868
- if not user:
869
- return jsonify({"detail": "Unauthorized: Invalid Telegram data"}), 401
870
 
871
- req_data = request.get_json()
872
- if not req_data:
873
- return jsonify({"detail": "No data provided"}), 400
 
 
 
 
874
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  data = load_data()
876
- if item_type not in data:
877
- return jsonify({"detail": "Invalid item type"}), 404
878
-
879
- new_item = {
880
- "id": str(uuid.uuid4()),
881
- "user_id": user.get('id'),
882
- "user_display_name": f"{user.get('first_name', '')} {user.get('last_name', '')}".strip() or user.get('username', 'Unknown User'),
883
- "timestamp": datetime.utcnow().isoformat() + "Z",
884
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
 
886
- # Dynamically add fields based on item_type from formFieldsConfig (implicitly)
887
- # Assuming client sends all relevant fields
888
- for key, value in req_data.items():
889
- if key not in ['itemType', 'itemId', 'user_id', 'user_display_name', 'timestamp', 'id']: # internal/managed fields
890
- new_item[key] = value
891
-
892
- # Basic validation (presence of a title-like field)
893
- title_field_present = any(k in new_item for k in ['title', 'job_title', 'offer_title'])
894
- if not title_field_present or not new_item.get(next(k for k in ['title', 'job_title', 'offer_title'] if k in new_item)): # ensure it's not empty
895
- return jsonify({"detail": "A title field is required and cannot be empty."}), 400
896
-
897
 
898
- data[item_type].append(new_item)
899
  save_data(data)
900
- logging.info(f"User {user.get('id')} created {item_type}: {new_item.get('id')}")
901
- return jsonify(new_item), 201
902
-
903
- @app.route('/api/delete_item/<item_type>/<item_id>', methods=['POST'])
904
- def delete_item_api(item_type, item_id):
905
- init_data_str = request.args.get('initData') # Or from request.json.get('initData')
906
- user = verify_telegram_init_data(init_data_str)
907
- if not user:
908
- return jsonify({"detail": "Unauthorized: Invalid Telegram data"}), 401
909
-
910
- data = load_data()
911
- if item_type not in data:
912
- return jsonify({"detail": "Invalid item type"}), 404
913
 
914
- items_list = data[item_type]
915
- item_to_delete = next((item for item in items_list if item['id'] == item_id), None)
916
 
917
- if not item_to_delete:
918
- return jsonify({"detail": "Item not found"}), 404
919
 
920
- if item_to_delete['user_id'] != user.get('id'):
921
- # Add admin check here if you have admin users
922
- # For now, only owners can delete
923
- logging.warning(f"User {user.get('id')} attempted to delete item {item_id} owned by {item_to_delete['user_id']}")
924
- return jsonify({"detail": "Forbidden: You can only delete your own items"}), 403
925
 
926
- data[item_type] = [item for item in items_list if item['id'] != item_id]
927
- save_data(data)
928
- logging.info(f"User {user.get('id')} deleted {item_type}: {item_id}")
929
- return jsonify({"detail": "Item deleted successfully"}), 200
930
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
 
 
932
  if __name__ == '__main__':
933
- logging.info("Application starting up. Performing initial data load/download...")
934
- if not os.path.exists(DATA_FILE):
935
- logging.info(f"{DATA_FILE} not found locally, attempting initial download from HF.")
936
- download_db_from_hf(DATA_FILE) # Attempt to fetch if not present
937
 
938
- load_data() # Load whatever is available or default
939
  logging.info("Initial data load/check complete.")
940
 
941
  if HF_TOKEN_WRITE:
@@ -945,6 +1224,6 @@ if __name__ == '__main__':
945
  else:
946
  logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
947
 
948
- port = int(os.environ.get('PORT', 7860))
949
  logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
950
  app.run(debug=False, host='0.0.0.0', port=port)
 
1
+ from flask import Flask, render_template_string, request, redirect, url_for, jsonify, session, flash
2
  import json
3
  import os
4
  import logging
 
13
  import uuid
14
  import hmac
15
  import hashlib
 
16
 
17
  load_dotenv()
18
 
19
  app = Flask(__name__)
20
+ app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_telegram_app_12345')
21
+ TONTALENT_DATA_FILE = 'tontalent_data.json'
 
22
 
23
+ SYNC_FILES = [TONTALENT_DATA_FILE]
 
 
24
 
25
+ REPO_ID = "Kgshop/tontalent2"
26
+ HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
27
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
+ BOT_TOKEN = "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8"
29
 
30
  DOWNLOAD_RETRIES = 3
31
  DOWNLOAD_DELAY = 5
32
 
33
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
34
 
35
+ # --- Hugging Face Sync Functions (largely unchanged, adapted for TONTALENT_DATA_FILE) ---
 
36
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
37
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
38
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
 
59
  except HfHubHTTPError as e:
60
  if e.response.status_code == 404:
61
  logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
62
+ if attempt == 0 and not os.path.exists(file_name) and file_name == TONTALENT_DATA_FILE:
63
  try:
64
  with open(file_name, 'w', encoding='utf-8') as f:
65
+ json.dump({'resumes': {}, 'vacancies': {}, 'freelance_offers': {}}, f)
66
+ logging.info(f"Created empty local file {file_name} because it was not found on HF.")
67
  except Exception as create_e:
68
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
69
+ success = False # Should be false if file not found unless it's the initial creation
70
+ break # Don't retry 404
71
  else:
72
  logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
73
  except requests.exceptions.RequestException as e:
 
78
  if not success:
79
  logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
80
  all_successful = False
81
+ logging.info(f"Download process finished. Overall success: {all_successful}")
82
  return all_successful
83
 
84
  def upload_db_to_hf(specific_file=None):
85
  if not HF_TOKEN_WRITE:
86
+ logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
87
  return
88
  try:
89
  api = HfApi()
 
115
  logging.info("Periodic backup finished.")
116
 
117
  # --- Data Loading and Saving Functions ---
 
118
  def load_data():
119
+ default_data = {'resumes': {}, 'vacancies': {}, 'freelance_offers': {}}
120
  try:
121
+ with open(TONTALENT_DATA_FILE, 'r', encoding='utf-8') as file:
122
  data = json.load(file)
123
+ if not isinstance(data, dict): raise FileNotFoundError
 
 
 
124
  for key in default_data:
125
+ if key not in data: data[key] = default_data[key]
126
+ if not isinstance(data[key], dict): data[key] = default_data[key] # Ensure correct type
127
+ logging.info(f"Local data loaded successfully from {TONTALENT_DATA_FILE}")
128
  return data
129
+ except (FileNotFoundError, json.JSONDecodeError) as e:
130
+ logging.warning(f"Error loading local file {TONTALENT_DATA_FILE} ({e}). Attempting download from HF.")
 
 
131
 
132
+ if download_db_from_hf(specific_file=TONTALENT_DATA_FILE):
133
  try:
134
+ with open(TONTALENT_DATA_FILE, 'r', encoding='utf-8') as file:
135
  data = json.load(file)
 
136
  if not isinstance(data, dict):
137
+ logging.error(f"Downloaded {TONTALENT_DATA_FILE} is not a dictionary. Using default.")
138
  return default_data
139
  for key in default_data:
140
+ if key not in data: data[key] = default_data[key]
141
+ if not isinstance(data[key], dict): data[key] = default_data[key]
142
+ logging.info(f"Data loaded successfully from {TONTALENT_DATA_FILE} after download.")
143
  return data
144
+ except Exception as ex:
145
+ logging.error(f"Error loading downloaded {TONTALENT_DATA_FILE}: {ex}. Using default.", exc_info=True)
 
 
 
 
 
 
146
  return default_data
147
  else:
148
+ logging.error(f"Failed to download {TONTALENT_DATA_FILE} from HF. Using default data structure.")
149
+ if not os.path.exists(TONTALENT_DATA_FILE):
150
  try:
151
+ with open(TONTALENT_DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f)
152
+ logging.info(f"Created empty local file {TONTALENT_DATA_FILE} after failed download.")
 
153
  except Exception as create_e:
154
+ logging.error(f"Failed to create empty local file {TONTALENT_DATA_FILE}: {create_e}")
155
  return default_data
156
 
157
  def save_data(data):
 
159
  if not isinstance(data, dict):
160
  logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
161
  return
162
+ for key in ['resumes', 'vacancies', 'freelance_offers']: # Ensure keys exist
163
+ if key not in data: data[key] = {}
164
+
165
+ with open(TONTALENT_DATA_FILE, 'w', encoding='utf-8') as file:
 
166
  json.dump(data, file, ensure_ascii=False, indent=4)
167
+ logging.info(f"Data successfully saved to {TONTALENT_DATA_FILE}")
168
+ upload_db_to_hf(specific_file=TONTALENT_DATA_FILE)
169
  except Exception as e:
170
+ logging.error(f"Error saving data to {TONTALENT_DATA_FILE}: {e}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
+ # --- Telegram Authentication ---
173
+ def validate_telegram_data(init_data_str):
174
  try:
175
+ params = dict(kv.split('=', 1) for kv in init_data_str.split('&'))
176
+ hash_received = params.pop('hash', None)
177
+ if not hash_received: return None
178
+
179
+ data_check_string = "\n".join(sorted([f"{k}={v}" for k, v in params.items()]))
180
+
181
+ secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
182
  calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
183
+
184
+ if calculated_hash == hash_received:
185
+ user_data_json = params.get('user')
186
+ if user_data_json:
187
+ return json.loads(requests.utils.unquote(user_data_json)) # Telegram user data needs unquoting
188
+ return {}
189
+ return None
190
  except Exception as e:
191
+ logging.error(f"Error validating Telegram data: {e}", exc_info=True)
192
  return None
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # --- Templates ---
195
+ # Base template including Telegram WebApp JS and basic styling
196
+ BASE_TEMPLATE = """
197
  <!DOCTYPE html>
198
  <html lang="en">
199
  <head>
 
201
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
202
  <title>TonTalent</title>
203
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
204
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
205
  <style>
206
+ :root {{
207
+ --tg-theme-bg-color: {{ '{background_color}' if '{background_color}' != 'None' else '#ffffff' }};
208
+ --tg-theme-text-color: {{ '{text_color}' if '{text_color}' != 'None' else '#000000' }};
209
+ --tg-theme-hint-color: {{ '{hint_color}' if '{hint_color}' != 'None' else '#999999' }};
210
+ --tg-theme-link-color: {{ '{link_color}' if '{link_color}' != 'None' else '#2481cc' }};
211
+ --tg-theme-button-color: {{ '{button_color}' if '{button_color}' != 'None' else '#2481cc' }};
212
+ --tg-theme-button-text-color: {{ '{button_text_color}' if '{button_text_color}' != 'None' else '#ffffff' }};
213
+ --tg-theme-secondary-bg-color: {{ '{secondary_bg_color}' if '{secondary_bg_color}' != 'None' else '#f0f0f0' }};
214
+ --tg-header-color: {{ '{header_color}' if '{header_color}' != 'None' else '#ffffff' }};
215
+ --tg-accent-text-color: {{ '{accent_text_color}' if '{accent_text_color}' != 'None' else '#2481cc' }};
216
+ --tg-section-bg-color: {{ '{section_bg_color}' if '{section_bg_color}' != 'None' else '#ffffff' }};
217
+ --tg-section-header-text-color: {{ '{section_header_text_color}' if '{section_header_text_color}' != 'None' else '#000000'}};
218
+ --tg-destructive-text-color: {{ '{destructive_text_color}' if '{destructive_text_color}' != 'None' else '#ff3b30' }};
219
+
220
+ --border-color: #e0e0e0;
221
+ --card-shadow: 0 1px 3px rgba(0,0,0,0.05);
222
+ --border-radius: 12px;
223
+ }}
224
+ body {{
225
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  margin: 0;
227
+ padding: 15px;
228
+ background-color: var(--tg-theme-bg-color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  color: var(--tg-theme-text-color);
230
+ line-height: 1.5;
231
+ font-size: 16px;
232
+ overscroll-behavior-y: none; /* Prevents pull-to-refresh */
233
+ }}
234
+ .dark {{
235
+ --border-color: #3a3a3c;
236
+ /* Add more dark mode specific overrides if needed */
237
+ }}
238
+ .container {{ max-width: 600px; margin: 0 auto; }}
239
+ .header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }}
240
+ .header h1 {{ font-size: 24px; font-weight: 600; margin: 0; color: var(--tg-theme-text-color); }}
241
+ .card {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  background-color: var(--tg-theme-secondary-bg-color);
243
+ border-radius: var(--border-radius);
244
+ padding: 15px;
245
+ margin-bottom: 15px;
246
+ box-shadow: var(--card-shadow);
247
  border: 1px solid var(--border-color);
248
+ }}
249
+ .card h2 {{ margin-top: 0; font-size: 18px; font-weight: 600; color: var(--tg-theme-text-color); }}
250
+ .card p {{ margin-bottom: 8px; font-size: 15px; color: var(--tg-theme-hint-color); }}
251
+ .card p strong {{ color: var(--tg-theme-text-color); }}
252
+ .card .meta {{ font-size: 13px; color: var(--tg-theme-hint-color); margin-bottom: 10px; }}
253
+ .card .actions {{ margin-top: 15px; display: flex; gap: 10px; }}
254
+ .button, button {{
255
+ display: inline-block;
256
+ padding: 10px 20px;
257
+ font-size: 16px;
258
+ font-weight: 500;
259
+ color: var(--tg-theme-button-text-color);
260
+ background-color: var(--tg-theme-button-color);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  border: none;
262
+ border-radius: 8px;
263
+ text-decoration: none;
264
  cursor: pointer;
265
+ text-align: center;
266
+ transition: opacity 0.2s;
267
+ }}
268
+ button:disabled {{ opacity: 0.6; cursor: not-allowed; }}
269
+ .button-secondary {{ background-color: var(--tg-theme-secondary-bg-color); color: var(--tg-theme-link-color); border: 1px solid var(--tg-theme-link-color); }}
270
+ .button-destructive {{ background-color: var(--tg-destructive-text-color); color: #fff; }}
271
+ a {{ color: var(--tg-theme-link-color); text-decoration: none; }}
272
+ .form-group {{ margin-bottom: 15px; }}
273
+ .form-group label {{ display: block; font-weight: 500; margin-bottom: 5px; font-size: 15px; color: var(--tg-theme-text-color); }}
274
+ .form-group input[type="text"], .form-group input[type="number"], .form-group input[type="email"], .form-group input[type="tel"], .form-group textarea, .form-group select {{
 
 
 
 
 
 
 
275
  width: 100%;
276
  padding: 12px;
277
+ font-size: 16px;
278
  border: 1px solid var(--border-color);
279
+ border-radius: 8px;
280
+ box-sizing: border-box;
 
281
  background-color: var(--tg-theme-secondary-bg-color);
282
  color: var(--tg-theme-text-color);
283
+ }}
284
+ .form-group input:focus, .form-group textarea:focus {{ border-color: var(--tg-theme-link-color); outline: none; }}
285
+ .form-group textarea {{ min-height: 100px; resize: vertical; }}
286
+ .tabs {{ display: flex; margin-bottom: 20px; border-bottom: 1px solid var(--border-color); }}
287
+ .tab-button {{
288
+ padding: 10px 15px;
 
 
 
 
 
 
 
 
 
 
 
 
289
  cursor: pointer;
 
 
 
 
 
 
 
 
 
290
  border: none;
291
+ background-color: transparent;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  color: var(--tg-theme-hint-color);
293
+ font-size: 16px;
294
+ font-weight: 500;
295
+ border-bottom: 2px solid transparent;
296
+ }}
297
+ .tab-button.active {{ color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); }}
298
+ .tab-content {{ display: none; }}
299
+ .tab-content.active {{ display: block; }}
300
+ .publish-buttons {{ display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }}
301
+ .publish-buttons .button {{ width: 100%; box-sizing: border-box; }}
302
+ .user-info {{ font-size: 13px; color: var(--tg-theme-hint-color); text-align: right; margin-bottom: 10px; }}
303
+ .item-photo {{ max-width: 80px; max-height: 80px; border-radius: 8px; object-fit: cover; margin-right: 15px; float: left; }}
304
+ .flash-messages {{ list-style: none; padding: 0; margin-bottom: 15px; }}
305
+ .flash-messages li {{ padding: 10px; border-radius: 8px; margin-bottom: 10px; }}
306
+ .flash-messages .success {{ background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }}
307
+ .flash-messages .error {{ background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }}
308
+ .no-items {{ text-align: center; color: var(--tg-theme-hint-color); padding: 20px 0; }}
309
+ .item-actions { display: flex; gap: 8px; margin-top: 10px; }
310
+ .item-actions a, .item-actions button { font-size: 14px; padding: 6px 12px; }
311
  </style>
312
  </head>
313
  <body>
314
+ <div class="container">
315
+ <div class="user-info">
316
+ {{% if 'user' in session and session['user'] %}}
317
+ Logged in as: {{ session['user'].get('first_name', '') }} {{ session['user'].get('last_name', '') }} ({{ session['user'].get('username', 'N/A') }})
318
+ {{% else %}}
319
+ Authenticating...
320
+ {{% endif %}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  </div>
322
+
323
+ {{% with messages = get_flashed_messages(with_categories=true) %}}
324
+ {{% if messages %}}
325
+ <ul class="flash-messages">
326
+ {{% for category, message in messages %}}
327
+ <li class="{{ category }}">{{ message }}</li>
328
+ {{% endfor %}}
329
+ </ul>
330
+ {{% endif %}}
331
+ {{% endwith %}}
332
+
333
+ {{% block content %}}{{% endblock %}}
334
  </div>
335
 
336
  <script>
337
  const tg = window.Telegram.WebApp;
338
+ tg.ready();
339
+ tg.expand();
340
+
341
+ function applyTheme(themeParams) {{
342
+ const root = document.documentElement;
343
+ root.style.setProperty('--tg-theme-bg-color', themeParams.bg_color || '#ffffff');
344
+ root.style.setProperty('--tg-theme-text-color', themeParams.text_color || '#000000');
345
+ root.style.setProperty('--tg-theme-hint-color', themeParams.hint_color || '#999999');
346
+ root.style.setProperty('--tg-theme-link-color', themeParams.link_color || '#2481cc');
347
+ root.style.setProperty('--tg-theme-button-color', themeParams.button_color || '#2481cc');
348
+ root.style.setProperty('--tg-theme-button-text-color', themeParams.button_text_color || '#ffffff');
349
+ root.style.setProperty('--tg-theme-secondary-bg-color', themeParams.secondary_bg_color || '#f0f0f0');
350
+ // Additional properties for more iOS-like theming if available
351
+ if (themeParams.header_bg_color) {{ // Renamed for consistency
352
+ root.style.setProperty('--tg-header-color', themeParams.header_bg_color);
353
+ }}
354
+ if (themeParams.accent_text_color) {{
355
+ root.style.setProperty('--tg-accent-text-color', themeParams.accent_text_color);
356
+ }}
357
+ if (themeParams.section_bg_color) {{
358
+ root.style.setProperty('--tg-section-bg-color', themeParams.section_bg_color);
359
+ }}
360
+ if (themeParams.section_header_text_color) {{
361
+ root.style.setProperty('--tg-section-header-text-color', themeParams.section_header_text_color);
362
+ }}
363
+ if (themeParams.destructive_text_color) {{
364
+ root.style.setProperty('--tg-destructive-text-color', themeParams.destructive_text_color);
365
+ }}
366
+
367
+ if (tg.colorScheme === 'dark') {{
 
 
 
 
 
 
 
368
  document.body.classList.add('dark');
369
+ // More specific dark overrides if themeParams aren't enough
370
+ if (!themeParams.bg_color || themeParams.bg_color === '#ffffff') root.style.setProperty('--tg-theme-bg-color', '#1c1c1e');
371
+ if (!themeParams.text_color || themeParams.text_color === '#000000') root.style.setProperty('--tg-theme-text-color', '#ffffff');
372
+ if (!themeParams.secondary_bg_color || themeParams.secondary_bg_color === '#f0f0f0') root.style.setProperty('--tg-theme-secondary-bg-color', '#2c2c2e');
373
+ if (!themeParams.border_color) root.style.setProperty('--border-color', '#3a3a3c');
374
+ }} else {{
375
  document.body.classList.remove('dark');
376
+ }}
377
+ }}
378
+
379
+ applyTheme(tg.themeParams);
380
+ tg.onEvent('themeChanged', function() {{
381
+ applyTheme(tg.themeParams);
382
+ }});
383
+
384
+ document.addEventListener('DOMContentLoaded', function() {{
385
+ if (typeof tg.initDataUnsafe !== 'undefined' && tg.initDataUnsafe.hash) {{
386
+ fetch('{{ url_for("auth_telegram") }}', {{
387
+ method: 'POST',
388
+ headers: {{ 'Content-Type': 'application/json' }},
389
+ body: JSON.stringify({{ init_data: tg.initData }})
390
+ }})
391
+ .then(response => response.json())
392
+ .then(data => {{
393
+ if (data.status === 'success') {{
394
+ console.log('Telegram user authenticated:', data.user);
395
+ if (document.querySelector('.user-info')) {{
396
+ document.querySelector('.user-info').textContent = `Logged in as: ${{data.user.first_name || ''}} ${{data.user.last_name || ''}} (${{data.user.username || 'N/A'}})`;
397
+ }}
398
+ // Optionally reload or update content that depends on auth
399
+ if (window.location.pathname === '{{ url_for("index") }}' && !sessionStorage.getItem('authReloaded')) {{
400
+ sessionStorage.setItem('authReloaded', 'true');
401
+ // window.location.reload(); // Can cause loop if not careful
402
+ }}
403
+ }} else {{
404
+ console.error('Telegram authentication failed:', data.message);
405
+ tg.showAlert(data.message || 'Authentication failed.');
406
+ }}
407
+ }})
408
+ .catch(error => {{
409
+ console.error('Error during Telegram auth request:', error);
410
+ tg.showAlert('Error communicating with server for authentication.');
411
+ }});
412
+ }} else {{
413
+ console.warn("Telegram WebApp.initDataUnsafe is not available. Are you in Telegram?");
414
+ // Potentially redirect or show a message if not in Telegram environment and auth is required
415
+ }}
416
+ }});
417
+
418
+ function confirmDelete(event) {{
419
+ if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {{
420
+ event.preventDefault();
421
+ }}
422
+ }}
423
+ {{% block extra_js %}}{{% endblock %}}
424
+ </script>
425
+ </body>
426
+ </html>
427
+ """.format(
428
+ background_color=None, text_color=None, hint_color=None, link_color=None,
429
+ button_color=None, button_text_color=None, secondary_bg_color=None,
430
+ header_color=None, accent_text_color=None, section_bg_color=None,
431
+ section_header_text_color=None, destructive_text_color=None
432
+ ) # Placeholders for JS to fill
433
+
434
+ INDEX_TEMPLATE = """
435
+ {{% extends "base_template" %}}
436
+ {{% block content %}}
437
+ <div class="header">
438
+ <h1>TonTalent</h1>
439
+ <a href="{{ url_for('admin_sync') }}" class="button button-secondary" style="font-size:14px; padding: 8px 12px;">Sync Panel</a>
440
+ </div>
441
+
442
+ {{% if 'user' in session and session['user'] %}}
443
+ <div class="publish-buttons">
444
+ <a href="{{ url_for('publish_item', item_type='resume') }}" class="button"><i class="fas fa-id-card"></i> Publish Resume</a>
445
+ <a href="{{ url_for('publish_item', item_type='vacancy') }}" class="button"><i class="fas fa-briefcase"></i> Publish Vacancy</a>
446
+ <a href="{{ url_for('publish_item', item_type='freelance_offer') }}" class="button"><i class="fas fa-handshake"></i> Publish Freelance Offer</a>
447
+ <a href="{{ url_for('my_postings') }}" class="button button-secondary"><i class="fas fa-list-alt"></i> My Postings</a>
448
+ </div>
449
+ {{% else %}}
450
+ <p class="no-items">Please wait for authentication to complete to publish or view your items.</p>
451
+ {{% endif %}}
452
+
453
+ <div class="tabs">
454
+ <button class="tab-button active" onclick="openTab(event, 'resumes')">Resumes</button>
455
+ <button class="tab-button" onclick="openTab(event, 'vacancies')">Vacancies</button>
456
+ <button class="tab-button" onclick="openTab(event, 'freelance_offers')">Freelance Offers</button>
457
+ </div>
458
 
459
+ <div id="resumes" class="tab-content active">
460
+ <h3>Latest Resumes</h3>
461
+ {{% if resumes %}}
462
+ {{% for resume_id, resume in resumes.items()|sort(attribute='1.published_at', reverse=True) %}}
463
+ <div class="card">
464
+ {{% if resume.photo_filename %}}
465
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ resume.photo_filename }}" alt="{{ resume.full_name }}" class="item-photo">
466
+ {{% endif %}}
467
+ <h2>{{ resume.full_name }} - {{ resume.title }}</h2>
468
+ <p class="meta">Published: {{ resume.published_at[:10] }}</p>
469
+ <p><strong>Skills:</strong> {{ resume.skills|join(', ') }}</p>
470
+ <a href="{{ url_for('view_item', item_type='resume', item_id=resume_id) }}" class="button button-secondary">View Details</a>
471
+ </div>
472
+ {{% endfor %}}
473
+ {{% else %}}
474
+ <p class="no-items">No resumes published yet.</p>
475
+ {{% endif %}}
476
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
 
478
+ <div id="vacancies" class="tab-content">
479
+ <h3>Latest Vacancies</h3>
480
+ {{% if vacancies %}}
481
+ {{% for vacancy_id, vacancy in vacancies.items()|sort(attribute='1.published_at', reverse=True) %}}
482
+ <div class="card">
483
+ {{% if vacancy.company_logo_filename %}}
484
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ vacancy.company_logo_filename }}" alt="{{ vacancy.company_name }}" class="item-photo">
485
+ {{% endif %}}
486
+ <h2>{{ vacancy.job_title }} at {{ vacancy.company_name }}</h2>
487
+ <p class="meta">Published: {{ vacancy.published_at[:10] }}</p>
488
+ <p><strong>Location:</strong> {{ vacancy.location }}</p>
489
+ <a href="{{ url_for('view_item', item_type='vacancy', item_id=vacancy_id) }}" class="button button-secondary">View Details</a>
490
+ </div>
491
+ {{% endfor %}}
492
+ {{% else %}}
493
+ <p class="no-items">No vacancies published yet.</p>
494
+ {{% endif %}}
495
+ </div>
496
 
497
+ <div id="freelance_offers" class="tab-content">
498
+ <h3>Latest Freelance Offers</h3>
499
+ {{% if freelance_offers %}}
500
+ {{% for offer_id, offer in freelance_offers.items()|sort(attribute='1.published_at', reverse=True) %}}
501
+ <div class="card">
502
+ <h2>{{ offer.title }}</h2>
503
+ <p class="meta">Published: {{ offer.published_at[:10] }}</p>
504
+ <p><strong>Budget:</strong> {{ offer.budget }}</p>
505
+ <a href="{{ url_for('view_item', item_type='freelance_offer', item_id=offer_id) }}" class="button button-secondary">View Details</a>
506
+ </div>
507
+ {{% endfor %}}
508
+ {{% else %}}
509
+ <p class="no-items">No freelance offers published yet.</p>
510
+ {{% endif %}}
511
+ </div>
512
+ {{% endblock %}}
513
+
514
+ {{% block extra_js %}}
515
+ <script>
516
+ function openTab(evt, tabName) {{
517
+ var i, tabcontent, tablinks;
518
+ tabcontent = document.getElementsByClassName("tab-content");
519
+ for (i = 0; i < tabcontent.length; i++) {{
520
+ tabcontent[i].style.display = "none";
521
+ tabcontent[i].classList.remove("active");
522
+ }}
523
+ tablinks = document.getElementsByClassName("tab-button");
524
+ for (i = 0; i < tablinks.length; i++) {{
525
+ tablinks[i].classList.remove("active");
526
+ }}
527
+ document.getElementById(tabName).style.display = "block";
528
+ document.getElementById(tabName).classList.add("active");
529
+ evt.currentTarget.classList.add("active");
530
+ }}
531
+ document.addEventListener('DOMContentLoaded', function() {{
532
+ tg.MainButton.hide();
533
+ const firstTab = document.querySelector('.tab-button');
534
+ if(firstTab) {{
535
+ // firstTab.click(); // Already active by default
536
+ }}
537
+ }});
538
+ </script>
539
+ {{% endblock %}}
540
+ """
 
 
541
 
542
+ PUBLISH_ITEM_TEMPLATE = """
543
+ {{% extends "base_template" %}}
544
+ {{% block content %}}
545
+ <h2>{{ "Edit" if item_data else "Publish" }} {{ item_type_display_name }}</h2>
546
+ <form method="POST" enctype="multipart/form-data">
547
+ {{% if item_type == 'resume' %}}
548
+ <div class="form-group">
549
+ <label for="full_name">Full Name *</label>
550
+ <input type="text" id="full_name" name="full_name" value="{{ item_data.full_name if item_data else '' }}" required>
551
+ </div>
552
+ <div class="form-group">
553
+ <label for="title">Job Title / Headline *</label>
554
+ <input type="text" id="title" name="title" value="{{ item_data.title if item_data else '' }}" required>
555
+ </div>
556
+ <div class="form-group">
557
+ <label for="skills">Skills (comma-separated) *</label>
558
+ <input type="text" id="skills" name="skills" value="{{ (item_data.skills|join(', ')) if item_data and item_data.skills else '' }}" required>
559
+ </div>
560
+ <div class="form-group">
561
+ <label for="experience">Experience (one entry per line: Company;Role;Duration;Description)</label>
562
+ <textarea id="experience" name="experience" rows="5" placeholder="e.g., Acme Corp;Software Engineer;2020-2023;Developed cool stuff.">{{ (item_data.experience_str if item_data else '') }}</textarea>
563
+ </div>
564
+ <div class="form-group">
565
+ <label for="education">Education (one entry per line: Institution;Degree;Duration)</label>
566
+ <textarea id="education" name="education" rows="3" placeholder="e.g., State University;B.S. Computer Science;2016-2020">{{ (item_data.education_str if item_data else '') }}</textarea>
567
+ </div>
568
+ <div class="form-group">
569
+ <label for="contact_info">Contact (e.g., Telegram @username, email) *</label>
570
+ <input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else (session['user'].username if session.get('user') else '') }}" required>
571
+ </div>
572
+ <div class="form-group">
573
+ <label for="portfolio_links">Portfolio Links (comma-separated)</label>
574
+ <input type="text" id="portfolio_links" name="portfolio_links" value="{{ (item_data.portfolio_links|join(', ')) if item_data and item_data.portfolio_links else '' }}">
575
+ </div>
576
+ <div class="form-group">
577
+ <label for="photo">Profile Photo (Optional, replaces existing if editing)</label>
578
+ <input type="file" id="photo" name="photo" accept="image/*">
579
+ {{% if item_data and item_data.photo_filename %}}
580
+ <p>Current photo: <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item_data.photo_filename }}" alt="Current Photo" style="max-width:50px; max-height:50px; vertical-align:middle; margin-left:10px;"></p>
581
+ {{% endif %}}
582
+ </div>
583
+ {{% elif item_type == 'vacancy' %}}
584
+ <div class="form-group">
585
+ <label for="company_name">Company Name *</label>
586
+ <input type="text" id="company_name" name="company_name" value="{{ item_data.company_name if item_data else '' }}" required>
587
+ </div>
588
+ <div class="form-group">
589
+ <label for="job_title">Job Title *</label>
590
+ <input type="text" id="job_title" name="job_title" value="{{ item_data.job_title if item_data else '' }}" required>
591
+ </div>
592
+ <div class="form-group">
593
+ <label for="description">Job Description *</label>
594
+ <textarea id="description" name="description" required>{{ item_data.description if item_data else '' }}</textarea>
595
+ </div>
596
+ <div class="form-group">
597
+ <label for="requirements">Requirements (comma-separated)</label>
598
+ <input type="text" id="requirements" name="requirements" value="{{ (item_data.requirements|join(', ')) if item_data and item_data.requirements else '' }}">
599
+ </div>
600
+ <div class="form-group">
601
+ <label for="location">Location *</label>
602
+ <input type="text" id="location" name="location" value="{{ item_data.location if item_data else '' }}" required>
603
+ </div>
604
+ <div class="form-group">
605
+ <label for="salary_range">Salary Range (e.g., $50k-$70k, Negotiable)</label>
606
+ <input type="text" id="salary_range" name="salary_range" value="{{ item_data.salary_range if item_data else '' }}">
607
+ </div>
608
+ <div class="form-group">
609
+ <label for="employment_type">Employment Type</label>
610
+ <select id="employment_type" name="employment_type">
611
+ <option value="Full-time" {{ "selected" if item_data and item_data.employment_type == "Full-time" }}>Full-time</option>
612
+ <option value="Part-time" {{ "selected" if item_data and item_data.employment_type == "Part-time" }}>Part-time</option>
613
+ <option value="Contract" {{ "selected" if item_data and item_data.employment_type == "Contract" }}>Contract</option>
614
+ <option value="Internship" {{ "selected" if item_data and item_data.employment_type == "Internship" }}>Internship</option>
615
+ </select>
616
+ </div>
617
+ <div class="form-group">
618
+ <label for="contact_info">Contact Info (for applications) *</label>
619
+ <input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else '' }}" required>
620
+ </div>
621
+ <div class="form-group">
622
+ <label for="photo">Company Logo (Optional)</label>
623
+ <input type="file" id="photo" name="photo" accept="image/*">
624
+ {{% if item_data and item_data.company_logo_filename %}}
625
+ <p>Current logo: <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item_data.company_logo_filename }}" alt="Current Logo" style="max-width:50px; max-height:50px; vertical-align:middle; margin-left:10px;"></p>
626
+ {{% endif %}}
627
+ </div>
628
+ {{% elif item_type == 'freelance_offer' %}}
629
+ <div class="form-group">
630
+ <label for="title">Offer Title *</label>
631
+ <input type="text" id="title" name="title" value="{{ item_data.title if item_data else '' }}" required>
632
+ </div>
633
+ <div class="form-group">
634
+ <label for="description">Offer Description *</label>
635
+ <textarea id="description" name="description" required>{{ item_data.description if item_data else '' }}</textarea>
636
+ </div>
637
+ <div class="form-group">
638
+ <label for="skills_required">Skills Required (comma-separated) *</label>
639
+ <input type="text" id="skills_required" name="skills_required" value="{{ (item_data.skills_required|join(', ')) if item_data and item_data.skills_required else '' }}" required>
640
+ </div>
641
+ <div class="form-group">
642
+ <label for="budget">Budget (e.g., $500, Negotiable) *</label>
643
+ <input type="text" id="budget" name="budget" value="{{ item_data.budget if item_data else '' }}" required>
644
+ </div>
645
+ <div class="form-group">
646
+ <label for="deadline">Deadline (e.g., 2 weeks, YYYY-MM-DD)</label>
647
+ <input type="text" id="deadline" name="deadline" value="{{ item_data.deadline if item_data else '' }}">
648
+ </div>
649
+ <div class="form-group">
650
+ <label for="contact_info">Contact Info *</label>
651
+ <input type="text" id="contact_info" name="contact_info" value="{{ item_data.contact_info if item_data else '' }}" required>
652
+ </div>
653
+ {{% endif %}}
654
+ <button type="submit" id="main-submit-button">{{ "Save Changes" if item_data else "Publish" }}</button>
655
+ </form>
656
+ {{% endblock %}}
657
+ {{% block extra_js %}}
658
+ <script>
659
+ document.addEventListener('DOMContentLoaded', function() {{
660
+ tg.MainButton.setText('{{ "Save Changes" if item_data else "Publish" }}');
661
+ tg.MainButton.show();
662
+ tg.MainButton.onClick(function() {{
663
+ document.getElementById('main-submit-button').click();
664
+ }});
665
+ tg.BackButton.show();
666
+ tg.onEvent('backButtonClicked', function() {{
667
+ window.history.back();
668
+ }});
669
+ }});
670
+ // Cleanup MainButton when navigating away from this page
671
+ window.addEventListener('beforeunload', function() {{
672
+ tg.MainButton.hide();
673
+ tg.MainButton.offClick(); // Remove previous handler
674
+ tg.BackButton.hide();
675
+ tg.offEvent('backButtonClicked');
676
+ }});
677
+ </script>
678
+ {{% endblock %}}
679
+ """
680
 
681
+ VIEW_ITEM_TEMPLATE = """
682
+ {{% extends "base_template" %}}
683
+ {{% block content %}}
684
+ <div class="card">
685
+ {{% if item_type == 'resume' %}}
686
+ {{% if item.photo_filename %}}
687
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item.photo_filename }}" alt="{{ item.full_name }}" class="item-photo" style="max-width:100px; max-height:100px; margin-bottom:15px;">
688
+ {{% endif %}}
689
+ <h2>{{ item.full_name }}</h2>
690
+ <h3>{{ item.title }}</h3>
691
+ <p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
692
+ <p><strong>Skills:</strong> {{ item.skills|join(', ') }}</p>
693
+ <h4>Experience:</h4>
694
+ {{% if item.experience %}}
695
+ <ul>{{% for exp in item.experience %}}<li><strong>{{ exp.role }}</strong> at {{ exp.company }} ({{ exp.duration }})<br>{{ exp.description }}</li>{{% endfor %}}</ul>
696
+ {{% else %}}<p>N/A</p>{{% endif %}}
697
+ <h4>Education:</h4>
698
+ {{% if item.education %}}
699
+ <ul>{{% for edu in item.education %}}<li><strong>{{ edu.degree }}</strong>, {{ edu.institution }} ({{ edu.duration }})</li>{{% endfor %}}</ul>
700
+ {{% else %}}<p>N/A</p>{{% endif %}}
701
+ <p><strong>Contact:</strong> {{ item.contact_info }}</p>
702
+ {{% if item.portfolio_links %}}<p><strong>Portfolio:</strong> {{ item.portfolio_links|map('urlize')|join(', ')|safe }}</p>{{% endif %}}
703
+
704
+ {{% elif item_type == 'vacancy' %}}
705
+ {{% if item.company_logo_filename %}}
706
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ item.company_logo_filename }}" alt="{{ item.company_name }}" class="item-photo" style="max-width:100px; max-height:100px; margin-bottom:15px;">
707
+ {{% endif %}}
708
+ <h2>{{ item.job_title }}</h2>
709
+ <h3>at {{ item.company_name }}</h3>
710
+ <p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
711
+ <p><strong>Location:</strong> {{ item.location }}</p>
712
+ <p><strong>Description:</strong><br>{{ item.description|replace('\\n', '<br>')|safe }}</p>
713
+ {{% if item.requirements %}}<p><strong>Requirements:</strong> {{ item.requirements|join(', ') }}</p>{{% endif %}}
714
+ {{% if item.salary_range %}}<p><strong>Salary:</strong> {{ item.salary_range }}</p>{{% endif %}}
715
+ <p><strong>Type:</strong> {{ item.employment_type }}</p>
716
+ <p><strong>Contact for Applications:</strong> {{ item.contact_info }}</p>
717
+
718
+ {{% elif item_type == 'freelance_offer' %}}
719
+ <h2>{{ item.title }}</h2>
720
+ <p class="meta">Published: {{ item.published_at[:10] }} by {{ item.user_display_name }}</p>
721
+ <p><strong>Description:</strong><br>{{ item.description|replace('\\n', '<br>')|safe }}</p>
722
+ <p><strong>Skills Required:</strong> {{ item.skills_required|join(', ') }}</p>
723
+ <p><strong>Budget:</strong> {{ item.budget }}</p>
724
+ {{% if item.deadline %}}<p><strong>Deadline:</strong> {{ item.deadline }}</p>{{% endif %}}
725
+ <p><strong>Contact:</strong> {{ item.contact_info }}</p>
726
+ {{% endif %}}
727
+
728
+ {{% if 'user' in session and session['user'] and item.user_id == session['user'].id %}}
729
+ <div class="item-actions">
730
+ <a href="{{ url_for('edit_item', item_type=item_type, item_id=item_id) }}" class="button">Edit</a>
731
+ <form method="POST" action="{{ url_for('delete_item', item_type=item_type, item_id=item_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
732
+ <button type="submit" class="button button-destructive">Delete</button>
733
+ </form>
734
+ </div>
735
+ {{% endif %}}
736
+ </div>
737
+ <a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Listings</a>
738
+ {{% endblock %}}
739
+ {{% block extra_js %}}
740
+ <script>
741
+ document.addEventListener('DOMContentLoaded', function() {{
742
+ tg.BackButton.show();
743
+ tg.onEvent('backButtonClicked', function() {{
744
+ window.location.href = '{{ url_for("index") }}';
745
+ }});
746
+ }});
747
+ window.addEventListener('beforeunload', function() {{
748
+ tg.BackButton.hide();
749
+ tg.offEvent('backButtonClicked');
750
+ }});
751
+ </script>
752
+ {{% endblock %}}
753
+ """
754
 
755
+ MY_POSTINGS_TEMPLATE = """
756
+ {{% extends "base_template" %}}
757
+ {{% block content %}}
758
+ <h2>My Postings</h2>
759
+
760
+ {{% if not (my_resumes or my_vacancies or my_freelance_offers) %}}
761
+ <p class="no-items">You haven't published anything yet.</p>
762
+ <a href="{{ url_for('index') }}" class="button">Publish Something</a>
763
+ {{% endif %}}
764
+
765
+ {{% if my_resumes %}}
766
+ <h3>My Resumes</h3>
767
+ {{% for resume_id, resume in my_resumes.items() %}}
768
+ <div class="card">
769
+ {{% if resume.photo_filename %}}
770
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ resume.photo_filename }}" alt="{{ resume.full_name }}" class="item-photo">
771
+ {{% endif %}}
772
+ <h4>{{ resume.full_name }} - {{ resume.title }}</h4>
773
+ <p class="meta">Published: {{ resume.published_at[:10] }}</p>
774
+ <div class="item-actions">
775
+ <a href="{{ url_for('view_item', item_type='resume', item_id=resume_id) }}" class="button button-secondary">View</a>
776
+ <a href="{{ url_for('edit_item', item_type='resume', item_id=resume_id) }}" class="button">Edit</a>
777
+ <form method="POST" action="{{ url_for('delete_item', item_type='resume', item_id=resume_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
778
+ <button type="submit" class="button button-destructive">Delete</button>
779
+ </form>
780
+ </div>
781
+ </div>
782
+ {{% endfor %}}
783
+ {{% endif %}}
784
+
785
+ {{% if my_vacancies %}}
786
+ <h3>My Vacancies</h3>
787
+ {{% for vacancy_id, vacancy in my_vacancies.items() %}}
788
+ <div class="card">
789
+ {{% if vacancy.company_logo_filename %}}
790
+ <img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ vacancy.company_logo_filename }}" alt="{{ vacancy.company_name }}" class="item-photo">
791
+ {{% endif %}}
792
+ <h4>{{ vacancy.job_title }} at {{ vacancy.company_name }}</h4>
793
+ <p class="meta">Published: {{ vacancy.published_at[:10] }}</p>
794
+ <div class="item-actions">
795
+ <a href="{{ url_for('view_item', item_type='vacancy', item_id=vacancy_id) }}" class="button button-secondary">View</a>
796
+ <a href="{{ url_for('edit_item', item_type='vacancy', item_id=vacancy_id) }}" class="button">Edit</a>
797
+ <form method="POST" action="{{ url_for('delete_item', item_type='vacancy', item_id=vacancy_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
798
+ <button type="submit" class="button button-destructive">Delete</button>
799
+ </form>
800
+ </div>
801
+ </div>
802
+ {{% endfor %}}
803
+ {{% endif %}}
804
+
805
+ {{% if my_freelance_offers %}}
806
+ <h3>My Freelance Offers</h3>
807
+ {{% for offer_id, offer in my_freelance_offers.items() %}}
808
+ <div class="card">
809
+ <h4>{{ offer.title }}</h4>
810
+ <p class="meta">Published: {{ offer.published_at[:10] }}</p>
811
+ <div class="item-actions">
812
+ <a href="{{ url_for('view_item', item_type='freelance_offer', item_id=offer_id) }}" class="button button-secondary">View</a>
813
+ <a href="{{ url_for('edit_item', item_type='freelance_offer', item_id=offer_id) }}" class="button">Edit</a>
814
+ <form method="POST" action="{{ url_for('delete_item', item_type='freelance_offer', item_id=offer_id) }}" onsubmit="confirmDelete(event);" style="display:inline;">
815
+ <button type="submit" class="button button-destructive">Delete</button>
816
+ </form>
817
+ </div>
818
+ </div>
819
+ {{% endfor %}}
820
+ {{% endif %}}
821
+
822
+ <a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Main Page</a>
823
+ {{% endblock %}}
824
+ {{% block extra_js %}}
825
+ <script>
826
+ document.addEventListener('DOMContentLoaded', function() {{
827
+ tg.BackButton.show();
828
+ tg.onEvent('backButtonClicked', function() {{
829
+ window.location.href = '{{ url_for("index") }}';
830
+ }});
831
+ }});
832
+ window.addEventListener('beforeunload', function() {{
833
+ tg.BackButton.hide();
834
+ tg.offEvent('backButtonClicked');
835
+ }});
836
+ </script>
837
+ {{% endblock %}}
838
+ """
839
 
840
+ ADMIN_SYNC_TEMPLATE = """
841
+ {{% extends "base_template" %}}
842
+ {{% block content %}}
843
+ <div class="header">
844
+ <h1>Admin Sync Panel</h1>
845
+ </div>
846
+ <div class="card">
847
+ <h2><i class="fas fa-sync-alt"></i> Sync with Data Store</h2>
848
+ <div style="display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap;">
849
+ <form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to force upload local data? This will overwrite server data.');">
850
+ <button type="submit" class="button"><i class="fas fa-upload"></i> Upload DB</button>
851
+ </form>
852
+ <form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to force download data? This will overwrite local files.');">
853
+ <button type="submit" class="button button-secondary"><i class="fas fa-download"></i> Download DB</button>
854
+ </form>
855
+ </div>
856
+ <p style="font-size: 14px; color: var(--tg-theme-hint-color);">Backup occurs automatically every 30 minutes and after each save. Use these buttons for immediate sync.</p>
857
+ </div>
858
+ <div class="card">
859
+ <h2>Data Overview</h2>
860
+ <p>Total Resumes: {{ data_counts.resumes }}</p>
861
+ <p>Total Vacancies: {{ data_counts.vacancies }}</p>
862
+ <p>Total Freelance Offers: {{ data_counts.freelance_offers }}</p>
863
+ </div>
864
+ <a href="{{ url_for('index') }}" class="button button-secondary" style="margin-top:20px;">Back to Main Page</a>
865
+ {{% endblock %}}
866
+ {{% block extra_js %}}
867
+ <script>
868
+ document.addEventListener('DOMContentLoaded', function() {{
869
+ tg.BackButton.show();
870
+ tg.onEvent('backButtonClicked', function() {{
871
+ window.location.href = '{{ url_for("index") }}';
872
+ }});
873
+ }});
874
+ window.addEventListener('beforeunload', function() {{
875
+ tg.BackButton.hide();
876
+ tg.offEvent('backButtonClicked');
877
+ }});
878
+ </script>
879
+ {{% endblock %}}
880
+ """
881
 
882
+ # --- Helper function to get item type display name ---
883
+ def get_item_type_display_name(item_type_slug):
884
+ names = {
885
+ 'resume': 'Resume',
886
+ 'vacancy': 'Vacancy',
887
+ 'freelance_offer': 'Freelance Offer'
888
+ }
889
+ return names.get(item_type_slug, 'Item')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
 
891
+ def get_item_collection_name(item_type_slug):
892
+ names = {
893
+ 'resume': 'resumes',
894
+ 'vacancy': 'vacancies',
895
+ 'freelance_offer': 'freelance_offers'
896
+ }
897
+ return names.get(item_type_slug)
898
 
899
  # --- Flask Routes ---
900
+ @app.before_request
901
+ def check_auth_for_publish():
902
+ if request.endpoint in ['publish_item', 'edit_item', 'delete_item', 'my_postings']:
903
+ if 'user' not in session or not session['user']:
904
+ flash("You need to be authenticated to access this page. Please ensure you are opening this app through Telegram.", "error")
905
+ return redirect(url_for('index'))
906
+ if not session['user'].get('id'):
907
+ flash("Authentication error: User ID missing.", "error")
908
+ session.pop('user', None)
909
+ return redirect(url_for('index'))
910
+
911
+ @app.route('/auth_telegram', methods=['POST'])
912
+ def auth_telegram():
913
+ data = request.get_json()
914
+ init_data_str = data.get('init_data')
915
+ if not init_data_str:
916
+ return jsonify({"status": "error", "message": "No initData received"}), 400
917
+
918
+ user_info = validate_telegram_data(init_data_str)
919
+
920
+ if user_info and user_info.get('id'):
921
+ session['user'] = user_info
922
+ logging.info(f"User {user_info.get('id')} authenticated via Telegram.")
923
+ return jsonify({"status": "success", "user": user_info})
924
+ else:
925
+ logging.warning(f"Telegram authentication failed for initData: {init_data_str[:100]}...")
926
+ session.pop('user', None)
927
+ return jsonify({"status": "error", "message": "Invalid Telegram data or hash mismatch"}), 403
928
+
929
  @app.route('/')
930
  def index():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
  data = load_data()
932
+ # Apply theme params from session if available, otherwise use defaults
933
+ theme_params = session.get('themeParams', {})
934
+ return render_template_string(
935
+ BASE_TEMPLATE.format(
936
+ background_color=theme_params.get('bg_color'),
937
+ text_color=theme_params.get('text_color'),
938
+ hint_color=theme_params.get('hint_color'),
939
+ link_color=theme_params.get('link_color'),
940
+ button_color=theme_params.get('button_color'),
941
+ button_text_color=theme_params.get('button_text_color'),
942
+ secondary_bg_color=theme_params.get('secondary_bg_color'),
943
+ header_color=theme_params.get('header_bg_color'),
944
+ accent_text_color=theme_params.get('accent_text_color'),
945
+ section_bg_color=theme_params.get('section_bg_color'),
946
+ section_header_text_color=theme_params.get('section_header_text_color'),
947
+ destructive_text_color=theme_params.get('destructive_text_color')
948
+ ) + INDEX_TEMPLATE,
949
+ resumes=data.get('resumes', {}),
950
+ vacancies=data.get('vacancies', {}),
951
+ freelance_offers=data.get('freelance_offers', {}),
952
+ repo_id=REPO_ID
953
+ )
954
+
955
+ @app.route('/publish/<item_type>', methods=['GET', 'POST'])
956
+ @app.route('/edit/<item_type>/<item_id>', methods=['GET', 'POST'])
957
+ def publish_item(item_type, item_id=None):
958
+ data = load_data()
959
+ collection_name = get_item_collection_name(item_type)
960
+ if not collection_name:
961
+ flash("Invalid item type.", "error")
962
+ return redirect(url_for('index'))
963
+
964
+ item_data_to_edit = None
965
+ if item_id:
966
+ item_data_to_edit = data.get(collection_name, {}).get(item_id)
967
+ if not item_data_to_edit or item_data_to_edit.get('user_id') != session['user']['id']:
968
+ flash("Item not found or you don't have permission to edit it.", "error")
969
+ return redirect(url_for('index'))
970
+ # Prepare complex fields for form display
971
+ if item_type == 'resume':
972
+ if item_data_to_edit.get('experience'):
973
+ item_data_to_edit['experience_str'] = "\n".join([f"{e['company']};{e['role']};{e['duration']};{e['description']}" for e in item_data_to_edit['experience']])
974
+ if item_data_to_edit.get('education'):
975
+ item_data_to_edit['education_str'] = "\n".join([f"{e['institution']};{e['degree']};{e['duration']}" for e in item_data_to_edit['education']])
976
+
977
+
978
+ if request.method == 'POST':
979
+ new_item_id = item_id or str(uuid.uuid4())
980
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
981
+
982
+ current_photo_filename = None
983
+ if item_data_to_edit:
984
+ if item_type == 'resume': current_photo_filename = item_data_to_edit.get('photo_filename')
985
+ elif item_type == 'vacancy': current_photo_filename = item_data_to_edit.get('company_logo_filename')
986
+
987
+ photo_file = request.files.get('photo')
988
+ uploaded_photo_filename = None
989
+
990
+ if photo_file and photo_file.filename:
991
+ if not HF_TOKEN_WRITE:
992
+ flash("Cannot upload photo: Hugging Face Write Token is not configured.", "warning")
993
+ else:
994
+ try:
995
+ uploads_dir = 'uploads_temp'
996
+ os.makedirs(uploads_dir, exist_ok=True)
997
+ api = HfApi()
998
+
999
+ safe_name_prefix = secure_filename(request.form.get('full_name', request.form.get('company_name', request.form.get('title', 'item'))).replace(' ', '_'))[:20]
1000
+ ext = os.path.splitext(photo_file.filename)[1].lower()
1001
+ if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
1002
+ flash(f"File {photo_file.filename} is not a supported image format and was skipped.", "warning")
1003
+ else:
1004
+ new_photo_filename_base = f"{safe_name_prefix}_{new_item_id[:8]}_{timestamp.replace(':','-').replace(' ','_')}{ext}"
1005
+ temp_path = os.path.join(uploads_dir, new_photo_filename_base)
1006
+ photo_file.save(temp_path)
1007
+
1008
+ api.upload_file(
1009
+ path_or_fileobj=temp_path,
1010
+ path_in_repo=f"photos/{new_photo_filename_base}",
1011
+ repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
1012
+ commit_message=f"Upload photo for {item_type} {new_item_id}"
1013
+ )
1014
+ uploaded_photo_filename = new_photo_filename_base
1015
+ logging.info(f"Photo {uploaded_photo_filename} uploaded for {item_type} {new_item_id}.")
1016
+ os.remove(temp_path)
1017
+ if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir)
1018
+
1019
+ # Delete old photo if a new one is uploaded and an old one existed
1020
+ if current_photo_filename and uploaded_photo_filename != current_photo_filename:
1021
+ try:
1022
+ api.delete_file(repo_id=REPO_ID, path_in_repo=f"photos/{current_photo_filename}", repo_type="dataset", token=HF_TOKEN_WRITE)
1023
+ logging.info(f"Old photo {current_photo_filename} deleted from HF.")
1024
+ except Exception as e_del:
1025
+ logging.error(f"Failed to delete old photo {current_photo_filename}: {e_del}")
1026
+ except Exception as e:
1027
+ logging.error(f"Error uploading photo: {e}", exc_info=True)
1028
+ flash("Error uploading photo.", "error")
1029
 
 
 
 
 
 
 
1030
 
1031
+ item_details = {
1032
+ 'id': new_item_id,
1033
+ 'user_id': session['user']['id'],
1034
+ 'user_display_name': f"{session['user'].get('first_name','')} {session['user'].get('last_name','')} ({session['user'].get('username','N/A')})".strip(),
1035
+ 'published_at': item_data_to_edit.get('published_at', timestamp) if item_id else timestamp,
1036
+ 'updated_at': timestamp
1037
+ }
1038
 
1039
+ if item_type == 'resume':
1040
+ item_details.update({
1041
+ 'full_name': request.form.get('full_name'),
1042
+ 'title': request.form.get('title'),
1043
+ 'skills': [s.strip() for s in request.form.get('skills', '').split(',') if s.strip()],
1044
+ 'contact_info': request.form.get('contact_info'),
1045
+ 'portfolio_links': [l.strip() for l in request.form.get('portfolio_links', '').split(',') if l.strip()],
1046
+ 'photo_filename': uploaded_photo_filename or current_photo_filename
1047
+ })
1048
+ exp_text = request.form.get('experience', '')
1049
+ item_details['experience'] = []
1050
+ for line in exp_text.splitlines():
1051
+ parts = [p.strip() for p in line.split(';')]
1052
+ if len(parts) == 4: item_details['experience'].append({'company': parts[0], 'role': parts[1], 'duration': parts[2], 'description': parts[3]})
1053
+
1054
+ edu_text = request.form.get('education', '')
1055
+ item_details['education'] = []
1056
+ for line in edu_text.splitlines():
1057
+ parts = [p.strip() for p in line.split(';')]
1058
+ if len(parts) == 3: item_details['education'].append({'institution': parts[0], 'degree': parts[1], 'duration': parts[2]})
1059
+
1060
+ elif item_type == 'vacancy':
1061
+ item_details.update({
1062
+ 'company_name': request.form.get('company_name'),
1063
+ 'job_title': request.form.get('job_title'),
1064
+ 'description': request.form.get('description'),
1065
+ 'requirements': [r.strip() for r in request.form.get('requirements', '').split(',') if r.strip()],
1066
+ 'location': request.form.get('location'),
1067
+ 'salary_range': request.form.get('salary_range'),
1068
+ 'employment_type': request.form.get('employment_type'),
1069
+ 'contact_info': request.form.get('contact_info'),
1070
+ 'company_logo_filename': uploaded_photo_filename or current_photo_filename
1071
+ })
1072
+ elif item_type == 'freelance_offer':
1073
+ item_details.update({
1074
+ 'title': request.form.get('title'),
1075
+ 'description': request.form.get('description'),
1076
+ 'skills_required': [s.strip() for s in request.form.get('skills_required', '').split(',') if s.strip()],
1077
+ 'budget': request.form.get('budget'),
1078
+ 'deadline': request.form.get('deadline'),
1079
+ 'contact_info': request.form.get('contact_info')
1080
+ })
1081
+
1082
+ data.setdefault(collection_name, {})[new_item_id] = item_details
1083
+ save_data(data)
1084
+ flash(f"{get_item_type_display_name(item_type)} {'updated' if item_id else 'published'} successfully!", "success")
1085
+ return redirect(url_for('view_item', item_type=item_type, item_id=new_item_id))
1086
+
1087
+ return render_template_string(
1088
+ BASE_TEMPLATE.format(**session.get('themeParams', {})) + PUBLISH_ITEM_TEMPLATE, # Pass empty dict if no themeParams
1089
+ item_type=item_type,
1090
+ item_type_display_name=get_item_type_display_name(item_type),
1091
+ item_data=item_data_to_edit,
1092
+ repo_id=REPO_ID
1093
+ )
1094
+
1095
+ @app.route('/view/<item_type>/<item_id>')
1096
+ def view_item(item_type, item_id):
1097
  data = load_data()
1098
+ collection_name = get_item_collection_name(item_type)
1099
+ if not collection_name:
1100
+ flash("Invalid item type.", "error")
1101
+ return redirect(url_for('index'))
1102
+
1103
+ item = data.get(collection_name, {}).get(item_id)
1104
+ if not item:
1105
+ flash("Item not found.", "error")
1106
+ return redirect(url_for('index'))
1107
+
1108
+ return render_template_string(
1109
+ BASE_TEMPLATE.format(**session.get('themeParams', {})) + VIEW_ITEM_TEMPLATE,
1110
+ item_type=item_type,
1111
+ item_id=item_id,
1112
+ item=item,
1113
+ repo_id=REPO_ID
1114
+ )
1115
+
1116
+ @app.route('/delete/<item_type>/<item_id>', methods=['POST'])
1117
+ def delete_item(item_type, item_id):
1118
+ data = load_data()
1119
+ collection_name = get_item_collection_name(item_type)
1120
+ if not collection_name:
1121
+ flash("Invalid item type.", "error")
1122
+ return redirect(url_for('index'))
1123
+
1124
+ item_to_delete = data.get(collection_name, {}).get(item_id)
1125
+ if not item_to_delete or item_to_delete.get('user_id') != session['user']['id']:
1126
+ flash("Item not found or you don't have permission to delete it.", "error")
1127
+ return redirect(url_for('index'))
1128
+
1129
+ photo_filename_key = None
1130
+ if item_type == 'resume': photo_filename_key = 'photo_filename'
1131
+ elif item_type == 'vacancy': photo_filename_key = 'company_logo_filename'
1132
 
1133
+ old_photo_to_delete = item_to_delete.get(photo_filename_key) if photo_filename_key else None
 
 
 
 
 
 
 
 
 
 
1134
 
1135
+ del data[collection_name][item_id]
1136
  save_data(data)
1137
+
1138
+ if old_photo_to_delete and HF_TOKEN_WRITE:
1139
+ try:
1140
+ api = HfApi()
1141
+ api.delete_file(repo_id=REPO_ID, path_in_repo=f"photos/{old_photo_to_delete}", repo_type="dataset", token=HF_TOKEN_WRITE)
1142
+ logging.info(f"Photo {old_photo_to_delete} deleted from HF for deleted {item_type} {item_id}.")
1143
+ except Exception as e:
1144
+ logging.error(f"Error deleting photo {old_photo_to_delete} from HF: {e}", exc_info=True)
1145
+ flash("Item deleted, but failed to delete associated photo from storage.", "warning")
 
 
 
 
1146
 
1147
+ flash(f"{get_item_type_display_name(item_type)} deleted successfully.", "success")
1148
+ return redirect(url_for('my_postings'))
1149
 
 
 
1150
 
1151
+ @app.route('/my_postings')
1152
+ def my_postings():
1153
+ data = load_data()
1154
+ user_id = session['user']['id']
 
1155
 
1156
+ my_resumes = {k: v for k, v in data.get('resumes', {}).items() if v.get('user_id') == user_id}
1157
+ my_vacancies = {k: v for k, v in data.get('vacancies', {}).items() if v.get('user_id') == user_id}
1158
+ my_freelance_offers = {k: v for k, v in data.get('freelance_offers', {}).items() if v.get('user_id') == user_id}
1159
+
1160
+ return render_template_string(
1161
+ BASE_TEMPLATE.format(**session.get('themeParams', {})) + MY_POSTINGS_TEMPLATE,
1162
+ my_resumes=my_resumes,
1163
+ my_vacancies=my_vacancies,
1164
+ my_freelance_offers=my_freelance_offers,
1165
+ repo_id=REPO_ID
1166
+ )
1167
+
1168
+ @app.route('/admin_sync', methods=['GET'])
1169
+ def admin_sync():
1170
+ # Basic protection: only allow if user is authenticated (any Telegram user)
1171
+ # For real admin, you'd check against a list of admin user IDs
1172
+ if 'user' not in session or not session['user']:
1173
+ flash("You must be authenticated to access the sync panel.", "error")
1174
+ return redirect(url_for('index'))
1175
+
1176
+ data = load_data()
1177
+ data_counts = {
1178
+ "resumes": len(data.get('resumes', {})),
1179
+ "vacancies": len(data.get('vacancies', {})),
1180
+ "freelance_offers": len(data.get('freelance_offers', {}))
1181
+ }
1182
+ return render_template_string(
1183
+ BASE_TEMPLATE.format(**session.get('themeParams', {})) + ADMIN_SYNC_TEMPLATE,
1184
+ data_counts=data_counts
1185
+ )
1186
+
1187
+ @app.route('/force_upload_sync', methods=['POST']) # Renamed route to avoid conflict if old code is around
1188
+ def force_upload():
1189
+ if 'user' not in session or not session['user']: # Basic protection
1190
+ flash("Authentication required.", "error")
1191
+ return redirect(url_for('index'))
1192
+ logging.info("Forcing upload to Hugging Face...")
1193
+ upload_db_to_hf()
1194
+ flash("Data successfully uploaded to Hugging Face.", 'success')
1195
+ return redirect(url_for('admin_sync'))
1196
+
1197
+ @app.route('/force_download_sync', methods=['POST']) # Renamed route
1198
+ def force_download():
1199
+ if 'user' not in session or not session['user']: # Basic protection
1200
+ flash("Authentication required.", "error")
1201
+ return redirect(url_for('index'))
1202
+ logging.info("Forcing download from Hugging Face...")
1203
+ if download_db_from_hf():
1204
+ flash("Data successfully downloaded. Local files updated.", 'success')
1205
+ load_data() # Reload data in memory
1206
+ else:
1207
+ flash("Failed to download data. Check logs.", 'error')
1208
+ return redirect(url_for('admin_sync'))
1209
 
1210
+ # --- App Initialization ---
1211
  if __name__ == '__main__':
1212
+ logging.info("TonTalent Application starting up...")
1213
+ if not os.path.exists(TONTALENT_DATA_FILE):
1214
+ logging.info(f"{TONTALENT_DATA_FILE} not found locally, attempting initial download/creation.")
1215
+ download_db_from_hf() # This will create an empty file if not on HF and not local
1216
 
1217
+ load_data() # Load initial data
1218
  logging.info("Initial data load/check complete.")
1219
 
1220
  if HF_TOKEN_WRITE:
 
1224
  else:
1225
  logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
1226
 
1227
+ port = int(os.environ.get('PORT', 7861)) # Different port from original app
1228
  logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
1229
  app.run(debug=False, host='0.0.0.0', port=port)