Spaces:
Running
Running
| from flask import Flask, render_template, request, jsonify | |
| import os, re, json, sqlite3 | |
| app = Flask(__name__) | |
| # ββββββββββββββββββββββββββ 1. CONFIGURATION ββββββββββββββββββββββββββ | |
| DB_FILE = "favorite_sites.json" # JSON file for backward compatibility | |
| SQLITE_DB = "favorite_sites.db" # SQLite database for persistence | |
| # Domains that commonly block iframes | |
| BLOCKED_DOMAINS = [ | |
| "naver.com", "daum.net", "google.com", | |
| "facebook.com", "instagram.com", "kakao.com", | |
| "ycombinator.com" | |
| ] | |
| # ββββββββββββββββββββββββββ 2. CURATED CATEGORIES ββββββββββββββββββββββββββ | |
| CATEGORIES = { | |
| "Productivity": [ | |
| "https://huggingface.co/spaces/VIDraft/Robo-Beam", | |
| "https://huggingface.co/spaces/VIDraft/voice-trans", | |
| "https://huggingface.co/spaces/Heartsync/FREE-NSFW-HUB", | |
| "https://huggingface.co/spaces/openfree/Chart-GPT", | |
| "https://huggingface.co/spaces/ginipick/AI-BOOK", | |
| "https://huggingface.co/spaces/VIDraft/Voice-Clone-Podcast", | |
| "https://huggingface.co/spaces/ginipick/PDF-EXAM", | |
| "https://huggingface.co/spaces/ginigen/perflexity-clone", | |
| "https://huggingface.co/spaces/ginipick/IDEA-DESIGN", | |
| "https://huggingface.co/spaces/ginipick/10m-marketing", | |
| "https://huggingface.co/spaces/openfree/Live-Podcast", | |
| "https://huggingface.co/spaces/openfree/AI-Podcast", | |
| "https://huggingface.co/spaces/ginipick/QR-Canvas-plus", | |
| "https://huggingface.co/spaces/openfree/Badge", | |
| "https://huggingface.co/spaces/VIDraft/mouse-webgen", | |
| "https://huggingface.co/spaces/openfree/Vibe-Game", | |
| "https://huggingface.co/spaces/VIDraft/NH-Prediction", | |
| "https://huggingface.co/spaces/ginipick/NH-Korea", | |
| "https://huggingface.co/spaces/openfree/Naming", | |
| "https://huggingface.co/spaces/ginipick/Change-Hair", | |
| ], | |
| "Multimodal": [ | |
| "https://huggingface.co/spaces/ginigen/VEO3-Free", | |
| "https://huggingface.co/spaces/ginigen/VEO3-Directors", | |
| "https://huggingface.co/spaces/Heartsync/WAN2-1-fast-T2V-FusioniX", | |
| "https://huggingface.co/spaces/Heartsync/adult", | |
| "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored", | |
| "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video2", | |
| "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video", | |
| "https://huggingface.co/spaces/Heartsync/WAN-VIDEO-AUDIO", | |
| "https://huggingface.co/spaces/Heartsync/wan2-1-fast-security", | |
| "https://huggingface.co/spaces/ginigen/Flux-VIDEO", | |
| "https://huggingface.co/spaces/ginigen/3D-LLAMA-V1", | |
| "https://huggingface.co/spaces/ginigen/Flux-VIDEO", | |
| "https://huggingface.co/spaces/openfree/Multilingual-TTS", | |
| "https://huggingface.co/spaces/VIDraft/ACE-Singer", | |
| "https://huggingface.co/spaces/openfree/DreamO-video", | |
| "https://huggingface.co/spaces/fantaxy/Sound-AI-SFX", | |
| "https://huggingface.co/spaces/ginigen/SFX-Sound-magic", | |
| "https://huggingface.co/spaces/ginigen/VoiceClone-TTS", | |
| "https://huggingface.co/spaces/aiqcamp/ENGLISH-Speaking-Scoring", | |
| "https://huggingface.co/spaces/fantaxy/Remove-Video-Background", | |
| ], | |
| "Professional": [ | |
| "https://huggingface.co/spaces/Heartsync/Novel-NSFW", | |
| "https://huggingface.co/spaces/fantaxy/fantasy-novel", | |
| "https://huggingface.co/spaces/VIDraft/money-radar", | |
| "https://huggingface.co/spaces/immunobiotech/drug-discovery", | |
| "https://huggingface.co/spaces/immunobiotech/Gemini-MICHELIN", | |
| "https://huggingface.co/spaces/openfree/Cycle-Navigator", | |
| "https://huggingface.co/spaces/VIDraft/Fashion-Fit", | |
| "https://huggingface.co/spaces/openfree/Stock-Trading-Analysis", | |
| "https://huggingface.co/spaces/ginipick/AgentX-Papers", | |
| "https://huggingface.co/spaces/Heartsync/Papers-Leaderboard", | |
| "https://huggingface.co/spaces/VIDraft/PapersImpact", | |
| "https://huggingface.co/spaces/ginigen/multimodal-chat-mbti-korea", | |
| ], | |
| "Image": [ | |
| "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-REAL", | |
| "https://huggingface.co/spaces/ginigen/FLUX-Ghibli-LoRA2", | |
| "https://huggingface.co/spaces/aiqcamp/REMOVAL-TEXT-IMAGE", | |
| "https://huggingface.co/spaces/VIDraft/BAGEL-Websearch", | |
| "https://huggingface.co/spaces/ginigen/Every-Text", | |
| "https://huggingface.co/spaces/ginigen/text3d-r1", | |
| "https://huggingface.co/spaces/ginipick/FLUXllama", | |
| "https://huggingface.co/spaces/ginigen/Workflow-Canvas", | |
| "https://huggingface.co/spaces/ginigen/canvas-studio", | |
| "https://huggingface.co/spaces/VIDraft/ReSize-Image-Outpainting", | |
| "https://huggingface.co/spaces/Heartsync/FLUX-Vision", | |
| "https://huggingface.co/spaces/fantos/textcutobject", | |
| "https://huggingface.co/spaces/aiqtech/imaginpaint", | |
| "https://huggingface.co/spaces/openfree/ColorRevive", | |
| "https://huggingface.co/spaces/openfree/ultpixgen", | |
| "https://huggingface.co/spaces/VIDraft/Polaroid-Style", | |
| "https://huggingface.co/spaces/ginigen/VisualCloze", | |
| "https://huggingface.co/spaces/fantaxy/ofai-flx-logo", | |
| "https://huggingface.co/spaces/ginigen/interior-design", | |
| "https://huggingface.co/spaces/ginigen/MagicFace-V3", | |
| "https://huggingface.co/spaces/fantaxy/flx-pulid", | |
| "https://huggingface.co/spaces/seawolf2357/Ghibli-Multilingual-Text-rendering", | |
| "https://huggingface.co/spaces/VIDraft/Open-Meme-Studio", | |
| "https://huggingface.co/spaces/VIDraft/stable-diffusion-3.5-large-turboX", | |
| "https://huggingface.co/spaces/aiqtech/flxgif", | |
| "https://huggingface.co/spaces/openfree/VectorFlow", | |
| "https://huggingface.co/spaces/ginigen/3D-LLAMA", | |
| "https://huggingface.co/spaces/ginigen/Multi-LoRAgen", | |
| ], | |
| "LLM / VLM": [ | |
| "https://huggingface.co/spaces/fantaxy/fantasy-novel", | |
| "https://huggingface.co/spaces/ginigen/deepseek-r1-0528-API", | |
| "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API" | |
| "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528", | |
| "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528-qwen3-8b", | |
| "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528", | |
| "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API", | |
| "https://huggingface.co/spaces/VIDraft/Mistral-RAG-BitSix", | |
| "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-4B", | |
| "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-12B", | |
| "https://huggingface.co/spaces/ginigen/Mistral-Perflexity", | |
| "https://huggingface.co/spaces/aiqcamp/gemini-2.5-flash-preview", | |
| "https://huggingface.co/spaces/openfree/qwen3-30b-a3b-research", | |
| "https://huggingface.co/spaces/openfree/qwen3-235b-a22b-research", | |
| "https://huggingface.co/spaces/openfree/Llama-4-Maverick-17B-Research", | |
| ], | |
| } | |
| # ββββββββββββββββββββββββββ 3. DATABASE FUNCTIONS ββββββββββββββββββββββββββ | |
| def init_db(): | |
| # Initialize JSON file if it doesn't exist | |
| if not os.path.exists(DB_FILE): | |
| with open(DB_FILE, "w", encoding="utf-8") as f: | |
| json.dump([], f, ensure_ascii=False) | |
| # Initialize SQLite database | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| cursor.execute(''' | |
| CREATE TABLE IF NOT EXISTS urls ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| url TEXT UNIQUE NOT NULL, | |
| date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |
| ) | |
| ''') | |
| conn.commit() | |
| # If we have data in JSON but not in SQLite (first run with new SQLite DB), | |
| # migrate the data from JSON to SQLite | |
| json_urls = load_json() | |
| if json_urls: | |
| db_urls = load_db_sqlite() | |
| for url in json_urls: | |
| if url not in db_urls: | |
| add_url_to_sqlite(url) | |
| conn.close() | |
| def load_json(): | |
| """Load URLs from JSON file (for backward compatibility)""" | |
| try: | |
| with open(DB_FILE, "r", encoding="utf-8") as f: | |
| raw = json.load(f) | |
| return raw if isinstance(raw, list) else [] | |
| except Exception: | |
| return [] | |
| def save_json(lst): | |
| """Save URLs to JSON file (for backward compatibility)""" | |
| try: | |
| with open(DB_FILE, "w", encoding="utf-8") as f: | |
| json.dump(lst, f, ensure_ascii=False, indent=2) | |
| return True | |
| except Exception: | |
| return False | |
| def load_db_sqlite(): | |
| """Load URLs from SQLite database""" | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| cursor.execute("SELECT url FROM urls ORDER BY date_added DESC") | |
| urls = [row[0] for row in cursor.fetchall()] | |
| conn.close() | |
| return urls | |
| def add_url_to_sqlite(url): | |
| """Add a URL to SQLite database""" | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| try: | |
| cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,)) | |
| conn.commit() | |
| success = True | |
| except sqlite3.IntegrityError: | |
| # URL already exists | |
| success = False | |
| conn.close() | |
| return success | |
| def update_url_in_sqlite(old_url, new_url): | |
| """Update a URL in SQLite database""" | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| try: | |
| cursor.execute("UPDATE urls SET url = ? WHERE url = ?", (new_url, old_url)) | |
| if cursor.rowcount > 0: | |
| conn.commit() | |
| success = True | |
| else: | |
| success = False | |
| except sqlite3.IntegrityError: | |
| # New URL already exists | |
| success = False | |
| conn.close() | |
| return success | |
| def delete_url_from_sqlite(url): | |
| """Delete a URL from SQLite database""" | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| cursor.execute("DELETE FROM urls WHERE url = ?", (url,)) | |
| if cursor.rowcount > 0: | |
| conn.commit() | |
| success = True | |
| else: | |
| success = False | |
| conn.close() | |
| return success | |
| def load_db(): | |
| """Primary function to load URLs - prioritizes SQLite DB but falls back to JSON""" | |
| urls = load_db_sqlite() | |
| if not urls: | |
| # If SQLite DB is empty, try loading from JSON | |
| urls = load_json() | |
| # If we found URLs in JSON, migrate them to SQLite | |
| for url in urls: | |
| add_url_to_sqlite(url) | |
| return urls | |
| def save_db(lst): | |
| """Save URLs to both SQLite and JSON""" | |
| # Get existing URLs from SQLite for comparison | |
| existing_urls = load_db_sqlite() | |
| # Clear all URLs from SQLite and add the new list | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| cursor.execute("DELETE FROM urls") | |
| for url in lst: | |
| cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,)) | |
| conn.commit() | |
| conn.close() | |
| # Also save to JSON for backward compatibility | |
| return save_json(lst) | |
| # ββββββββββββββββββββββββββ 4. URL HELPERS ββββββββββββββββββββββββββ | |
| def direct_url(hf_url): | |
| m = re.match(r"https?://huggingface\.co/spaces/([^/]+)/([^/?#]+)", hf_url) | |
| if not m: | |
| return hf_url | |
| owner, name = m.groups() | |
| owner = owner.lower() | |
| name = name.replace('.', '-').replace('_', '-').lower() | |
| return f"https://{owner}-{name}.hf.space" | |
| def screenshot_url(url): | |
| return f"https://image.thum.io/get/fullpage/{url}" | |
| def process_url_for_preview(url): | |
| """Returns (preview_url, mode)""" | |
| # Handle blocked domains first | |
| if any(d for d in BLOCKED_DOMAINS if d in url): | |
| return screenshot_url(url), "snapshot" | |
| # Special case handling for problematic URLs | |
| if "vibe-coding-tetris" in url or "World-of-Tank-GAME" in url or "Minesweeper-Game" in url: | |
| return screenshot_url(url), "snapshot" | |
| # General HF space handling | |
| try: | |
| if "huggingface.co/spaces" in url: | |
| parts = url.rstrip("/").split("/") | |
| if len(parts) >= 5: | |
| owner = parts[-2] | |
| name = parts[-1] | |
| embed_url = f"https://huggingface.co/spaces/{owner}/{name}/embed" | |
| return embed_url, "iframe" | |
| except Exception: | |
| return screenshot_url(url), "snapshot" | |
| # Default handling | |
| return url, "iframe" | |
| # ββββββββββββββββββββββββββ 5. API ROUTES ββββββββββββββββββββββββββ | |
| def api_category(): | |
| cat = request.args.get('name', '') | |
| urls = CATEGORIES.get(cat, []) | |
| # Add pagination for categories as well | |
| page = int(request.args.get('page', 1)) | |
| per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page | |
| total_pages = max(1, (len(urls) + per_page - 1) // per_page) | |
| start = (page - 1) * per_page | |
| end = min(start + per_page, len(urls)) | |
| urls_page = urls[start:end] | |
| items = [ | |
| { | |
| "title": url.split('/')[-1], | |
| "owner": url.split('/')[-2] if '/spaces/' in url else '', | |
| "iframe": direct_url(url), | |
| "shot": screenshot_url(url), | |
| "hf": url | |
| } for url in urls_page | |
| ] | |
| return jsonify({ | |
| "items": items, | |
| "page": page, | |
| "total_pages": total_pages | |
| }) | |
| def api_favorites(): | |
| # Load URLs from SQLite database | |
| urls = load_db() | |
| page = int(request.args.get('page', 1)) | |
| per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page | |
| total_pages = max(1, (len(urls) + per_page - 1) // per_page) | |
| start = (page - 1) * per_page | |
| end = min(start + per_page, len(urls)) | |
| urls_page = urls[start:end] | |
| result = [] | |
| for url in urls_page: | |
| try: | |
| preview_url, mode = process_url_for_preview(url) | |
| result.append({ | |
| "title": url.split('/')[-1], | |
| "url": url, | |
| "preview_url": preview_url, | |
| "mode": mode | |
| }) | |
| except Exception: | |
| # Fallback to screenshot mode | |
| result.append({ | |
| "title": url.split('/')[-1], | |
| "url": url, | |
| "preview_url": screenshot_url(url), | |
| "mode": "snapshot" | |
| }) | |
| return jsonify({ | |
| "items": result, | |
| "page": page, | |
| "total_pages": total_pages | |
| }) | |
| def add_url(): | |
| url = request.form.get('url', '').strip() | |
| if not url: | |
| return jsonify({"success": False, "message": "URL is required"}) | |
| # SQLiteμ μΆκ° μλ | |
| conn = sqlite3.connect(SQLITE_DB) | |
| cursor = conn.cursor() | |
| try: | |
| cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,)) | |
| conn.commit() | |
| success = True | |
| except sqlite3.IntegrityError: | |
| # URLμ΄ μ΄λ―Έ μ‘΄μ¬νλ κ²½μ° | |
| success = False | |
| except Exception as e: | |
| print(f"SQLite error: {str(e)}") | |
| success = False | |
| finally: | |
| conn.close() | |
| if not success: | |
| return jsonify({"success": False, "message": "URL already exists or could not be added"}) | |
| # JSON νμΌμλ μΆκ° (λ°±μ μ©) | |
| data = load_json() | |
| if url not in data: | |
| data.insert(0, url) | |
| save_json(data) | |
| return jsonify({"success": True, "message": "URL added successfully"}) | |
| def update_url(): | |
| old = request.form.get('old', '') | |
| new = request.form.get('new', '').strip() | |
| if not new: | |
| return jsonify({"success": False, "message": "New URL is required"}) | |
| # Update in SQLite DB | |
| if not update_url_in_sqlite(old, new): | |
| return jsonify({"success": False, "message": "URL not found or new URL already exists"}) | |
| # Also update JSON file for backward compatibility | |
| data = load_json() | |
| try: | |
| idx = data.index(old) | |
| data[idx] = new | |
| save_json(data) | |
| except ValueError: | |
| # If URL not in JSON, add it | |
| data.append(new) | |
| save_json(data) | |
| return jsonify({"success": True, "message": "URL updated successfully"}) | |
| def delete_url(): | |
| url = request.form.get('url', '') | |
| # Delete from SQLite DB | |
| if not delete_url_from_sqlite(url): | |
| return jsonify({"success": False, "message": "URL not found"}) | |
| # Also update JSON file for backward compatibility | |
| data = load_json() | |
| try: | |
| data.remove(url) | |
| save_json(data) | |
| except ValueError: | |
| pass | |
| return jsonify({"success": True, "message": "URL deleted successfully"}) | |
| # ββββββββββββββββββββββββββ 6. MAIN ROUTES ββββββββββββββββββββββββββ | |
| def home(): | |
| os.makedirs('templates', exist_ok=True) | |
| with open('templates/index.html', 'w', encoding='utf-8') as fp: | |
| fp.write(r'''<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Web Gallery</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;600&display=swap'); | |
| body{margin:0;font-family:Nunito,sans-serif;background:#f6f8fb;} | |
| .tabs{display:flex;flex-wrap:wrap;gap:8px;padding:16px;} | |
| .tab{padding:6px 14px;border:none;border-radius:18px;background:#e2e8f0;font-weight:600;cursor:pointer;} | |
| .tab.active{background:#a78bfa;color:#1a202c;} | |
| .tab.manage{background:#ff6e91;color:white;} | |
| .tab.manage.active{background:#ff2d62;color:white;} | |
| /* Updated grid to show 2x2 layout */ | |
| .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;padding:0 16px 60px;max-width:1200px;margin:0 auto;} | |
| @media(max-width:800px){.grid{grid-template-columns:1fr;}} | |
| /* Increased card height for larger display */ | |
| .card{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);overflow:hidden;height:540px;display:flex;flex-direction:column;position:relative;} | |
| .frame{flex:1;position:relative;overflow:hidden;} | |
| .frame iframe{position:absolute;width:166.667%;height:166.667%;transform:scale(.6);transform-origin:top left;border:0;} | |
| .frame img{width:100%;height:100%;object-fit:cover;} | |
| .card-label{position:absolute;top:10px;left:10px;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:bold;z-index:100;text-transform:uppercase;letter-spacing:0.5px;box-shadow:0 2px 4px rgba(0,0,0,0.2);} | |
| .label-live{background:linear-gradient(135deg, #00c6ff, #0072ff);color:white;} | |
| .label-static{background:linear-gradient(135deg, #ff9a9e, #fad0c4);color:#333;} | |
| .foot{height:44px;background:#fafafa;display:flex;align-items:center;justify-content:center;border-top:1px solid #eee;} | |
| .foot a{font-size:.82rem;font-weight:700;color:#4a6dd8;text-decoration:none;} | |
| .pagination{display:flex;justify-content:center;margin:20px 0;gap:10px;} | |
| .pagination button{padding:5px 15px;border:none;border-radius:20px;background:#e2e8f0;cursor:pointer;} | |
| .pagination button:disabled{opacity:0.5;cursor:not-allowed;} | |
| .manage-panel{background:white;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);margin:16px;padding:20px;} | |
| .form-group{margin-bottom:15px;} | |
| .form-group label{display:block;margin-bottom:5px;font-weight:600;} | |
| .form-control{width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;} | |
| .btn{padding:8px 15px;border:none;border-radius:4px;cursor:pointer;font-weight:600;} | |
| .btn-primary{background:#4a6dd8;color:white;} | |
| .btn-danger{background:#e53e3e;color:white;} | |
| .btn-success{background:#38a169;color:white;} | |
| .status{padding:10px;margin:10px 0;border-radius:4px;display:none;} | |
| .status.success{display:block;background:#c6f6d5;color:#22543d;} | |
| .status.error{display:block;background:#fed7d7;color:#822727;} | |
| .url-list{margin:20px 0;border:1px solid #eee;border-radius:4px;max-height:300px;overflow-y:auto;} | |
| .url-item{padding:10px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;} | |
| .url-item:last-child{border-bottom:none;} | |
| .url-controls{display:flex;gap:5px;} | |
| </style> | |
| </head> | |
| <body> | |
| <header style="text-align: center; padding: 20px; background: linear-gradient(135deg, #f6f8fb, #e2e8f0); border-bottom: 1px solid #ddd;"> | |
| <h1 style="margin-bottom: 10px;">πAI Playground</h1> | |
| <p> | |
| <a href="https://discord.gg/openfreeai" target="_blank"><img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="badge"></a> | |
| </p> | |
| </header> | |
| <div class="tabs" id="tabs"></div> | |
| <div id="content"></div> | |
| <script> | |
| // Basic configuration | |
| const cats = {{cats|tojson}}; | |
| const tabs = document.getElementById('tabs'); | |
| const content = document.getElementById('content'); | |
| let active = ""; | |
| let currentPage = 1; | |
| // Simple utility functions | |
| function loadHTML(url, callback) { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open('GET', url, true); | |
| xhr.onreadystatechange = function() { | |
| if (xhr.readyState === 4 && xhr.status === 200) { | |
| callback(xhr.responseText); | |
| } | |
| }; | |
| xhr.send(); | |
| } | |
| function makeRequest(url, method, data, callback) { | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open(method, url, true); | |
| xhr.onreadystatechange = function() { | |
| if (xhr.readyState === 4 && xhr.status === 200) { | |
| callback(JSON.parse(xhr.responseText)); | |
| } | |
| }; | |
| if (method === 'POST') { | |
| xhr.send(data); | |
| } else { | |
| xhr.send(); | |
| } | |
| } | |
| function updateTabs() { | |
| Array.from(tabs.children).forEach(b => { | |
| b.classList.toggle('active', b.dataset.c === active); | |
| }); | |
| } | |
| // Tab handlers | |
| function loadCategory(cat, page) { | |
| if(cat === active && currentPage === page) return; | |
| active = cat; | |
| currentPage = page || 1; | |
| updateTabs(); | |
| content.innerHTML = '<p style="text-align:center;padding:40px">Loadingβ¦</p>'; | |
| makeRequest('/api/category?name=' + encodeURIComponent(cat) + '&page=' + currentPage + '&per_page=4', 'GET', null, function(data) { | |
| let html = '<div class="grid">'; | |
| if(data.items.length === 0) { | |
| html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No items in this category.</p>'; | |
| } else { | |
| data.items.forEach(item => { | |
| html += ` | |
| <div class="card"> | |
| <div class="card-label label-live">LIVE</div> | |
| <div class="frame"> | |
| <iframe src="${item.iframe}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe> | |
| </div> | |
| <div class="foot"> | |
| <a href="${item.hf}" target="_blank">${item.title}</a> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| } | |
| html += '</div>'; | |
| // Add pagination | |
| html += ` | |
| <div class="pagination"> | |
| <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage-1})">Β« Previous</button> | |
| <span>Page ${currentPage} of ${data.total_pages}</span> | |
| <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage+1})">Next Β»</button> | |
| </div> | |
| `; | |
| content.innerHTML = html; | |
| }); | |
| } | |
| function loadFavorites(page) { | |
| if(active === 'Favorites' && currentPage === page) return; | |
| active = 'Favorites'; | |
| currentPage = page || 1; | |
| updateTabs(); | |
| content.innerHTML = '<p style="text-align:center;padding:40px">Loadingβ¦</p>'; | |
| makeRequest('/api/favorites?page=' + currentPage + '&per_page=4', 'GET', null, function(data) { | |
| let html = '<div class="grid">'; | |
| if(data.items.length === 0) { | |
| html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No favorites saved yet.</p>'; | |
| } else { | |
| data.items.forEach(item => { | |
| if(item.mode === 'snapshot') { | |
| html += ` | |
| <div class="card"> | |
| <div class="card-label label-static">Static</div> | |
| <div class="frame"> | |
| <img src="${item.preview_url}" loading="lazy"> | |
| </div> | |
| <div class="foot"> | |
| <a href="${item.url}" target="_blank">${item.title}</a> | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| html += ` | |
| <div class="card"> | |
| <div class="card-label label-live">LIVE</div> | |
| <div class="frame"> | |
| <iframe src="${item.preview_url}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe> | |
| </div> | |
| <div class="foot"> | |
| <a href="${item.url}" target="_blank">${item.title}</a> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| }); | |
| } | |
| html += '</div>'; | |
| // Add pagination | |
| html += ` | |
| <div class="pagination"> | |
| <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadFavorites(${currentPage-1})">Β« Previous</button> | |
| <span>Page ${currentPage} of ${data.total_pages}</span> | |
| <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadFavorites(${currentPage+1})">Next Β»</button> | |
| </div> | |
| `; | |
| content.innerHTML = html; | |
| }); | |
| } | |
| function loadManage() { | |
| if(active === 'Manage') return; | |
| active = 'Manage'; | |
| updateTabs(); | |
| content.innerHTML = ` | |
| <div class="manage-panel"> | |
| <h2>Add New URL</h2> | |
| <div class="form-group"> | |
| <label for="new-url">URL</label> | |
| <input type="text" id="new-url" class="form-control" placeholder="https://example.com"> | |
| </div> | |
| <button onclick="addUrl()" class="btn btn-primary">Add URL</button> | |
| <div id="add-status" class="status"></div> | |
| <h2>Manage Saved URLs</h2> | |
| <div id="url-list" class="url-list">Loading...</div> | |
| </div> | |
| `; | |
| loadUrlList(); | |
| } | |
| // URL management functions | |
| function loadUrlList() { | |
| makeRequest('/api/favorites?per_page=100', 'GET', null, function(data) { | |
| const urlList = document.getElementById('url-list'); | |
| if(data.items.length === 0) { | |
| urlList.innerHTML = '<p style="text-align:center;padding:20px">No URLs saved yet.</p>'; | |
| return; | |
| } | |
| let html = ''; | |
| data.items.forEach(item => { | |
| // Escape the URL to prevent JavaScript injection when used in onclick handlers | |
| const escapedUrl = item.url.replace(/'/g, "\\'"); | |
| html += ` | |
| <div class="url-item"> | |
| <div>${item.url}</div> | |
| <div class="url-controls"> | |
| <button class="btn" onclick="editUrl('${escapedUrl}')">Edit</button> | |
| <button class="btn btn-danger" onclick="deleteUrl('${escapedUrl}')">Delete</button> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| urlList.innerHTML = html; | |
| }); | |
| } | |
| function addUrl() { | |
| const url = document.getElementById('new-url').value.trim(); | |
| if(!url) { | |
| showStatus('add-status', 'Please enter a URL', false); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('url', url); | |
| makeRequest('/api/url/add', 'POST', formData, function(data) { | |
| showStatus('add-status', data.message, data.success); | |
| if(data.success) { | |
| document.getElementById('new-url').value = ''; | |
| loadUrlList(); | |
| // If currently in Favorites tab, reload to see changes immediately | |
| if(active === 'Favorites') { | |
| loadFavorites(currentPage); | |
| } | |
| } | |
| }); | |
| } | |
| function editUrl(url) { | |
| // Decode URL if it was previously escaped | |
| const decodedUrl = url.replace(/\\'/g, "'"); | |
| const newUrl = prompt('Edit URL:', decodedUrl); | |
| if(!newUrl || newUrl === decodedUrl) return; | |
| const formData = new FormData(); | |
| formData.append('old', decodedUrl); | |
| formData.append('new', newUrl); | |
| makeRequest('/api/url/update', 'POST', formData, function(data) { | |
| if(data.success) { | |
| loadUrlList(); | |
| // If currently in Favorites tab, reload to see changes immediately | |
| if(active === 'Favorites') { | |
| loadFavorites(currentPage); | |
| } | |
| } else { | |
| alert(data.message); | |
| } | |
| }); | |
| } | |
| function deleteUrl(url) { | |
| // Decode URL if it was previously escaped | |
| const decodedUrl = url.replace(/\\'/g, "'"); | |
| if(!confirm('Are you sure you want to delete this URL?')) return; | |
| const formData = new FormData(); | |
| formData.append('url', decodedUrl); | |
| makeRequest('/api/url/delete', 'POST', formData, function(data) { | |
| if(data.success) { | |
| loadUrlList(); | |
| // If currently in Favorites tab, reload to see changes immediately | |
| if(active === 'Favorites') { | |
| loadFavorites(currentPage); | |
| } | |
| } else { | |
| alert(data.message); | |
| } | |
| }); | |
| } | |
| function showStatus(id, message, success) { | |
| const status = document.getElementById(id); | |
| status.textContent = message; | |
| status.className = success ? 'status success' : 'status error'; | |
| setTimeout(() => { | |
| status.className = 'status'; | |
| }, 3000); | |
| } | |
| // Create tabs | |
| // Favorites tab first | |
| const favTab = document.createElement('button'); | |
| favTab.className = 'tab'; | |
| favTab.textContent = 'Favorites'; | |
| favTab.dataset.c = 'Favorites'; | |
| favTab.onclick = function() { loadFavorites(1); }; | |
| tabs.appendChild(favTab); | |
| // Category tabs | |
| cats.forEach(c => { | |
| const b = document.createElement('button'); | |
| b.className = 'tab'; | |
| b.textContent = c; | |
| b.dataset.c = c; | |
| b.onclick = function() { loadCategory(c, 1); }; | |
| tabs.appendChild(b); | |
| }); | |
| // Manage tab last | |
| const manageTab = document.createElement('button'); | |
| manageTab.className = 'tab manage'; | |
| manageTab.textContent = 'Manage'; | |
| manageTab.dataset.c = 'Manage'; | |
| manageTab.onclick = function() { loadManage(); }; | |
| tabs.appendChild(manageTab); | |
| // Start with Favorites tab | |
| loadFavorites(1); | |
| </script> | |
| </body> | |
| </html>''') | |
| # Return the rendered template | |
| return render_template('index.html', cats=list(CATEGORIES.keys())) | |
| # Initialize database on startup | |
| init_db() | |
| # Define a function to ensure database consistency | |
| def ensure_db_consistency(): | |
| # Make sure we have the latest data in both JSON and SQLite | |
| urls = load_db_sqlite() | |
| save_json(urls) | |
| # For Flask 2.0+ compatibility | |
| def before_request_func(): | |
| # Use a flag to run this only once | |
| if not hasattr(app, '_got_first_request'): | |
| ensure_db_consistency() | |
| app._got_first_request = True | |
| if __name__ == '__main__': | |
| # μ± μμ μ μ λͺ μμ μΌλ‘ DB μ΄κΈ°ν | |
| print("Initializing database...") | |
| init_db() | |
| # λ°μ΄ν°λ² μ΄μ€ νμΌ κ²½λ‘ λ° μ‘΄μ¬ μ¬λΆ νμΈ | |
| db_path = os.path.abspath(SQLITE_DB) | |
| print(f"SQLite DB path: {db_path}") | |
| if os.path.exists(SQLITE_DB): | |
| print(f"Database file exists, size: {os.path.getsize(SQLITE_DB)} bytes") | |
| else: | |
| print("Warning: Database file does not exist after initialization!") | |
| app.run(host='0.0.0.0', port=7860) |