|
from flask import Flask, render_template, request, jsonify |
|
import os, re, json, sqlite3 |
|
|
|
app = Flask(__name__) |
|
|
|
|
|
DB_FILE = "favorite_sites.json" |
|
SQLITE_DB = "favorite_sites.db" |
|
|
|
|
|
BLOCKED_DOMAINS = [ |
|
"naver.com", "daum.net", "google.com", |
|
"facebook.com", "instagram.com", "kakao.com", |
|
"ycombinator.com" |
|
] |
|
|
|
|
|
|
|
CATEGORIES = { |
|
|
|
"Productivity": [ |
|
|
|
"https://huggingface.co/spaces/aiqtech/Heatmap-Leaderboard", |
|
"https://huggingface.co/spaces/VIDraft/DNA-CASINO", |
|
|
|
"https://huggingface.co/spaces/openfree/Open-GAMMA", |
|
"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/Heartsync/NSFW-Uncensored-photo", |
|
|
|
"https://huggingface.co/spaces/fantaxy/YTB-TEST", |
|
"https://huggingface.co/spaces/ginigen/Seedance-Free", |
|
"https://huggingface.co/spaces/Heartsync/VEO3-RealTime", |
|
"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/NSFW-novels", |
|
"https://huggingface.co/spaces/aiqtech/SOMA-Oriental", |
|
"https://huggingface.co/spaces/VIDraft/SOMA-AGI", |
|
"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/ginigen/Flux-Kontext-FaceLORA", |
|
"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", |
|
], |
|
} |
|
|
|
|
|
def init_db(): |
|
|
|
if not os.path.exists(DB_FILE): |
|
with open(DB_FILE, "w", encoding="utf-8") as f: |
|
json.dump([], f, ensure_ascii=False) |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
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: |
|
|
|
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: |
|
|
|
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: |
|
|
|
urls = load_json() |
|
|
|
for url in urls: |
|
add_url_to_sqlite(url) |
|
return urls |
|
|
|
def save_db(lst): |
|
"""Save URLs to both SQLite and JSON""" |
|
|
|
existing_urls = load_db_sqlite() |
|
|
|
|
|
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() |
|
|
|
|
|
return save_json(lst) |
|
|
|
|
|
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)""" |
|
|
|
if any(d for d in BLOCKED_DOMAINS if d in url): |
|
return screenshot_url(url), "snapshot" |
|
|
|
|
|
if "vibe-coding-tetris" in url or "World-of-Tank-GAME" in url or "Minesweeper-Game" in url: |
|
return screenshot_url(url), "snapshot" |
|
|
|
|
|
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" |
|
|
|
|
|
return url, "iframe" |
|
|
|
|
|
@app.route('/api/category') |
|
def api_category(): |
|
cat = request.args.get('name', '') |
|
urls = CATEGORIES.get(cat, []) |
|
|
|
|
|
page = int(request.args.get('page', 1)) |
|
per_page = int(request.args.get('per_page', 4)) |
|
|
|
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 |
|
}) |
|
|
|
@app.route('/api/favorites') |
|
def api_favorites(): |
|
|
|
urls = load_db() |
|
|
|
page = int(request.args.get('page', 1)) |
|
per_page = int(request.args.get('per_page', 4)) |
|
|
|
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: |
|
|
|
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 |
|
}) |
|
|
|
@app.route('/api/url/add', methods=['POST']) |
|
def add_url(): |
|
url = request.form.get('url', '').strip() |
|
if not url: |
|
return jsonify({"success": False, "message": "URL is required"}) |
|
|
|
|
|
conn = sqlite3.connect(SQLITE_DB) |
|
cursor = conn.cursor() |
|
try: |
|
cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,)) |
|
conn.commit() |
|
success = True |
|
except sqlite3.IntegrityError: |
|
|
|
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"}) |
|
|
|
|
|
data = load_json() |
|
if url not in data: |
|
data.insert(0, url) |
|
save_json(data) |
|
|
|
return jsonify({"success": True, "message": "URL added successfully"}) |
|
|
|
@app.route('/api/url/update', methods=['POST']) |
|
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"}) |
|
|
|
|
|
if not update_url_in_sqlite(old, new): |
|
return jsonify({"success": False, "message": "URL not found or new URL already exists"}) |
|
|
|
|
|
data = load_json() |
|
try: |
|
idx = data.index(old) |
|
data[idx] = new |
|
save_json(data) |
|
except ValueError: |
|
|
|
data.append(new) |
|
save_json(data) |
|
|
|
return jsonify({"success": True, "message": "URL updated successfully"}) |
|
|
|
@app.route('/api/url/delete', methods=['POST']) |
|
def delete_url(): |
|
url = request.form.get('url', '') |
|
|
|
|
|
if not delete_url_from_sqlite(url): |
|
return jsonify({"success": False, "message": "URL not found"}) |
|
|
|
|
|
data = load_json() |
|
try: |
|
data.remove(url) |
|
save_json(data) |
|
except ValueError: |
|
pass |
|
|
|
return jsonify({"success": True, "message": "URL deleted successfully"}) |
|
|
|
|
|
@app.route('/') |
|
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 render_template('index.html', cats=list(CATEGORIES.keys())) |
|
|
|
|
|
init_db() |
|
|
|
|
|
def ensure_db_consistency(): |
|
|
|
urls = load_db_sqlite() |
|
save_json(urls) |
|
|
|
|
|
@app.before_request |
|
def before_request_func(): |
|
|
|
if not hasattr(app, '_got_first_request'): |
|
ensure_db_consistency() |
|
app._got_first_request = True |
|
|
|
if __name__ == '__main__': |
|
|
|
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) |