forked from retoor/devplacepy
Progss
This commit is contained in:
@@ -28,6 +28,18 @@ def cmd_role_set(args):
|
||||
print(f"User '{args.username}' role set to '{role}'")
|
||||
|
||||
|
||||
def cmd_news_clear(args):
|
||||
from devplacepy.database import db
|
||||
for table in ("news", "news_images", "news_sync"):
|
||||
if table in db.tables:
|
||||
count = len(list(db[table].all()))
|
||||
db[table].delete()
|
||||
print(f"Deleted {count} rows from '{table}'")
|
||||
else:
|
||||
print(f"Table '{table}' does not exist, skipping")
|
||||
print("News data cleared")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="DevPlace admin CLI")
|
||||
sub = parser.add_subparsers(title="commands", dest="command")
|
||||
@@ -44,6 +56,11 @@ def main():
|
||||
role_set.add_argument("role", choices=["member", "admin"])
|
||||
role_set.set_defaults(func=cmd_role_set)
|
||||
|
||||
news = sub.add_parser("news", help="News management")
|
||||
news_sub = news.add_subparsers(title="action", dest="action")
|
||||
news_clear = news_sub.add_parser("clear", help="Delete all news from local database")
|
||||
news_clear.set_defaults(func=cmd_news_clear)
|
||||
|
||||
args = parser.parse_args()
|
||||
if hasattr(args, "func"):
|
||||
args.func(args)
|
||||
|
||||
@@ -71,6 +71,23 @@ def init_db():
|
||||
if not existing:
|
||||
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
||||
|
||||
_index(db, "news", "idx_news_external_id", ["external_id"])
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
|
||||
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
||||
|
||||
if "site_settings" in tables:
|
||||
news_defaults = {
|
||||
"news_grade_threshold": "7",
|
||||
"news_api_url": "https://news.app.molodetz.nl/api",
|
||||
"news_ai_url": "https://openai.app.molodetz.nl/v1/chat/completions",
|
||||
"news_ai_model": "molodetz",
|
||||
}
|
||||
for key, value in news_defaults.items():
|
||||
existing = db["site_settings"].find_one(key=key)
|
||||
if not existing:
|
||||
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
|
||||
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
|
||||
+15
-1
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
@@ -7,7 +8,9 @@ from devplacepy.database import init_db, get_daily_topic
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, services as services_router
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.services.news import NewsService
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -31,6 +34,8 @@ app.include_router(avatar.router, prefix="/avatar")
|
||||
app.include_router(follow.router, prefix="/follow")
|
||||
app.include_router(admin.router, prefix="/admin")
|
||||
app.include_router(seo.router)
|
||||
app.include_router(bugs.router, prefix="/bugs")
|
||||
app.include_router(services_router.router, prefix="/admin/services")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -45,9 +50,18 @@ async def add_security_headers(request: Request, call_next):
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
init_db()
|
||||
news_service = NewsService()
|
||||
service_manager.register(news_service)
|
||||
asyncio.create_task(service_manager.start_all())
|
||||
logger.info("DevPlace started on port 10500")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
logger.info("Shutting down services...")
|
||||
await service_manager.stop_all()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def landing(request: Request):
|
||||
user = get_current_user(request)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, require_user, time_ago, get_current_user
|
||||
from devplacepy.seo import base_seo_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def bugs_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
bugs_table = get_table("bug_reports")
|
||||
all_bugs = list(bugs_table.find(order_by=["-created_at"]))
|
||||
|
||||
from devplacepy.database import get_users_by_uids
|
||||
uids = [b["user_uid"] for b in all_bugs]
|
||||
users_map = get_users_by_uids(uids)
|
||||
|
||||
bug_list = []
|
||||
for b in all_bugs:
|
||||
bug_list.append({
|
||||
"bug": b,
|
||||
"author": users_map.get(b["user_uid"]),
|
||||
"time_ago": time_ago(b["created_at"]),
|
||||
})
|
||||
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Bug Reports",
|
||||
description="Report bugs and issues on DevPlace.",
|
||||
)
|
||||
return templates.TemplateResponse("bugs.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"bugs": bug_list,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_bug(request: Request):
|
||||
user = require_user(request)
|
||||
form = await request.form()
|
||||
title = form.get("title", "").strip()
|
||||
description = form.get("description", "").strip()
|
||||
|
||||
if not title or not description:
|
||||
return RedirectResponse(url="/bugs", status_code=302)
|
||||
|
||||
bugs_table = get_table("bug_reports")
|
||||
bugs_table.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "open",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
logger.info(f"Bug report created by {user['username']}: {title}")
|
||||
return RedirectResponse(url="/bugs", status_code=302)
|
||||
@@ -30,7 +30,7 @@ async def create_post(request: Request):
|
||||
errors.append("Content must be at least 10 characters")
|
||||
if len(content) > 2000:
|
||||
errors.append("Content too long (max 2000 characters)")
|
||||
if topic not in ("devlog", "showcase", "question", "rant", "fun", "random"):
|
||||
if topic not in ("devlog", "showcase", "question", "rant", "fun", "random", "signals"):
|
||||
topic = "random"
|
||||
|
||||
if errors:
|
||||
@@ -168,6 +168,8 @@ async def view_post(request: Request, post_uid: str):
|
||||
"time_ago": time_ago(r["created_at"]),
|
||||
})
|
||||
|
||||
topics = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
|
||||
return templates.TemplateResponse("post.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
@@ -178,9 +180,47 @@ async def view_post(request: Request, post_uid: str):
|
||||
"time_ago": time_ago(post["created_at"]),
|
||||
"comment_count": comment_count,
|
||||
"related_posts": related_posts,
|
||||
"topics": topics,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/edit/{post_uid}")
|
||||
async def edit_post(request: Request, post_uid: str):
|
||||
user = require_user(request)
|
||||
form = await request.form()
|
||||
content = form.get("content", "").strip()
|
||||
title = form.get("title", "").strip()
|
||||
topic = form.get("topic", "random")
|
||||
|
||||
errors = []
|
||||
if not content:
|
||||
errors.append("Content is required")
|
||||
if content and len(content) < 10:
|
||||
errors.append("Content must be at least 10 characters")
|
||||
if len(content) > 2000:
|
||||
errors.append("Content too long (max 2000 characters)")
|
||||
if topic not in ("devlog", "showcase", "question", "rant", "fun", "random", "signals"):
|
||||
topic = "random"
|
||||
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=post_uid)
|
||||
if not post or post["user_uid"] != user["uid"]:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
|
||||
if errors:
|
||||
return RedirectResponse(url=f"/posts/{post_uid}", status_code=302)
|
||||
|
||||
posts.update({
|
||||
"uid": post_uid,
|
||||
"content": content,
|
||||
"title": title or None,
|
||||
"topic": topic,
|
||||
}, ["uid"])
|
||||
|
||||
logger.info(f"Post {post_uid} edited by {user['username']}")
|
||||
return RedirectResponse(url=f"/posts/{post_uid}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/delete/{post_uid}")
|
||||
async def delete_post(request: Request, post_uid: str):
|
||||
user = require_user(request)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_table, get_vote_counts
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -85,6 +85,58 @@ async def projects_page(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_uid}", response_class=HTMLResponse)
|
||||
async def project_detail(request: Request, project_uid: str):
|
||||
user = get_current_user(request)
|
||||
projects = get_table("projects")
|
||||
project = projects.find_one(uid=project_uid)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
from devplacepy.database import get_users_by_uids
|
||||
users_map = get_users_by_uids([project["user_uid"]])
|
||||
author = users_map.get(project["user_uid"])
|
||||
|
||||
is_owner = user and user["uid"] == project["user_uid"]
|
||||
|
||||
ups, downs = get_vote_counts([project_uid])
|
||||
star_count = ups.get(project_uid, 0) - downs.get(project_uid, 0)
|
||||
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title=project.get("title", "Project"),
|
||||
description=project.get("description", "")[:160],
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Projects", "url": "/projects"},
|
||||
{"name": project.get("title", "Project"), "url": f"/projects/{project_uid}"},
|
||||
],
|
||||
schemas=[website_schema(base), software_application_schema(project, base)],
|
||||
)
|
||||
return templates.TemplateResponse("project_detail.html", {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"project": project,
|
||||
"author": author,
|
||||
"is_owner": is_owner,
|
||||
"star_count": star_count,
|
||||
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/delete/{project_uid}")
|
||||
async def delete_project(request: Request, project_uid: str):
|
||||
user = require_user(request)
|
||||
projects = get_table("projects")
|
||||
project = projects.find_one(uid=project_uid)
|
||||
if project and project["user_uid"] == user["uid"]:
|
||||
projects.delete(id=project["id"])
|
||||
logger.info(f"Project {project_uid} deleted by {user['username']}")
|
||||
return RedirectResponse(url="/projects", status_code=302)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_project(request: Request):
|
||||
user = require_user(request)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import require_admin
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def services_page(request: Request):
|
||||
admin = require_admin(request)
|
||||
services = service_manager.list_services()
|
||||
return templates.TemplateResponse("services.html", {
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"services": services,
|
||||
"admin_section": "services",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/data")
|
||||
async def services_data(request: Request):
|
||||
require_admin(request)
|
||||
services = service_manager.list_services()
|
||||
return JSONResponse({"services": services})
|
||||
+1
-1
@@ -194,7 +194,7 @@ def make_sitemap(base_url):
|
||||
projects = list(get_table("projects").all())
|
||||
for p in projects:
|
||||
urlset.append(url_element(
|
||||
f"{base_url}/projects?user_uid={p['uid']}",
|
||||
f"{base_url}/projects/{p['uid']}",
|
||||
lastmod=p.get("created_at", ""),
|
||||
changefreq="weekly",
|
||||
priority="0.6"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from devplacepy.services.base import BaseService
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
__all__ = ["BaseService", "service_manager"]
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseService(ABC):
|
||||
def __init__(self, name: str, interval_seconds: int = 3600):
|
||||
self.name = name
|
||||
self.interval_seconds = interval_seconds
|
||||
self.log_buffer = deque(maxlen=20)
|
||||
self._task = None
|
||||
self._running = False
|
||||
self._last_run = None
|
||||
self._next_run = None
|
||||
self._started_at = None
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "running" if self._running else "stopped"
|
||||
|
||||
@property
|
||||
def last_run(self) -> str | None:
|
||||
return self._last_run
|
||||
|
||||
@property
|
||||
def next_run(self) -> str | None:
|
||||
return self._next_run
|
||||
|
||||
@property
|
||||
def uptime(self) -> str | None:
|
||||
if self._started_at is None:
|
||||
return None
|
||||
delta = datetime.utcnow() - self._started_at
|
||||
return str(delta).split(".")[0]
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
stamp = datetime.utcnow().strftime("%H:%M:%S")
|
||||
entry = f"[{stamp}] {message}"
|
||||
self.log_buffer.append(entry)
|
||||
logger.info(f"[{self.name}] {message}")
|
||||
|
||||
@abstractmethod
|
||||
async def run_once(self) -> None:
|
||||
pass
|
||||
|
||||
async def _run_loop(self) -> None:
|
||||
self._running = True
|
||||
self._started_at = datetime.utcnow()
|
||||
self.log(f"Service started (interval={self.interval_seconds}s)")
|
||||
while self._running:
|
||||
try:
|
||||
self._last_run = datetime.utcnow().isoformat()
|
||||
self._next_run = None
|
||||
await self.run_once()
|
||||
except asyncio.CancelledError:
|
||||
self.log("Service cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
self.log(f"Error in run_once: {e}")
|
||||
if not self._running:
|
||||
break
|
||||
self._next_run = datetime.utcnow().isoformat()
|
||||
self.log(f"Sleeping for {self.interval_seconds}s")
|
||||
try:
|
||||
await asyncio.sleep(self.interval_seconds)
|
||||
except asyncio.CancelledError:
|
||||
self.log("Service cancelled during sleep")
|
||||
break
|
||||
self._running = False
|
||||
self.log("Service stopped")
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
self.log("Already running")
|
||||
return
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=10.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
self._task = None
|
||||
@@ -0,0 +1,47 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceManager:
|
||||
def __init__(self):
|
||||
self._services = {}
|
||||
|
||||
def register(self, service) -> None:
|
||||
self._services[service.name] = service
|
||||
logger.info(f"Registered service: {service.name}")
|
||||
|
||||
def get_service(self, name: str):
|
||||
return self._services.get(name)
|
||||
|
||||
def list_services(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": svc.name,
|
||||
"status": svc.status,
|
||||
"interval_seconds": svc.interval_seconds,
|
||||
"last_run": svc.last_run,
|
||||
"next_run": svc.next_run,
|
||||
"uptime": svc.uptime,
|
||||
"log_buffer": list(svc.log_buffer),
|
||||
}
|
||||
for svc in self._services.values()
|
||||
]
|
||||
|
||||
async def start_all(self) -> None:
|
||||
for name, svc in self._services.items():
|
||||
await svc.start()
|
||||
logger.info(f"Started service: {name}")
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
tasks = []
|
||||
for name, svc in self._services.items():
|
||||
tasks.append(svc.stop())
|
||||
logger.info(f"Stopping service: {name}")
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
logger.info("All services stopped")
|
||||
|
||||
|
||||
service_manager = ServiceManager()
|
||||
@@ -0,0 +1,184 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.base import BaseService
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NEWS_API_URL_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
AI_URL_DEFAULT = "https://openai.app.molodetz.nl/v1/chat/completions"
|
||||
AI_MODEL_DEFAULT = "molodetz"
|
||||
GRADE_THRESHOLD_DEFAULT = 7
|
||||
|
||||
|
||||
def _get_setting(key: str, default: str) -> str:
|
||||
table = get_table("site_settings")
|
||||
row = table.find_one(key=key)
|
||||
if row is None:
|
||||
return default
|
||||
return row.get("value", default)
|
||||
|
||||
|
||||
def _extract_grade(text: str) -> int | None:
|
||||
match = re.search(r"\d+", text.strip())
|
||||
if match:
|
||||
val = int(match.group())
|
||||
if 1 <= val <= 10:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
async def _get_article_images(url: str) -> list[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
pattern = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.IGNORECASE)
|
||||
matches = pattern.findall(html)
|
||||
images = []
|
||||
seen = set()
|
||||
for src in matches:
|
||||
if src in seen:
|
||||
continue
|
||||
seen.add(src)
|
||||
if src.startswith("http") and not any(ext in src.lower() for ext in [".svg", ".ico"]):
|
||||
images.append({"url": src, "alt_text": ""})
|
||||
return images[:10]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class NewsService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(name="news", interval_seconds=3600)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
api_url = _get_setting("news_api_url", NEWS_API_URL_DEFAULT)
|
||||
ai_url = _get_setting("news_ai_url", AI_URL_DEFAULT)
|
||||
ai_model = _get_setting("news_ai_model", AI_MODEL_DEFAULT)
|
||||
threshold = int(_get_setting("news_grade_threshold", str(GRADE_THRESHOLD_DEFAULT)))
|
||||
|
||||
self.log(f"Fetching news from {api_url}")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
resp = await client.get(api_url)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
self.log(f"Failed to fetch news API: {e}")
|
||||
return
|
||||
|
||||
articles = data.get("articles", [])
|
||||
self.log(f"Received {len(articles)} articles")
|
||||
|
||||
sync_table = get_table("news_sync")
|
||||
news_table = get_table("news")
|
||||
images_table = get_table("news_images")
|
||||
|
||||
synced = 0
|
||||
skipped = 0
|
||||
below = 0
|
||||
failed = 0
|
||||
|
||||
for article in articles:
|
||||
external_id = article.get("guid", "")
|
||||
if not external_id:
|
||||
continue
|
||||
|
||||
existing = sync_table.find_one(external_id=external_id)
|
||||
if existing is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
grade = await self._grade_article(article, ai_url, ai_model)
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
if grade is None:
|
||||
failed += 1
|
||||
sync_table.insert({
|
||||
"external_id": external_id,
|
||||
"grade": 0,
|
||||
"status": "grading_failed",
|
||||
"synced_at": now,
|
||||
})
|
||||
continue
|
||||
|
||||
sync_table.insert({
|
||||
"external_id": external_id,
|
||||
"grade": grade,
|
||||
"status": "below_threshold" if grade < threshold else "synced",
|
||||
"synced_at": now,
|
||||
})
|
||||
|
||||
if grade < threshold:
|
||||
below += 1
|
||||
continue
|
||||
|
||||
news_uid = generate_uid()
|
||||
|
||||
news_table.insert({
|
||||
"uid": news_uid,
|
||||
"external_id": external_id,
|
||||
"title": article.get("title", ""),
|
||||
"description": (article.get("description", "") or "")[:5000],
|
||||
"url": article.get("link", ""),
|
||||
"image_url": "",
|
||||
"source_name": article.get("feed_name", ""),
|
||||
"grade": grade,
|
||||
"content": (article.get("content", "") or "")[:10000],
|
||||
"author": article.get("author", ""),
|
||||
"published": article.get("published", ""),
|
||||
"synced_at": now,
|
||||
})
|
||||
|
||||
link = article.get("link", "")
|
||||
if link:
|
||||
images = await _get_article_images(link)
|
||||
for img in images:
|
||||
images_table.insert({
|
||||
"uid": generate_uid(),
|
||||
"news_uid": news_uid,
|
||||
"url": img["url"],
|
||||
"alt_text": img.get("alt_text", ""),
|
||||
})
|
||||
|
||||
synced += 1
|
||||
|
||||
self.log(f"Synced {synced}, skipped {skipped}, below {below}, grading failed {failed}")
|
||||
|
||||
async def _grade_article(self, article: dict, ai_url: str, ai_model: str) -> int | None:
|
||||
title = (article.get("title", "") or "")[:500]
|
||||
description = (article.get("description", "") or "")[:1000]
|
||||
content = (article.get("content", "") or "")[:1500]
|
||||
|
||||
prompt = (
|
||||
"Rate this article's relevance to software developers on a scale of 1-10. "
|
||||
"Return only a single integer between 1 and 10, nothing else.\n\n"
|
||||
f"Title: {title}\n"
|
||||
f"Description: {description}\n"
|
||||
f"Content: {content}"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": ai_model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 10,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(ai_url, json=payload)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
text = result.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
return _extract_grade(text)
|
||||
except Exception as e:
|
||||
self.log(f"AI grading failed: {e}")
|
||||
return None
|
||||
@@ -155,6 +155,7 @@ img {
|
||||
.badge-rant { background: var(--topic-rant); color: #fff; }
|
||||
.badge-fun { background: var(--topic-fun); color: #000; }
|
||||
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
||||
.badge-signals { background: var(--topic-signals); color: #fff; }
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
@@ -380,6 +381,33 @@ img {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.footer-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.footer-copy {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.footer-links a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.footer-links a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
.feed-category[data-topic="question"] .dot { background: var(--topic-question); }
|
||||
.feed-category[data-topic="rant"] .dot { background: var(--topic-rant); }
|
||||
.feed-category[data-topic="fun"] .dot { background: var(--topic-fun); }
|
||||
.feed-category[data-topic="signals"] .dot { background: var(--topic-signals); }
|
||||
|
||||
.feed-nav {
|
||||
display: flex;
|
||||
@@ -341,6 +342,33 @@
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
|
||||
.feed-comment-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.feed-comment-form input {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
min-height: auto;
|
||||
}
|
||||
.feed-comment-submit {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed-comment-submit:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.feed-layout {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
.services-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.services-page h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.services-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.service-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.service-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.service-status.running {
|
||||
background: var(--topic-devlog, #10b981);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.service-status.stopped {
|
||||
background: var(--topic-discussion, #ef4444);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.service-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.service-log {
|
||||
background: var(--bg-code, #1a1a2e);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-code, #e0e0e0);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-services {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
@@ -41,6 +41,7 @@
|
||||
--topic-question: #448aff;
|
||||
--topic-rant: #ff5252;
|
||||
--topic-fun: #ffab00;
|
||||
--topic-signals: #00bcd4;
|
||||
|
||||
--bg-gradient: linear-gradient(135deg, #0f0a1a 0%, #1a0a2e 50%, #0f0a1a 100%);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class Application {
|
||||
|
||||
this.initFormDisable();
|
||||
this.initContentRenderer();
|
||||
this.initCommentReply();
|
||||
this.initEmojiPickers();
|
||||
}
|
||||
|
||||
@@ -195,6 +196,33 @@ class Application {
|
||||
contentRenderer.highlightAll();
|
||||
}
|
||||
|
||||
initCommentReply() {
|
||||
document.querySelectorAll(".comment-action-btn").forEach((btn) => {
|
||||
if (btn.textContent.trim() !== "Reply") return;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const comment = btn.closest(".comment");
|
||||
const commentForm = document.querySelector(".comment-form");
|
||||
if (!comment || !commentForm) return;
|
||||
const textarea = commentForm.querySelector("textarea");
|
||||
if (!textarea) return;
|
||||
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
|
||||
if (!parentInput) {
|
||||
parentInput = document.createElement("input");
|
||||
parentInput.type = "hidden";
|
||||
parentInput.name = "parent_uid";
|
||||
commentForm.appendChild(parentInput);
|
||||
}
|
||||
const commentBody = comment.querySelector(".comment-body");
|
||||
if (commentBody && commentBody.dataset.commentUid) {
|
||||
parentInput.value = commentBody.dataset.commentUid;
|
||||
}
|
||||
textarea.focus();
|
||||
textarea.scrollIntoView({ behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initEmojiPickers() {
|
||||
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
|
||||
if (textarea.dataset.emojiInitialized) return;
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<a href="/admin/users" class="admin-nav-link {% if admin_section == 'users' %}active{% endif %}">
|
||||
<span class="admin-nav-icon">👥</span> Users
|
||||
</a>
|
||||
<a href="/admin/services" class="admin-nav-link {% if admin_section == 'services' %}active{% endif %}">
|
||||
<span class="admin-nav-icon">⚙️</span> Services
|
||||
</a>
|
||||
<a href="/admin/settings" class="admin-nav-link {% if admin_section == 'settings' %}active{% endif %}">
|
||||
<span class="admin-nav-icon">⚙️</span> Settings
|
||||
</a>
|
||||
|
||||
@@ -91,6 +91,15 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-inner">
|
||||
<span class="footer-copy">© DevPlace</span>
|
||||
<nav class="footer-links">
|
||||
<a href="/bugs">Bug Report</a>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<style>
|
||||
.bugs-layout {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.bugs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bug-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bug-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.bug-status {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.bug-status.open {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-status.closed {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
.bug-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout">
|
||||
<div class="bugs-header">
|
||||
<h1>Bug Reports</h1>
|
||||
{% if user %}
|
||||
<button class="btn btn-primary btn-sm" data-modal="create-bug-modal">Report Bug</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bugs-list">
|
||||
{% for item in bugs %}
|
||||
<div class="bug-card fade-in">
|
||||
<div class="bug-card-header">
|
||||
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<span class="bug-title">{{ item.bug['title'] }}</span>
|
||||
<span class="bug-status {{ item.bug['status'] }}">{{ item.bug['status'] }}</span>
|
||||
</div>
|
||||
<div class="bug-desc">{{ item.bug['description'][:300] }}</div>
|
||||
<div class="bug-meta">{{ item.author['username'] if item.author else 'Unknown' }} · {{ item.time_ago }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No bug reports yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="modal-overlay" id="create-bug-modal">
|
||||
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 700;">Report a Bug</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">×</button>
|
||||
</div>
|
||||
<form method="POST" action="/bugs/create">
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<label for="bug-title">Title</label>
|
||||
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
||||
</div>
|
||||
<div class="auth-field" style="margin-bottom: 1rem;">
|
||||
<label for="bug-description">Description</label>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." style="min-height: 120px;"></textarea>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Submit Report</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,10 @@
|
||||
<span class="dot"></span>
|
||||
Fun
|
||||
</a>
|
||||
<a href="/feed?topic=signals" class="feed-category {% if current_topic == 'signals' %}active{% endif %}" data-topic="signals">
|
||||
<span class="dot"></span>
|
||||
Signals
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -86,6 +90,15 @@
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<form class="feed-comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ item.post['uid'] }}">
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 24) }}" class="avatar-img" style="width: 24px; height: 24px; border-radius: 50%;" alt="" loading="lazy">
|
||||
<input type="text" name="content" placeholder="Your opinion goes here..." maxlength="1000" autocomplete="off">
|
||||
<button type="submit" class="feed-comment-submit">Post</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="empty-state">No posts yet. Be the first!</div>
|
||||
@@ -105,8 +118,8 @@
|
||||
<h4>{{ daily_topic.get('title', '') }}</h4>
|
||||
<p>{{ daily_topic.get('summary', '') }}</p>
|
||||
<div class="topic-links">
|
||||
<a href="#">Read More</a>
|
||||
<a href="#">Source</a>
|
||||
<a href="/feed">Read More</a>
|
||||
<a href="/feed">Source</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +167,7 @@
|
||||
|
||||
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
|
||||
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
{% for t in ['devlog', 'showcase', 'question', 'rant', 'fun', 'random'] %}
|
||||
{% for t in ['devlog', 'showcase', 'question', 'rant', 'fun', 'random', 'signals'] %}
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover); transition: all 0.2s;">
|
||||
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} style="width: auto; accent-color: var(--accent);">
|
||||
{{ t|capitalize }}
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
<h3>{{ daily_topic.get('title', '') }}</h3>
|
||||
<p>{{ daily_topic.get('summary', '') }}</p>
|
||||
<div class="landing-daily-topic-links">
|
||||
<a href="#">Read More</a>
|
||||
<a href="#">Source</a>
|
||||
<a href="/feed">Read More</a>
|
||||
<a href="/feed">Source</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<label>
|
||||
<input type="checkbox" name="remember_me"> Remember me
|
||||
</label>
|
||||
<a href="#">Forgot your password?</a>
|
||||
<a href="/auth/forgot-password">Forgot your password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit">Sign in</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -37,6 +38,7 @@
|
||||
</form>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal">Edit</button>
|
||||
<form method="POST" action="/posts/delete/{{ post['uid'] }}" style="display:inline;">
|
||||
<button type="submit" class="post-action-btn" onclick="return confirm('Delete this post?')">Delete</button>
|
||||
</form>
|
||||
@@ -44,6 +46,39 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<div class="modal-overlay" id="edit-post-modal">
|
||||
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem; max-height: 90vh; overflow-y: auto;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 700;">Edit Post</h3>
|
||||
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">×</button>
|
||||
</div>
|
||||
<form method="POST" action="/posts/edit/{{ post['uid'] }}">
|
||||
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
{% for t in topics %}
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover); transition: all 0.2s;">
|
||||
<input type="radio" name="topic" value="{{ t }}" {% if t == post['topic'] %}checked{% endif %} style="width: auto; accent-color: var(--accent);">
|
||||
{{ t|capitalize }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<label for="edit-title">Title</label>
|
||||
<input type="text" id="edit-title" name="title" maxlength="500" value="{{ post.get('title', '') }}">
|
||||
</div>
|
||||
<div class="auth-field" style="margin-bottom: 0.75rem;">
|
||||
<label for="edit-content">Content</label>
|
||||
<textarea id="edit-content" name="content" required maxlength="2000" style="min-height: 200px;">{{ post['content'] }}</textarea>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>Comments</h3>
|
||||
|
||||
@@ -61,7 +96,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="comment-body">
|
||||
<div class="comment-body" data-comment-uid="{{ item.comment['uid'] }}">
|
||||
<div class="comment-header">
|
||||
<a href="/profile/{{ item.author['username'] if item.author else '#' }}">
|
||||
<img src="{{ avatar_url('multiavatar', item.author['username'] if item.author else '?', 24) }}" class="avatar-img" style="width: 24px; height: 24px; border-radius: 50%;" alt="{{ item.author['username'] if item.author else '?' }}" loading="lazy">
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
</div>
|
||||
|
||||
{% if user and user['uid'] != profile_user['uid'] %}
|
||||
<div style="margin-top: 1rem;">
|
||||
<div style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<a href="/messages?with_uid={{ profile_user['uid'] }}" class="btn btn-secondary btn-sm" style="width: 100%; justify-content: center;">Send Message</a>
|
||||
{% if is_following %}
|
||||
<form method="POST" action="/follow/unfollow/{{ profile_user['username'] }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm" style="width: 100%;">Unfollow</button>
|
||||
@@ -171,7 +172,7 @@
|
||||
{% if item.post.get('title') %}
|
||||
<h3 class="post-title">{{ item.post['title'] }}</h3>
|
||||
{% endif %}
|
||||
<div class="post-content rendered-content" data-render>{{ item.post['content'][:200] }}{% if item.post['content']|length > 200 %}...{% endif %}</div>
|
||||
<div class="post-content rendered-content" data-render>{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
|
||||
<div class="post-actions">
|
||||
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
|
||||
<span class="post-action-btn" style="cursor: default;">💬 {{ item.comment_count }}</span>
|
||||
@@ -183,7 +184,7 @@
|
||||
{% endfor %}
|
||||
{% elif current_tab == 'projects' %}
|
||||
{% for p in projects %}
|
||||
<div class="project-card fade-in">
|
||||
<a href="/projects/{{ p['uid'] }}" class="project-card fade-in" style="display: block; text-decoration: none; color: inherit;">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ p['title'] }}</h3>
|
||||
</div>
|
||||
@@ -201,7 +202,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty-state">No projects yet.</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<style>
|
||||
.project-detail-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.project-detail {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.project-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.project-detail-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.project-detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.project-detail-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.project-detail-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.project-detail-author a {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.project-detail-desc {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.project-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.project-star-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-star-btn:hover {
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="project-detail-page">
|
||||
<a href="/projects" class="back-link">← Back to Projects</a>
|
||||
|
||||
<article class="project-detail">
|
||||
<div class="project-detail-header">
|
||||
<h1 class="project-detail-title">{{ project['title'] }}</h1>
|
||||
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||
● {{ project.get('status', 'In Development') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-detail-meta">
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted); text-transform: capitalize;">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
||||
{% if project.get('release_date') %}
|
||||
<span>📅 Released: {{ project['release_date'] }}</span>
|
||||
{% endif %}
|
||||
{% if project.get('demo_date') %}
|
||||
<span>🎭 Demo: {{ project['demo_date'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="project-detail-author">
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
<a href="/profile/{{ author['username'] if author else '#' }}">{{ author['username'] if author else 'Unknown' }}</a>
|
||||
{% if author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-detail-desc rendered-content" data-render>{{ project.get('description', '') }}</div>
|
||||
|
||||
{% if platforms %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<h4 style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">Platforms</h4>
|
||||
<div class="project-card-platforms">
|
||||
{% for plat in platforms %}
|
||||
<span class="platform-tag">{{ plat.strip() }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-actions">
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-star-btn">☆ {{ star_count }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<form method="POST" action="/projects/delete/{{ project['uid'] }}" style="display:inline;">
|
||||
<button type="submit" class="project-star-btn" onclick="return confirm('Delete this project?')">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
<div class="projects-grid">
|
||||
{% for project in projects %}
|
||||
<div class="project-card fade-in">
|
||||
<a href="/projects/{{ project['uid'] }}" class="project-card fade-in" style="display: block; text-decoration: none; color: inherit;">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty-state" style="grid-column: 1 / -1;">No projects found. Create one!</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="/static/css/services.css">
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
<div class="services-page">
|
||||
<h1>Services</h1>
|
||||
<div class="services-list" id="services-list">
|
||||
{% for svc in services %}
|
||||
<div class="service-card" data-service="{{ svc.name }}">
|
||||
<div class="service-header">
|
||||
<span class="service-name">{{ svc.name }}</span>
|
||||
<span class="service-status {{ svc.status }}">{{ svc.status }}</span>
|
||||
</div>
|
||||
<div class="service-meta">
|
||||
{% if svc.uptime %}
|
||||
<span class="meta-item">Uptime: {{ svc.uptime }}</span>
|
||||
{% endif %}
|
||||
{% if svc.last_run %}
|
||||
<span class="meta-item">Last run: {{ svc.last_run[:19] }}</span>
|
||||
{% endif %}
|
||||
{% if svc.next_run %}
|
||||
<span class="meta-item">Next run: {{ svc.next_run[:19] }}</span>
|
||||
{% endif %}
|
||||
<span class="meta-item">Interval: every {{ svc.interval_seconds }}s</span>
|
||||
</div>
|
||||
<div class="service-log">
|
||||
<div class="log-header">Log (last 20 lines)</div>
|
||||
<pre class="log-output">{% for line in svc.log_buffer %}{{ line }}
|
||||
{% else %}No log entries yet.
|
||||
{% endfor %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-services">No services registered.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script type="module">
|
||||
const POLL_INTERVAL = 5000;
|
||||
|
||||
async function pollServices() {
|
||||
try {
|
||||
const resp = await fetch('/admin/services/data');
|
||||
const data = await resp.json();
|
||||
const container = document.getElementById('services-list');
|
||||
if (!container) return;
|
||||
for (const svc of data.services) {
|
||||
const card = container.querySelector(`.service-card[data-service="${svc.name}"]`);
|
||||
if (!card) continue;
|
||||
card.querySelector('.service-status').textContent = svc.status;
|
||||
card.querySelector('.service-status').className = 'service-status ' + svc.status;
|
||||
const metaItems = card.querySelectorAll('.meta-item');
|
||||
if (metaItems.length >= 3) {
|
||||
metaItems[0].textContent = 'Uptime: ' + (svc.uptime || '-');
|
||||
metaItems[1].textContent = 'Last run: ' + (svc.last_run ? svc.last_run.slice(0, 19) : '-');
|
||||
metaItems[2].textContent = 'Next run: ' + (svc.next_run ? svc.next_run.slice(0, 19) : '-');
|
||||
}
|
||||
const logPre = card.querySelector('.log-output');
|
||||
if (logPre) {
|
||||
logPre.textContent = svc.log_buffer.map(l => l + '\n').join('') || 'No log entries yet.\n';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silently ignore poll errors
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(pollServices, POLL_INTERVAL);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user