Shveiauto commited on
Commit
15bf1f8
·
verified ·
1 Parent(s): 6a4a656

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +650 -790
app.py CHANGED
@@ -18,16 +18,15 @@ import requests
18
  load_dotenv()
19
 
20
  app = Flask(__name__)
21
- app.secret_key = os.getenv("FLASK_SECRET_KEY", 'telegram_wall_secret_key_for_flash_messages')
22
  DATA_FILE = 'wall_data.json'
23
  SYNC_FILES = [DATA_FILE]
24
 
25
- REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2") # Using existing repo config
26
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
27
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
29
- # IMPORTANT: Replace with your actual Bot Token from .env or config if necessary
30
- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:8283649768:AAGYWatM-nUVQirgBiBwoAtWZgzfp3QnQjY")
31
 
32
  DOWNLOAD_RETRIES = 3
33
  DOWNLOAD_DELAY = 5
@@ -41,11 +40,11 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
41
  files_to_download = [specific_file] if specific_file else SYNC_FILES
42
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
43
  all_successful = True
44
- default_data = {'users': {}, 'posts': {}}
45
  for file_name in files_to_download:
46
  success = False
47
  for attempt in range(retries + 1):
48
  try:
 
49
  local_path = hf_hub_download(
50
  repo_id=REPO_ID, filename=file_name, repo_type="dataset",
51
  token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
@@ -64,10 +63,8 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
64
  try:
65
  if file_name == DATA_FILE:
66
  with open(file_name, 'w', encoding='utf-8') as f:
67
- json.dump(default_data, f)
68
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
69
- success = True # Consider a 404 for DATA_FILE as successful if we create it
70
- break
71
  except Exception as create_e:
72
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
73
  success = True
@@ -118,7 +115,7 @@ def periodic_backup():
118
  logging.info("Periodic backup finished.")
119
 
120
  def load_data():
121
- default_data = {'users': {}, 'posts': {}}
122
  try:
123
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
124
  data = json.load(file)
@@ -161,7 +158,7 @@ def save_data(data):
161
  if not isinstance(data, dict):
162
  logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
163
  return
164
- default_keys = {'users': {}, 'posts': {}}
165
  for key in default_keys:
166
  if key not in data: data[key] = default_keys[key]
167
 
@@ -200,30 +197,6 @@ def verify_telegram_auth_data(auth_data_str, bot_token):
200
  return False, None
201
  return False, None
202
 
203
- def send_telegram_notification(chat_id, text):
204
- if not TELEGRAM_BOT_TOKEN:
205
- logging.warning("TELEGRAM_BOT_TOKEN not set. Skipping notification.")
206
- return False
207
-
208
- url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
209
- payload = {
210
- 'chat_id': chat_id,
211
- 'text': text,
212
- 'parse_mode': 'Markdown'
213
- }
214
-
215
- try:
216
- response = requests.post(url, json=payload)
217
- if response.status_code == 200:
218
- logging.info(f"Notification sent to chat ID {chat_id} successfully.")
219
- return True
220
- else:
221
- logging.error(f"Failed to send notification to {chat_id}: {response.text}")
222
- return False
223
- except requests.RequestException as e:
224
- logging.error(f"Request error sending notification to {chat_id}: {e}")
225
- return False
226
-
227
  def get_authenticated_user_details(request_headers):
228
  auth_data_str = request_headers.get('X-Telegram-Auth')
229
  if not auth_data_str:
@@ -235,371 +208,29 @@ def get_authenticated_user_details(request_headers):
235
  return data.get('users', {}).get(user_id_str)
236
  return None
237
 
238
- # --- API Endpoints ---
239
-
240
- @app.route('/api/auth_user', methods=['POST'])
241
- def auth_user():
242
- auth_data_str = request.headers.get('X-Telegram-Auth')
243
- if not auth_data_str:
244
- init_data_payload = request.json.get('init_data')
245
- if init_data_payload:
246
- auth_data_str = init_data_payload
247
- else:
248
- return jsonify({"error": "Authentication data not provided"}), 401
249
-
250
- is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
251
-
252
- if not is_valid or not user_data_from_auth:
253
- return jsonify({"error": "Invalid authentication data"}), 403
254
-
255
- data = load_data()
256
- users = data.get('users', {})
257
- user_id_str = str(user_data_from_auth.get('id'))
258
- chat_id = str(user_data_from_auth.get('id'))
259
-
260
- if user_id_str not in users:
261
- users[user_id_str] = {
262
- 'id': user_data_from_auth.get('id'),
263
- 'first_name': user_data_from_auth.get('first_name'),
264
- 'last_name': user_data_from_auth.get('last_name'),
265
- 'username': user_data_from_auth.get('username'),
266
- 'language_code': user_data_from_auth.get('language_code'),
267
- 'photo_url': user_data_from_auth.get('photo_url'),
268
- 'chat_id': chat_id, # Use user ID as chat_id for notifications
269
- 'first_seen': datetime.now().isoformat()
270
- }
271
-
272
- user_updates = {
273
- 'last_seen': datetime.now().isoformat(),
274
- 'language_code': user_data_from_auth.get('language_code'),
275
- 'username': user_data_from_auth.get('username'),
276
- 'first_name': user_data_from_auth.get('first_name'),
277
- 'last_name': user_data_from_auth.get('last_name'),
278
- }
279
- if user_data_from_auth.get('photo_url'):
280
- user_updates['photo_url'] = user_data_from_auth.get('photo_url')
281
-
282
- users[user_id_str].update(user_updates)
283
-
284
- data['users'] = users
285
- save_data(data)
286
-
287
- return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
288
-
289
- @app.route('/api/users', methods=['GET'])
290
- def get_users():
291
- data = load_data()
292
- users = list(data.get('users', {}).values())
293
-
294
- # Sort users by last seen, most recent first
295
- sorted_users = sorted(users, key=lambda x: x.get('last_seen', ''), reverse=True)
296
-
297
- # Simple list of users
298
- user_list = [
299
- {
300
- 'id': u['id'],
301
- 'first_name': u['first_name'],
302
- 'last_name': u['last_name'],
303
- 'username': u['username'],
304
- 'photo_url': u.get('photo_url'),
305
- 'last_seen': u['last_seen']
306
- } for u in sorted_users
307
- ]
308
- return jsonify(user_list), 200
309
-
310
- @app.route('/api/posts', methods=['GET'])
311
- def get_feed_posts():
312
- data = load_data()
313
- all_posts = []
314
-
315
- # Flatten all posts from all users
316
- for wall_owner_id, posts in data.get('posts', {}).items():
317
- all_posts.extend(posts)
318
-
319
- # Sort all posts by timestamp, newest first
320
- sorted_posts = sorted(all_posts, key=lambda x: x.get('timestamp', ''), reverse=True)
321
-
322
- # Enrich with user info
323
- users = data.get('users', {})
324
- for post in sorted_posts:
325
- author = users.get(str(post['author_id']), {})
326
- post['author_name'] = f"{author.get('first_name', 'Unknown')} {author.get('last_name', '')}".strip() or "Unknown User"
327
- post['author_username'] = author.get('username')
328
- wall_owner = users.get(str(post['wall_owner_id']), {})
329
- post['wall_owner_name'] = f"{wall_owner.get('first_name', 'Unknown')} {wall_owner.get('last_name', '')}".strip() or "Unknown User"
330
- post['wall_owner_username'] = wall_owner.get('username')
331
-
332
- return jsonify(sorted_posts), 200
333
-
334
- @app.route('/api/posts/<user_id>', methods=['GET'])
335
- def get_user_wall_posts(user_id):
336
- data = load_data()
337
- user_posts = data.get('posts', {}).get(user_id, [])
338
-
339
- # Sort posts by timestamp, newest first
340
- sorted_posts = sorted(user_posts, key=lambda x: x.get('timestamp', ''), reverse=True)
341
-
342
- # Enrich with author info
343
- users = data.get('users', {})
344
- wall_owner = users.get(user_id)
345
-
346
- for post in sorted_posts:
347
- author = users.get(str(post['author_id']), {})
348
- post['author_name'] = f"{author.get('first_name', 'Unknown')} {author.get('last_name', '')}".strip() or "Unknown User"
349
- post['author_username'] = author.get('username')
350
- post['wall_owner_name'] = f"{wall_owner.get('first_name', 'Unknown')} {wall_owner.get('last_name', '')}".strip() if wall_owner else "Unknown User"
351
- post['wall_owner_username'] = wall_owner.get('username')
352
-
353
- return jsonify(sorted_posts), 200
354
-
355
- @app.route('/api/post_to_wall/<wall_owner_id>', methods=['POST'])
356
- def create_post(wall_owner_id):
357
- user = get_authenticated_user_details(request.headers)
358
- if not user:
359
- return jsonify({"error": "Authentication required"}), 401
360
-
361
- if str(wall_owner_id) not in load_data().get('users', {}):
362
- return jsonify({"error": "Wall owner not found"}), 404
363
-
364
- req_data = request.json
365
- text = req_data.get('text', '').strip()
366
- media_type = req_data.get('media_type') # e.g., 'photo', 'video', 'document'
367
- media_url = req_data.get('media_url') # Placeholder for uploaded file URL
368
 
369
- if not text and not media_url:
370
- return jsonify({"error": "Post must contain text or media"}), 400
371
-
372
- data = load_data()
373
- wall_owner = data['users'].get(str(wall_owner_id))
374
-
375
- new_post = {
376
- "id": str(uuid.uuid4()),
377
- "wall_owner_id": str(wall_owner_id),
378
- "author_id": str(user['id']),
379
- "text": text,
380
- "media_type": media_type if media_type and media_url else None,
381
- "media_url": media_url if media_url else None,
382
- "timestamp": datetime.now().isoformat(),
383
  }
384
-
385
- if wall_owner_id not in data['posts']:
386
- data['posts'][wall_owner_id] = []
387
-
388
- data['posts'][wall_owner_id].append(new_post)
389
- save_data(data)
390
-
391
- # --- Notification Logic ---
392
- if str(wall_owner_id) != str(user['id']):
393
- author_name = f"{user.get('first_name', 'Someone')} {user.get('last_name', '')}".strip()
394
- author_username = user.get('username')
395
-
396
- # Build notification text
397
- notification_text = f"🚨 *New Post on your Wall!* 🚨\n\n"
398
- if author_username:
399
- notification_text += f"From: @{author_username}\n"
400
- else:
401
- notification_text += f"From: {author_name} (ID: {user['id']})\n"
402
-
403
- if text:
404
- notification_text += f"Text: \"{text[:100]}...\"" if len(text) > 100 else f"Text: \"{text}\""
405
-
406
- if new_post['media_type']:
407
- notification_text += f"\n_Contains a {new_post['media_type']}_"
408
-
409
- # NOTE: Cannot open the mini-app directly from a notification without a specific bot setup
410
- # notification_text += f"\n\n[Open Wall](https://t.me/your_bot_username/your_miniapp?startapp={wall_owner_id})"
411
-
412
- if wall_owner and wall_owner.get('chat_id'):
413
- send_telegram_notification(wall_owner['chat_id'], notification_text)
414
-
415
- # Enrich post for immediate response
416
- new_post['author_name'] = f"{user.get('first_name', 'Unknown')} {user.get('last_name', '')}".strip() or "Unknown User"
417
- new_post['author_username'] = user.get('username')
418
- new_post['wall_owner_name'] = f"{wall_owner.get('first_name', 'Unknown')} {wall_owner.get('last_name', '')}".strip() or "Unknown User"
419
- new_post['wall_owner_username'] = wall_owner.get('username')
420
-
421
- return jsonify(new_post), 201
422
-
423
- # --- Admin Panel (Simplified) ---
424
-
425
- @app.route('/admin')
426
- def admin_panel():
427
- data = load_data()
428
- users = data.get('users', {})
429
-
430
- # Flatten all posts for admin view
431
- all_posts = []
432
- for wall_owner_id, posts in data.get('posts', {}).items():
433
- all_posts.extend(posts)
434
-
435
- sorted_posts = sorted(all_posts, key=lambda x: x.get('timestamp', ''), reverse=True)
436
-
437
- admin_template = '''
438
- <!DOCTYPE html>
439
- <html lang="en">
440
- <head>
441
- <meta charset="UTF-8">
442
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
443
- <title>Telegram Wall Admin</title>
444
- <style>
445
- body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
446
- .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
447
- h1, h2 { color: #333; }
448
- .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
449
- .item { border-bottom: 1px solid #eee; padding: 10px 0; }
450
- .item:last-child { border-bottom: none; }
451
- .item h3 { margin: 0 0 5px 0; }
452
- .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
453
- .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
454
- .button-primary { background-color: #007bff; color: white; }
455
- .button-danger { background-color: #dc3545; color: white; }
456
- .button-secondary { background-color: #6c757d; color: white; }
457
- .message { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
458
- .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
459
- .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
460
- .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
461
- .sync-buttons form { display: inline-block; margin-right: 10px; }
462
- </style>
463
- </head>
464
- <body>
465
- <div class="container">
466
- <h1>Telegram Wall Admin Panel</h1>
467
-
468
- {% with messages = get_flashed_messages(with_categories=true) %}
469
- {% if messages %}
470
- {% for category, message in messages %}
471
- <div class="message {{ category }}">{{ message }}</div>
472
- {% endfor %}
473
- {% endif %}
474
- {% endwith %}
475
-
476
- <div class="section">
477
- <h2>Data Synchronization with Hugging Face</h2>
478
- <div class="sync-buttons">
479
- <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
480
- <button type="submit" class="button button-primary">Upload DB to HF</button>
481
- </form>
482
- <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
483
- <button type="submit" class="button button-secondary">Download DB from HF</button>
484
- </form>
485
- </div>
486
- <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
487
- </div>
488
-
489
- <div class="section">
490
- <h2>Users ({{ users|length }})</h2>
491
- {% for user_id, user in users.items() %}
492
- <div class="item">
493
- <h3>{{ user.first_name }} {{ user.last_name }} (@{{ user.username or 'No Username' }})</h3>
494
- <p>ID: {{ user_id }} | Last Seen: {{ user.last_seen }}</p>
495
- <p>Posts on wall: {{ (data.posts.get(user_id) or [])|length }}</p>
496
- </div>
497
- {% endfor %}
498
- </div>
499
-
500
- <div class="section">
501
- <h2>All Posts ({{ sorted_posts|length }})</h2>
502
- {% for post in sorted_posts %}
503
- {% set author = users.get(post.author_id) %}
504
- {% set wall_owner = users.get(post.wall_owner_id) %}
505
- <div class="item">
506
- <h3>
507
- {% if post.text %}
508
- {{ post.text[:50] }}{% if post.text|length > 50 %}...{% endif %}
509
- {% else %}
510
- [No Text]
511
- {% endif %}
512
- </h3>
513
- <p>
514
- Author:
515
- {% if author %}
516
- {{ author.first_name }} {{ author.last_name }} (@{{ author.username or 'No Username' }})
517
- {% else %}
518
- Unknown Author ({{ post.author_id }})
519
- {% endif %}
520
- | On Wall of:
521
- {% if wall_owner %}
522
- {{ wall_owner.first_name }} {{ wall_owner.last_name }}
523
- {% else %}
524
- Unknown Wall Owner ({{ post.wall_owner_id }})
525
- {% endif %}
526
- </p>
527
- {% if post.media_type %}<p>Media: {{ post.media_type|capitalize }} (URL: {{ post.media_url }})</p>{% endif %}
528
- <p>Posted: {{ post.timestamp }}</p>
529
- <form method="POST" action="{{ url_for('admin_delete_post') }}" style="display:inline;" onsubmit="return confirm('Delete this post?');">
530
- <input type="hidden" name="wall_owner_id" value="{{ post.wall_owner_id }}">
531
- <input type="hidden" name="post_id" value="{{ post.id }}">
532
- <button type="submit" class="button button-danger">Delete Post</button>
533
- </form>
534
- </div>
535
- {% else %}
536
- <p>No posts found.</p>
537
- {% endfor %}
538
- </div>
539
- </div>
540
- </body>
541
- </html>
542
- '''
543
- return render_template_string(admin_template,
544
- users=users,
545
- sorted_posts=sorted_posts,
546
- data=data)
547
-
548
- @app.route('/admin/delete_post', methods=['POST'])
549
- def admin_delete_post():
550
- wall_owner_id = request.form.get('wall_owner_id')
551
- post_id = request.form.get('post_id')
552
-
553
- if not wall_owner_id or not post_id:
554
- flash('Invalid Post ID or Wall Owner ID for deletion.', 'error')
555
- return redirect(url_for('admin_panel'))
556
-
557
- data = load_data()
558
- posts_list = data.get('posts', {}).get(wall_owner_id, [])
559
- original_length = len(posts_list)
560
-
561
- data['posts'][wall_owner_id] = [p for p in posts_list if p['id'] != post_id]
562
-
563
- if len(data['posts'][wall_owner_id]) < original_length:
564
- save_data(data)
565
- flash(f'Post {post_id} deleted successfully from wall {wall_owner_id}.', 'success')
566
- else:
567
- flash(f'Post {post_id} not found on wall {wall_owner_id} or already deleted.', 'warning')
568
-
569
- # Clean up empty wall list
570
- if not data['posts'][wall_owner_id]:
571
- del data['posts'][wall_owner_id]
572
- save_data(data)
573
-
574
- return redirect(url_for('admin_panel'))
575
-
576
- @app.route('/admin/force_upload', methods=['POST'])
577
- def force_upload_admin():
578
- logging.info("Admin forcing upload to Hugging Face...")
579
- try:
580
- upload_db_to_hf()
581
- flash("Data successfully uploaded to Hugging Face.", 'success')
582
- except Exception as e:
583
- logging.error(f"Error during forced upload: {e}", exc_info=True)
584
- flash(f"Error uploading to Hugging Face: {e}", 'error')
585
- return redirect(url_for('admin_panel'))
586
-
587
- @app.route('/admin/force_download', methods=['POST'])
588
- def force_download_admin():
589
- logging.info("Admin forcing download from Hugging Face...")
590
  try:
591
- if download_db_from_hf():
592
- flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
593
- load_data()
594
- else:
595
- flash("Failed to download data from Hugging Face. Check logs.", 'error')
596
- except Exception as e:
597
- logging.error(f"Error during forced download: {e}", exc_info=True)
598
- flash(f"Error downloading from Hugging Face: {e}", 'error')
599
- return redirect(url_for('admin_panel'))
600
-
601
 
602
- # --- MAIN APP TEMPLATE (New Wall UI) ---
603
 
604
  MAIN_APP_TEMPLATE = '''
605
  <!DOCTYPE html>
@@ -607,9 +238,8 @@ MAIN_APP_TEMPLATE = '''
607
  <head>
608
  <meta charset="UTF-8">
609
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
610
- <title id="appTitle">Telegram Wall</title>
611
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
612
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLMDJdZ4wPuyzYfFw4u3Q23oNqgqFj5Lh2mX5L+R5E5u+5F5A2V5a5S+5A5A5Q==" crossorigin="anonymous" referrerpolicy="no-referrer" />
613
  <style>
614
  :root {
615
  --tg-theme-bg-color: #ffffff;
@@ -635,11 +265,8 @@ MAIN_APP_TEMPLATE = '''
635
  -webkit-font-smoothing: antialiased;
636
  -moz-osx-font-smoothing: grayscale;
637
  line-height: 1.4;
638
- padding-bottom: 60px; /* Space for bottom nav */
639
- min-height: 100vh;
640
- min-height: -webkit-fill-available;
641
  }
642
- .app-container { display: flex; flex-direction: column; }
643
  .header {
644
  background-color: var(--tg-theme-header-bg-color);
645
  padding: 12px 15px;
@@ -651,70 +278,85 @@ MAIN_APP_TEMPLATE = '''
651
  top: 0;
652
  z-index: 100;
653
  }
654
- .content { flex-grow: 1; padding: 10px 0; overflow-x: hidden; transition: opacity 0.2s ease-out; }
655
- .nav-bar {
656
- position: fixed;
657
- bottom: 0;
658
- left: 0;
659
- right: 0;
660
- height: 56px;
661
- background-color: var(--tg-theme-section-bg-color);
662
- border-top: 0.5px solid var(--tg-theme-secondary-bg-color);
663
  display: flex;
664
  justify-content: space-around;
665
- align-items: center;
666
- z-index: 1000;
 
 
 
 
667
  }
668
- .nav-item {
669
  flex: 1;
670
  display: flex;
671
  flex-direction: column;
672
  align-items: center;
673
  justify-content: center;
 
674
  cursor: pointer;
 
 
675
  color: var(--tg-theme-hint-color);
676
- font-size: 11px;
677
- padding-top: 5px;
678
- height: 100%;
679
  transition: color 0.2s ease;
680
  -webkit-tap-highlight-color: transparent;
681
  }
682
- .nav-item i { font-size: 20px; margin-bottom: 3px; }
683
- .nav-item.active { color: var(--tg-theme-link-color); }
684
 
685
- /* General Item Styles */
686
- .list-item, .post-item {
687
  background-color: var(--tg-theme-section-bg-color);
688
- padding: 15px;
689
- margin: 10px 15px;
690
  border-radius: 10px;
691
  box-shadow: 0 2px 8px rgba(0,0,0,0.06);
692
  cursor: pointer;
693
- transition: background-color 0.1s ease;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  }
695
- .list-item:active, .post-item:active { background-color: var(--tg-theme-secondary-bg-color); }
696
- .list-item h3 { margin: 0; font-size: 17px; font-weight: 600; color: var(--tg-theme-text-color); }
697
- .list-item p { margin: 3px 0; font-size: 14px; color: var(--tg-theme-hint-color); }
698
- .user-avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; margin-right: 10px; background-color: var(--tg-theme-secondary-bg-color); }
699
- .user-info { display: flex; align-items: center; }
700
-
701
- /* Post Specific Styles */
702
- .post-header { display: flex; align-items: center; margin-bottom: 10px; }
703
- .post-author-name { font-weight: 600; font-size: 15px; color: var(--tg-theme-text-color); }
704
- .post-username { font-size: 13px; color: var(--tg-theme-hint-color); margin-left: 5px; }
705
- .post-text { margin: 10px 0; font-size: 16px; white-space: pre-wrap; word-wrap: break-word; color: var(--tg-theme-text-color); }
706
- .post-meta { font-size: 12px; color: var(--tg-theme-hint-color); margin-top: 10px; border-top: 0.5px solid var(--tg-theme-secondary-bg-color); padding-top: 5px; }
707
- .post-media { max-width: 100%; height: auto; border-radius: 8px; margin-top: 10px; }
708
- .post-media-placeholder { font-size: 14px; color: var(--tg-theme-link-color); margin-top: 10px; display: block; }
709
- .post-wall-owner-link { font-size: 13px; color: var(--tg-theme-link-color); margin-left: 5px; }
710
-
711
- /* Form/View Styles */
712
- .view-container { padding: 15px; }
713
- .form-group { margin-bottom: 15px; }
714
- .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 5px; font-weight: 500; }
715
  .form-group input, .form-group textarea, .form-group select {
716
  width: 100%;
717
- padding: 10px;
718
  border: 1px solid var(--tg-theme-secondary-bg-color);
719
  border-radius: 8px;
720
  font-size: 16px;
@@ -724,104 +366,111 @@ MAIN_APP_TEMPLATE = '''
724
  transition: border-color 0.2s ease;
725
  }
726
  .form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--tg-theme-link-color); outline: none; }
727
- .form-group textarea { min-height: 80px; resize: vertical; }
728
-
729
- .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
730
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; }
731
 
732
- /* Profile View Styles */
733
- .profile-header { text-align: center; padding: 20px 15px; background-color: var(--tg-theme-secondary-bg-color); border-radius: 10px; margin: 10px 15px; }
734
- .profile-avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; margin-bottom: 10px; background-color: var(--tg-theme-bg-color); }
735
- .profile-name { font-size: 20px; font-weight: 600; margin: 0; color: var(--tg-theme-text-color); }
736
- .profile-username { font-size: 15px; color: var(--tg-theme-hint-color); margin: 5px 0 0 0; }
737
- .profile-stats { display: flex; justify-content: space-around; margin-top: 15px; }
738
- .profile-stat span { display: block; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
739
- .profile-stat small { font-size: 12px; color: var(--tg-theme-hint-color); }
740
- .profile-post-section h2 { margin: 10px 15px; font-size: 18px; color: var(--tg-theme-section-header-text-color); font-weight: 500;}
741
- .profile-post-button {
742
- background-color: var(--tg-theme-button-color);
743
- color: var(--tg-theme-button-text-color);
744
- padding: 12px 15px;
745
- border: none;
746
- border-radius: 8px;
747
- font-size: 16px;
748
- font-weight: 500;
749
  width: 100%;
 
750
  margin-top: 15px;
 
 
 
 
751
  cursor: pointer;
752
- transition: opacity 0.2s;
 
 
 
753
  }
754
- .profile-post-button:active { opacity: 0.8; }
755
-
756
  </style>
757
  </head>
758
  <body>
759
  <div class="app-container">
760
- <div class="header" id="appHeader">Telegram Wall</div>
761
  <div class="content" id="mainContent">
762
- <div class="loading">Loading...</div>
763
  </div>
764
 
765
- <div class="nav-bar" id="bottomNav">
766
- <div class="nav-item active" data-view="feed">
767
- <i class="fas fa-home"></i>
768
- <span id="nav-feed-text">Feed</span>
769
- </div>
770
- <div class="nav-item" data-view="my_wall">
771
- <i class="fas fa-user-circle"></i>
772
- <span id="nav-mywall-text">My Wall</span>
773
- </div>
774
- <div class="nav-item" data-view="users">
775
- <i class="fas fa-users"></i>
776
- <span id="nav-users-text">Users</span>
777
- </div>
778
  </div>
779
  </div>
780
 
781
  <script>
782
  const tg = window.Telegram.WebApp;
783
  let currentUser = null;
784
- let currentView = 'feed';
785
- let currentWallOwnerId = null;
786
-
787
  const mainContent = document.getElementById('mainContent');
788
- const appHeader = document.getElementById('appHeader');
789
- const bottomNav = document.getElementById('bottomNav');
790
-
791
- const langStrings = {
792
- en: {
793
- appTitle: "Telegram Wall", feed: "Feed", myWall: "My Wall", users: "Users",
794
- welcome: "Welcome to the Wall!", loading: "Loading...", emptyFeed: "No posts yet. Be the first!",
795
- emptyWall: "This wall is empty. Post something!", userList: "User List",
796
- writePost: "Write a Post", writeOnWall: "Write on Wall", postText: "Text", postMedia: "Media Type", postMediaUrl: "Media URL",
797
- postPlaceholder: "What's on your mind?", postButton: "Post to Wall",
798
- postedBy: "Posted by", onWallOf: "on wall of", postedOn: "Posted on", mediaPhoto: "Photo", mediaVideo: "Video", mediaDoc: "Document", mediaNone: "None/Text Only",
799
- profilePosts: "Wall Posts", editProfile: "Edit Profile",
800
- authFailed: "Authentication with the server failed. Some features might be limited.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  },
802
- ru: {
803
- appTitle: "Стена в Telegram", feed: "Лента", myWall: "Моя Стена", users: "Пользователи",
804
- welcome: "Добро пожаловать на Стену!", loading: "Загрузка...", emptyFeed: "Постов пока нет. Будьте первыми!",
805
- emptyWall: "Эта стена пуста. Оставьте запись!", userList: "Список Пользователей",
806
- writePost: "Написать Пост", writeOnWall: "Написать на Стене", postText: "Текст", postMedia: "Тип Медиа", postMediaUrl: "URL Медиа",
807
- postPlaceholder: "О чем вы думаете?", postButton: "Опубликовать",
808
- postedBy: "Опубликовал", onWallOf: "на стене", postedOn: "Опубликовано", mediaPhoto: "Фотография", mediaVideo: "Видео", mediaDoc: "Документ", mediaNone: "Нет/Только Текст",
809
- profilePosts: "Записи на Стене", editProfile: "Редактировать Профиль",
810
- authFailed: "Не удалось авторизоваться на сервере. Функционал может быть ограничен.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
811
  }
812
  };
813
- let lang = 'en';
814
- let T = langStrings[lang];
815
-
816
- function setLanguage(langCode) {
817
- lang = langCode === 'ru' ? 'ru' : 'en';
818
- T = langStrings[lang];
819
- document.getElementById('appTitle').textContent = T.appTitle;
820
- document.getElementById('nav-feed-text').textContent = T.feed;
821
- document.getElementById('nav-mywall-text').textContent = T.myWall;
822
- document.getElementById('nav-users-text').textContent = T.users;
823
- appHeader.textContent = T.appTitle;
824
- }
825
 
826
  function applyThemeParams() {
827
  const rootStyle = document.documentElement.style;
@@ -837,6 +486,9 @@ MAIN_APP_TEMPLATE = '''
837
  rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
838
  rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
839
  rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
 
 
 
840
  }
841
 
842
  async function apiCall(endpoint, method = 'GET', body = null) {
@@ -855,374 +507,279 @@ MAIN_APP_TEMPLATE = '''
855
  return response.json();
856
  } catch (error) {
857
  console.error('API Call Error:', error);
858
- // tg.showAlert(error.message || 'An API error occurred.'); // Don't block flow on every minor error
859
  throw error;
860
  }
861
  }
862
-
863
- function getAvatarUrl(user) {
864
- return user.photo_url || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // Default placeholder
865
- }
866
-
867
- function formatUserLink(username) {
868
- return username ? `<a href="https://t.me/${username}" target="_blank">@${username}</a>` : 'Unknown';
869
- }
870
 
871
- function formatDate(isoString) {
872
- try {
873
- const date = new Date(isoString);
874
- return date.toLocaleDateString(lang, { day: 'numeric', month: 'short', year: 'numeric' }) + ' ' +
875
- date.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit' });
876
- } catch (e) {
877
- return isoString || 'N/A';
878
- }
879
  }
880
 
881
- // --- Renderer Functions ---
 
 
 
 
 
 
 
 
 
882
 
883
- function renderPostList(posts, containerId = 'mainContent') {
884
- const container = document.getElementById(containerId);
885
- container.style.opacity = 0;
886
-
887
  if (!posts || posts.length === 0) {
888
- const emptyMessage = currentView === 'feed' ? T.emptyFeed : T.emptyWall;
889
- container.innerHTML = `<div class="empty-state">${emptyMessage}</div>`;
890
  } else {
891
- container.innerHTML = posts.map(post => {
892
- const authorLink = formatUserLink(post.author_username);
893
- const wallOwnerLink = formatUserLink(post.wall_owner_username);
894
-
895
  let mediaHtml = '';
896
- if (post.media_type === 'photo' && post.media_url) {
897
- mediaHtml = `<img src="${post.media_url}" alt="${T.mediaPhoto}" class="post-media" />`;
898
- } else if (post.media_url) {
899
- // Simple link/placeholder for other media types
900
- const mediaTypeDisplay = T['media' + post.media_type.charAt(0).toUpperCase() + post.media_type.slice(1)] || post.media_type;
901
- mediaHtml = `<a href="${post.media_url}" target="_blank" rel="noopener noreferrer" class="post-media-placeholder"><i class="fas fa-paperclip"></i> ${mediaTypeDisplay} Link</a>`;
 
 
 
 
 
902
  }
903
 
904
  return `
905
- <div class="post-item">
906
- <div class="post-header">
907
- <img src="${getAvatarUrl(post)}" alt="Avatar" class="user-avatar">
908
- <div>
909
- <div class="post-author-name">${post.author_name}</div>
910
- <div class="post-username">${authorLink}</div>
911
- </div>
912
- </div>
913
  ${mediaHtml}
914
- ${post.text ? `<p class="post-text">${post.text.replace(/\\n/g, '<br>')}</p>` : ''}
915
- <div class="post-meta">
916
- ${T.postedOn}: ${formatDate(post.timestamp)}
917
- ${post.wall_owner_id !== post.author_id
918
- ? ` | ${T.onWallOf}: ${wallOwnerLink}`
919
- : ''}
920
- </div>
921
  </div>
922
  `;
923
  }).join('');
924
  }
925
- setTimeout(() => { container.style.opacity = 1; }, 50);
 
 
 
 
 
 
 
 
 
 
 
 
926
  }
927
 
928
- function renderUserList(users) {
929
  mainContent.style.opacity = 0;
930
- appHeader.textContent = T.userList;
 
 
 
931
 
932
  if (!users || users.length === 0) {
933
- mainContent.innerHTML = `<div class="empty-state">No registered users found.</div>`;
934
  } else {
935
- mainContent.innerHTML = `<div class="view-container">` + users.map(user => {
936
- const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || 'No Name';
937
- const username = user.username ? `@${user.username}` : 'ID: ' + user.id;
938
-
939
  return `
940
- <div class="list-item user-info" onclick="loadView('user_wall', '${user.id}')">
941
- <img src="${getAvatarUrl(user)}" alt="Avatar" class="user-avatar">
942
- <div>
943
- <h3>${fullName}</h3>
944
- <p>${username}</p>
945
- </div>
946
  </div>
947
  `;
948
- }).join('') + `</div>`;
949
  }
950
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
951
  }
952
 
953
- function renderMyWallView(user, posts) {
954
- mainContent.style.opacity = 0;
955
- appHeader.textContent = T.myWall;
956
- currentWallOwnerId = user.id;
957
-
958
- const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || 'No Name';
959
- const username = user.username ? `@${user.username}` : '';
960
-
961
- let profileHtml = `
962
- <div class="profile-header">
963
- <img src="${getAvatarUrl(user)}" alt="Avatar" class="profile-avatar">
964
- <h1 class="profile-name">${fullName}</h1>
965
- <p class="profile-username">${username}</p>
966
- <div class="profile-stats">
967
- <div class="profile-stat">
968
- <span>${posts.length}</span>
969
- <small>${T.profilePosts}</small>
970
- </div>
971
- </div>
972
- <button class="profile-post-button" onclick="showPostForm('${user.id}')">${T.writePost}</button>
973
- </div>
974
- <div class="profile-post-section">
975
- <h2>${T.profilePosts}</h2>
976
- <div id="wallPostsContainer"></div>
977
- </div>
978
- `;
979
-
980
- mainContent.innerHTML = profileHtml;
981
- renderPostList(posts, 'wallPostsContainer');
982
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
983
- }
984
-
985
- function renderUserWallView(wallOwner, posts) {
986
  mainContent.style.opacity = 0;
987
- const fullName = `${wallOwner.first_name || ''} ${wallOwner.last_name || ''}`.trim() || 'No Name';
988
- appHeader.textContent = fullName + (wallOwner.username ? ` (@${wallOwner.username})` : '');
989
- currentWallOwnerId = wallOwner.id;
990
-
991
- const username = wallOwner.username ? `@${wallOwner.username}` : '';
992
 
993
- let profileHtml = `
994
- <div class="profile-header">
995
- <img src="${getAvatarUrl(wallOwner)}" alt="Avatar" class="profile-avatar">
996
- <h1 class="profile-name">${fullName}</h1>
997
- <p class="profile-username">${username}</p>
998
- <button class="profile-post-button" onclick="showPostForm('${wallOwner.id}')">${T.writeOnWall}</button>
999
- </div>
1000
- <div class="profile-post-section">
1001
- <h2>${T.profilePosts}</h2>
1002
- <div id="wallPostsContainer"></div>
1003
- </div>
1004
- `;
1005
 
1006
- mainContent.innerHTML = profileHtml;
1007
- renderPostList(posts, 'wallPostsContainer');
1008
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
 
 
 
 
 
 
 
 
 
 
 
1009
  }
1010
-
1011
- // --- Navigation Logic ---
1012
-
1013
- function loadView(viewName, userId = null) {
1014
- if (currentView === viewName && viewName !== 'user_wall') return;
1015
- tg.HapticFeedback.impactOccurred('light');
1016
-
1017
  currentView = viewName;
1018
- currentWallOwnerId = null;
1019
 
1020
- document.querySelectorAll('.nav-item').forEach(btn => btn.classList.remove('active'));
1021
- const activeNav = document.querySelector(`.nav-item[data-view="${viewName}"]`);
1022
- if (activeNav) {
1023
- activeNav.classList.add('active');
1024
- } else {
1025
- appHeader.textContent = T.appTitle; // Reset header for sub-views
1026
- }
1027
-
1028
  mainContent.style.opacity = 0;
1029
- mainContent.innerHTML = `<div class="loading">${T.loading}</div>`;
 
1030
  tg.BackButton.hide();
1031
  tg.MainButton.hide();
1032
 
1033
- if (viewName === 'feed') {
1034
- appHeader.textContent = T.feed;
1035
- apiCall('/api/posts')
1036
- .then(posts => renderPostList(posts))
1037
- .catch(err => mainContent.innerHTML = `<div class="empty-state">Error loading feed.</div>`);
1038
- } else if (viewName === 'users') {
1039
- appHeader.textContent = T.userList;
1040
- apiCall('/api/users')
1041
- .then(users => renderUserList(users.filter(u => u.id !== currentUser.id)))
1042
- .catch(err => mainContent.innerHTML = `<div class="empty-state">Error loading user list.</div>`);
1043
- } else if (viewName === 'my_wall') {
1044
- if (!currentUser) {
1045
- mainContent.innerHTML = `<div class="empty-state">${T.authFailed}</div>`;
1046
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
1047
- return;
1048
  }
1049
- Promise.all([
1050
- apiCall(`/api/users`),
1051
- apiCall(`/api/posts/${currentUser.id}`)
1052
- ]).then(([allUsers, posts]) => {
1053
- renderMyWallView(currentUser, posts);
1054
- }).catch(err => mainContent.innerHTML = `<div class="empty-state">Error loading wall.</div>`);
1055
- } else if (viewName === 'user_wall' && userId) {
1056
- tg.BackButton.show();
1057
- tg.BackButton.onClick(() => loadView('users'));
1058
-
1059
- Promise.all([
1060
- apiCall('/api/users'),
1061
- apiCall(`/api/posts/${userId}`)
1062
- ]).then(([allUsers, posts]) => {
1063
- const wallOwner = allUsers.find(u => u.id === userId);
1064
- if (wallOwner) {
1065
- renderUserWallView(wallOwner, posts);
1066
- } else {
1067
- mainContent.innerHTML = `<div class="empty-state">User not found.</div>`;
1068
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
1069
- }
1070
- }).catch(err => mainContent.innerHTML = `<div class="empty-state">Error loading user wall.</div>`);
1071
  }
1072
  }
1073
-
1074
- function showPostForm(wallOwnerId) {
1075
- if (!currentUser) {
1076
- tg.showAlert(T.authFailed);
1077
- return;
1078
- }
1079
-
1080
  mainContent.style.opacity = 0;
1081
- currentWallOwnerId = wallOwnerId;
1082
-
1083
  tg.BackButton.show();
1084
  tg.BackButton.onClick(() => {
1085
  tg.HapticFeedback.impactOccurred('light');
1086
- if (wallOwnerId === currentUser.id) {
1087
- loadView('my_wall');
1088
- } else {
1089
- loadView('user_wall', wallOwnerId);
1090
- }
1091
  });
1092
-
1093
- appHeader.textContent = wallOwnerId === currentUser.id ? T.writePost : T.writeOnWall;
1094
-
1095
- const mediaOptions = [
1096
- { value: '', text: T.mediaNone },
1097
- { value: 'photo', text: T.mediaPhoto },
1098
- { value: 'video', text: T.mediaVideo },
1099
- { value: 'document', text: T.mediaDoc }
1100
- ];
1101
-
1102
- const formHtml = `
1103
- <div class="view-container">
1104
- <form id="postForm">
1105
- <div class="form-group">
1106
- <label for="postText">${T.postText}</label>
1107
- <textarea id="postText" placeholder="${T.postPlaceholder}"></textarea>
1108
- </div>
1109
- <div class="form-group">
1110
- <label for="mediaType">${T.postMedia}</label>
1111
- <select id="mediaType">
1112
- ${mediaOptions.map(opt => `<option value="${opt.value}">${opt.text}</option>`).join('')}
1113
- </select>
1114
- </div>
1115
- <div class="form-group" id="mediaUrlGroup" style="display:none;">
1116
- <label for="mediaUrl">${T.postMediaUrl} (HTTPS link)</label>
1117
- <input type="url" id="mediaUrl" placeholder="https://example.com/media.jpg">
1118
- </div>
1119
- <div id="formError" class="error-message"></div>
1120
- </form>
1121
  </div>
1122
  `;
 
1123
  mainContent.innerHTML = formHtml;
1124
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
1125
 
1126
- const mediaTypeSelect = document.getElementById('mediaType');
1127
- const mediaUrlGroup = document.getElementById('mediaUrlGroup');
1128
-
1129
- mediaTypeSelect.addEventListener('change', (e) => {
1130
- if (e.target.value) {
1131
- mediaUrlGroup.style.display = 'block';
1132
- } else {
1133
- mediaUrlGroup.style.display = 'none';
1134
- }
1135
  });
1136
 
1137
- tg.MainButton.setText(T.postButton);
1138
  tg.MainButton.show();
1139
- tg.MainButton.onClick(() => handleSubmitPost(wallOwnerId));
1140
  }
1141
 
1142
- function handleSubmitPost(wallOwnerId) {
1143
- const text = document.getElementById('postText').value.trim();
1144
- const mediaType = document.getElementById('mediaType').value;
1145
- const mediaUrl = document.getElementById('mediaUrl').value.trim();
1146
- const formError = document.getElementById('formError');
1147
- formError.textContent = '';
1148
 
1149
- if (!text && !mediaUrl) {
1150
- formError.textContent = T.postPlaceholder;
1151
  tg.HapticFeedback.notificationOccurred('error');
1152
  return;
1153
  }
 
 
 
 
 
 
 
1154
 
1155
- if (mediaType && !mediaUrl) {
1156
- formError.textContent = `${T.postMediaUrl} is required for media post.`;
1157
- tg.HapticFeedback.notificationOccurred('error');
1158
- return;
1159
  }
1160
-
1161
  tg.MainButton.showProgress();
1162
  tg.HapticFeedback.impactOccurred('light');
1163
 
1164
- const payload = {
1165
- text: text,
1166
- media_type: mediaType,
1167
- media_url: mediaUrl
1168
- };
1169
-
1170
- apiCall(`/api/post_to_wall/${wallOwnerId}`, 'POST', payload)
1171
  .then(response => {
1172
  tg.HapticFeedback.notificationOccurred('success');
1173
  tg.MainButton.hideProgress();
1174
- tg.MainButton.hide();
1175
-
1176
- if (wallOwnerId === currentUser.id) {
1177
- loadView('my_wall');
1178
- } else {
1179
- loadView('user_wall', wallOwnerId);
1180
- }
1181
  })
1182
  .catch(err => {
1183
  tg.HapticFeedback.notificationOccurred('error');
1184
  tg.MainButton.hideProgress();
1185
- formError.textContent = err.message || 'Failed to submit post.';
1186
  });
1187
  }
1188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
  async function init() {
1190
  tg.ready();
1191
-
1192
- // 1. Language detection
1193
- const langCode = tg.initDataUnsafe.user?.language_code;
1194
- setLanguage(langCode);
1195
-
1196
  applyThemeParams();
1197
  tg.expand();
1198
  tg.enableClosingConfirmation();
1199
 
1200
  tg.onEvent('themeChanged', applyThemeParams);
1201
 
1202
- // 2. Auth user
1203
  try {
1204
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
1205
  currentUser = authResponse.user;
1206
  } catch (error) {
1207
  console.error("Auth error:", error);
1208
- // tg.showAlert(T.authFailed);
1209
- // Proceed with limited functionality if auth fails
1210
  }
1211
 
1212
- // 3. Setup Nav Bar
1213
- document.querySelectorAll('.nav-item').forEach(button => {
1214
- button.addEventListener('click', () => {
1215
- const view = button.dataset.view;
1216
- if (view === 'my_wall' && !currentUser) {
1217
- tg.showAlert(T.authFailed);
1218
- return;
1219
- }
1220
- loadView(view);
1221
- });
1222
  });
1223
 
1224
- // 4. Load initial view
1225
- loadView('feed');
1226
  }
1227
 
1228
  init();
@@ -1231,11 +788,314 @@ MAIN_APP_TEMPLATE = '''
1231
  </html>
1232
  '''
1233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1234
 
1235
  @app.route('/')
1236
  def main_app_view():
1237
  return render_template_string(MAIN_APP_TEMPLATE)
1238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1239
  if __name__ == '__main__':
1240
  logging.info("Application starting up. Performing initial data load/download...")
1241
  download_db_from_hf()
 
18
  load_dotenv()
19
 
20
  app = Flask(__name__)
21
+ app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
22
  DATA_FILE = 'wall_data.json'
23
  SYNC_FILES = [DATA_FILE]
24
 
25
+ REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
26
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
27
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
29
+ TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:AAGYWatM-nUVQirgBiBwoAtWZgzfp3QnQjY")
 
30
 
31
  DOWNLOAD_RETRIES = 3
32
  DOWNLOAD_DELAY = 5
 
40
  files_to_download = [specific_file] if specific_file else SYNC_FILES
41
  logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
42
  all_successful = True
 
43
  for file_name in files_to_download:
44
  success = False
45
  for attempt in range(retries + 1):
46
  try:
47
+ logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
48
  local_path = hf_hub_download(
49
  repo_id=REPO_ID, filename=file_name, repo_type="dataset",
50
  token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
 
63
  try:
64
  if file_name == DATA_FILE:
65
  with open(file_name, 'w', encoding='utf-8') as f:
66
+ json.dump({'posts': [], 'users': {}}, f)
67
  logging.info(f"Created empty local file {file_name} because it was not found on HF.")
 
 
68
  except Exception as create_e:
69
  logging.error(f"Failed to create empty local file {file_name}: {create_e}")
70
  success = True
 
115
  logging.info("Periodic backup finished.")
116
 
117
  def load_data():
118
+ default_data = {'posts': [], 'users': {}}
119
  try:
120
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
121
  data = json.load(file)
 
158
  if not isinstance(data, dict):
159
  logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
160
  return
161
+ default_keys = {'posts': [], 'users': {}}
162
  for key in default_keys:
163
  if key not in data: data[key] = default_keys[key]
164
 
 
197
  return False, None
198
  return False, None
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  def get_authenticated_user_details(request_headers):
201
  auth_data_str = request_headers.get('X-Telegram-Auth')
202
  if not auth_data_str:
 
208
  return data.get('users', {}).get(user_id_str)
209
  return None
210
 
211
+ def notify_user(chat_id, message):
212
+ if not TELEGRAM_BOT_TOKEN:
213
+ logging.warning("TELEGRAM_BOT_TOKEN not set. Skipping notification.")
214
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
+ url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
217
+ payload = {
218
+ 'chat_id': chat_id,
219
+ 'text': message,
220
+ 'parse_mode': 'HTML'
 
 
 
 
 
 
 
 
 
221
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  try:
223
+ response = requests.post(url, json=payload, timeout=5)
224
+ response.raise_for_status()
225
+ logging.info(f"Notification sent to user {chat_id}")
226
+ return True
227
+ except requests.exceptions.HTTPError as e:
228
+ logging.error(f"Telegram Bot API HTTP Error for user {chat_id}: {e.response.text}")
229
+ return False
230
+ except requests.exceptions.RequestException as e:
231
+ logging.error(f"Error sending notification to user {chat_id}: {e}")
232
+ return False
233
 
 
234
 
235
  MAIN_APP_TEMPLATE = '''
236
  <!DOCTYPE html>
 
238
  <head>
239
  <meta charset="UTF-8">
240
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
241
+ <title>TonTalent Wall</title>
242
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
 
243
  <style>
244
  :root {
245
  --tg-theme-bg-color: #ffffff;
 
265
  -webkit-font-smoothing: antialiased;
266
  -moz-osx-font-smoothing: grayscale;
267
  line-height: 1.4;
 
 
 
268
  }
269
+ .app-container { display: flex; flex-direction: column; min-height: 100vh; min-height: -webkit-fill-available; }
270
  .header {
271
  background-color: var(--tg-theme-header-bg-color);
272
  padding: 12px 15px;
 
278
  top: 0;
279
  z-index: 100;
280
  }
281
+ .content { flex-grow: 1; padding: 0; overflow-x: hidden; transition: opacity 0.2s ease-out; }
282
+
283
+ .footer-nav {
 
 
 
 
 
 
284
  display: flex;
285
  justify-content: space-around;
286
+ background-color: var(--tg-theme-secondary-bg-color);
287
+ border-top: 0.5px solid var(--tg-theme-secondary-bg-color);
288
+ padding: 5px 0 calc(5px + env(safe-area-inset-bottom));
289
+ position: sticky;
290
+ bottom: 0;
291
+ z-index: 200;
292
  }
293
+ .nav-button {
294
  flex: 1;
295
  display: flex;
296
  flex-direction: column;
297
  align-items: center;
298
  justify-content: center;
299
+ padding: 5px 0;
300
  cursor: pointer;
301
+ background: none;
302
+ border: none;
303
  color: var(--tg-theme-hint-color);
304
+ font-size: 12px;
305
+ font-weight: 500;
 
306
  transition: color 0.2s ease;
307
  -webkit-tap-highlight-color: transparent;
308
  }
309
+ .nav-button.active { color: var(--tg-theme-link-color); }
310
+ .nav-button svg { width: 24px; height: 24px; margin-bottom: 2px; }
311
 
312
+ .list-container { padding: 10px 15px; }
313
+ .list-item {
314
  background-color: var(--tg-theme-section-bg-color);
315
+ padding: 12px 15px;
316
+ margin-bottom: 10px;
317
  border-radius: 10px;
318
  box-shadow: 0 2px 8px rgba(0,0,0,0.06);
319
  cursor: pointer;
320
+ transition: transform 0.1s ease-out, background-color 0.1s ease;
321
+ }
322
+ .list-item:active { transform: scale(0.99); background-color: var(--tg-theme-secondary-bg-color); }
323
+ .list-item h3 { margin: 0 0 4px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
324
+ .list-item p { margin: 0 0 4px 0; font-size: 14px; color: var(--tg-theme-text-color); }
325
+ .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); margin-top: 8px; }
326
+
327
+ .user-list-item {
328
+ display: flex;
329
+ align-items: center;
330
+ padding: 12px 15px;
331
+ background-color: var(--tg-theme-section-bg-color);
332
+ border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
333
+ cursor: pointer;
334
+ }
335
+ .user-list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
336
+ .user-list-item img {
337
+ width: 40px;
338
+ height: 40px;
339
+ border-radius: 50%;
340
+ margin-right: 12px;
341
+ object-fit: cover;
342
+ background-color: var(--tg-theme-secondary-bg-color);
343
+ }
344
+ .user-list-item span {
345
+ font-size: 15px;
346
+ font-weight: 500;
347
+ color: var(--tg-theme-text-color);
348
  }
349
+
350
+ .post-content { margin-top: 8px; white-space: pre-wrap; word-break: break-word; }
351
+ .post-media { max-width: 100%; height: auto; display: block; margin-top: 10px; border-radius: 6px; }
352
+
353
+ .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
354
+ .form-container { padding: 20px 15px; background-color: var(--tg-theme-section-bg-color); }
355
+ .form-group { margin-bottom: 18px; }
356
+ .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 6px; font-weight: 500; }
 
 
 
 
 
 
 
 
 
 
 
 
357
  .form-group input, .form-group textarea, .form-group select {
358
  width: 100%;
359
+ padding: 12px;
360
  border: 1px solid var(--tg-theme-secondary-bg-color);
361
  border-radius: 8px;
362
  font-size: 16px;
 
366
  transition: border-color 0.2s ease;
367
  }
368
  .form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--tg-theme-link-color); outline: none; }
369
+ .form-group textarea { min-height: 100px; resize: vertical; }
 
 
370
  .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; }
371
 
372
+ .delete-button {
373
+ display: block;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  width: 100%;
375
+ padding: 12px 15px;
376
  margin-top: 15px;
377
+ border: none;
378
+ border-radius: 8px;
379
+ font-size: 16px;
380
+ font-weight: 500;
381
  cursor: pointer;
382
+ text-align: center;
383
+ transition: background-color 0.2s ease;
384
+ background-color: var(--tg-theme-destructive-text-color);
385
+ color: #ffffff;
386
  }
 
 
387
  </style>
388
  </head>
389
  <body>
390
  <div class="app-container">
391
+ <div class="header" id="appHeader">TonTalent Wall</div>
392
  <div class="content" id="mainContent">
393
+ <div class="loading">Loading content...</div>
394
  </div>
395
 
396
+ <div class="footer-nav">
397
+ <button class="nav-button active" data-nav="my_wall">
398
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 3c1.38 0 2.5 1.12 2.5 2.5S13.38 10 12 10 9.5 8.88 9.5 7.5 10.62 5 12 5zm0 14.2c-2.45 0-4.66-1.23-5.78-3.08l.05-.08a7 7 0 0 1 11.46 0l.05.08c-1.12 1.85-3.33 3.08-5.78 3.08z"/></svg>
399
+ <span id="navMyWallText">My Wall</span>
400
+ </button>
401
+ <button class="nav-button" data-nav="users">
402
+ <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-6.08 1.46-6.08 3.5v3.5h12.16v-3.5c0-2.04-3.75-3.5-6.08-3.5zm8 0c-.29 0-.62.05-.97.12 1.25.96 1.97 2.22 1.97 3.38v3.5h6v-3.5c0-2.04-3.75-3.5-6.91-3.5z"/></svg>
403
+ <span id="navUsersText">Users</span>
404
+ </button>
 
 
 
 
405
  </div>
406
  </div>
407
 
408
  <script>
409
  const tg = window.Telegram.WebApp;
410
  let currentUser = null;
411
+ let currentView = 'my_wall';
412
+ let currentTargetUser = null;
 
413
  const mainContent = document.getElementById('mainContent');
414
+
415
+ const L = {
416
+ 'ru': {
417
+ 'title': 'Стена TonTalent',
418
+ 'myWall': 'Моя Стена',
419
+ 'users': 'Пользователи',
420
+ 'newPost': 'Новая Запись',
421
+ 'loading': 'Загрузка...',
422
+ 'welcome': 'Добро пожаловать, ',
423
+ 'noPosts': 'На э��ой стене пока нет записей.',
424
+ 'noUsers': 'Нет доступных пользователей.',
425
+ 'postButton': 'Опубликовать',
426
+ 'deleteConfirm': 'Вы уверены, что хотите удалить эту запись?',
427
+ 'postDeleted': 'Запись успешно удалена.',
428
+ 'failedSubmit': 'Не удалось опубликовать запись.',
429
+ 'failedDelete': 'Не удалось удалить запись.',
430
+ 'postText': 'Текст записи',
431
+ 'postType': 'Тип контента',
432
+ 'text': 'Текст',
433
+ 'photo': 'Фото',
434
+ 'video': 'Видео',
435
+ 'document': 'Документ',
436
+ 'contentUrl': 'Ссылка на контент (URL/ID)',
437
+ 'requiredField': 'Пожалуйста, заполните поле',
438
+ 'postTo': 'Оставить запись на стене ',
439
+ 'deletePost': 'Удалить Запись',
440
+ 'wallOf': 'Стена пользователя',
441
+ 'from': 'от',
442
  },
443
+ 'en': {
444
+ 'title': 'TonTalent Wall',
445
+ 'myWall': 'My Wall',
446
+ 'users': 'Users',
447
+ 'newPost': 'New Post',
448
+ 'loading': 'Loading...',
449
+ 'welcome': 'Welcome, ',
450
+ 'noPosts': 'No posts found on this wall yet.',
451
+ 'noUsers': 'No users available.',
452
+ 'postButton': 'Post',
453
+ 'deleteConfirm': 'Are you sure you want to delete this post?',
454
+ 'postDeleted': 'Post deleted successfully.',
455
+ 'failedSubmit': 'Failed to submit post.',
456
+ 'failedDelete': 'Failed to delete post.',
457
+ 'postText': 'Post Text',
458
+ 'postType': 'Content Type',
459
+ 'text': 'Text',
460
+ 'photo': 'Photo',
461
+ 'video': 'Video',
462
+ 'document': 'Document',
463
+ 'contentUrl': 'Content Link (URL/ID)',
464
+ 'requiredField': 'Please fill in the required field',
465
+ 'postTo': 'Leave a post on the wall of ',
466
+ 'deletePost': 'Delete Post',
467
+ 'wallOf': "'s Wall",
468
+ 'from': 'from',
469
  }
470
  };
471
+
472
+ let lang = (tg.initDataUnsafe.user?.language_code === 'ru') ? 'ru' : 'en';
473
+ const texts = L[lang];
 
 
 
 
 
 
 
 
 
474
 
475
  function applyThemeParams() {
476
  const rootStyle = document.documentElement.style;
 
486
  rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
487
  rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
488
  rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
489
+ document.getElementById('appHeader').textContent = texts.title;
490
+ document.getElementById('navMyWallText').textContent = texts.myWall;
491
+ document.getElementById('navUsersText').textContent = texts.users;
492
  }
493
 
494
  async function apiCall(endpoint, method = 'GET', body = null) {
 
507
  return response.json();
508
  } catch (error) {
509
  console.error('API Call Error:', error);
510
+ tg.showAlert(error.message || 'An API error occurred.');
511
  throw error;
512
  }
513
  }
 
 
 
 
 
 
 
 
514
 
515
+ function getAuthorDisplay(post) {
516
+ const name = post.author_first_name || 'Anonymous';
517
+ const username = post.author_username;
518
+ const userLink = username ? `<a href="tg://resolve?domain=${username}" target="_blank" rel="noopener noreferrer">@${username}</a>` : name;
519
+ return userLink;
 
 
 
520
  }
521
 
522
+ function renderWallPosts(posts, targetUser) {
523
+ mainContent.style.opacity = 0;
524
+ const targetName = targetUser.first_name || targetUser.username || `User ${targetUser.id}`;
525
+ const headerText = targetUser.id === currentUser.id ? texts.myWall : `${targetName}${texts.wallOf}`;
526
+ document.getElementById('appHeader').textContent = headerText;
527
+
528
+ tg.BackButton.hide();
529
+ tg.MainButton.hide();
530
+
531
+ let html = `<div class="list-container">`;
532
 
 
 
 
 
533
  if (!posts || posts.length === 0) {
534
+ html += `<div class="empty-state">${texts.noPosts}</div>`;
 
535
  } else {
536
+ html += posts.map(post => {
 
 
 
537
  let mediaHtml = '';
538
+ if (post.type !== 'text' && post.content) {
539
+ if (post.type === 'photo') {
540
+ mediaHtml = `<img src="${post.content}" alt="Photo" class="post-media" loading="lazy">`;
541
+ } else {
542
+ mediaHtml = `<p><strong>${texts[post.type]}:</strong> <a href="${post.content}" target="_blank">${post.content}</a></p>`;
543
+ }
544
+ }
545
+
546
+ let deleteButton = '';
547
+ if (String(post.user_id) === String(currentUser.id)) {
548
+ deleteButton = `<button class="delete-button" onclick="handleDeletePost('${post.id}')">${texts.deletePost}</button>`;
549
  }
550
 
551
  return `
552
+ <div class="list-item">
553
+ <h3>${texts.from} ${getAuthorDisplay(post)}</h3>
 
 
 
 
 
 
554
  ${mediaHtml}
555
+ <p class="post-content">${post.text}</p>
556
+ <p class="meta">Posted on ${new Date(post.timestamp).toLocaleDateString()}</p>
557
+ ${deleteButton}
 
 
 
 
558
  </div>
559
  `;
560
  }).join('');
561
  }
562
+ html += `</div>`;
563
+ mainContent.innerHTML = html;
564
+
565
+ tg.MainButton.setText(texts.newPost);
566
+ tg.MainButton.show();
567
+ tg.MainButton.onClick(() => showPostForm(targetUser));
568
+
569
+ if (targetUser.id !== currentUser.id) {
570
+ tg.BackButton.show();
571
+ tg.BackButton.onClick(() => loadView('users'));
572
+ }
573
+
574
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
575
  }
576
 
577
+ function renderUsersList(users) {
578
  mainContent.style.opacity = 0;
579
+ document.getElementById('appHeader').textContent = texts.users;
580
+
581
+ tg.BackButton.hide();
582
+ tg.MainButton.hide();
583
 
584
  if (!users || users.length === 0) {
585
+ mainContent.innerHTML = `<div class="empty-state">${texts.noUsers}</div>`;
586
  } else {
587
+ mainContent.innerHTML = users.map(user => {
588
+ if (String(user.id) === String(currentUser.id)) return ''; // Hide self from user list
589
+ const userName = user.first_name || user.username || `User ${user.id}`;
 
590
  return `
591
+ <div class="user-list-item" onclick="loadUserWall('${user.id}')">
592
+ <img src="${user.photo_url || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="Avatar">
593
+ <span>${userName} ${user.username ? `(@${user.username})` : ''}</span>
 
 
 
594
  </div>
595
  `;
596
+ }).join('');
597
  }
598
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
599
  }
600
 
601
+ function loadUserWall(userId) {
602
+ tg.HapticFeedback.impactOccurred('light');
603
+ currentView = 'user_wall';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  mainContent.style.opacity = 0;
605
+ mainContent.innerHTML = `<div class="loading">${texts.loading}</div>`;
 
 
 
 
606
 
607
+ const usersEndpoint = '/api/users';
608
+ const postsEndpoint = `/api/users/${userId}/wall`;
 
 
 
 
 
 
 
 
 
 
609
 
610
+ Promise.all([apiCall(usersEndpoint), apiCall(postsEndpoint)])
611
+ .then(([allUsers, wallPosts]) => {
612
+ currentTargetUser = allUsers.find(u => String(u.id) === String(userId));
613
+ if (currentTargetUser) {
614
+ renderWallPosts(wallPosts, currentTargetUser);
615
+ } else {
616
+ mainContent.innerHTML = `<div class="empty-state">User not found.</div>`;
617
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
618
+ }
619
+ })
620
+ .catch(err => {
621
+ mainContent.innerHTML = `<div class="empty-state">Error loading wall.</div>`;
622
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
623
+ });
624
  }
625
+
626
+ function loadView(viewName) {
627
+ if (currentView !== viewName) tg.HapticFeedback.impactOccurred('light');
 
 
 
 
628
  currentView = viewName;
 
629
 
630
+ document.querySelectorAll('.nav-button').forEach(btn => btn.classList.remove('active'));
631
+ document.querySelector(`.nav-button[data-nav="${viewName}"]`)?.classList.add('active');
632
+
 
 
 
 
 
633
  mainContent.style.opacity = 0;
634
+ mainContent.innerHTML = `<div class="loading">${texts.loading}</div>`;
635
+
636
  tg.BackButton.hide();
637
  tg.MainButton.hide();
638
 
639
+ if (viewName === 'my_wall') {
640
+ if (currentUser) {
641
+ loadUserWall(currentUser.id);
642
+ } else {
643
+ mainContent.innerHTML = `<div class="empty-state">Authentication failed. Cannot load wall.</div>`;
 
 
 
 
 
 
 
 
644
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
 
645
  }
646
+ } else if (viewName === 'users') {
647
+ apiCall('/api/users')
648
+ .then(renderUsersList)
649
+ .catch(err => {
650
+ mainContent.innerHTML = `<div class="empty-state">Error loading users.</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
652
+ });
 
653
  }
654
  }
655
+
656
+ function showPostForm(targetUser) {
 
 
 
 
 
657
  mainContent.style.opacity = 0;
 
 
658
  tg.BackButton.show();
659
  tg.BackButton.onClick(() => {
660
  tg.HapticFeedback.impactOccurred('light');
661
+ loadUserWall(targetUser.id);
 
 
 
 
662
  });
663
+ tg.MainButton.hide();
664
+
665
+ const targetName = targetUser.first_name || targetUser.username || `User ${targetUser.id}`;
666
+ let formHtml = `<div class="form-container"><h2>${texts.postTo} ${targetName}</h2>`;
667
+
668
+ formHtml += `
669
+ <div class="form-group">
670
+ <label for="postText">${texts.postText} *</label>
671
+ <textarea id="postText" required></textarea>
672
+ </div>
673
+ <div class="form-group">
674
+ <label for="postType">${texts.postType}</label>
675
+ <select id="postType">
676
+ <option value="text">${texts.text}</option>
677
+ <option value="photo">${texts.photo}</option>
678
+ <option value="video">${texts.video}</option>
679
+ <option value="document">${texts.document}</option>
680
+ </select>
681
+ </div>
682
+ <div class="form-group" id="contentGroup" style="display:none;">
683
+ <label for="contentUrl">${texts.contentUrl}</label>
684
+ <input type="text" id="contentUrl">
 
 
 
 
 
 
 
685
  </div>
686
  `;
687
+ formHtml += `<div id="formError" class="error-message"></div></div>`;
688
  mainContent.innerHTML = formHtml;
689
  setTimeout(() => { mainContent.style.opacity = 1; }, 50);
690
 
691
+ document.getElementById('postType').addEventListener('change', (e) => {
692
+ document.getElementById('contentGroup').style.display = e.target.value === 'text' ? 'none' : 'block';
 
 
 
 
 
 
 
693
  });
694
 
695
+ tg.MainButton.setText(texts.postButton);
696
  tg.MainButton.show();
697
+ tg.MainButton.onClick(() => handleSubmitPost(targetUser.id));
698
  }
699
 
700
+ function handleSubmitPost(targetUserId) {
701
+ const postText = document.getElementById('postText').value.trim();
702
+ const postType = document.getElementById('postType').value;
703
+ const contentUrl = document.getElementById('contentUrl').value.trim();
704
+
705
+ document.getElementById('formError').textContent = '';
706
 
707
+ if (!postText) {
708
+ document.getElementById('formError').textContent = texts.requiredField;
709
  tg.HapticFeedback.notificationOccurred('error');
710
  return;
711
  }
712
+
713
+ const payload = {
714
+ target_user_id: targetUserId,
715
+ text: postText,
716
+ type: postType,
717
+ content: postType === 'text' ? '' : contentUrl
718
+ };
719
 
720
+ if (postType !== 'text' && !contentUrl) {
721
+ document.getElementById('formError').textContent = texts.requiredField + ' (' + texts.contentUrl + ')';
722
+ tg.HapticFeedback.notificationOccurred('error');
723
+ return;
724
  }
725
+
726
  tg.MainButton.showProgress();
727
  tg.HapticFeedback.impactOccurred('light');
728
 
729
+ apiCall('/api/posts', 'POST', payload)
 
 
 
 
 
 
730
  .then(response => {
731
  tg.HapticFeedback.notificationOccurred('success');
732
  tg.MainButton.hideProgress();
733
+ loadUserWall(targetUserId);
 
 
 
 
 
 
734
  })
735
  .catch(err => {
736
  tg.HapticFeedback.notificationOccurred('error');
737
  tg.MainButton.hideProgress();
738
+ document.getElementById('formError').textContent = texts.failedSubmit;
739
  });
740
  }
741
 
742
+ function handleDeletePost(postId) {
743
+ tg.showConfirm(texts.deleteConfirm, (confirmed) => {
744
+ if (confirmed) {
745
+ tg.HapticFeedback.impactOccurred('medium');
746
+ apiCall(`/api/posts/${postId}`, 'DELETE')
747
+ .then(() => {
748
+ tg.HapticFeedback.notificationOccurred('success');
749
+ tg.showAlert(texts.postDeleted);
750
+ loadUserWall(currentTargetUser ? currentTargetUser.id : currentUser.id);
751
+ })
752
+ .catch(err => {
753
+ tg.HapticFeedback.notificationOccurred('error');
754
+ tg.showAlert(texts.failedDelete);
755
+ });
756
+ } else {
757
+ tg.HapticFeedback.impactOccurred('light');
758
+ }
759
+ });
760
+ }
761
+
762
  async function init() {
763
  tg.ready();
 
 
 
 
 
764
  applyThemeParams();
765
  tg.expand();
766
  tg.enableClosingConfirmation();
767
 
768
  tg.onEvent('themeChanged', applyThemeParams);
769
 
 
770
  try {
771
  const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
772
  currentUser = authResponse.user;
773
  } catch (error) {
774
  console.error("Auth error:", error);
775
+ // Handle auth failure (e.g., show error message)
 
776
  }
777
 
778
+ document.querySelectorAll('.nav-button').forEach(button => {
779
+ button.addEventListener('click', () => loadView(button.dataset.nav));
 
 
 
 
 
 
 
 
780
  });
781
 
782
+ loadView('my_wall');
 
783
  }
784
 
785
  init();
 
788
  </html>
789
  '''
790
 
791
+ ADMIN_TEMPLATE = '''
792
+ <!DOCTYPE html>
793
+ <html lang="en">
794
+ <head>
795
+ <meta charset="UTF-8">
796
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
797
+ <title>TonTalent Admin</title>
798
+ <style>
799
+ body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
800
+ .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
801
+ h1, h2 { color: #333; }
802
+ .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
803
+ .item { border-bottom: 1px solid #eee; padding: 10px 0; }
804
+ .item:last-child { border-bottom: none; }
805
+ .item h3 { margin: 0 0 5px 0; }
806
+ .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
807
+ .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
808
+ .button-primary { background-color: #007bff; color: white; }
809
+ .button-danger { background-color: #dc3545; color: white; }
810
+ .button-secondary { background-color: #6c757d; color: white; }
811
+ .message { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
812
+ .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
813
+ .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
814
+ .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
815
+ .sync-buttons form { display: inline-block; margin-right: 10px; }
816
+ </style>
817
+ </head>
818
+ <body>
819
+ <div class="container">
820
+ <h1>TonTalent Wall Admin Panel</h1>
821
+
822
+ {% with messages = get_flashed_messages(with_categories=true) %}
823
+ {% if messages %}
824
+ {% for category, message in messages %}
825
+ <div class="message {{ category }}">{{ message }}</div>
826
+ {% endfor %}
827
+ {% endif %}
828
+ {% endwith %}
829
+
830
+ <div class="section">
831
+ <h2>Data Synchronization with Hugging Face</h2>
832
+ <div class="sync-buttons">
833
+ <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
834
+ <button type="submit" class="button button-primary">Upload DB to HF</button>
835
+ </form>
836
+ <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
837
+ <button type="submit" class="button button-secondary">Download DB from HF</button>
838
+ </form>
839
+ </div>
840
+ <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
841
+ </div>
842
+
843
+ <div class="section">
844
+ <h2>Users ({{ users|length }})</h2>
845
+ {% for user_id, user in users.items() %}
846
+ <div class="item">
847
+ <h3>{{ user.first_name }} {% if user.last_name %}{{ user.last_name }}{% endif %} (@{{ user.username }})</h3>
848
+ <p>ID: {{ user.id }} | Last Seen: {{ user.last_seen.split('T')[0] }}</p>
849
+ <p>Chat ID (for notifications): {{ user.chat_id }}</p>
850
+ <form method="POST" action="{{ url_for('admin_delete_user') }}" style="display:inline;" onsubmit="return confirm('Delete user and all their posts/comments?');">
851
+ <input type="hidden" name="user_id" value="{{ user.id }}">
852
+ <button type="submit" class="button button-danger">Delete User</button>
853
+ </form>
854
+ </div>
855
+ {% else %}
856
+ <p>No users found.</p>
857
+ {% endfor %}
858
+ </div>
859
+
860
+ <div class="section">
861
+ <h2>Posts ({{ posts|length }})</h2>
862
+ {% for post in posts %}
863
+ <div class="item">
864
+ <h3>Post {{ post.id[:8] }}... to User {{ post.target_user_id }}</h3>
865
+ <p>Author ID: {{ post.user_id }} | Type: {{ post.type }} | Posted: {{ post.timestamp.split('T')[0] }}</p>
866
+ <p>Text: {{ post.text[:100] }}...</p>
867
+ {% if post.type != 'text' %}
868
+ <p>Content: {{ post.content[:100] }}...</p>
869
+ {% endif %}
870
+ <form method="POST" action="{{ url_for('admin_delete_post') }}" style="display:inline;" onsubmit="return confirm('Delete this post?');">
871
+ <input type="hidden" name="post_id" value="{{ post.id }}">
872
+ <button type="submit" class="button button-danger">Delete Post</button>
873
+ </form>
874
+ </div>
875
+ {% else %}
876
+ <p>No posts found.</p>
877
+ {% endfor %}
878
+ </div>
879
+ </div>
880
+ </body>
881
+ </html>
882
+ '''
883
 
884
  @app.route('/')
885
  def main_app_view():
886
  return render_template_string(MAIN_APP_TEMPLATE)
887
 
888
+ @app.route('/api/auth_user', methods=['POST'])
889
+ def auth_user():
890
+ auth_data_str = request.headers.get('X-Telegram-Auth')
891
+ if not auth_data_str:
892
+ init_data_payload = request.json.get('init_data')
893
+ if init_data_payload:
894
+ auth_data_str = init_data_payload
895
+ else:
896
+ return jsonify({"error": "Authentication data not provided"}), 401
897
+
898
+ is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
899
+
900
+ if not is_valid or not user_data_from_auth:
901
+ return jsonify({"error": "Invalid authentication data"}), 403
902
+
903
+ data = load_data()
904
+ users = data.get('users', {})
905
+ user_id_str = str(user_data_from_auth.get('id'))
906
+
907
+ if user_id_str not in users:
908
+ users[user_id_str] = {
909
+ 'id': user_data_from_auth.get('id'),
910
+ 'first_name': user_data_from_auth.get('first_name'),
911
+ 'last_name': user_data_from_auth.get('last_name'),
912
+ 'username': user_data_from_auth.get('username'),
913
+ 'language_code': user_data_from_auth.get('language_code'),
914
+ 'photo_url': user_data_from_auth.get('photo_url'),
915
+ 'chat_id': user_id_str,
916
+ 'first_seen': datetime.now().isoformat()
917
+ }
918
+
919
+ users[user_id_str]['last_seen'] = datetime.now().isoformat()
920
+ if user_data_from_auth.get('photo_url'):
921
+ users[user_id_str]['photo_url'] = user_data_from_auth.get('photo_url')
922
+ if user_data_from_auth.get('username'):
923
+ users[user_id_str]['username'] = user_data_from_auth.get('username')
924
+
925
+ data['users'] = users
926
+ save_data(data)
927
+
928
+ return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
929
+
930
+ @app.route('/api/users', methods=['GET'])
931
+ def get_all_users():
932
+ data = load_data()
933
+ users = list(data.get('users', {}).values())
934
+ users.sort(key=lambda u: u.get('first_name', '').lower())
935
+ for user in users:
936
+ user.pop('chat_id', None)
937
+ return jsonify(users), 200
938
+
939
+ @app.route('/api/users/<user_id>/wall', methods=['GET'])
940
+ def get_user_wall(user_id):
941
+ data = load_data()
942
+ all_posts = data.get('posts', [])
943
+ wall_posts = [p for p in all_posts if str(p.get('target_user_id')) == str(user_id)]
944
+
945
+ wall_posts.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
946
+
947
+ users = data.get('users', {})
948
+ for post in wall_posts:
949
+ author = users.get(str(post.get('user_id')))
950
+ if author:
951
+ post['author_username'] = author.get('username', 'anonymous')
952
+ post['author_first_name'] = author.get('first_name', 'Anonymous')
953
+ post['author_photo_url'] = author.get('photo_url')
954
+
955
+ return jsonify(wall_posts), 200
956
+
957
+ @app.route('/api/posts', methods=['POST'])
958
+ def create_post():
959
+ author = get_authenticated_user_details(request.headers)
960
+ if not author:
961
+ return jsonify({"error": "Authentication required or user not found in DB"}), 401
962
+
963
+ req_data = request.json
964
+ target_user_id = str(req_data.get('target_user_id'))
965
+ post_text = req_data.get('text', '').strip()
966
+ post_type = req_data.get('type', 'text')
967
+ post_content = req_data.get('content', '').strip()
968
+
969
+ if not target_user_id or (not post_text and not post_content):
970
+ return jsonify({"error": "Missing target user ID or post content"}), 400
971
+
972
+ if post_type not in ['text', 'photo', 'video', 'document']:
973
+ return jsonify({"error": "Invalid post type"}), 400
974
+
975
+ data = load_data()
976
+ target_user = data.get('users', {}).get(target_user_id)
977
+ if not target_user:
978
+ return jsonify({"error": "Target user not found"}), 404
979
+
980
+ if post_type != 'text' and not post_content:
981
+ return jsonify({"error": f"Content is required for post type {post_type}"}), 400
982
+
983
+ new_post = {
984
+ "id": str(uuid.uuid4()),
985
+ "user_id": str(author.get('id')),
986
+ "target_user_id": target_user_id,
987
+ "text": post_text,
988
+ "type": post_type,
989
+ "content": post_content,
990
+ "timestamp": datetime.now().isoformat(),
991
+ }
992
+
993
+ data['posts'].append(new_post)
994
+ save_data(data)
995
+
996
+ if str(author.get('id')) != target_user_id:
997
+ author_name = author.get('first_name', 'Someone')
998
+ author_username = author.get('username')
999
+ notification_message = (
1000
+ f"<b>New post on your wall!</b>\n\n"
1001
+ f"{author_name} ({f'@{author_username}' if author_username else 'ID: ' + str(author['id'])}) "
1002
+ f"left a message: <i>{post_text[:50]}...</i>"
1003
+ )
1004
+ notify_user(target_user.get('chat_id'), notification_message)
1005
+
1006
+ return jsonify(new_post), 201
1007
+
1008
+ @app.route('/api/posts/<post_id>', methods=['DELETE'])
1009
+ def delete_post(post_id):
1010
+ user = get_authenticated_user_details(request.headers)
1011
+ if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401
1012
+
1013
+ data = load_data()
1014
+ posts_list = data.get('posts', [])
1015
+ original_length = len(posts_list)
1016
+
1017
+ post_to_delete = next((p for p in posts_list if p['id'] == post_id), None)
1018
+ if not post_to_delete: return jsonify({"error": "Post not found"}), 404
1019
+
1020
+ if str(post_to_delete.get('user_id')) != str(user.get('id')):
1021
+ return jsonify({"error": "Forbidden: You can only delete your own posts"}), 403
1022
+
1023
+ data['posts'] = [p for p in posts_list if p['id'] != post_id]
1024
+
1025
+ if len(data['posts']) < original_length:
1026
+ save_data(data)
1027
+ return jsonify({"message": "Post deleted successfully"}), 200
1028
+ return jsonify({"error": "Post not found or deletion failed"}), 404
1029
+
1030
+ @app.route('/admin', methods=['GET'])
1031
+ def admin_panel():
1032
+ data = load_data()
1033
+ sorted_users = dict(sorted(data.get('users', {}).items(), key=lambda item: item[1].get('last_seen', ''), reverse=True))
1034
+ sorted_posts = sorted(data.get('posts', []), key=lambda x: x.get('timestamp', ''), reverse=True)
1035
+
1036
+ return render_template_string(ADMIN_TEMPLATE,
1037
+ users=sorted_users,
1038
+ posts=sorted_posts)
1039
+
1040
+ @app.route('/admin/delete_user', methods=['POST'])
1041
+ def admin_delete_user():
1042
+ user_id = request.form.get('user_id')
1043
+
1044
+ data = load_data()
1045
+ if user_id in data.get('users', {}):
1046
+ del data['users'][user_id]
1047
+
1048
+ data['posts'] = [p for p in data.get('posts', []) if str(p.get('user_id')) != user_id and str(p.get('target_user_id')) != user_id]
1049
+
1050
+ save_data(data)
1051
+ flash(f"User {user_id} and all related posts deleted successfully.", 'success')
1052
+ else:
1053
+ flash("User not found.", 'warning')
1054
+ return redirect(url_for('admin_panel'))
1055
+
1056
+ @app.route('/admin/delete_post', methods=['POST'])
1057
+ def admin_delete_post():
1058
+ post_id = request.form.get('post_id')
1059
+
1060
+ data = load_data()
1061
+ posts_list = data.get('posts', [])
1062
+ original_length = len(posts_list)
1063
+
1064
+ data['posts'] = [p for p in posts_list if p['id'] != post_id]
1065
+
1066
+ if len(data['posts']) < original_length:
1067
+ save_data(data)
1068
+ flash('Post deleted successfully.', 'success')
1069
+ else:
1070
+ flash('Post not found or already deleted.', 'warning')
1071
+ return redirect(url_for('admin_panel'))
1072
+
1073
+ @app.route('/admin/force_upload', methods=['POST'])
1074
+ def force_upload_admin():
1075
+ logging.info("Admin forcing upload to Hugging Face...")
1076
+ try:
1077
+ upload_db_to_hf()
1078
+ flash("Data successfully uploaded to Hugging Face.", 'success')
1079
+ except Exception as e:
1080
+ logging.error(f"Error during forced upload: {e}", exc_info=True)
1081
+ flash(f"Error uploading to Hugging Face: {e}", 'error')
1082
+ return redirect(url_for('admin_panel'))
1083
+
1084
+ @app.route('/admin/force_download', methods=['POST'])
1085
+ def force_download_admin():
1086
+ logging.info("Admin forcing download from Hugging Face...")
1087
+ try:
1088
+ if download_db_from_hf():
1089
+ flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
1090
+ load_data()
1091
+ else:
1092
+ flash("Failed to download data from Hugging Face. Check logs.", 'error')
1093
+ except Exception as e:
1094
+ logging.error(f"Error during forced download: {e}", exc_info=True)
1095
+ flash(f"Error downloading from Hugging Face: {e}", 'error')
1096
+ return redirect(url_for('admin_panel'))
1097
+
1098
+
1099
  if __name__ == '__main__':
1100
  logging.info("Application starting up. Performing initial data load/download...")
1101
  download_db_from_hf()