| from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash |
| import json |
| import os |
| import logging |
| import threading |
| import time |
| from datetime import datetime, timedelta |
| from huggingface_hub import HfApi, hf_hub_download |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError |
| from werkzeug.utils import secure_filename |
| from dotenv import load_dotenv |
| import uuid |
| import hmac |
| import hashlib |
| import urllib.parse |
| import urllib.request |
|
|
| load_dotenv() |
|
|
| app = Flask(__name__) |
| app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only') |
| DATA_FILE = 'tontalent_data.json' |
| SYNC_FILES = [DATA_FILE] |
|
|
| REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2") |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
| TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:AAGhdbf6x1JEzpH0mUtuxTF83Soi7MFVNZ8") |
| TON_RECIPIENT_WALLET = os.getenv("TON_RECIPIENT_WALLET", "UQDCf710_mgpep8YCkJgBrdyhs7Tomr_ywq6VrzPCHez4Tvl") |
| PIN_PRICE_TON = os.getenv("PIN_PRICE_TON", "1.0") |
| PIN_DURATION_DAYS = 7 |
|
|
| DOWNLOAD_RETRIES = 3 |
| DOWNLOAD_DELAY = 5 |
|
|
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
| def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): |
| if not HF_TOKEN_READ and not HF_TOKEN_WRITE: |
| logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.") |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE |
| files_to_download = [specific_file] if specific_file else SYNC_FILES |
| logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...") |
| all_successful = True |
| for file_name in files_to_download: |
| success = False |
| for attempt in range(retries + 1): |
| try: |
| logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...") |
| local_path = hf_hub_download( |
| repo_id=REPO_ID, filename=file_name, repo_type="dataset", |
| token=token_to_use, local_dir=".", local_dir_use_symlinks=False, |
| force_download=True, resume_download=False |
| ) |
| logging.info(f"Successfully downloaded {file_name} to {local_path}.") |
| success = True |
| break |
| except RepositoryNotFoundError: |
| logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.") |
| return False |
| except HfHubHTTPError as e: |
| if e.response.status_code == 404: |
| logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.") |
| if attempt == 0 and not os.path.exists(file_name): |
| try: |
| if file_name == DATA_FILE: |
| with open(file_name, 'w', encoding='utf-8') as f: |
| json.dump({'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}}, f) |
| logging.info(f"Created empty local file {file_name} because it was not found on HF.") |
| except Exception as create_e: |
| logging.error(f"Failed to create empty local file {file_name}: {create_e}") |
| success = True |
| break |
| else: |
| logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...") |
| except Exception as e: |
| logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True) |
| if attempt < retries: time.sleep(delay) |
| if not success: |
| logging.error(f"Failed to download {file_name} after {retries + 1} attempts.") |
| all_successful = False |
| logging.info(f"Download process finished. Overall success: {all_successful}") |
| return all_successful |
|
|
| def upload_db_to_hf(specific_file=None): |
| if not HF_TOKEN_WRITE: |
| logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.") |
| return |
| try: |
| api = HfApi() |
| files_to_upload = [specific_file] if specific_file else SYNC_FILES |
| logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...") |
| for file_name in files_to_upload: |
| if os.path.exists(file_name): |
| try: |
| api.upload_file( |
| path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, |
| repo_type="dataset", token=HF_TOKEN_WRITE, |
| commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" |
| ) |
| logging.info(f"File {file_name} successfully uploaded to Hugging Face.") |
| except Exception as e: |
| logging.error(f"Error uploading file {file_name} to Hugging Face: {e}") |
| else: |
| logging.warning(f"File {file_name} not found locally, skipping upload.") |
| logging.info("Finished uploading files to HF.") |
| except Exception as e: |
| logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True) |
|
|
| def periodic_backup(): |
| backup_interval = 1800 |
| logging.info(f"Setting up periodic backup every {backup_interval} seconds.") |
| while True: |
| time.sleep(backup_interval) |
| logging.info("Starting periodic backup...") |
| upload_db_to_hf() |
| logging.info("Periodic backup finished.") |
|
|
| def load_data(): |
| default_data = {'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}} |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: |
| data = json.load(file) |
| logging.info(f"Local data loaded successfully from {DATA_FILE}") |
| if not isinstance(data, dict): |
| logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.") |
| raise FileNotFoundError |
| for key in default_data: |
| if key not in data: data[key] = default_data[key] |
| return data |
| except (FileNotFoundError, json.JSONDecodeError) as e: |
| logging.warning(f"Error loading local data ({e}). Attempting download from HF.") |
|
|
| if download_db_from_hf(specific_file=DATA_FILE): |
| try: |
| with open(DATA_FILE, 'r', encoding='utf-8') as file: |
| data = json.load(file) |
| logging.info(f"Data loaded successfully from {DATA_FILE} after download.") |
| if not isinstance(data, dict): |
| logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.") |
| return default_data |
| for key in default_data: |
| if key not in data: data[key] = default_data[key] |
| return data |
| except Exception as load_e: |
| logging.error(f"Error loading downloaded {DATA_FILE}: {load_e}. Using default.", exc_info=True) |
| return default_data |
| else: |
| logging.error(f"Failed to download {DATA_FILE} from HF. Using empty default data structure.") |
| if not os.path.exists(DATA_FILE): |
| try: |
| with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f) |
| logging.info(f"Created empty local file {DATA_FILE} after failed download.") |
| except Exception as create_e: |
| logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}") |
| return default_data |
|
|
| def save_data(data): |
| try: |
| if not isinstance(data, dict): |
| logging.error("Attempted to save invalid data structure (not a dict). Aborting save.") |
| return |
| default_keys = {'resumes': [], 'vacancies': [], 'freelance_offers': [], 'users': {}} |
| for key in default_keys: |
| if key not in data: data[key] = default_keys[key] |
|
|
| with open(DATA_FILE, 'w', encoding='utf-8') as file: |
| json.dump(data, file, ensure_ascii=False, indent=4) |
| logging.info(f"Data successfully saved to {DATA_FILE}") |
| upload_db_to_hf(specific_file=DATA_FILE) |
| except Exception as e: |
| logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True) |
|
|
| def verify_telegram_auth_data(auth_data_str, bot_token): |
| if not auth_data_str: |
| return False, None |
| |
| params = dict(urllib.parse.parse_qsl(auth_data_str)) |
| if 'hash' not in params: |
| return False, None |
|
|
| received_hash = params.pop('hash') |
| |
| sorted_params = sorted(params.items()) |
| data_check_string_parts = [] |
| for key, value in sorted_params: |
| data_check_string_parts.append(f"{key}={value}") |
| |
| data_check_string = "\n".join(data_check_string_parts) |
| |
| secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest() |
| calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest() |
| |
| if calculated_hash == received_hash: |
| try: |
| user_data = json.loads(params.get('user', '{}')) |
| return True, user_data |
| except json.JSONDecodeError: |
| return False, None |
| return False, None |
|
|
|
|
| MAIN_APP_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> |
| <title>TonTalent</title> |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> |
| <script src="https://telegram.org/js/telegram-analytics.js"></script> |
| <script src="https://unpkg.com/@tonconnect/ui@latest/dist/tonconnect-ui.min.js"></script> |
| <style> |
| :root { |
| --tg-theme-bg-color: #ffffff; |
| --tg-theme-text-color: #000000; |
| --tg-theme-hint-color: #999999; |
| --tg-theme-link-color: #007aff; |
| --tg-theme-button-color: #007aff; |
| --tg-theme-button-text-color: #ffffff; |
| --tg-theme-secondary-bg-color: #f0f0f0; |
| --tg-theme-header-bg-color: #efeff4; |
| --tg-theme-section-bg-color: #ffffff; |
| --tg-theme-section-header-text-color: #8e8e93; |
| --tg-theme-destructive-text-color: #ff3b30; |
| --tg-theme-accent-text-color: #007aff; |
| } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif; |
| margin: 0; |
| padding: 0; |
| background-color: var(--tg-theme-bg-color); |
| color: var(--tg-theme-text-color); |
| overscroll-behavior-y: none; |
| -webkit-font-smoothing: antialiased; |
| -moz-osx-font-smoothing: grayscale; |
| line-height: 1.4; |
| } |
| .app-container { display: flex; flex-direction: column; min-height: 100vh; min-height: -webkit-fill-available; } |
| .header { |
| background-color: var(--tg-theme-header-bg-color); |
| padding: 12px 15px; |
| text-align: center; |
| font-weight: 600; |
| font-size: 17px; |
| border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color); |
| position: sticky; |
| top: 0; |
| z-index: 100; |
| } |
| .user-info-bar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 10px 15px; |
| background-color: var(--tg-theme-section-bg-color); |
| border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color); |
| } |
| .user-details { |
| display: flex; |
| align-items: center; |
| cursor: pointer; |
| transition: background-color 0.2s ease; |
| flex-grow: 1; |
| min-width: 0; |
| } |
| .user-info-bar img { |
| width: 40px; |
| height: 40px; |
| border-radius: 50%; |
| margin-right: 12px; |
| object-fit: cover; |
| background-color: var(--tg-theme-secondary-bg-color); |
| } |
| .user-info-bar span { |
| font-size: 15px; |
| font-weight: 500; |
| color: var(--tg-theme-text-color); |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| .ton-connect-button-container { |
| margin-left: 10px; |
| } |
| .tabs { display: flex; background-color: var(--tg-theme-secondary-bg-color); padding: 5px; } |
| .tab-button { |
| flex: 1; |
| padding: 12px 10px; |
| text-align: center; |
| cursor: pointer; |
| background: none; |
| border: none; |
| color: var(--tg-theme-hint-color); |
| font-size: 15px; |
| font-weight: 500; |
| border-bottom: 2.5px solid transparent; |
| transition: color 0.2s ease, border-bottom-color 0.2s ease; |
| -webkit-tap-highlight-color: transparent; |
| } |
| .tab-button.active { color: var(--tg-theme-link-color); border-bottom-color: var(--tg-theme-link-color); } |
| .content { flex-grow: 1; padding: 0; overflow-x: hidden; transition: opacity 0.2s ease-out; } |
| .list-item { |
| background-color: var(--tg-theme-section-bg-color); |
| padding: 12px 15px; |
| margin: 10px 15px; |
| border-radius: 10px; |
| box-shadow: 0 2px 8px rgba(0,0,0,0.06); |
| cursor: pointer; |
| transition: transform 0.1s ease-out, background-color 0.1s ease; |
| position: relative; |
| } |
| .list-item.pinned { |
| background-color: color-mix(in srgb, var(--tg-theme-accent-text-color) 8%, var(--tg-theme-section-bg-color)); |
| border: 1px solid color-mix(in srgb, var(--tg-theme-accent-text-color) 20%, transparent); |
| } |
| .pin-icon { |
| position: absolute; |
| top: 10px; |
| right: 10px; |
| font-size: 16px; |
| color: var(--tg-theme-accent-text-color); |
| } |
| .list-item:active { transform: scale(0.98); background-color: var(--tg-theme-secondary-bg-color); } |
| .list-item h3 { margin: 0 0 6px 0; font-size: 17px; font-weight: 600; color: var(--tg-theme-text-color); } |
| .list-item p { margin: 0 0 4px 0; font-size: 14px; color: var(--tg-theme-hint-color); } |
| .list-item .meta { font-size: 13px; color: var(--tg-theme-hint-color); margin-top: 8px; } |
| .form-container, .detail-view { padding: 20px 15px; background-color: var(--tg-theme-section-bg-color); min-height: calc(100vh - 180px); } |
| .form-group { margin-bottom: 18px; } |
| .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 6px; font-weight: 500; } |
| .form-group input, .form-group textarea { |
| width: 100%; |
| padding: 12px; |
| border: 1px solid var(--tg-theme-secondary-bg-color); |
| border-radius: 8px; |
| font-size: 16px; |
| background-color: var(--tg-theme-bg-color); |
| color: var(--tg-theme-text-color); |
| box-sizing: border-box; |
| transition: border-color 0.2s ease; |
| } |
| .form-group input:focus, .form-group textarea:focus { border-color: var(--tg-theme-link-color); outline: none; } |
| .form-group textarea { min-height: 100px; resize: vertical; } |
| .fab { |
| position: fixed; |
| bottom: 25px; |
| right: 25px; |
| width: 56px; |
| height: 56px; |
| background-color: var(--tg-theme-button-color); |
| color: var(--tg-theme-button-text-color); |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 28px; |
| line-height: 1; |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); |
| cursor: pointer; |
| z-index: 1000; |
| border: none; |
| transition: transform 0.15s ease-out; |
| } |
| .fab:active { transform: scale(0.92); } |
| .detail-view h2 { margin-top: 0; font-size: 22px; font-weight: 600; color: var(--tg-theme-text-color); margin-bottom: 15px; } |
| .detail-view p { margin-bottom: 10px; line-height: 1.6; font-size: 16px; } |
| .detail-view strong { font-weight: 500; color: var(--tg-theme-text-color); } |
| .detail-view .meta-detail { font-size: 13px; color: var(--tg-theme-hint-color); margin-top: 20px; } |
| .detail-view a { color: var(--tg-theme-link-color); text-decoration: none; } |
| .detail-view a:hover { text-decoration: underline; } |
| .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; } |
| |
| .action-button { |
| display: block; |
| width: 100%; |
| padding: 12px 15px; |
| margin-top: 15px; |
| border: none; |
| border-radius: 8px; |
| font-size: 16px; |
| font-weight: 500; |
| cursor: pointer; |
| text-align: center; |
| transition: background-color 0.2s ease; |
| } |
| .button-primary { |
| background-color: var(--tg-theme-button-color); |
| color: var(--tg-theme-button-text-color); |
| } |
| .button-primary:active { |
| background-color: color-mix(in srgb, var(--tg-theme-button-color) 80%, black); |
| } |
| .button-destructive { |
| background-color: var(--tg-theme-destructive-text-color); |
| color: #ffffff; |
| } |
| .button-destructive:active { |
| background-color: color-mix(in srgb, var(--tg-theme-destructive-text-color) 80%, black); |
| } |
| .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div class="header" id="appHeader">TonTalent</div> |
| <div class="user-info-bar"> |
| <div class="user-details" id="userDetails"> |
| <img id="userAvatar" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="Avatar"> |
| <span id="userInfoText">Loading user...</span> |
| </div> |
| <div id="tonConnectButton" class="ton-connect-button-container"></div> |
| </div> |
| <div class="tabs"> |
| <button class="tab-button active" data-tab="resumes">Resumes</button> |
| <button class="tab-button" data-tab="vacancies">Vacancies</button> |
| <button class="tab-button" data-tab="freelance_offers">Freelance</button> |
| </div> |
| <div class="content" id="mainContent"> |
| <div class="loading">Loading content...</div> |
| </div> |
| <button class="fab" id="fabButton" title="Add New Item">+</button> |
| </div> |
| |
| <script> |
| const tg = window.Telegram.WebApp; |
| let currentUser = null; |
| let currentView = 'resumes'; |
| let currentItem = null; |
| let previousViewBeforeMyPosts = 'resumes'; |
| const mainContent = document.getElementById('mainContent'); |
| const fabButton = document.getElementById('fabButton'); |
| const appHeader = document.getElementById('appHeader'); |
| const tabOrder = ['resumes', 'vacancies', 'freelance_offers']; |
| const PIN_PRICE_TON = "{{ PIN_PRICE_TON }}"; |
| const PIN_DURATION_DAYS = "{{ PIN_DURATION_DAYS }}"; |
| |
| const tonConnectUI = new TON_CONNECT_UI.TonConnectUI({ |
| manifestUrl: `${window.location.origin}/tonconnect-manifest.json`, |
| buttonRootId: 'tonConnectButton' |
| }); |
| |
| function capitalizeFirstLetter(string) { |
| return string.charAt(0).toUpperCase() + string.slice(1); |
| } |
| |
| function applyThemeParams() { |
| const rootStyle = document.documentElement.style; |
| rootStyle.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff'); |
| rootStyle.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000'); |
| rootStyle.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999'); |
| rootStyle.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff'); |
| rootStyle.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff'); |
| rootStyle.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff'); |
| rootStyle.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0'); |
| rootStyle.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4'); |
| rootStyle.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff'); |
| rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93'); |
| rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30'); |
| rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff'); |
| } |
| |
| async function apiCall(endpoint, method = 'GET', body = null) { |
| const headers = { 'Content-Type': 'application/json' }; |
| if (tg.initData) { |
| headers['X-Telegram-Auth'] = tg.initData; |
| } |
| const options = { method, headers }; |
| if (body) options.body = JSON.stringify(body); |
| try { |
| const response = await fetch(endpoint, options); |
| if (!response.ok) { |
| const errorData = await response.json().catch(() => ({ error: 'Request failed without JSON body' })); |
| throw new Error(errorData.error || `HTTP error ${response.status}`); |
| } |
| return response.json(); |
| } catch (error) { |
| console.error('API Call Error:', error); |
| tg.showAlert(error.message || 'An API error occurred.'); |
| throw error; |
| } |
| } |
| |
| function renderList(items, type) { |
| mainContent.style.opacity = 0; |
| if (!items || items.length === 0) { |
| mainContent.innerHTML = `<div class="empty-state">No ${type} found. Be the first to add one!</div>`; |
| } else { |
| mainContent.innerHTML = items.map(item => ` |
| <div class="list-item ${item.pinned_until && new Date(item.pinned_until) > new Date() ? 'pinned' : ''}" onclick="handleItemClick('${type}', '${item.id}')"> |
| ${item.pinned_until && new Date(item.pinned_until) > new Date() ? '<div class="pin-icon">📌</div>' : ''} |
| <h3>${item.title || item.name || 'Untitled'}</h3> |
| ${type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''} |
| ${type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''} |
| <p class="meta">Posted by: @${item.user_telegram_username || 'anonymous'} on ${new Date(item.timestamp).toLocaleDateString()}</p> |
| </div> |
| `).join(''); |
| } |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| } |
| |
| function handleItemClick(type, id) { |
| tg.HapticFeedback.impactOccurred('light'); |
| showDetailView(type, id); |
| } |
| |
| function formatContactField(contactValue, userTelegramUsername) { |
| let displayContact = contactValue; |
| if (!displayContact && userTelegramUsername) { |
| return `<a href="tg://resolve?domain=${userTelegramUsername}" target="_blank" rel="noopener noreferrer">@${userTelegramUsername}</a>`; |
| } |
| if (displayContact) { |
| if (displayContact.startsWith('@')) { |
| const username = displayContact.substring(1); |
| return `<a href="tg://resolve?domain=${username}" target="_blank" rel="noopener noreferrer">${displayContact}</a>`; |
| } else if (displayContact.startsWith('http://') || displayContact.startsWith('https://')) { |
| return `<a href="${displayContact}" target="_blank" rel="noopener noreferrer">${displayContact}</a>`; |
| } else if (displayContact.includes('@') && displayContact.includes('.') && !displayContact.startsWith('http')) { |
| return `<a href="mailto:${displayContact}">${displayContact}</a>`; |
| } |
| } |
| return displayContact || 'N/A'; |
| } |
| |
| function showDetailView(type, id) { |
| mainContent.style.opacity = 0; |
| tg.BackButton.show(); |
| tg.BackButton.onClick(() => { |
| tg.HapticFeedback.impactOccurred('light'); |
| if (currentView === 'my_posts') { |
| showMyPostsView(); |
| } else { |
| loadView(type); |
| } |
| }); |
| tg.MainButton.hide(); |
| fabButton.style.display = 'none'; |
| |
| apiCall(`/api/${type}/${id}`) |
| .then(item => { |
| currentItem = item; |
| let detailsHtml = `<div class="detail-view"><h2>${item.title || item.name}</h2>`; |
| |
| const itemContact = formatContactField(item.contact, item.user_telegram_username); |
| const postedByLink = item.user_telegram_username |
| ? `<a href="tg://resolve?domain=${item.user_telegram_username}" target="_blank" rel="noopener noreferrer">@${item.user_telegram_username}</a>` |
| : 'anonymous'; |
| |
| const isPinned = item.pinned_until && new Date(item.pinned_until) > new Date(); |
| |
| if (type === 'resumes') { |
| detailsHtml += `<p><strong>Skills:</strong> ${item.skills || 'N/A'}</p><p><strong>Experience:</strong><br>${item.experience ? item.experience.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Education:</strong><br>${item.education ? item.education.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Contact:</strong> ${itemContact}</p>${item.portfolio_link ? `<p><strong>Portfolio:</strong> <a href="${item.portfolio_link}" target="_blank" rel="noopener noreferrer">${item.portfolio_link}</a></p>` : ''}`; |
| } else if (type === 'vacancies') { |
| detailsHtml += `<p><strong>Company:</strong> ${item.company_name || 'N/A'}</p><p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Requirements:</strong><br>${item.requirements ? item.requirements.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Salary:</strong> ${item.salary || 'N/A'}</p><p><strong>Location:</strong> ${item.location || 'N/A'}</p><p><strong>Contact/Apply:</strong> ${itemContact}</p>`; |
| } else if (type === 'freelance_offers') { |
| detailsHtml += `<p><strong>Description:</strong><br>${item.description ? item.description.replace(/\\n/g, '<br>') : 'N/A'}</p><p><strong>Budget:</strong> ${item.budget || 'N/A'}</p><p><strong>Deadline:</strong> ${item.deadline || 'N/A'}</p><p><strong>Skills Needed:</strong> ${item.skills_needed || 'N/A'}</p><p><strong>Contact:</strong> ${itemContact}</p>`; |
| } |
| detailsHtml += `<p class="meta-detail">Posted by: ${postedByLink} on ${new Date(item.timestamp).toLocaleDateString()}</p>`; |
| |
| if (isPinned) { |
| detailsHtml += `<p class="meta-detail" style="color: var(--tg-theme-accent-text-color);">📌 Pinned until ${new Date(item.pinned_until).toLocaleDateString()}</p>`; |
| } |
| |
| if (currentUser && item.user_id === String(currentUser.id)) { |
| if (!isPinned) { |
| detailsHtml += `<button id="pinItemButton" class="action-button button-primary" style="margin-top: 25px;">📌 Pin for ${PIN_PRICE_TON} TON</button>`; |
| } |
| detailsHtml += `<button id="editItemButton" class="action-button" style="background-color: var(--tg-theme-button-color); color: var(--tg-theme-button-text-color);">Edit Post</button>`; |
| detailsHtml += `<button id="deleteItemButton" class="action-button button-destructive">Delete Post</button>`; |
| } |
| detailsHtml += `</div>`; |
| mainContent.innerHTML = detailsHtml; |
| |
| if (currentUser && item.user_id === String(currentUser.id)) { |
| document.getElementById('pinItemButton')?.addEventListener('click', () => { |
| tg.HapticFeedback.impactOccurred('light'); |
| handlePinItem(type, item.id); |
| }); |
| document.getElementById('editItemButton')?.addEventListener('click', () => { |
| tg.HapticFeedback.impactOccurred('light'); |
| showForm(type, item); |
| }); |
| document.getElementById('deleteItemButton')?.addEventListener('click', () => { |
| tg.HapticFeedback.impactOccurred('light'); |
| handleDeleteItem(type, item.id); |
| }); |
| } |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| }) |
| .catch(err => { |
| mainContent.innerHTML = `<div class="empty-state">Error loading details.</div>`; |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| }); |
| } |
| |
| async function handlePinItem(type, itemId) { |
| if (!tonConnectUI.connected) { |
| tg.showAlert('Please connect your TON wallet first.'); |
| return; |
| } |
| |
| const nanoTON = (parseFloat(PIN_PRICE_TON) * 1_000_000_000).toString(); |
| const transaction = { |
| validUntil: Math.floor(Date.now() / 1000) + 600, |
| messages: [ |
| { |
| address: "{{ TON_RECIPIENT_WALLET }}", |
| amount: nanoTON |
| } |
| ] |
| }; |
| |
| try { |
| tg.showPopup({ |
| title: 'Confirm Payment', |
| message: `You are about to pay ${PIN_PRICE_TON} TON to pin this post for ${PIN_DURATION_DAYS} days.`, |
| buttons: [ {id: 'ok', type: 'default', text: 'Proceed'}, {type: 'cancel'} ] |
| }, async (buttonId) => { |
| if (buttonId === 'ok') { |
| try { |
| tg.MainButton.showProgress(); |
| const result = await tonConnectUI.sendTransaction(transaction); |
| |
| await apiCall(`/api/pin/${type}/${itemId}`, 'POST', { boc: result.boc }); |
| |
| tg.HapticFeedback.notificationOccurred('success'); |
| tg.showAlert('Payment successful! Your post has been pinned.'); |
| showDetailView(type, itemId); |
| } catch (error) { |
| tg.HapticFeedback.notificationOccurred('error'); |
| tg.showAlert(error?.message || 'Transaction failed or was rejected.'); |
| console.error(error); |
| } finally { |
| tg.MainButton.hideProgress(); |
| } |
| } |
| }); |
| |
| } catch (error) { |
| console.error('Error initiating transaction:', error); |
| tg.showAlert('Could not initiate transaction.'); |
| } |
| } |
| |
| function showForm(type, itemToEdit = null) { |
| mainContent.style.opacity = 0; |
| currentItem = itemToEdit; |
| tg.BackButton.show(); |
| tg.BackButton.onClick(() => { |
| tg.HapticFeedback.impactOccurred('light'); |
| if (itemToEdit) showDetailView(type, itemToEdit.id); |
| else if (currentView === 'my_posts') showMyPostsView(); |
| else loadView(type); |
| }); |
| fabButton.style.display = 'none'; |
| |
| let formHtml = `<div class="form-container"><h2>${itemToEdit ? 'Edit' : 'New'} ${type.slice(0, -1)}</h2>`; |
| if (type === 'resumes') { |
| formHtml += `<div class="form-group"><label for="name">Full Name *</label><input type="text" id="name" value="${itemToEdit?.name || ''}" required></div><div class="form-group"><label for="title">Job Title / Desired Position *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="skills">Skills (comma separated)</label><textarea id="skills">${itemToEdit?.skills || ''}</textarea></div><div class="form-group"><label for="experience">Experience</label><textarea id="experience">${itemToEdit?.experience || ''}</textarea></div><div class="form-group"><label for="education">Education</label><textarea id="education">${itemToEdit?.education || ''}</textarea></div><div class="form-group"><label for="contact">Contact Info (e.g., email, or leave blank to use Telegram)</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div><div class="form-group"><label for="portfolio_link">Portfolio Link (optional)</label><input type="url" id="portfolio_link" value="${itemToEdit?.portfolio_link || ''}"></div>`; |
| } else if (type === 'vacancies') { |
| formHtml += `<div class="form-group"><label for="company_name">Company Name *</label><input type="text" id="company_name" value="${itemToEdit?.company_name || ''}" required></div><div class="form-group"><label for="title">Job Title *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="description">Description</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div><div class="form-group"><label for="requirements">Requirements</label><textarea id="requirements">${itemToEdit?.requirements || ''}</textarea></div><div class="form-group"><label for="salary">Salary/Compensation</label><input type="text" id="salary" value="${itemToEdit?.salary || ''}"></div><div class="form-group"><label for="location">Location (e.g., Remote, City)</label><input type="text" id="location" value="${itemToEdit?.location || ''}"></div><div class="form-group"><label for="contact">Contact Info / How to Apply</label><textarea id="contact">${itemToEdit?.contact || ''}</textarea></div>`; |
| } else if (type === 'freelance_offers') { |
| formHtml += `<div class="form-group"><label for="title">Project Title *</label><input type="text" id="title" value="${itemToEdit?.title || ''}" required></div><div class="form-group"><label for="description">Description of Work</label><textarea id="description">${itemToEdit?.description || ''}</textarea></div><div class="form-group"><label for="budget">Budget</label><input type="text" id="budget" value="${itemToEdit?.budget || ''}"></div><div class="form-group"><label for="deadline">Expected Deadline</label><input type="text" id="deadline" value="${itemToEdit?.deadline || ''}"></div><div class="form-group"><label for="skills_needed">Skills Needed (comma separated)</label><textarea id="skills_needed">${itemToEdit?.skills_needed || ''}</textarea></div><div class="form-group"><label for="contact">Contact Info (or leave blank to use Telegram)</label><input type="text" id="contact" value="${itemToEdit?.contact || ''}"></div>`; |
| } |
| formHtml += `<div id="formError" class="error-message"></div></div>`; |
| mainContent.innerHTML = formHtml; |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| |
| tg.MainButton.setText(itemToEdit ? 'Save Changes' : 'Post'); |
| tg.MainButton.show(); |
| tg.MainButton.onClick(() => handleSubmit(type, itemToEdit ? itemToEdit.id : null)); |
| } |
| |
| function handleSubmit(type, itemId = null) { |
| const payload = {}; |
| let isValid = true; |
| document.getElementById('formError').textContent = ''; |
| |
| if (type === 'resumes') { |
| payload.name = document.getElementById('name').value.trim(); |
| payload.title = document.getElementById('title').value.trim(); |
| payload.skills = document.getElementById('skills').value.trim(); |
| payload.experience = document.getElementById('experience').value.trim(); |
| payload.education = document.getElementById('education').value.trim(); |
| payload.contact = document.getElementById('contact').value.trim(); |
| payload.portfolio_link = document.getElementById('portfolio_link').value.trim(); |
| if (!payload.name || !payload.title) isValid = false; |
| } else if (type === 'vacancies') { |
| payload.company_name = document.getElementById('company_name').value.trim(); |
| payload.title = document.getElementById('title').value.trim(); |
| payload.description = document.getElementById('description').value.trim(); |
| payload.requirements = document.getElementById('requirements').value.trim(); |
| payload.salary = document.getElementById('salary').value.trim(); |
| payload.location = document.getElementById('location').value.trim(); |
| payload.contact = document.getElementById('contact').value.trim(); |
| if (!payload.company_name || !payload.title) isValid = false; |
| } else if (type === 'freelance_offers') { |
| payload.title = document.getElementById('title').value.trim(); |
| payload.description = document.getElementById('description').value.trim(); |
| payload.budget = document.getElementById('budget').value.trim(); |
| payload.deadline = document.getElementById('deadline').value.trim(); |
| payload.skills_needed = document.getElementById('skills_needed').value.trim(); |
| payload.contact = document.getElementById('contact').value.trim(); |
| if (!payload.title) isValid = false; |
| } |
| |
| if (!isValid) { |
| document.getElementById('formError').textContent = 'Please fill in all required fields (*).'; |
| tg.HapticFeedback.notificationOccurred('error'); |
| return; |
| } |
| |
| tg.MainButton.showProgress(); |
| tg.HapticFeedback.impactOccurred('light'); |
| |
| const method = itemId ? 'PUT' : 'POST'; |
| const endpoint = itemId ? `/api/${type}/${itemId}` : `/api/${type}`; |
| |
| apiCall(endpoint, method, payload) |
| .then(response => { |
| tg.HapticFeedback.notificationOccurred('success'); |
| tg.MainButton.hideProgress(); |
| tg.MainButton.hide(); |
| if (currentView === 'my_posts') showMyPostsView(); |
| else loadView(type); |
| }) |
| .catch(err => { |
| tg.HapticFeedback.notificationOccurred('error'); |
| tg.MainButton.hideProgress(); |
| document.getElementById('formError').textContent = err.message || 'Failed to submit.'; |
| }); |
| } |
| |
| function handleDeleteItem(type, itemId) { |
| tg.showConfirm('Are you sure you want to delete this post?', (confirmed) => { |
| if (confirmed) { |
| tg.HapticFeedback.impactOccurred('medium'); |
| apiCall(`/api/${type}/${itemId}`, 'DELETE') |
| .then(() => { |
| tg.HapticFeedback.notificationOccurred('success'); |
| tg.showAlert('Post deleted successfully.'); |
| if (currentView === 'my_posts') showMyPostsView(); |
| else loadView(type); |
| }) |
| .catch(err => { |
| tg.HapticFeedback.notificationOccurred('error'); |
| tg.showAlert(err.message || 'Failed to delete post.'); |
| }); |
| } else { |
| tg.HapticFeedback.impactOccurred('light'); |
| } |
| }); |
| } |
| |
| function loadView(tabName, fromSwipe = false) { |
| if (!fromSwipe && currentView !== tabName) tg.HapticFeedback.impactOccurred('light'); |
| |
| if (currentView === 'my_posts' && tabName !== 'my_posts') { |
| appHeader.textContent = 'TonTalent'; |
| } |
| currentView = tabName; |
| |
| document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); |
| document.querySelector(`.tab-button[data-tab="${tabName}"]`).classList.add('active'); |
| |
| mainContent.style.opacity = 0; |
| mainContent.innerHTML = `<div class="loading">Loading ${tabName}...</div>`; |
| |
| tg.BackButton.hide(); |
| tg.MainButton.hide(); |
| fabButton.style.display = 'block'; |
| |
| apiCall(`/api/${tabName}`) |
| .then(data => renderList(data, tabName)) |
| .catch(err => { |
| mainContent.innerHTML = `<div class="empty-state">Error loading ${tabName}.</div>`; |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| }); |
| } |
| |
| function renderMyPostsList(items) { |
| mainContent.style.opacity = 0; |
| if (!items || items.length === 0) { |
| mainContent.innerHTML = `<div class="empty-state">You haven't posted anything yet.</div>`; |
| } else { |
| mainContent.innerHTML = items.map(item => ` |
| <div class="list-item ${item.pinned_until && new Date(item.pinned_until) > new Date() ? 'pinned' : ''}" onclick="handleItemClick('${item.type}', '${item.id}')"> |
| ${item.pinned_until && new Date(item.pinned_until) > new Date() ? '<div class="pin-icon">📌</div>' : ''} |
| <h3>${item.title || item.name || 'Untitled'} <span style="font-weight:normal; font-size: 0.8em; color: var(--tg-theme-hint-color);">(${capitalizeFirstLetter(item.type.slice(0,-1))})</span></h3> |
| ${item.type === 'vacancies' && item.company_name ? `<p><strong>Company:</strong> ${item.company_name}</p>` : ''} |
| ${item.type === 'freelance_offers' && item.budget ? `<p><strong>Budget:</strong> ${item.budget}</p>` : ''} |
| <p class="meta">Posted on ${new Date(item.timestamp).toLocaleDateString()}${item.updated_timestamp ? ' (Edited: ' + new Date(item.updated_timestamp).toLocaleDateString() + ')' : ''}</p> |
| </div> |
| `).join(''); |
| } |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| } |
| |
| function showMyPostsView() { |
| tg.HapticFeedback.impactOccurred('light'); |
| if (currentView !== 'my_posts') { |
| previousViewBeforeMyPosts = currentView; |
| } |
| currentView = 'my_posts'; |
| appHeader.textContent = 'My Posts'; |
| |
| document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); |
| fabButton.style.display = 'none'; |
| tg.MainButton.hide(); |
| |
| tg.BackButton.show(); |
| tg.BackButton.onClick(() => { |
| tg.HapticFeedback.impactOccurred('light'); |
| appHeader.textContent = 'TonTalent'; |
| loadView(previousViewBeforeMyPosts); |
| }); |
| |
| mainContent.style.opacity = 0; |
| mainContent.innerHTML = '<div class="loading">Loading your posts...</div>'; |
| |
| Promise.all([ |
| apiCall('/api/resumes'), |
| apiCall('/api/vacancies'), |
| apiCall('/api/freelance_offers') |
| ]).then(([resumes, vacancies, freelanceOffers]) => { |
| const myResumes = resumes.filter(item => String(item.user_id) === String(currentUser.id)); |
| const myVacancies = vacancies.filter(item => String(item.user_id) === String(currentUser.id)); |
| const myFreelanceOffers = freelanceOffers.filter(item => String(item.user_id) === String(currentUser.id)); |
| |
| const allMyPosts = [ |
| ...myResumes.map(item => ({ ...item, type: 'resumes' })), |
| ...myVacancies.map(item => ({ ...item, type: 'vacancies' })), |
| ...myFreelanceOffers.map(item => ({ ...item, type: 'freelance_offers' })) |
| ].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); |
| |
| const now = new Date(); |
| const pinnedPosts = allMyPosts.filter(p => p.pinned_until && new Date(p.pinned_until) > now); |
| const regularPosts = allMyPosts.filter(p => !p.pinned_until || new Date(p.pinned_until) <= now); |
| |
| renderMyPostsList([...pinnedPosts, ...regularPosts]); |
| }).catch(err => { |
| mainContent.innerHTML = '<div class="empty-state">Error loading your posts.</div>'; |
| setTimeout(() => { mainContent.style.opacity = 1; }, 50); |
| }); |
| } |
| |
| let touchstartX = 0; |
| let touchendX = 0; |
| const swipeThreshold = 70; |
| |
| mainContent.addEventListener('touchstart', e => { |
| if (currentView === 'my_posts' || document.querySelector('.detail-view') || document.querySelector('.form-container')) return; |
| touchstartX = e.changedTouches[0].screenX; |
| }, { passive: true }); |
| |
| mainContent.addEventListener('touchend', e => { |
| if (currentView === 'my_posts' || document.querySelector('.detail-view') || document.querySelector('.form-container')) return; |
| touchendX = e.changedTouches[0].screenX; |
| handleSwipeGesture(); |
| }); |
| |
| function handleSwipeGesture() { |
| const swipeLength = touchendX - touchstartX; |
| if (Math.abs(swipeLength) < swipeThreshold) return; |
| |
| let currentIndex = tabOrder.indexOf(currentView); |
| if (currentIndex === -1) return; |
| let newIndex; |
| |
| if (touchendX < touchstartX) { |
| newIndex = (currentIndex + 1) % tabOrder.length; |
| } else { |
| newIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length; |
| } |
| |
| if (newIndex !== currentIndex) { |
| tg.HapticFeedback.impactOccurred('light'); |
| loadView(tabOrder[newIndex], true); |
| } |
| } |
| |
| async function init() { |
| tg.ready(); |
| applyThemeParams(); |
| tg.expand(); |
| tg.enableClosingConfirmation(); |
| |
| tg.onEvent('themeChanged', applyThemeParams); |
| |
| const userInfoText = document.getElementById('userInfoText'); |
| const userAvatar = document.getElementById('userAvatar'); |
| const userDetails = document.getElementById('userDetails'); |
| |
| userInfoText.textContent = `Welcome, ${tg.initDataUnsafe.user?.first_name || 'User'}!`; |
| if (tg.initDataUnsafe.user?.username) { |
| userInfoText.textContent += ` (@${tg.initDataUnsafe.user.username})`; |
| } |
| |
| try { |
| const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData }); |
| currentUser = authResponse.user; |
| if (currentUser) { |
| userInfoText.textContent = `${currentUser.first_name || ''} ${currentUser.last_name || ''}`.trim(); |
| if (currentUser.username) userInfoText.textContent += ` (@${currentUser.username})`; |
| else userInfoText.textContent += ` (ID: ${currentUser.id})`; |
| |
| if (currentUser.photo_url) { |
| userAvatar.src = currentUser.photo_url; |
| } |
| userDetails.addEventListener('click', () => { |
| if(currentUser) showMyPostsView(); |
| }); |
| } |
| } catch (error) { |
| console.error("Auth error:", error); |
| userInfoText.textContent = `Auth failed. Please restart the app.`; |
| } |
| |
| document.querySelectorAll('.tab-button').forEach(button => { |
| button.addEventListener('click', () => loadView(button.dataset.tab)); |
| }); |
| fabButton.addEventListener('click', () => { |
| tg.HapticFeedback.impactOccurred('medium'); |
| showForm(currentView); |
| }); |
| |
| loadView('resumes'); |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| ADMIN_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>TonTalent Admin</title> |
| <style> |
| body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; } |
| .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); } |
| h1, h2 { color: #333; } |
| .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;} |
| .item { border-bottom: 1px solid #eee; padding: 10px 0; } |
| .item:last-child { border-bottom: none; } |
| .item h3 { margin: 0 0 5px 0; } |
| .item p { margin: 3px 0; font-size: 0.9em; color: #555; } |
| .item .pinned-info { color: #007bff; font-weight: bold; } |
| .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; } |
| .button-primary { background-color: #007bff; color: white; } |
| .button-danger { background-color: #dc3545; color: white; } |
| .button-secondary { background-color: #6c757d; color: white; } |
| .message { padding: 10px; margin-bottom: 15px; border-radius: 4px; } |
| .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } |
| .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } |
| .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } |
| .sync-buttons form { display: inline-block; margin-right: 10px; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>TonTalent Admin Panel</h1> |
| |
| {% with messages = get_flashed_messages(with_categories=true) %} |
| {% if messages %} |
| {% for category, message in messages %} |
| <div class="message {{ category }}">{{ message }}</div> |
| {% endfor %} |
| {% endif %} |
| {% endwith %} |
| |
| <div class="section"> |
| <h2>Data Synchronization with Hugging Face</h2> |
| <div class="sync-buttons"> |
| <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');"> |
| <button type="submit" class="button button-primary">Upload DB to HF</button> |
| </form> |
| <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');"> |
| <button type="submit" class="button button-secondary">Download DB from HF</button> |
| </form> |
| </div> |
| <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p> |
| </div> |
| |
| <div class="section"> |
| <h2>Resumes ({{ resumes|length }})</h2> |
| {% for resume in resumes %} |
| <div class="item"> |
| <h3>{{ resume.name }} - {{ resume.title }}</h3> |
| <p>User ID: {{ resume.user_id }} (@{{ resume.user_telegram_username }})</p> |
| <p>Posted: {{ resume.timestamp }}</p> |
| {% if resume.pinned_until and resume.pinned_until > now_iso %} |
| <p class="pinned-info">📌 Pinned until: {{ resume.pinned_until }}</p> |
| {% endif %} |
| <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this resume?');"> |
| <input type="hidden" name="item_type" value="resumes"> |
| <input type="hidden" name="item_id" value="{{ resume.id }}"> |
| <button type="submit" class="button button-danger">Delete</button> |
| </form> |
| </div> |
| {% else %} |
| <p>No resumes found.</p> |
| {% endfor %} |
| </div> |
| |
| <div class="section"> |
| <h2>Vacancies ({{ vacancies|length }})</h2> |
| {% for vacancy in vacancies %} |
| <div class="item"> |
| <h3>{{ vacancy.title }} - {{ vacancy.company_name }}</h3> |
| <p>User ID: {{ vacancy.user_id }} (@{{ vacancy.user_telegram_username }})</p> |
| <p>Posted: {{ vacancy.timestamp }}</p> |
| {% if vacancy.pinned_until and vacancy.pinned_until > now_iso %} |
| <p class="pinned-info">📌 Pinned until: {{ vacancy.pinned_until }}</p> |
| {% endif %} |
| <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this vacancy?');"> |
| <input type="hidden" name="item_type" value="vacancies"> |
| <input type="hidden" name="item_id" value="{{ vacancy.id }}"> |
| <button type="submit" class="button button-danger">Delete</button> |
| </form> |
| </div> |
| {% else %} |
| <p>No vacancies found.</p> |
| {% endfor %} |
| </div> |
| |
| <div class="section"> |
| <h2>Freelance Offers ({{ freelance_offers|length }})</h2> |
| {% for offer in freelance_offers %} |
| <div class="item"> |
| <h3>{{ offer.title }}</h3> |
| <p>User ID: {{ offer.user_id }} (@{{ offer.user_telegram_username }})</p> |
| <p>Budget: {{ offer.budget }}</p> |
| <p>Posted: {{ offer.timestamp }}</p> |
| {% if offer.pinned_until and offer.pinned_until > now_iso %} |
| <p class="pinned-info">📌 Pinned until: {{ offer.pinned_until }}</p> |
| {% endif %} |
| <form method="POST" action="{{ url_for('admin_delete_item') }}" style="display:inline;" onsubmit="return confirm('Delete this freelance offer?');"> |
| <input type="hidden" name="item_type" value="freelance_offers"> |
| <input type="hidden" name="item_id" value="{{ offer.id }}"> |
| <button type="submit" class="button button-danger">Delete</button> |
| </form> |
| </div> |
| {% else %} |
| <p>No freelance offers found.</p> |
| {% endfor %} |
| </div> |
| </div> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def main_app_view(): |
| return render_template_string( |
| MAIN_APP_TEMPLATE, |
| TON_RECIPIENT_WALLET=TON_RECIPIENT_WALLET, |
| PIN_PRICE_TON=PIN_PRICE_TON, |
| PIN_DURATION_DAYS=PIN_DURATION_DAYS |
| ) |
|
|
| @app.route('/tonconnect-manifest.json') |
| def tonconnect_manifest(): |
| return jsonify({ |
| "url": url_for('main_app_view', _external=True), |
| "name": "TonTalent", |
| "iconUrl": "https://i.ibb.co/6yP0wHz/DALLE-2024-05-18-14-38-16-A-sleek-professional-logo-for-Ton-Talent-a-job-search-and-freelanc.png" |
| }) |
|
|
| @app.route('/api/auth_user', methods=['POST']) |
| def auth_user(): |
| auth_data_str = request.headers.get('X-Telegram-Auth') |
| if not auth_data_str: |
| init_data_payload = request.json.get('init_data') |
| if init_data_payload: |
| auth_data_str = init_data_payload |
| else: |
| return jsonify({"error": "Authentication data not provided"}), 401 |
|
|
| is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN) |
|
|
| if not is_valid or not user_data_from_auth: |
| return jsonify({"error": "Invalid authentication data"}), 403 |
|
|
| data = load_data() |
| users = data.get('users', {}) |
| user_id_str = str(user_data_from_auth.get('id')) |
|
|
| if user_id_str not in users: |
| users[user_id_str] = { |
| 'id': user_data_from_auth.get('id'), |
| 'first_name': user_data_from_auth.get('first_name'), |
| 'last_name': user_data_from_auth.get('last_name'), |
| 'username': user_data_from_auth.get('username'), |
| 'language_code': user_data_from_auth.get('language_code'), |
| 'photo_url': user_data_from_auth.get('photo_url'), |
| 'first_seen': datetime.now().isoformat() |
| } |
| users[user_id_str]['last_seen'] = datetime.now().isoformat() |
| if user_data_from_auth.get('photo_url'): |
| users[user_id_str]['photo_url'] = user_data_from_auth.get('photo_url') |
| |
| data['users'] = users |
| save_data(data) |
| |
| return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200 |
|
|
| def get_authenticated_user_details(request_headers): |
| auth_data_str = request_headers.get('X-Telegram-Auth') |
| if not auth_data_str: |
| return None |
| is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN) |
| if is_valid and user_data_from_auth: |
| data = load_data() |
| user_id_str = str(user_data_from_auth.get('id')) |
| return data.get('users', {}).get(user_id_str) |
| return None |
|
|
| @app.route('/api/<item_type>', methods=['GET']) |
| def get_items(item_type): |
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
| data = load_data() |
| items = data.get(item_type, []) |
| |
| now = datetime.now() |
| pinned_items = sorted( |
| [i for i in items if i.get('pinned_until') and datetime.fromisoformat(i['pinned_until']) > now], |
| key=lambda x: x.get('timestamp', ''), reverse=True |
| ) |
| regular_items = sorted( |
| [i for i in items if not i.get('pinned_until') or datetime.fromisoformat(i['pinned_until']) <= now], |
| key=lambda x: x.get('timestamp', ''), reverse=True |
| ) |
| |
| return jsonify(pinned_items + regular_items), 200 |
|
|
| @app.route('/api/<item_type>/<item_id>', methods=['GET']) |
| def get_item(item_type, item_id): |
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
| data = load_data() |
| item = next((i for i in data.get(item_type, []) if i['id'] == item_id), None) |
| if item: |
| return jsonify(item), 200 |
| return jsonify({"error": "Item not found"}), 404 |
|
|
| @app.route('/api/<item_type>', methods=['POST']) |
| def create_item(item_type): |
| user = get_authenticated_user_details(request.headers) |
| if not user: |
| return jsonify({"error": "Authentication required or user not found in DB"}), 401 |
|
|
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
| |
| req_data = request.json |
| if not req_data: |
| return jsonify({"error": "No data provided"}), 400 |
|
|
| new_item = { |
| "id": str(uuid.uuid4()), |
| "user_id": str(user.get('id')), |
| "user_telegram_username": user.get('username', 'unknown'), |
| "timestamp": datetime.now().isoformat(), |
| } |
|
|
| if item_type == 'resumes': |
| required_fields = ['name', 'title'] |
| for field in required_fields: |
| if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400 |
| new_item.update({ |
| "name": req_data.get('name'), "title": req_data.get('title'), |
| "skills": req_data.get('skills', ''), "experience": req_data.get('experience', ''), |
| "education": req_data.get('education', ''), "contact": req_data.get('contact', ''), |
| "portfolio_link": req_data.get('portfolio_link', '') |
| }) |
| elif item_type == 'vacancies': |
| required_fields = ['company_name', 'title'] |
| for field in required_fields: |
| if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400 |
| new_item.update({ |
| "company_name": req_data.get('company_name'), "title": req_data.get('title'), |
| "description": req_data.get('description', ''), "requirements": req_data.get('requirements', ''), |
| "salary": req_data.get('salary', ''), "location": req_data.get('location', ''), |
| "contact": req_data.get('contact', '') |
| }) |
| elif item_type == 'freelance_offers': |
| required_fields = ['title'] |
| for field in required_fields: |
| if not req_data.get(field): return jsonify({"error": f"Missing field: {field}"}), 400 |
| new_item.update({ |
| "title": req_data.get('title'), "description": req_data.get('description', ''), |
| "budget": req_data.get('budget', ''), "deadline": req_data.get('deadline', ''), |
| "skills_needed": req_data.get('skills_needed', ''), "contact": req_data.get('contact', '') |
| }) |
| |
| data = load_data() |
| data[item_type].append(new_item) |
| save_data(data) |
| return jsonify(new_item), 201 |
|
|
| @app.route('/api/<item_type>/<item_id>', methods=['PUT']) |
| def update_item(item_type, item_id): |
| user = get_authenticated_user_details(request.headers) |
| if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401 |
|
|
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
| |
| req_data = request.json |
| if not req_data: return jsonify({"error": "No data provided"}), 400 |
|
|
| data = load_data() |
| items_list = data.get(item_type, []) |
| item_index = -1 |
| for idx, i in enumerate(items_list): |
| if i['id'] == item_id: |
| item_index = idx |
| break |
| |
| if item_index == -1: return jsonify({"error": "Item not found"}), 404 |
| |
| original_item = items_list[item_index] |
| if str(original_item.get('user_id')) != str(user.get('id')): |
| return jsonify({"error": "Forbidden: You can only edit your own items"}), 403 |
|
|
| updated_item = original_item.copy() |
| updated_item['updated_timestamp'] = datetime.now().isoformat() |
|
|
| if item_type == 'resumes': |
| updated_item.update({ |
| "name": req_data.get('name', original_item.get('name')), |
| "title": req_data.get('title', original_item.get('title')), |
| "skills": req_data.get('skills', original_item.get('skills')), |
| "experience": req_data.get('experience', original_item.get('experience')), |
| "education": req_data.get('education', original_item.get('education')), |
| "contact": req_data.get('contact', original_item.get('contact')), |
| "portfolio_link": req_data.get('portfolio_link', original_item.get('portfolio_link')) |
| }) |
| elif item_type == 'vacancies': |
| updated_item.update({ |
| "company_name": req_data.get('company_name', original_item.get('company_name')), |
| "title": req_data.get('title', original_item.get('title')), |
| "description": req_data.get('description', original_item.get('description')), |
| "requirements": req_data.get('requirements', original_item.get('requirements')), |
| "salary": req_data.get('salary', original_item.get('salary')), |
| "location": req_data.get('location', original_item.get('location')), |
| "contact": req_data.get('contact', original_item.get('contact')) |
| }) |
| elif item_type == 'freelance_offers': |
| updated_item.update({ |
| "title": req_data.get('title', original_item.get('title')), |
| "description": req_data.get('description', original_item.get('description')), |
| "budget": req_data.get('budget', original_item.get('budget')), |
| "deadline": req_data.get('deadline', original_item.get('deadline')), |
| "skills_needed": req_data.get('skills_needed', original_item.get('skills_needed')), |
| "contact": req_data.get('contact', original_item.get('contact')) |
| }) |
|
|
| data[item_type][item_index] = updated_item |
| save_data(data) |
| return jsonify(updated_item), 200 |
|
|
| @app.route('/api/<item_type>/<item_id>', methods=['DELETE']) |
| def delete_item(item_type, item_id): |
| user = get_authenticated_user_details(request.headers) |
| if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401 |
|
|
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
|
|
| data = load_data() |
| items_list = data.get(item_type, []) |
| original_length = len(items_list) |
| |
| item_to_delete = next((i for i in items_list if i['id'] == item_id), None) |
| if not item_to_delete: return jsonify({"error": "Item not found"}), 404 |
|
|
| if str(item_to_delete.get('user_id')) != str(user.get('id')): |
| return jsonify({"error": "Forbidden: You can only delete your own items"}), 403 |
|
|
| data[item_type] = [i for i in items_list if i['id'] != item_id] |
| |
| if len(data[item_type]) < original_length: |
| save_data(data) |
| return jsonify({"message": "Item deleted successfully"}), 200 |
| return jsonify({"error": "Item not found or deletion failed"}), 404 |
|
|
|
|
| @app.route('/api/pin/<item_type>/<item_id>', methods=['POST']) |
| def pin_item(item_type, item_id): |
| user = get_authenticated_user_details(request.headers) |
| if not user: return jsonify({"error": "Authentication required"}), 401 |
|
|
| if item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| return jsonify({"error": "Invalid item type"}), 400 |
|
|
| req_data = request.json |
| if not req_data or 'boc' not in req_data: |
| return jsonify({"error": "Transaction data (boc) not provided"}), 400 |
|
|
| data = load_data() |
| items_list = data.get(item_type, []) |
| item_index = -1 |
| for idx, i in enumerate(items_list): |
| if i['id'] == item_id: |
| item_index = idx |
| break |
| |
| if item_index == -1: return jsonify({"error": "Item not found"}), 404 |
| |
| item = items_list[item_index] |
| if str(item.get('user_id')) != str(user.get('id')): |
| return jsonify({"error": "Forbidden: You can only pin your own items"}), 403 |
|
|
| |
| |
| |
| |
| |
| |
| item['pinned_until'] = (datetime.now() + timedelta(days=PIN_DURATION_DAYS)).isoformat() |
| data[item_type][item_index] = item |
| save_data(data) |
| |
| return jsonify({"message": "Item pinned successfully", "pinned_until": item['pinned_until']}), 200 |
|
|
|
|
| @app.route('/admin', methods=['GET']) |
| def admin_panel(): |
| data = load_data() |
| return render_template_string(ADMIN_TEMPLATE, |
| now_iso=datetime.now().isoformat(), |
| resumes=sorted(data.get('resumes', []), key=lambda x: x.get('timestamp', ''), reverse=True), |
| vacancies=sorted(data.get('vacancies', []), key=lambda x: x.get('timestamp', ''), reverse=True), |
| freelance_offers=sorted(data.get('freelance_offers', []), key=lambda x: x.get('timestamp', ''), reverse=True)) |
|
|
| @app.route('/admin/delete', methods=['POST']) |
| def admin_delete_item(): |
| item_type = request.form.get('item_type') |
| item_id = request.form.get('item_id') |
|
|
| if not item_type or not item_id or item_type not in ['resumes', 'vacancies', 'freelance_offers']: |
| flash('Invalid item type or ID for deletion.', 'error') |
| return redirect(url_for('admin_panel')) |
|
|
| data = load_data() |
| items_list = data.get(item_type, []) |
| original_length = len(items_list) |
| data[item_type] = [i for i in items_list if i['id'] != item_id] |
|
|
| if len(data[item_type]) < original_length: |
| save_data(data) |
| flash(f'{item_type.capitalize()[:-1]} deleted successfully.', 'success') |
| else: |
| flash('Item not found or already deleted.', 'warning') |
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/force_upload', methods=['POST']) |
| def force_upload_admin(): |
| logging.info("Admin forcing upload to Hugging Face...") |
| try: |
| upload_db_to_hf() |
| flash("Data successfully uploaded to Hugging Face.", 'success') |
| except Exception as e: |
| logging.error(f"Error during forced upload: {e}", exc_info=True) |
| flash(f"Error uploading to Hugging Face: {e}", 'error') |
| return redirect(url_for('admin_panel')) |
|
|
| @app.route('/admin/force_download', methods=['POST']) |
| def force_download_admin(): |
| logging.info("Admin forcing download from Hugging Face...") |
| try: |
| if download_db_from_hf(): |
| flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success') |
| load_data() |
| else: |
| flash("Failed to download data from Hugging Face. Check logs.", 'error') |
| except Exception as e: |
| logging.error(f"Error during forced download: {e}", exc_info=True) |
| flash(f"Error downloading from Hugging Face: {e}", 'error') |
| return redirect(url_for('admin_panel')) |
|
|
|
|
| if __name__ == '__main__': |
| logging.info("Application starting up. Performing initial data load/download...") |
| download_db_from_hf() |
| load_data() |
| logging.info("Initial data load complete.") |
|
|
| if HF_TOKEN_WRITE: |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
| backup_thread.start() |
| logging.info("Periodic backup thread started.") |
| else: |
| logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).") |
|
|
| port = int(os.environ.get('PORT', 7860)) |
| logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}") |
| app.run(debug=False, host='0.0.0.0', port=port) |