This commit is contained in:
2026-05-11 07:02:06 +02:00
parent def6a08c0c
commit f61b1a4762
40 changed files with 1554 additions and 49 deletions
+17
View File
@@ -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)
+17
View File
@@ -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
View File
@@ -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)
+66
View File
@@ -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)
+41 -1
View File
@@ -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)
+56 -4
View File
@@ -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)
+28
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
from devplacepy.services.base import BaseService
from devplacepy.services.manager import service_manager
__all__ = ["BaseService", "service_manager"]
+93
View File
@@ -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
+47
View File
@@ -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()
+184
View File
@@ -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
+28
View File
@@ -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;
+28
View File
@@ -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;
+102
View File
@@ -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;
}
+1
View File
@@ -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%);
}
+28
View File
@@ -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;
+3
View File
@@ -10,6 +10,9 @@
<a href="/admin/users" class="admin-nav-link {% if admin_section == 'users' %}active{% endif %}">
<span class="admin-nav-icon">&#x1F465;</span> Users
</a>
<a href="/admin/services" class="admin-nav-link {% if admin_section == 'services' %}active{% endif %}">
<span class="admin-nav-icon">&#x2699;&#xFE0F;</span> Services
</a>
<a href="/admin/settings" class="admin-nav-link {% if admin_section == 'settings' %}active{% endif %}">
<span class="admin-nav-icon">&#x2699;&#xFE0F;</span> Settings
</a>
+9
View File
@@ -91,6 +91,15 @@
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="footer-inner">
<span class="footer-copy">&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>
+115
View File
@@ -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' }} &middot; {{ 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;">&times;</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 %}
+16 -3
View File
@@ -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">&#x1F517; 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 }}
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+36 -1
View File
@@ -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">&#x1F517; 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;">&times;</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">
+5 -4
View File
@@ -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;">&#x1F4AC; {{ 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 %}
+144
View File
@@ -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">&larr; 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 %}">
&#x25CF; {{ 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>&#x1F4C5; Released: {{ project['release_date'] }}</span>
{% endif %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; 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);">&middot; {{ 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">&#x2606; {{ 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 %}
+2 -2
View File
@@ -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 %}
+74
View File
@@ -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 %}