Trae Assistant
调整类型
e63975c
from __future__ import annotations
import os
import json
import datetime
import threading
import time
import signal
import sys
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
import urllib.parse
import mimetypes
import requests
import shutil
try:
from dotenv import load_dotenv
load_dotenv(override=True)
except ImportError:
pass
try:
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
except ImportError:
HfApi = None
# Configuration
# Use local repository data directory directly
DATA_DIR = Path("hf_project_showcase_data")
DATA_DIR.mkdir(parents=True, exist_ok=True)
PROJECTS_FILE = DATA_DIR / "projects.json"
COMMENTS_FILE = DATA_DIR / "comments.json"
VISITS_FILE = DATA_DIR / "visits.jsonl"
# Sync Manager
class SyncManager:
def __init__(self, repo_id: str, token: str, data_dir: Path):
self.repo_id = repo_id
self.token = token
self.data_dir = data_dir
self.api = HfApi(token=token) if HfApi else None
self.initial_pull_success = False
# Async Queue Push
self.push_queue = set()
self.push_lock = threading.Lock()
self.is_pushing = False
def pull(self):
if not self.api: return
print("Sync: Pulling data...")
try:
temp_dir = self.data_dir.parent / "temp_data_sync"
if temp_dir.exists(): shutil.rmtree(temp_dir)
temp_dir.mkdir()
snapshot_download(
repo_id=self.repo_id,
repo_type="dataset",
local_dir=temp_dir,
token=self.token,
allow_patterns=["*.json", "*.jsonl", "images/*"]
)
self._merge_json(self.data_dir / "projects.json", temp_dir / "projects.json", "updated_at")
self._merge_json(self.data_dir / "comments.json", temp_dir / "comments.json", "created_at")
src_img = temp_dir / "images"
dst_img = self.data_dir / "images"
if src_img.exists():
dst_img.mkdir(exist_ok=True)
for f in src_img.iterdir():
if f.is_file():
# Always overwrite images from remote to ensure consistency (especially for fixed assets like QR code)
shutil.copy2(f, dst_img / f.name)
self._merge_visits(self.data_dir / "visits.jsonl", temp_dir / "visits.jsonl")
shutil.rmtree(temp_dir)
self.initial_pull_success = True
print("Sync: Pull completed successfully.")
except Exception as e:
print(f"Sync: Failed to pull data: {e}")
self.initial_pull_success = False
def _merge_visits(self, local_path: Path, remote_path: Path):
if not remote_path.exists(): return
# Read all visits
visits = []
seen = set()
# Helper to process file
def process_file(p):
if not p.exists(): return
try:
with open(p, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line: continue
# Deduplicate by full JSON string content
if line not in seen:
seen.add(line)
visits.append(line)
except Exception as e:
print(f"Sync: Error reading visits {p}: {e}")
# Remote first (history), then local (recent) - order doesn't matter for set, but for list order
process_file(remote_path)
process_file(local_path)
# Write back
try:
with open(local_path, "w", encoding="utf-8") as f:
for v in visits:
f.write(v + "\n")
except Exception as e:
print(f"Sync: Error writing merged visits: {e}")
def _merge_json(self, local_path: Path, remote_path: Path, time_key: str):
if not remote_path.exists(): return
if not local_path.exists():
shutil.copy2(remote_path, local_path)
return
try:
with open(local_path, "r", encoding="utf-8") as f: l_data = json.load(f)
with open(remote_path, "r", encoding="utf-8") as f: r_data = json.load(f)
merged = {str(x.get("id")): x for x in l_data}
for x in r_data:
kid = str(x.get("id"))
if kid in merged:
if x.get(time_key, "") > merged[kid].get(time_key, ""):
merged[kid] = x
else:
merged[kid] = x
res = list(merged.values())
res.sort(key=lambda x: x.get(time_key, ""), reverse=True)
with open(local_path, "w", encoding="utf-8") as f:
json.dump(res, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"Sync: Merge error {local_path}: {e}")
def _merge_visits_atomic(self):
"""
Special atomic fetch-merge-push strategy for visits.jsonl.
This ensures that we can always recover and sync visits even if initial pull failed.
"""
print("Sync: Performing atomic merge-push for visits.jsonl...")
temp_file = self.data_dir.parent / "temp_visits_remote.jsonl"
local_file = self.data_dir / "visits.jsonl"
try:
# 1. Fetch remote file specifically (ignore failures if remote doesn't exist yet)
if self.api:
try:
hf_hub_download(
repo_id=self.repo_id,
repo_type="dataset",
filename="visits.jsonl",
local_dir=self.data_dir.parent,
local_dir_use_symlinks=False,
token=self.token
)
# Note: hf_hub_download downloads to a specific structure, we need to find it or specify local_dir carefully.
# Actually, if we use hf_hub_download with local_dir, it keeps structure.
# Simpler: just use the path it returns.
# But let's use a simpler approach: download to a temp name?
# hf_hub_download returns the path.
remote_path = hf_hub_download(
repo_id=self.repo_id,
repo_type="dataset",
filename="visits.jsonl",
token=self.token
)
# Copy to our temp location to be safe
shutil.copy2(remote_path, temp_file)
except Exception as e:
print(f"Sync: Could not fetch remote visits.jsonl (might be new): {e}")
if temp_file.exists(): temp_file.unlink()
# 2. Merge logic (Remote + Local -> Local)
if temp_file.exists():
self._merge_visits(local_file, temp_file)
temp_file.unlink()
# 3. Push Local -> Remote
if self.api and local_file.exists():
self.api.upload_file(
path_or_fileobj=local_file,
path_in_repo="visits.jsonl",
repo_id=self.repo_id,
repo_type="dataset",
commit_message="Update visits.jsonl (Atomic Sync)"
)
print("Sync: Atomic visits sync completed.")
except Exception as e:
print(f"Sync: Atomic visits sync failed: {e}")
def push(self, filenames: List[str] = None):
if not self.api: return
# Special handling for visits.jsonl to bypass global lock and ensure data safety
if filenames and "visits.jsonl" in filenames and len(filenames) == 1:
self._merge_visits_atomic()
return
# Global safety check for bulk operations
if not self.initial_pull_success:
print("Sync: Warning - Initial pull was not successful. Attempting re-pull before push...")
self.pull()
if not self.initial_pull_success:
print("Sync: CRITICAL - Pull failed. Aborting push to prevent data overwriting.")
return
print(f"Sync: Pushing {filenames if filenames else 'all'}...")
try:
if filenames is None:
# Push everything (including images)
# If running locally (not in Space), exclude visits.jsonl to prevent overwriting live data
ignore_patterns = ["*.tmp", ".*"]
if not os.getenv("SPACE_ID"):
print("Sync: Local environment detected. Excluding visits.jsonl from bulk push to protect live data.")
ignore_patterns.append("visits.jsonl")
self.api.upload_folder(
folder_path=self.data_dir,
repo_id=self.repo_id,
repo_type="dataset",
commit_message="Sync update",
path_in_repo=".",
ignore_patterns=ignore_patterns
)
else:
# Push specific files
for filename in filenames:
# Safety check for visits.jsonl in local env
if filename == "visits.jsonl" and not os.getenv("SPACE_ID"):
print("Sync: Skipping explicit push of visits.jsonl in local environment to protect live data.")
continue
path = self.data_dir / filename
if path.exists():
self.api.upload_file(
path_or_fileobj=path,
path_in_repo=filename,
repo_id=self.repo_id,
repo_type="dataset",
commit_message=f"Update {filename}"
)
except Exception as e:
print(f"Sync: Failed to push: {e}")
def trigger_push(self, filenames: List[str] = None):
"""
Thread-safe, queue-based push trigger.
Merges concurrent requests and processes them sequentially in a background worker.
"""
with self.push_lock:
if filenames:
self.push_queue.update(filenames)
else:
# None means push all. We can represent "all" with a special marker or just empty
# But to keep it simple, if we get None, we might want to set a flag "push_all"
# For now, let's just handle specific files or fallback to periodic full push.
# Actually, our main use case is "visits.jsonl".
pass
if not self.is_pushing:
self.is_pushing = True
threading.Thread(target=self._push_worker, daemon=True).start()
def _push_worker(self):
while True:
# Get files to push
to_push = set()
with self.push_lock:
if not self.push_queue:
self.is_pushing = False
return
to_push = self.push_queue.copy()
self.push_queue.clear()
# Perform push
try:
# If we have files, push them.
# If list is empty (shouldn't be due to check above), do nothing.
if to_push:
self.push(list(to_push))
except Exception as e:
print(f"Sync: Worker error: {e}")
# Sleep briefly to batch very rapid subsequent requests (debounce)
time.sleep(2)
def push_async(self, filenames: List[str] = None):
# Legacy method, redirect to smart trigger
self.trigger_push(filenames)
def start_periodic_push(self, interval=60):
# Only start periodic push if running on Hugging Face Space
if not os.getenv("SPACE_ID"):
print("Sync: Local environment detected. Periodic push disabled.")
return
def _loop():
while True:
time.sleep(interval)
# Only push visits periodically
self.push(["visits.jsonl"])
t = threading.Thread(target=_loop, daemon=True)
t.start()
# Initialize Sync Manager
HF_TOKEN = os.getenv("HF_TOKEN")
if not HF_TOKEN:
print("WARNING: HF_TOKEN not found in environment!")
elif HF_TOKEN.startswith("hf_") and len(HF_TOKEN) < 30:
# Simple heuristic, not perfect
print("WARNING: HF_TOKEN looks suspiciously short. Ensure it is a valid token with WRITE permissions.")
# Check for Write Permission (Basic check by scope if possible, or just log reminder)
print("Startup: Ensure HF_TOKEN has WRITE permissions to the dataset. Read-only tokens will cause sync failures.")
HF_DB_REPO = os.getenv("HF_DB_REPO", "duqing2026/project-show-data")
sync_manager = SyncManager(HF_DB_REPO, HF_TOKEN, DATA_DIR)
PROJECT_TYPES = [
{"key": "agent", "label": "Agent", "gradient": "linear-gradient(135deg, #f43f5e, #f59e0b)"},
{"key": "prjprt", "label": "项目实战", "gradient": "linear-gradient(135deg, #7c3aed, #c026d3)"},
{"key": "rag", "label": "RAG 系统", "gradient": "linear-gradient(135deg, #3566d3, #06b6d4)"},
{"key": "new_human", "label": "新人类", "gradient": "linear-gradient(135deg, #f43f5e, #f59e0b)"},
{"key": "app", "label": "APP", "gradient": "linear-gradient(135deg, #0d9488, #2dd4bf)"},
{"key": "model", "label": "模型", "gradient": "linear-gradient(135deg, #db2777, #f472b6)"},
{"key": "commerce", "label": "电商", "gradient": "linear-gradient(135deg, #ea580c, #f59e0b)"},
{"key": "game", "label": "游戏", "gradient": "linear-gradient(135deg, #059669, #34d399)"},
{"key": "static", "label": "静态网页", "gradient": "linear-gradient(135deg, #475569, #94a3b8)"},
{"key": "proLan", "label": "程序语言", "gradient": "linear-gradient(135deg, #dc2626, #f87171)"},
{"key": "miniapp", "label": "小程序", "gradient": "linear-gradient(135deg, #0d9488, #2dd4bf)"},
]
def _now() -> str:
# Use Beijing Time (UTC+8)
return (datetime.datetime.utcnow() + datetime.timedelta(hours=8)).isoformat()
class DataStore:
def __init__(self):
self.projects: List[Dict[str, Any]] = []
self.comments: List[Dict[str, Any]] = []
self.stats_cache = {"pv": 0, "uv": 0, "today": 0, "uv_set": set(), "last_update": ""}
self.visit_buffer_count = 0
self.lock = threading.Lock()
def load_projects_and_comments(self):
if PROJECTS_FILE.exists():
try:
with open(PROJECTS_FILE, "r", encoding="utf-8") as f:
self.projects = json.load(f)
except Exception as e:
print(f"Error loading projects: {e}")
self.projects = []
if COMMENTS_FILE.exists():
try:
with open(COMMENTS_FILE, "r", encoding="utf-8") as f:
self.comments = json.load(f)
except:
self.comments = []
def load_visits(self):
self._recalc_stats()
def load(self):
self.load_projects_and_comments()
self.load_visits()
def _recalc_stats(self):
if not VISITS_FILE.exists():
return
print("Recalculating stats from visits log...")
total_pv = 0
uv_set = set()
today_pv = 0
now_utc = datetime.datetime.utcnow()
beijing_now = now_utc + datetime.timedelta(hours=8)
beijing_today_start = beijing_now.replace(hour=0, minute=0, second=0, microsecond=0)
start_iso = beijing_today_start.isoformat()
try:
with open(VISITS_FILE, "r", encoding="utf-8") as f:
for line in f:
if not line.strip(): continue
try:
v = json.loads(line)
if v.get("is_local"): continue
total_pv += 1
if v.get("ip"): uv_set.add(v.get("ip"))
if v.get("created_at", "") >= start_iso:
today_pv += 1
except: pass
except Exception as e:
print(f"Stats calc error: {e}")
with self.lock:
self.stats_cache["pv"] = total_pv
self.stats_cache["uv"] = len(uv_set)
self.stats_cache["uv_set"] = uv_set
self.stats_cache["today"] = today_pv
self.stats_cache["last_update"] = start_iso
def save_projects(self):
with self.lock:
with open(PROJECTS_FILE, "w", encoding="utf-8") as f:
json.dump(self.projects, f, ensure_ascii=False, indent=2)
sync_manager.push_async(["projects.json"])
def save_comments(self):
with self.lock:
with open(COMMENTS_FILE, "w", encoding="utf-8") as f:
json.dump(self.comments, f, ensure_ascii=False, indent=2)
sync_manager.push_async(["comments.json"])
def record_visit(self, ip: str, path: str, force: bool = False):
is_local = 1 if ip in ("127.0.0.1", "::1", "localhost") else 0
# Strictly ignore local visits to keep data clean, unless forced for testing
if is_local and not force:
return
visit = {
"ip": ip,
"path": path,
"is_local": is_local,
"created_at": _now()
}
# Append to file
try:
with open(VISITS_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(visit, ensure_ascii=False) + "\n")
# Sync: Trigger immediate background push for visits
# This ensures that even if the app crashes or restarts shortly after, the visit is saved.
# The SyncManager handles debouncing and queueing to prevent performance issues.
if not is_local or force:
sync_manager.trigger_push(["visits.jsonl"])
except Exception as e:
print(f"Error recording visit: {e}")
# Update memory stats
if not is_local or force:
with self.lock:
self.stats_cache["pv"] += 1
if ip: self.stats_cache["uv_set"].add(ip)
self.stats_cache["uv"] = len(self.stats_cache["uv_set"])
# Check if day changed
now_utc = datetime.datetime.utcnow()
beijing_now = now_utc + datetime.timedelta(hours=8)
beijing_today_start = beijing_now.replace(hour=0, minute=0, second=0, microsecond=0)
start_iso = beijing_today_start.isoformat()
if start_iso > self.stats_cache["last_update"]:
# New day, reset today count (approximate, strictly should re-read or track timestamps in memory, but for efficiency reset is ok if we assume monotonic)
# Actually if we reset to 0, we lose today's visits before this moment if we don't re-scan.
# Ideally we just increment today_pv if visit time >= start_iso.
# If cached start_iso is old, it means we crossed midnight.
self.stats_cache["today"] = 1 # Start with 1 (this visit)
self.stats_cache["last_update"] = start_iso
else:
self.stats_cache["today"] += 1
def get_stats_dict(self) -> Dict[str, int]:
with self.lock:
return {
"pv": self.stats_cache["pv"],
"uv": self.stats_cache["uv"],
"today": self.stats_cache["today"]
}
store = DataStore()
class ShowcaseHandler(BaseHTTPRequestHandler):
def _json(self, payload: Dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self, html: str, status: int = 200) -> None:
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _parse(self) -> Tuple[str, str]:
path = self.path.split("?", 1)[0]
return self.command, path
def do_GET(self) -> None:
_, path = self._parse()
if path == "/robots.txt":
content = "User-agent: *\nAllow: /\nSitemap: https://huggingface.co/spaces/duqing2026/project-show/sitemap.xml"
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content.encode("utf-8"))
return
if path == "/sitemap.xml":
base_url = "https://huggingface.co/spaces/duqing2026/project-show"
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
xml += f' <url>\n <loc>{base_url}</loc>\n <changefreq>daily</changefreq>\n <priority>1.0</priority>\n </url>\n'
# Add projects to sitemap
try:
for p in store.projects:
pid = p.get("id")
if pid is not None:
updated = p.get("updated_at", "")
if updated and len(updated) >= 10: updated = updated[:10]
else: updated = datetime.datetime.utcnow().strftime("%Y-%m-%d")
# XML escape for loc if needed, but IDs are usually ints. safe.
xml += f' <url>\n <loc>{base_url}?project_id={pid}</loc>\n <changefreq>weekly</changefreq>\n <priority>0.8</priority>\n <lastmod>{updated}</lastmod>\n </url>\n'
except Exception as e:
print(f"Sitemap gen error: {e}")
xml += '</urlset>'
self.send_response(200)
self.send_header("Content-Type", "application/xml")
self.send_header("Content-Length", str(len(xml)))
self.end_headers()
self.wfile.write(xml.encode("utf-8"))
return
if path.startswith("/static/"):
# Serve static files relative to app.py location
file_path = Path(__file__).parent / path.lstrip("/")
# Security check: ensure path is within static directory
try:
# Resolve to absolute path to check containment
file_path = file_path.resolve()
static_root = (Path(__file__).parent / "static").resolve()
if static_root not in file_path.parents and file_path != static_root:
# Allow serving the static dir itself? No, usually files.
# Actually, if path is /static/favicon.ico, file_path is .../static/favicon.ico
# static_root is .../static
# file_path.parents includes .../static.
pass
else:
# Double check for ".." in original string to be safe against traversal before resolve
if ".." in path:
self._json({"ok": False, "error": "forbidden"}, status=403)
return
except Exception:
self._json({"ok": False, "error": "forbidden"}, status=403)
return
if file_path.exists() and file_path.is_file():
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type: mime_type = "application/octet-stream"
try:
with open(file_path, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
except Exception as e:
print(f"Error serving static file {path}: {e}")
self._json({"ok": False, "error": "internal_error"}, status=500)
else:
self._json({"ok": False, "error": "not_found"}, status=404)
return
if path.startswith("/data/"):
# Serve files from DATA_DIR (hf_project_showcase_data)
# path is like /data/images/1.jpg -> hf_project_showcase_data/images/1.jpg
subpath = path[6:] # remove /data/
file_path = DATA_DIR / subpath
# Security check
if ".." in subpath:
self._json({"ok": False, "error": "forbidden"}, status=403)
return
if file_path.exists() and file_path.is_file():
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type: mime_type = "application/octet-stream"
try:
with open(file_path, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
except Exception as e:
print(f"Error serving data file {path}: {e}")
self._json({"ok": False, "error": "internal_error"}, status=500)
else:
self._json({"ok": False, "error": "not_found"}, status=404)
return
if path in ("/", "/index.html", "/showcase"):
self._html(self._page())
return
if path == "/api/stats":
self._json({"ok": True, "stats": store.get_stats_dict()})
return
if path.startswith("/api/projects"):
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
q = qs.get("q", [""])[0] or None
ptypes = qs.get("type")
all_items = store.projects
# Filter
if q:
q = q.lower()
all_items = [
p for p in all_items
if q in (p.get("name") or "").lower() or
q in (p.get("description") or "").lower() or
q in (p.get("keywords") or "").lower()
]
if ptypes:
# ptypes is a list of strings
# Item ptypes is also a list
all_items = [
p for p in all_items
if set(p.get("ptypes", [])) & set(ptypes)
]
# Sort by updated_at desc
all_items.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
# Counts
counts: Dict[str, int] = {t["key"]: 0 for t in PROJECT_TYPES}
# Recalculate counts based on full list (before query filter? usually counts show global availability or filtered? Standard is filtered counts or global? Let's do global for now as per UI typical behavior, or filtered. Original SQL did global if no query?)
# Original code:
# 1. Get all matches for counts (based on q)
# 2. Then filter by ptypes for items
# Let's replicate original logic:
# First filter by q
q_items = store.projects
if q:
q_items = [p for p in q_items if q in (p.get("name") or "").lower() or q in (p.get("description") or "").lower() or q in (p.get("keywords") or "").lower()]
for p in q_items:
for t in p.get("ptypes", []):
counts[t] = counts.get(t, 0) + 1
# Then filter by type for result
items = q_items
if ptypes:
items = [p for p in items if set(p.get("ptypes", [])) & set(ptypes)]
self._json({"ok": True, "items": items, "counts": counts, "total": len(q_items)})
return
if path.startswith("/api/comments"):
qs = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query)
try:
pid = int(qs.get("project_id", ["0"])[0])
except: pid = 0
# Get comments for project
# Assuming project_id in comments refers to... wait, JSON projects don't have stable integer IDs unless we assigned them.
# SQLite had autoincrement ID.
# When migrating, we kept IDs?
# In SQLite: id INTEGER PRIMARY KEY.
# In JSON: we should preserve 'id'.
# New projects? We need to assign ID.
# Let's find max ID in projects.
# Filter comments
items = [c for c in store.comments if c.get("project_id") == pid]
items.sort(key=lambda x: x.get("id", 0), reverse=True)
self._json({"ok": True, "items": items})
return
self._json({"ok": False, "error": "not_found"}, status=404)
def do_POST(self) -> None:
_, path = self._parse()
length = int(self.headers.get("Content-Length") or "0")
# Optimize: Don't read full body yet if we might stream, but here we read all for simplicity
# For binary upload, we use data directly.
data = self.rfile.read(length) if length > 0 else b"{}"
# Try parse JSON for standard APIs
try:
payload = json.loads(data.decode("utf-8"))
except:
payload = {}
if path == "/api/projects/add":
# Upsert
url = payload.get("hf_space_url")
existing = None
idx = -1
if url:
for i, p in enumerate(store.projects):
if p.get("hf_space_url") == url:
existing = p
idx = i
break
now = _now()
new_item = {
"name": payload.get("name") or "",
"description": payload.get("description") or "",
"hf_space_url": url,
"embed_url": payload.get("embed_url"),
"keywords": payload.get("keywords") or "",
"video_url": payload.get("video_url"),
"updated_at": now,
"tags": payload.get("tags") or []
}
# Ptypes
ptypes_in = payload.get("ptypes") or payload.get("ptype")
if isinstance(ptypes_in, list): new_item["ptypes"] = [str(x) for x in ptypes_in]
elif isinstance(ptypes_in, str) and ptypes_in: new_item["ptypes"] = [ptypes_in]
else: new_item["ptypes"] = []
if existing:
new_item["id"] = existing.get("id")
new_item["created_at"] = existing.get("created_at")
store.projects[idx] = new_item
else:
# Generate ID
max_id = 0
for p in store.projects:
pid = p.get("id")
if isinstance(pid, int) and pid > max_id: max_id = pid
new_item["id"] = max_id + 1
new_item["created_at"] = now
store.projects.insert(0, new_item)
store.save_projects()
self._json({"ok": True, "item": new_item})
return
if path == "/api/visit":
user = self.headers.get("X-Hf-User")
if user and user.lower() == "duqing2026":
self._json({"ok": True, "ignored": True})
return
client_ref = payload.get("referrer")
client_url = payload.get("url") # From Navigation.currentEntry.url or location.href
header_ref = self.headers.get("Referer", "")
# Debug log
print(f"Visit: url={client_url}, ref={client_ref}, header_ref={header_ref}")
ip = self.headers.get("X-Forwarded-For", self.client_address[0])
if "," in ip: ip = ip.split(",")[0].strip()
force = payload.get("force", False)
store.record_visit(ip, "/", force=force)
self._json({"ok": True})
return
# Serve data files (like images in hf_project_showcase_data)
if path.startswith("/data/"):
# Security check: prevent directory traversal
if ".." in path:
self._json({"ok": False, "error": "forbidden"}, status=403)
return
# Remove /data/ prefix
rel_path = path[6:]
file_path = DATA_DIR / rel_path
if file_path.exists() and file_path.is_file():
# Check if it's within DATA_DIR
try:
file_path.resolve().relative_to(DATA_DIR.resolve())
except ValueError:
self._json({"ok": False, "error": "forbidden"}, status=403)
return
mime_type, _ = mimetypes.guess_type(file_path)
if not mime_type: mime_type = "application/octet-stream"
try:
with open(file_path, "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", mime_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
except Exception as e:
print(f"Error serving data file {path}: {e}")
self._json({"ok": False, "error": "internal_error"}, status=500)
else:
self._json({"ok": False, "error": "not_found"}, status=404)
return
if path == "/api/projects/delete":
url = payload.get("hf_space_url")
pid = payload.get("id")
initial_len = len(store.projects)
if pid is not None:
store.projects = [p for p in store.projects if p.get("id") != pid]
elif url:
store.projects = [p for p in store.projects if p.get("hf_space_url") != url]
if len(store.projects) < initial_len:
store.save_projects()
self._json({"ok": True})
return
if path == "/api/comments/add":
pid = int(payload.get("project_id") or 0)
author = payload.get("author") or ""
content = payload.get("content") or ""
# Get max comment ID
max_id = 0
for c in store.comments:
cid = c.get("id")
if isinstance(cid, int) and cid > max_id: max_id = cid
new_comment = {
"id": max_id + 1,
"project_id": pid,
"author": author,
"content": content,
"rating": payload.get("rating"),
"created_at": _now()
}
store.comments.insert(0, new_comment)
store.save_comments()
self._json({"ok": True, "item": new_comment})
return
def _page(self) -> str:
options_html = '<option value="">选择项目类型</option>'
for t in PROJECT_TYPES:
options_html += f'<option value="{t["key"]}">{t["label"]}</option>'
types_json = json.dumps(PROJECT_TYPES, ensure_ascii=False)
# NOTE: Updated frontend fetch logic
template = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>渡青的项目展示 - 全栈开发与AI实战 | Duqing's Showcase</title>
<meta name="description" content="探索渡青的个人项目集,涵盖RAG系统、电商应用、游戏开发、小程序及AI模型实战。分享编程心得与技术创新。" />
<meta name="keywords" content="渡青, 个人主页, 项目展示, 全栈开发, Python, RAG, AI, 编程博客, 语雀" />
<meta name="author" content="渡青" />
<meta property="og:type" content="website" />
<meta property="og:title" content="渡青的项目展示 - 全栈开发与AI实战" />
<meta property="og:description" content="探索渡青的个人项目集,涵盖RAG系统、电商应用、游戏开发、小程序及AI模型实战。" />
<meta property="twitter:card" content="summary" />
<meta property="twitter:title" content="渡青的项目展示" />
<meta property="twitter:description" content="探索渡青的个人项目集,涵盖RAG系统、电商应用、游戏开发、小程序及AI模型实战。" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProfilePage",
"mainEntity": {
"@type": "Person",
"name": "渡青",
"alternateName": "Duqing",
"description": "全栈开发者,专注于 Python, RAG, AI Agent 及 Web 应用开发。",
"url": "https://huggingface.co/spaces/duqing2026/project-show",
"sameAs": [
"https://www.yuque.com/lianmt"
]
}
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { --bg: #f3f4f6; --panel: #ffffff; --card: #ffffff; --muted: #6b7280; --fg: #111827; --accent: #09787e; --border: rgba(148,163,184,0.35); }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif; background: var(--bg); color: var(--fg); min-height: 100vh; overflow-y: scroll; display: flex; flex-direction: column; }
.shell { width: 100%; max-width: 1400px; margin: 0 auto; padding: 20px; box-sizing: border-box; display: flex; flex-direction: column; min-height: 100vh; }
.topbar { top: 10px; z-index: 50; flex-shrink: 0; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 16px; border:1px solid var(--border); border-radius:18px; background:rgba(255,255,255,0.98); backdrop-filter: blur(20px); box-shadow: 0 18px 45px rgba(15,23,42,0.1); }
.back-to-top { position: fixed; bottom: 30px; right: 30px; width: 44px; height: 44px; background: #ffffff; border: 1px solid var(--border); border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.1); display: flex; align-items: center; justify-content: center; cursor: pointer; opacity: 0; pointer-events: none; transition: all 0.3s ease; z-index: 100; color: var(--muted); }
.back-to-top.visible { opacity: 1; pointer-events: auto; }
.back-to-top:hover { transform: translateY(-3px); box-shadow: 0 8px 16px rgba(0,0,0,0.15); color: var(--accent); border-color: var(--accent); }
.back-to-top svg { width: 20px; height: 20px; fill: currentColor; }
.brand { display:flex; flex-direction:column; gap:4px; }
.title { font-weight:700; font-size:20px; color:#0f172a; }
.sub { font-size:12px; color: var(--muted); }
.actions { display:flex; align-items:center; gap:8px; }
.btn { border:1px solid var(--border); border-radius:999px; padding:8px 18px; font-size:14px; font-weight:600; cursor:pointer; color:var(--fg); background:#ffffff; box-shadow: 0 4px 12px rgba(0,0,0,0.05); transition: all .2s ease; }
.btn:hover { border-color:var(--accent); color:var(--accent); box-shadow: 0 8px 16px rgba(17,119,142,0.15); transform: translateY(-1px); }
.btn-outline-primary { border-color: var(--accent); color: var(--accent); background: rgba(17,119,142,0.04); }
.btn-outline-primary:hover { background: var(--accent); color: #ffffff; box-shadow: 0 8px 16px rgba(17,119,142,0.25); }
.layout { flex: 1; display:flex; gap:24px; margin-top:24px; align-items: flex-start; }
aside.panel { width: 280px; display: flex; flex-direction: column; flex-shrink: 0; top: 90px; align-self: flex-start; overflow-y: auto; }
main.panel { flex: 1; display: flex; flex-direction: column; background: transparent; border: none; padding: 0; box-shadow: none; }
.panel { background: var(--panel); border:1px solid var(--border); border-radius:18px; padding:20px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); }
.panel-title { font-size:15px; font-weight:700; margin-bottom:12px; color:#0f172a; display: flex; align-items: center; gap: 8px; }
.filters { display:flex; flex-wrap:wrap; gap:8px; }
.chip { padding:8px 16px; border-radius:12px; font-size:13px; cursor:pointer; transition: all .2s ease; border:1px solid var(--border); background: #ffffff; color: var(--fg); user-select: none; }
.chip:hover { background: #f8fafc; border-color: #cbd5e1; }
.chip.active { background: #f1f5f9; color: #334155; border-color: #cbd5e1; box-shadow: inset 0 2px 4px rgba(0,0,0,0.03); }
.search { display:flex; gap:8px; margin-bottom:16px; align-items: center; }
.search-wrapper { position: relative; flex: 1; }
.search input { width:100%; outline:none; border:1px solid var(--border); background:#ffffff; color:var(--fg); border-radius:10px; padding:8px 40px 8px 12px; font-size:14px; transition: all .2s; height: 36px; }
.search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(17,119,142,0.1); }
.search-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: #94a3b8; cursor: pointer; display: none; padding: 4px; border-radius: 50%; transition: all .2s; z-index: 10; }
.search-clear:hover { background: #f1f5f9; color: #64748b; }
.search button { height: 36px; }
.form-input { width:100%; outline:none; border:1px solid var(--border); background:#ffffff; color:var(--fg); border-radius:10px; padding:8px 12px; font-size:14px; transition: all .2s; height: 36px; }
.form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(17,119,142,0.1); }
.form-textarea { width:100%; outline:none; border:1px solid var(--border); background:#ffffff; color:var(--fg); border-radius:10px; padding:8px 12px; font-size:14px; transition: all .2s; min-height: 80px; resize: vertical; font-family: inherit; }
.form-textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(17,119,142,0.1); }
.sponsor-card { margin-top: 24px; padding-top: 20px; text-align: center; border-top: 1px solid var(--border); }
.sponsor-btn { color: var(--muted); font-size: 13px; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 8px; transition: all .2s; }
.sponsor-btn:hover { background: #f8fafc; color: var(--accent); }
.qr-container { overflow: visible; opacity: 1; margin-top: 0; }
.qr-container.open { max-height: 300px; opacity: 1; margin-top: 12px; }
.grid { display:grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap:20px; }
.card { display: flex; flex-direction: column; border:1px solid var(--border); border-radius:18px; overflow:hidden; background: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); height: 100%; transition: box-shadow .2s ease, border-color .2s ease; }
.card:hover { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); border-color: #cbd5e1; }
.card-body { flex: 1; padding:20px; display:flex; flex-direction:column; gap:10px; background: #ffffff; border-radius:0; }
.name { font-size:18px; font-weight:800; color:#0f172a; line-height: 1.4; margin-bottom: 4px; letter-spacing: -0.025em; }
.tags { display:flex; flex-wrap:wrap; gap:6px; margin-bottom: 4px; }
.tag { font-size:11px; color:#475569; background: #f1f5f9; padding:3px 8px; border-radius:6px; border:none; font-weight: 500; }
.desc { font-size:13px; color: #475569; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: auto; }
.card-actions { display:flex; align-items:center; gap:10px; margin-top:16px; padding-top: 16px; border-top: 1px solid #f1f5f9; }
.btn-sm { font-size:13px; padding:0 16px; height:36px; border-radius:10px; border:1px solid var(--border); background:#ffffff; color: #334155; cursor:pointer; transition: all .2s ease; text-decoration: none; font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.btn-sm:hover { background:#f8fafc; color: #0f172a; border-color: #cbd5e1; }
.btn-sm.primary { border: 1px solid var(--border); color: var(--accent); background: #ffffff; box-shadow: none; }
.btn-sm.primary:hover { border-color: var(--accent); background: #f8fafc; color: var(--accent); box-shadow: 0 4px 12px rgba(17,119,142,0.1); }
.empty-state { grid-column: 1 / -1; padding: 60px 20px; text-align: center; color: var(--muted); background: #ffffff; border-radius: 18px; border: 1px solid var(--border); display: flex; flex-direction: column; align-items: center; gap: 16px; }
.empty-icon { width: 64px; height: 64px; color: #cbd5e1; }
.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15,23,42,0.55); z-index:999; }
.modal.open { display:flex; }
.modal-content { width: 90vw; max-width: 68vw; height: 80vh; border-radius: 18px; overflow:hidden; border:none; background:#ffffff; display:flex; flex-direction:column; box-shadow: 0 24px 70px rgba(15,23,42,0.25); }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--border); background:#f9fafb; }
.modal-body { flex:1; background:#ffffff; position:relative; }
.iframe { width:100%; height:100%; border:0; }
.modal-loading { position:absolute; inset:0; display:none; align-items:center; justify-content:center; background:rgba(249,250,251,0.9); font-size:13px; color:#6b7280; z-index:1; }
.spinner { width:18px; height:18px; border-radius:999px; border:2px solid rgba(148,163,184,0.5); border-top-color: var(--accent); margin-right:8px; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.comments { margin-top:14px; border-top:1px solid var(--border); padding-top:10px; }
.comment-item { font-size:12px; color: var(--muted); margin-bottom:6px; }
.modal-empty { padding:16px; font-size:13px; color: var(--muted); }
.pwd-modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; background: rgba(15,23,42,0.55); z-index: 1000; }
.pwd-modal.open { display: flex; }
.pwd-box { background: #ffffff; padding: 24px; border-radius: 18px; width: 320px; box-shadow: 0 24px 70px rgba(15,23,42,0.25); display: flex; flex-direction: column; gap: 16px; }
.pwd-input { width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 10px; outline: none; }
.pwd-error { color: #ef4444; font-size: 12px; visibility: hidden; text-align: center; height: 16px; }
.pwd-error.visible { visibility: visible; }
.edit-icon { cursor: pointer; width: 16px; height: 16px; fill: var(--muted); transition: fill .15s; }
.edit-icon:hover { fill: var(--accent); }
.multi-select { position: relative; width: 100%; }
.select-box { border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; background: #ffffff; cursor: pointer; font-size: 14px; min-height: 36px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; transition: all .2s; }
.select-options { position: absolute; top: 100%; left: 0; right: 0; background: #ffffff; border: 1px solid var(--border); border-radius: 10px; margin-top: 4px; display: none; z-index: 10; max-height: 200px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.select-options.open { display: block; }
.option-item { padding: 8px 12px; display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; }
.option-item:hover { background: #f3f4f6; }
.select-tag { background: #eff6ff; color: #1d4ed8; padding: 2px 8px; border-radius: 4px; font-size: 11px; display: flex; align-items: center; gap: 4px; }
.select-placeholder { color: #9ca3af; }
.img-placeholder { background: #f3f4f6; border-radius: 8px; position: relative; overflow: hidden; }
.img-placeholder::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.6), transparent); animation: shimmer 1.5s infinite; }
@keyframes shimmer { 100% { transform: translateX(100%); } }
.fade-in { opacity: 0; transition: opacity 0.5s ease; }
.fade-in.loaded { opacity: 1; }
@media (max-width: 900px) {
body { overflow-y: auto; }
.shell { padding: 12px; max-width: 100%; overflow-x: hidden; }
.layout { flex-direction: column; margin-top: 12px; gap: 16px; }
/* Mobile Sticky Horizontal Filters */
aside.panel {
width: 100%;
flex-shrink: 0;
top: 74px; /* Below topbar */
z-index: 40;
max-height: none;
padding: 12px 16px;
border-radius: 12px;
overflow-y: visible; /* Allow horizontal scroll for children */
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}
.filters {
flex-wrap: wrap;
/* overflow-x: auto; */
padding-bottom: 4px;
margin: 0 -4px; /* Compensate for padding */
padding: 4px; /* Space for shadow */
gap: 10px;
}
/* .filters::-webkit-scrollbar { display: none; } */
.chip { flex-shrink: 0; white-space: nowrap; }
.panel-title { display: none; } /* Hide titles on mobile to save space */
/* Hide less important sidebar items on mobile */
#admin-panel { display: none !important; }
/* #qr-placeholder { display: none; } */
/* .img-placeholder { display: none; } */
.qr-container { display: none; }
.sponsor-card { padding-top: 4px; }
/* Show a compact search bar in main area or topbar? Let's keep search visible in aside but styled differently */
aside.panel .search { display: flex; margin-top: 0; margin-bottom: 12px; }
main.panel { flex-shrink: 0; width: 100%; }
.grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.topbar { padding: 10px 14px; top: 0; border-radius: 0 0 16px 16px; border-top: none; border-left: none; border-right: none; }
.back-to-top { bottom: 20px; right: 20px; width: 40px; height: 40px; }
}
</style>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body>
<div class="shell">
<div class="topbar">
<div class="brand"><div class="title">项目集</div><div class="sub">项目分类与搜索</div></div>
<div class="actions">
<a href="https://www.yuque.com/lianmt" target="_blank" class="btn btn-outline-primary" style="text-decoration:none;">我的博客</a>
<button class="btn" id="admin-toggle">管理</button>
</div>
</div>
<div class="layout">
<aside class="panel">
<div class="search">
<div class="search-wrapper">
<input id="search" placeholder="搜索项目..." />
<span class="search-clear" id="search-clear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</span>
</div>
<button class="btn-sm" id="search-btn">搜索</button>
</div>
<div class="panel-title">项目类型</div>
<div class="filters" id="filters"></div>
<div id="admin-panel" style="display:none;margin-top:12px;">
<div class="panel-title">后台管理</div>
<div style="display:flex; flex-direction:column; gap:10px;">
<input id="p-name" placeholder="项目名称" class="form-input" />
<div class="multi-select" id="p-type-wrapper">
<div class="select-box" id="p-type-display"><span class="select-placeholder">选择项目类型(可多选)</span></div>
<div class="select-options" id="p-type-options"></div>
</div>
<input id="p-hf" placeholder="项目链接" class="form-input" />
<input id="p-keywords" placeholder="关键词" class="form-input" />
<textarea id="p-desc" placeholder="简短描述" class="form-textarea"></textarea>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button class="btn-sm" id="delete-btn">删除项目</button>
<button class="btn-sm primary" id="add-btn">添加/更新项目</button>
</div>
</div>
</div>
<div class="sponsor-card">
<div class="qr-container" id="qr-box">
<div style="margin-bottom: 8px; position: relative; width: 100%; max-width: 160px; margin: 12px auto 0; aspect-ratio: 1/1;" class="img-placeholder" id="qr-placeholder">
<img src="/data/images/pay-qr.png"
style="width:100%; height:100%; object-fit:cover; display:block;"
class="fade-in"
alt="赞赏二维码"
onload="this.classList.add('loaded'); document.getElementById('qr-placeholder').classList.remove('img-placeholder'); document.getElementById('qr-placeholder').classList.remove('shimmer');"
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size:12px;color:var(--muted);display:flex;align-items:center;justify-content:center;height:100%;\'>(二维码加载失败)</span>';"/>
</div>
<div class="sponsor-btn" onclick="document.getElementById('qr-box').classList.toggle('open')">
<svg style="width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;" viewBox="0 0 24 24"><path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"/></svg>
<span>小额赞赏,资助研发</span>
</div>
</div>
<div style="padding:16px 8px 0; display:flex; flex-direction:column; gap:6px; font-size:12px; color:var(--muted); text-align:left;">
<div style="display:flex; justify-content:space-between;"><span>总访问</span><span id="stat-pv" style="color:#334155; font-weight:600;">--</span></div>
<div style="display:flex; justify-content:space-between;"><span>访客数</span><span id="stat-uv" style="color:#334155; font-weight:600;">--</span></div>
<div style="display:flex; justify-content:space-between;"><span>今日</span><span id="stat-today" style="color:#334155; font-weight:600;">--</span></div>
</div>
</div>
</aside>
<main class="panel">
<div class="panel-title">项目列表</div>
<div class="grid" id="grid"></div>
</main>
</div>
</div>
<div class="modal" id="modal">
<div class="modal-content">
<div class="modal-head"><div class="modal-title" id="modal-title"></div><button class="btn-sm" id="close-modal">关闭</button></div>
<div class="modal-body">
<div class="modal-loading" id="modal-loading"><div class="spinner"></div><span>正在加载演示...</span></div>
<iframe id="iframe" class="iframe" loading="lazy"></iframe>
<div class="modal-empty" id="modal-empty" style="display:none;">当前项目没有可嵌入演示,请点击“访问空间”查看详情。</div>
</div>
<div class="comments" id="comments"></div>
</div>
</div>
<div class="pwd-modal" id="pwd-modal">
<div class="pwd-box">
<div class="pwd-title">请输入管理密码</div>
<input type="password" class="pwd-input" id="pwd-input" placeholder="密码" />
<div class="pwd-error" id="pwd-error">密码错误,请重试</div>
<div style="display:flex; gap:10px; justify-content:center;">
<button class="btn-sm" id="pwd-cancel">取消</button>
<button class="btn-sm primary" id="pwd-confirm">确定</button>
</div>
</div>
</div>
<div class="back-to-top" id="back-to-top" onclick="window.scrollTo({top: 0, behavior: 'smooth'})">
<svg viewBox="0 0 24 24"><path d="M12 4l-8 8h6v8h4v-8h6z"/></svg>
</div>
<script>
const TYPES = {types_json};
const state = { items: [], counts: {}, total: 0, q: "", type: "", admin: false, authenticated: false, newProjectTypes: [], initialLoad: true };
const PRESET_GRADIENTS = [
"linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)", // Indigo 100-200
"linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)", // Blue 100-200
"linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)", // Emerald 100-200
"linear-gradient(135deg, #ffedd5 0%, #fed7aa 100%)", // Orange 100-200
"linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%)", // Pink 100-200
"linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)", // Gray 100-200
"linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%)", // Teal 100-200
"linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%)", // Violet 100-200
];
function getProjectGradient(str) {
if (!str || str.length === 0) return PRESET_GRADIENTS[0];
let hash = 0;
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
return PRESET_GRADIENTS[Math.abs(hash) % PRESET_GRADIENTS.length];
}
function updateTypeDisplay() {
const display = document.getElementById("p-type-display");
const selected = state.newProjectTypes;
if (selected.length === 0) { display.innerHTML = '<span class="select-placeholder">选择项目类型(可多选)</span>'; return; }
display.innerHTML = "";
selected.forEach(k => {
const t = TYPES.find(x => x.key === k);
if (t) {
const tag = document.createElement("div"); tag.className = "select-tag"; tag.textContent = t.label;
const x = document.createElement("span"); x.textContent = "×"; x.style.cursor = "pointer";
x.onclick = (e) => { e.stopPropagation(); toggleType(k); };
tag.appendChild(x); display.appendChild(tag);
}
});
}
function toggleType(key) {
const idx = state.newProjectTypes.indexOf(key);
if (idx > -1) state.newProjectTypes.splice(idx, 1); else state.newProjectTypes.push(key);
updateTypeDisplay();
const cbs = document.querySelectorAll("#p-type-options input");
cbs.forEach(cb => { cb.checked = state.newProjectTypes.includes(cb.value); });
}
function renderTypeSelector() {
const options = document.getElementById("p-type-options");
if (!options) return;
options.innerHTML = "";
TYPES.forEach(t => {
const item = document.createElement("div"); item.className = "option-item";
item.onclick = (e) => { if (e.target.tagName !== "INPUT") toggleType(t.key); };
const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = t.key; cb.checked = state.newProjectTypes.includes(t.key);
cb.onclick = (e) => { e.stopPropagation(); toggleType(t.key); };
const label = document.createElement("span"); label.textContent = t.label;
item.appendChild(cb); item.appendChild(label); options.appendChild(item);
});
const display = document.getElementById("p-type-display");
if (display) display.onclick = () => { options.classList.toggle("open"); };
document.addEventListener("click", (e) => {
const wrapper = document.getElementById("p-type-wrapper");
if (wrapper && !wrapper.contains(e.target)) options.classList.remove("open");
});
}
function renderFilters() {
const el = document.getElementById("filters"); el.innerHTML = "";
const all = document.createElement("div"); all.className = "chip" + (state.type === "" ? " active" : "");
all.textContent = `全部 ${state.total || 0}`;
all.onclick = () => { state.type = ""; load(); };
el.appendChild(all);
for (const t of TYPES) {
const d = document.createElement("div"); d.className = "chip" + (state.type === t.key ? " active" : "");
const count = state.counts[t.key] || 0;
d.textContent = `${t.label} ${count}`;
d.onclick = () => { state.type = t.key; load(); };
el.appendChild(d);
}
}
function card(project) {
const wrap = document.createElement("div"); wrap.className = "card";
wrap.style.border = "1px solid var(--border)";
const bar = document.createElement("div");
bar.style.height = "3px";
bar.style.width = "100%";
bar.style.background = getProjectGradient(project.name || "");
wrap.appendChild(bar);
const body = document.createElement("div"); body.className = "card-body";
body.style.borderRadius = "0 0 16px 16px";
const name = document.createElement("div"); name.className = "name"; name.textContent = project.name || "未命名项目"; body.appendChild(name);
const tags = document.createElement("div"); tags.className = "tags";
const ptypes = project.ptypes && project.ptypes.length > 0 ? project.ptypes : (project.ptype ? [project.ptype] : []);
if (ptypes.length === 0) {
const typeTag = document.createElement("div"); typeTag.className = "tag"; typeTag.textContent = "其它"; tags.appendChild(typeTag);
} else {
ptypes.forEach(pt => {
const typeTag = document.createElement("div"); typeTag.className = "tag";
const tlabel = TYPES.find(x=>x.key===pt)?.label || pt;
typeTag.textContent = tlabel;
typeTag.onclick = (e) => { e.stopPropagation(); if (state.type === pt) state.type = ""; else state.type = pt; load(); };
tags.appendChild(typeTag);
});
}
for (const tg of (project.tags||[])) { const t = document.createElement("div"); t.className="tag"; t.textContent=tg; tags.appendChild(t); }
body.appendChild(tags);
const desc = document.createElement("div"); desc.className = "desc"; desc.textContent = project.description || ""; body.appendChild(desc);
const actions = document.createElement("div"); actions.className = "card-actions";
const demo = document.createElement("button"); demo.className = "btn-sm primary"; demo.textContent = "查看演示"; demo.onclick = () => openModal(project);
const visit = document.createElement("a"); visit.className = "btn-sm"; visit.textContent = "访问空间"; visit.href = project.hf_space_url || "#"; visit.target = "_blank";
actions.appendChild(demo); actions.appendChild(visit);
if (state.admin) {
const edit = document.createElement("div"); edit.className = "edit-icon"; edit.title = "编辑项目";
edit.innerHTML = '<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>';
edit.onclick = (e) => { e.stopPropagation(); editProject(project); };
const del = document.createElement("div"); del.className = "edit-icon"; del.title = "删除项目"; del.style.marginLeft = "4px";
del.innerHTML = '<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>';
del.onclick = (e) => {
e.stopPropagation();
if(confirm('确定要删除项目 "' + project.name + '" 吗?')) {
deleteProjectById(project.id);
}
};
actions.appendChild(edit);
actions.appendChild(del);
}
body.appendChild(actions);
wrap.appendChild(body);
return wrap;
}
function editProject(p) {
state.editingId = p.id;
document.getElementById("p-name").value = p.name || "";
document.getElementById("p-hf").value = p.hf_space_url || "";
document.getElementById("p-keywords").value = p.keywords || "";
document.getElementById("p-desc").value = p.description || "";
state.newProjectTypes = p.ptypes ? [...p.ptypes] : (p.ptype ? [p.ptype] : []);
updateTypeDisplay();
const cbs = document.querySelectorAll("#p-type-options input");
cbs.forEach(cb => { cb.checked = state.newProjectTypes.includes(cb.value); });
document.getElementById("admin-panel").scrollIntoView({ behavior: "smooth" });
}
async function load() {
const params = new URLSearchParams(); if (state.q) params.set("q", state.q); if (state.type) params.append("type", state.type);
const res = await fetch("/api/projects?" + params.toString());
const data = await res.json();
state.items = data.items || []; state.counts = data.counts || {}; state.total = data.total || 0;
renderFilters();
const grid = document.getElementById("grid"); grid.innerHTML = "";
const empty = document.getElementById("empty-state");
if (state.items.length === 0) {
if (empty) empty.style.display = "flex";
} else {
if (empty) empty.style.display = "none";
for (const p of state.items) grid.appendChild(card(p));
}
if (state.initialLoad) {
state.initialLoad = false;
const params = new URLSearchParams(window.location.search);
const pid = params.get("project_id");
if (pid) {
const p = state.items.find(x => x.id == pid);
if (p) openModal(p);
}
}
}
function openModal(project) {
const url = new URL(window.location);
url.searchParams.set("project_id", project.id);
window.history.pushState({}, "", url);
document.getElementById("modal").classList.add("open");
// Update header with copy link btn
const head = document.querySelector(".modal-head");
head.innerHTML = "";
const title = document.createElement("div"); title.className = "modal-title"; title.textContent = project.name || "项目演示";
const actions = document.createElement("div"); actions.style.display = "flex"; actions.style.gap = "8px";
const copyBtn = document.createElement("button"); copyBtn.className = "btn-sm"; copyBtn.textContent = "复制链接";
copyBtn.onclick = () => {
const link = window.location.href;
navigator.clipboard.writeText(link).then(() => {
const orig = copyBtn.textContent;
copyBtn.textContent = "已复制";
setTimeout(() => copyBtn.textContent = orig, 2000);
});
};
const closeBtn = document.createElement("button"); closeBtn.className = "btn-sm"; closeBtn.textContent = "关闭";
closeBtn.id = "close-modal"; // Restore ID for event listener binding if needed, but we bind click below
closeBtn.onclick = closeModal;
actions.appendChild(copyBtn);
actions.appendChild(closeBtn);
head.appendChild(title);
head.appendChild(actions);
let src = "";
if (project.embed_url || project.hf_space_url) {
const base = project.embed_url || project.hf_space_url;
src = base.includes("embed=true") ? base : (base + (base.includes("?") ? "&" : "?") + "embed=true");
}
const iframe = document.getElementById("iframe");
const empty = document.getElementById("modal-empty");
const loading = document.getElementById("modal-loading");
if (loading) loading.style.display = src ? "flex" : "none";
if (src) { iframe.style.display = "block"; iframe.src = src; iframe.onload = () => { if (loading) loading.style.display = "none"; }; if (empty) empty.style.display = "none"; }
else { iframe.style.display = "none"; iframe.src = ""; if (empty) empty.style.display = "block"; }
fetch("/api/comments?project_id="+project.id).then(r=>r.json()).then(d=>{
const c = document.getElementById("comments");
c.innerHTML = "<div style='padding:10px;'>访客留言</div>";
for (const it of (d.items||[])) {
const div = document.createElement("div"); div.className="comment-item"; div.textContent = (it.author||"匿名") + ": " + it.content; c.appendChild(div);
}
const form = document.createElement("div"); form.style="display:flex; gap:8px; padding:10px;";
form.innerHTML = "<input id='c-author' placeholder='昵称' style='flex:0; padding:6px; border:1px solid var(--border); border-radius:8px; background:#ffffff; color:#111827;' /><input id='c-content' placeholder='留言' style='flex:1; padding:6px; border:1px solid var(--border); border-radius:8px; background:#ffffff; color:#111827;' /><button class='btn-sm' id='c-submit'>提交</button>";
c.appendChild(form);
document.getElementById("c-submit").onclick = async () => {
const author = document.getElementById("c-author").value; const content = document.getElementById("c-content").value;
await fetch("/api/comments/add", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ project_id: project.id, author, content }) });
openModal(project);
};
});
}
function closeModal() {
const url = new URL(window.location);
url.searchParams.delete("project_id");
window.history.pushState({}, "", url);
document.getElementById("modal").classList.remove("open");
document.getElementById("iframe").src = "";
}
async function deleteProjectById(id) {
if (!id) return;
await fetch("/api/projects/delete", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ id: id }) });
load();
}
async function addProject() {
const name = document.getElementById("p-name").value.trim();
if (!name) { alert("请输入项目名称"); return; }
if (state.editingId) {
if (!confirm("更新模式:这将删除旧项目并创建一个新项目(ID会改变)。确定要更新吗?")) return;
await deleteProjectById(state.editingId);
}
const p = { name: name, ptypes: state.newProjectTypes, hf_space_url: document.getElementById("p-hf").value.trim(), keywords: document.getElementById("p-keywords").value, description: document.getElementById("p-desc").value };
await fetch("/api/projects/add", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(p) });
load();
state.editingId = null;
document.getElementById("p-name").value = "";
document.getElementById("p-hf").value = "";
document.getElementById("p-keywords").value = "";
document.getElementById("p-desc").value = "";
}
async function deleteProject() {
if (state.editingId) {
if(!confirm("确定要删除当前编辑的项目吗?")) return;
await deleteProjectById(state.editingId);
state.editingId = null;
document.getElementById("p-name").value = "";
document.getElementById("p-hf").value = "";
document.getElementById("p-keywords").value = "";
document.getElementById("p-desc").value = "";
return;
}
const url = document.getElementById("p-hf").value.trim();
if (!url) { alert("请先在链接输入框中填入要删除的项目链接,或点击列表中的编辑按钮进行操作"); return; }
await fetch("/api/projects/delete", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ hf_space_url: url }) });
load();
}
document.addEventListener("DOMContentLoaded", () => {
const doSearch = () => { state.q = document.getElementById("search").value.trim(); load(); };
// Search Clear Logic
const searchInput = document.getElementById("search");
const clearBtn = document.getElementById("search-clear");
const toggleClear = () => {
if (searchInput.value.trim().length > 0) clearBtn.style.display = "block";
else clearBtn.style.display = "none";
};
let debounceTimer;
searchInput.addEventListener("input", () => {
toggleClear();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { doSearch(); }, 300);
});
clearBtn.onclick = () => {
searchInput.value = "";
toggleClear();
searchInput.focus();
doSearch();
};
document.getElementById("search-btn").onclick = doSearch;
document.getElementById("search").onkeydown = (e) => { if (e.key === "Enter") doSearch(); };
const adminToggle = document.getElementById("admin-toggle");
const pwdModal = document.getElementById("pwd-modal");
const pwdInput = document.getElementById("pwd-input");
const pwdError = document.getElementById("pwd-error");
adminToggle.onclick = () => {
if (state.admin) { state.admin = false; document.getElementById("admin-panel").style.display = "none"; adminToggle.textContent = "管理"; load(); }
else {
if (state.authenticated) { state.admin = true; document.getElementById("admin-panel").style.display = "block"; adminToggle.textContent = "收起"; load(); }
else { pwdModal.classList.add("open"); pwdInput.value = ""; pwdError.classList.remove("visible"); pwdInput.focus(); }
}
};
document.getElementById("pwd-cancel").onclick = () => { pwdModal.classList.remove("open"); };
const checkPwd = () => {
if (pwdInput.value === "1234qwer") { state.authenticated = true; state.admin = true; pwdModal.classList.remove("open"); document.getElementById("admin-panel").style.display = "block"; document.getElementById("admin-toggle").textContent = "收起"; load(); }
else { pwdError.classList.add("visible"); }
};
document.getElementById("pwd-confirm").onclick = checkPwd;
pwdInput.onkeydown = (e) => { if (e.key === "Enter") checkPwd(); };
document.getElementById("close-modal").onclick = () => closeModal();
document.getElementById("modal").addEventListener("click", (e) => { if (!document.querySelector(".modal-content").contains(e.target)) closeModal(); });
document.getElementById("add-btn").onclick = () => addProject();
document.getElementById("delete-btn").onclick = () => deleteProject();
renderFilters(); renderTypeSelector(); load();
// Admin Mode Logic
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get("admin") === "true") {
localStorage.setItem("isAdmin", "true");
alert("Admin Mode Enabled: Visits will be ignored.");
}
// Auto-authenticate if admin mode is persistent
if (localStorage.getItem("isAdmin") === "true") {
state.authenticated = true;
}
try {
const isTest = urlParams.get("test_visit") === "true";
if (isTest || (localStorage.getItem("isAdmin") !== "true" && urlParams.get("ignore_stats") !== "true")) {
let currentUrl = window.location.href;
if (window.navigation && window.navigation.currentEntry) {
currentUrl = window.navigation.currentEntry.url;
}
fetch("/api/visit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
referrer: document.referrer,
url: currentUrl,
force: isTest
})
}).catch(console.error);
} else {
console.log("Visit ignored (Admin or ignore_stats).");
}
} catch(e) { console.error(e); }
fetch("/api/stats").then(r=>r.json()).then(d=>{
if(d.ok && d.stats) {
document.getElementById("stat-pv").textContent = d.stats.pv;
document.getElementById("stat-uv").textContent = d.stats.uv;
document.getElementById("stat-today").textContent = d.stats.today;
}
});
});
window.addEventListener("scroll", () => {
const btn = document.getElementById("back-to-top");
if (window.scrollY > 300) btn.classList.add("visible"); else btn.classList.remove("visible");
});
</script>
</body>
</html>
"""
return template.replace("{options_html}", options_html).replace("{types_json}", types_json)
def run(host: str | None = None, port: int | None = None) -> None:
# Handle graceful shutdown
def signal_handler(sig, frame):
print("Shutdown signal received. Pushing data...")
# Force sync of visits before exit
if os.getenv("SPACE_ID"):
sync_manager.push(["visits.jsonl"])
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
# Sync: Pull latest data
sync_manager.pull()
# Sync: Start periodic push for visits
sync_manager.start_periodic_push()
# Load data directly from local storage
store.load()
print(f"Startup: Loaded {len(store.projects)} projects from local storage.")
# Auto-push to ensure remote is in sync (e.g. if we restored local data)
# But ONLY in Space environment. Local environment should be READ-ONLY (Pull-Only) for safety.
if os.getenv("SPACE_ID") and (store.projects or store.comments):
print("Startup: Triggering auto-push (Space environment)...")
sync_manager.push_async()
elif not os.getenv("SPACE_ID"):
print("Startup: Local environment detected. Auto-push DISABLED to protect remote data.")
host = host or "0.0.0.0"
port = port or int(os.getenv("PORT", "7860"))
display_host = "127.0.0.1" if host == "0.0.0.0" else host
print(f"Starting server at http://{display_host}:{port}/ (bind {host})")
server = ThreadingHTTPServer((host, port), ShowcaseHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
def run_with_reloader():
import subprocess
# Watch for changes in current directory
def check_changes(last_mtimes):
changed = False
for root, dirs, files in os.walk("."):
# Ignore data and hidden directories
if "hf_project_showcase_data" in root or "__pycache__" in root or "/." in root:
continue
for f in files:
if f.endswith((".py", ".html", ".js", ".css")):
path = os.path.join(root, f)
try:
mtime = os.stat(path).st_mtime
if path not in last_mtimes:
last_mtimes[path] = mtime
elif mtime > last_mtimes[path]:
last_mtimes[path] = mtime
print(f" * Detected change in {path}, reloading...")
changed = True
except OSError:
pass
return changed
# Initial scan
last_mtimes = {}
check_changes(last_mtimes)
args = [sys.executable] + sys.argv + ["--worker"]
while True:
print(f" * Starting server with reloader (PID: {os.getpid()})...")
p = subprocess.Popen(args)
try:
while p.poll() is None:
time.sleep(1)
if check_changes(last_mtimes):
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
break
except KeyboardInterrupt:
p.terminate()
sys.exit(0)
if __name__ == "__main__":
if "--worker" in sys.argv:
sys.argv.remove("--worker")
run()
else:
if os.getenv("NO_RELOAD"):
run()
else:
run_with_reloader()