forked from retoor/devplacepy
feat: add soft delete and media tab for user attachments with admin restore
This commit is contained in:
@@ -219,6 +219,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
|
||||
"has_thumbnail": 1 if thumbnail else 0,
|
||||
"thumbnail_name": thumbnail,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"deleted_at": None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -383,6 +384,24 @@ def delete_attachment(uid):
|
||||
_delete_attachment_row(row)
|
||||
|
||||
|
||||
def soft_delete_attachment(uid):
|
||||
row = get_table("attachments").find_one(uid=uid)
|
||||
if not row or row.get("deleted_at"):
|
||||
return None
|
||||
get_table("attachments").update(
|
||||
{"uid": uid, "deleted_at": datetime.now(timezone.utc).isoformat()}, ["uid"]
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def restore_attachment(uid):
|
||||
row = get_table("attachments").find_one(uid=uid)
|
||||
if not row or not row.get("deleted_at"):
|
||||
return False
|
||||
get_table("attachments").update({"uid": uid, "deleted_at": None}, ["uid"])
|
||||
return True
|
||||
|
||||
|
||||
def delete_target_attachments(target_type, target_uid):
|
||||
for row in get_table("attachments").find(
|
||||
target_type=target_type, target_uid=target_uid
|
||||
@@ -416,7 +435,10 @@ def get_attachments(target_type, target_uid):
|
||||
return []
|
||||
rows = list(
|
||||
get_table("attachments").find(
|
||||
target_type=target_type, target_uid=target_uid, order_by=["created_at"]
|
||||
target_type=target_type,
|
||||
target_uid=target_uid,
|
||||
deleted_at=None,
|
||||
order_by=["created_at"],
|
||||
)
|
||||
)
|
||||
return [_row_to_attachment(r) for r in rows]
|
||||
@@ -430,7 +452,7 @@ def get_attachments_batch(target_type, uids):
|
||||
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
|
||||
params = {f"p{i}": uid for i, uid in enumerate(uids)}
|
||||
rows = db.query(
|
||||
f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
|
||||
f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({placeholders}) AND deleted_at IS NULL ORDER BY created_at",
|
||||
tt=target_type,
|
||||
**params,
|
||||
)
|
||||
@@ -467,6 +489,9 @@ def _row_to_attachment(row):
|
||||
"has_thumbnail": bool(row.get("has_thumbnail")),
|
||||
"is_image": row.get("mime_type", "").startswith("image/"),
|
||||
"is_video": row.get("mime_type", "").startswith("video/"),
|
||||
"target_type": row.get("target_type", ""),
|
||||
"target_uid": row.get("target_uid", ""),
|
||||
"created_at": row.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ def init_db():
|
||||
db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"]
|
||||
)
|
||||
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
|
||||
if "attachments" in db.tables and not db["attachments"].has_column("deleted_at"):
|
||||
db["attachments"].create_column_by_example("deleted_at", "")
|
||||
_index(db, "attachments", "idx_attachments_user_created", ["user_uid", "created_at"])
|
||||
_index(db, "reactions", "idx_reactions_target", ["target_type", "target_uid"])
|
||||
_index(
|
||||
db,
|
||||
@@ -1504,6 +1507,63 @@ def build_pagination(page, total, per_page=25):
|
||||
}
|
||||
|
||||
|
||||
def get_user_media(user_uid: str, page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
total = list(
|
||||
db.query(
|
||||
"SELECT COUNT(*) AS n FROM attachments "
|
||||
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL",
|
||||
u=user_uid,
|
||||
)
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
"SELECT * FROM attachments "
|
||||
"WHERE user_uid=:u AND target_type != '' AND deleted_at IS NULL "
|
||||
"ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
||||
u=user_uid,
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _row_to_attachment(row)
|
||||
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
|
||||
items.append(item)
|
||||
return items, pagination
|
||||
|
||||
|
||||
def get_deleted_media(page: int = 1, per_page: int = 24) -> tuple:
|
||||
if "attachments" not in db.tables:
|
||||
return [], build_pagination(page, 0, per_page)
|
||||
from devplacepy.attachments import _row_to_attachment
|
||||
|
||||
total = list(
|
||||
db.query("SELECT COUNT(*) AS n FROM attachments WHERE deleted_at IS NOT NULL")
|
||||
)[0]["n"]
|
||||
pagination = build_pagination(page, total, per_page)
|
||||
offset = (pagination["page"] - 1) * pagination["per_page"]
|
||||
rows = db.query(
|
||||
"SELECT * FROM attachments WHERE deleted_at IS NOT NULL "
|
||||
"ORDER BY deleted_at DESC LIMIT :limit OFFSET :offset",
|
||||
limit=pagination["per_page"],
|
||||
offset=offset,
|
||||
)
|
||||
usernames = {u["uid"]: u["username"] for u in get_table("users").find()}
|
||||
items = []
|
||||
for row in rows:
|
||||
item = _row_to_attachment(row)
|
||||
item["target_url"] = resolve_object_url(item["target_type"], item["target_uid"])
|
||||
item["deleted_at"] = row.get("deleted_at", "")
|
||||
item["uploader"] = usernames.get(row.get("user_uid"), "unknown")
|
||||
items.append(item)
|
||||
return items, pagination
|
||||
|
||||
|
||||
def get_follow_counts(user_uid: str) -> dict:
|
||||
if "follows" not in db.tables:
|
||||
return {"followers": 0, "following": 0}
|
||||
|
||||
+76
-1
@@ -1448,7 +1448,7 @@ four ways to sign requests.
|
||||
False,
|
||||
"posts",
|
||||
"Profile tab.",
|
||||
["posts", "activity", "followers", "following"],
|
||||
["posts", "activity", "followers", "following", "media"],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1555,6 +1555,29 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="media-delete",
|
||||
method="POST",
|
||||
path="/media/{uid}/delete",
|
||||
title="Delete media",
|
||||
summary="Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.",
|
||||
auth="user",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid",
|
||||
"path",
|
||||
"string",
|
||||
True,
|
||||
"",
|
||||
"Attachment uid, taken from the Media tab response.",
|
||||
),
|
||||
],
|
||||
sample_response={
|
||||
"ok": True,
|
||||
"redirect": "/profile/{{ username }}?tab=media",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="follow-user",
|
||||
method="POST",
|
||||
@@ -3006,6 +3029,58 @@ four ways to sign requests.
|
||||
interactive=True,
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
id="admin-media",
|
||||
method="GET",
|
||||
path="/admin/media",
|
||||
title="Media trash",
|
||||
summary=(
|
||||
"Moderation view of deleted media. When a member or admin deletes an "
|
||||
"attachment it is soft-deleted: hidden from the gallery and from its parent "
|
||||
"object, but the row and file are kept and the relation to the parent is "
|
||||
"preserved. This page lists every soft-deleted attachment, newest first, with "
|
||||
"its uploader, so an admin can restore or permanently purge it. Returns HTML."
|
||||
),
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[field("page", "query", "int", False, "1", "Page number.")],
|
||||
),
|
||||
endpoint(
|
||||
id="media-restore",
|
||||
method="POST",
|
||||
path="/media/{uid}/restore",
|
||||
title="Restore media",
|
||||
summary=(
|
||||
"Restore a soft-deleted attachment. Because the parent relation is never "
|
||||
"cleared, it reappears in the owner's Media tab and on its original post, "
|
||||
"project, or gist immediately."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
"uid", "path", "string", True, "", "Soft-deleted attachment uid."
|
||||
),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-media-purge",
|
||||
method="POST",
|
||||
path="/admin/media/{uid}/purge",
|
||||
title="Purge media",
|
||||
summary=(
|
||||
"Permanently delete a soft-deleted attachment: removes the database row and "
|
||||
"deletes the file from disk. This cannot be undone and is the only way media "
|
||||
"is hard-deleted from the UI."
|
||||
),
|
||||
auth="admin",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("uid", "path", "string", True, "", "Attachment uid to purge."),
|
||||
],
|
||||
sample_response={"ok": True, "redirect": "/admin/media"},
|
||||
),
|
||||
endpoint(
|
||||
id="admin-analytics",
|
||||
method="GET",
|
||||
|
||||
@@ -48,6 +48,7 @@ from devplacepy.routers import (
|
||||
gists,
|
||||
services as services_router,
|
||||
uploads,
|
||||
media,
|
||||
push,
|
||||
leaderboard,
|
||||
reactions,
|
||||
@@ -290,6 +291,7 @@ app.include_router(gists.router, prefix="/gists")
|
||||
app.include_router(news.router, prefix="/news")
|
||||
app.include_router(services_router.router, prefix="/admin/services")
|
||||
app.include_router(uploads.router, prefix="/uploads")
|
||||
app.include_router(media.router, prefix="/media")
|
||||
app.include_router(zips.router, prefix="/zips")
|
||||
app.include_router(forks.router, prefix="/forks")
|
||||
app.include_router(containers.router, prefix="/projects")
|
||||
|
||||
@@ -10,7 +10,9 @@ from devplacepy.database import (
|
||||
get_news_images_by_uids,
|
||||
clear_settings_cache,
|
||||
get_platform_analytics,
|
||||
get_deleted_media,
|
||||
)
|
||||
from devplacepy.attachments import delete_attachment
|
||||
from devplacepy.utils import (
|
||||
require_admin,
|
||||
hash_password,
|
||||
@@ -26,6 +28,7 @@ from devplacepy.schemas import (
|
||||
AdminUsersOut,
|
||||
AdminNewsOut,
|
||||
AdminSettingsOut,
|
||||
AdminMediaOut,
|
||||
GatewayUsageOut,
|
||||
)
|
||||
from devplacepy.services.manager import service_manager
|
||||
@@ -203,6 +206,45 @@ async def admin_user_reset_ai_quota(request: Request, uid: str):
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@router.get("/media", response_class=HTMLResponse)
|
||||
async def admin_media(request: Request, page: int = 1):
|
||||
admin = require_admin(request)
|
||||
media, pagination = get_deleted_media(page)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
title="Media - Admin",
|
||||
description="Restore or permanently remove soft-deleted media.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Admin", "url": "/admin"},
|
||||
{"name": "Media", "url": "/admin/media"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"admin_media.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": admin,
|
||||
"media": media,
|
||||
"pagination": pagination,
|
||||
"admin_section": "media",
|
||||
},
|
||||
model=AdminMediaOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/media/{uid}/purge")
|
||||
async def admin_media_purge(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
delete_attachment(uid)
|
||||
logger.info(f"Admin {admin['username']} purged media {uid}")
|
||||
return action_result(request, "/admin/media")
|
||||
|
||||
|
||||
@router.post("/ai-quota/reset-guests")
|
||||
async def admin_reset_guest_ai_quota(request: Request):
|
||||
admin = require_admin(request)
|
||||
|
||||
@@ -38,6 +38,12 @@ DOCS_PAGES = [
|
||||
"kind": "live",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "media-gallery",
|
||||
"title": "Media gallery",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
# Components - custom HTML web components with live examples (everyone)
|
||||
{
|
||||
"slug": "components",
|
||||
@@ -87,6 +93,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
{
|
||||
"slug": "component-dp-lightbox",
|
||||
"title": "dp-lightbox",
|
||||
"kind": "prose",
|
||||
"section": SECTION_COMPONENTS,
|
||||
},
|
||||
{
|
||||
"slug": "component-devii-terminal",
|
||||
"title": "devii-terminal",
|
||||
@@ -147,6 +159,14 @@ DOCS_PAGES = [
|
||||
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
|
||||
for page in api_doc_pages()
|
||||
],
|
||||
# Administration - operational guides (admins only)
|
||||
{
|
||||
"slug": "media-moderation",
|
||||
"title": "Media moderation",
|
||||
"kind": "prose",
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
# Devii internals - technical reference for the Devii assistant (admins only)
|
||||
{
|
||||
"slug": "devii-internals",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.attachments import soft_delete_attachment, restore_attachment
|
||||
from devplacepy.utils import require_user, require_admin, is_admin, not_found
|
||||
from devplacepy.responses import action_result, wants_json, json_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _media_redirect(request: Request, attachment: dict) -> str:
|
||||
referer = request.headers.get("referer", "")
|
||||
if referer:
|
||||
return referer
|
||||
uploader = get_table("users").find_one(uid=attachment.get("user_uid"))
|
||||
if uploader:
|
||||
return f"/profile/{uploader['username']}?tab=media"
|
||||
return "/feed"
|
||||
|
||||
|
||||
@router.post("/{uid}/delete")
|
||||
async def delete_media(request: Request, uid: str):
|
||||
user = require_user(request)
|
||||
attachment = get_table("attachments").find_one(uid=uid)
|
||||
if not attachment:
|
||||
raise not_found("Media not found")
|
||||
if attachment.get("user_uid") != user["uid"] and not is_admin(user):
|
||||
if wants_json(request):
|
||||
return json_error(403, "Not allowed")
|
||||
return action_result(request, _media_redirect(request, attachment))
|
||||
soft_delete_attachment(uid)
|
||||
logger.info(f"Media {uid} soft-deleted by {user['username']}")
|
||||
return action_result(request, _media_redirect(request, attachment))
|
||||
|
||||
|
||||
@router.post("/{uid}/restore")
|
||||
async def restore_media(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
restore_attachment(uid)
|
||||
logger.info(f"Media {uid} restored by {admin['username']}")
|
||||
return action_result(request, "/admin/media")
|
||||
@@ -20,6 +20,7 @@ from devplacepy.database import (
|
||||
get_follow_counts,
|
||||
get_follow_list,
|
||||
get_following_among,
|
||||
get_user_media,
|
||||
)
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.utils import (
|
||||
@@ -152,6 +153,10 @@ async def profile_page(
|
||||
profile_user["uid"], tab, current_user, page
|
||||
)
|
||||
|
||||
media, media_pagination = [], None
|
||||
if tab == "media":
|
||||
media, media_pagination = get_user_media(profile_user["uid"], page)
|
||||
|
||||
posts = []
|
||||
if tab == "posts":
|
||||
posts_table = get_table("posts")
|
||||
@@ -302,6 +307,9 @@ async def profile_page(
|
||||
"posts_count": posts_count,
|
||||
"is_following": is_following,
|
||||
"is_owner": is_owner,
|
||||
"viewer_is_admin": viewer_is_admin,
|
||||
"media": media,
|
||||
"media_pagination": media_pagination,
|
||||
"can_view_api_key": can_view_api_key,
|
||||
"api_key": api_key,
|
||||
"can_manage_customization": can_manage_customization,
|
||||
|
||||
@@ -493,6 +493,22 @@ class NewsDetailOut(_Out):
|
||||
bookmarked: bool = False
|
||||
|
||||
|
||||
class MediaItemOut(_Out):
|
||||
uid: str = ""
|
||||
original_filename: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
thumbnail_url: Optional[str] = None
|
||||
has_thumbnail: bool = False
|
||||
is_image: bool = False
|
||||
is_video: bool = False
|
||||
target_type: Optional[str] = None
|
||||
target_uid: Optional[str] = None
|
||||
target_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class ProfileOut(_Out):
|
||||
profile_user: UserOut
|
||||
posts: list[FeedItemOut] = []
|
||||
@@ -518,6 +534,8 @@ class ProfileOut(_Out):
|
||||
follow_pagination: Optional[Any] = None
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
|
||||
|
||||
class MessagesOut(_Out):
|
||||
@@ -570,6 +588,17 @@ class AdminSettingsOut(_Out):
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaItemOut(MediaItemOut):
|
||||
deleted_at: Optional[str] = None
|
||||
uploader: Optional[str] = None
|
||||
|
||||
|
||||
class AdminMediaOut(_Out):
|
||||
media: list[AdminMediaItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class GatewayUsageOut(_Out):
|
||||
window_hours: int = 48
|
||||
generated_at: Optional[str] = None
|
||||
|
||||
@@ -59,6 +59,8 @@ class DevPlaceBot:
|
||||
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._break_scale = max(0.1, cfg.break_scale)
|
||||
self._ai_decisions = cfg.ai_decisions
|
||||
self._decision_temperature = cfg.decision_temperature
|
||||
self.faker = Faker()
|
||||
self.b = BotBrowser(headless=cfg.headless)
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
@@ -66,6 +68,9 @@ class DevPlaceBot:
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
self._session_start_cost: float = 0.0
|
||||
self._session_start_calls: int = 0
|
||||
self._session_history: list[str] = []
|
||||
self._session_energy: float = 0.5
|
||||
self._session_stop_after: int = 0
|
||||
|
||||
def _identity(self) -> str:
|
||||
return f"{self.state.username or '?'}/{self.state.persona}"
|
||||
@@ -355,6 +360,294 @@ class DevPlaceBot:
|
||||
else:
|
||||
return ("deep", random.randint(15, 30), (14400, 43200))
|
||||
|
||||
async def _ensure_identity(self) -> None:
|
||||
if self.state.identity:
|
||||
return
|
||||
identity = await self._generate(
|
||||
self.llm.generate_identity, self.state.persona
|
||||
)
|
||||
if identity:
|
||||
self.state.identity = identity
|
||||
self._log(
|
||||
f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
|
||||
)
|
||||
self._save()
|
||||
|
||||
async def _page_summary(self, page: str) -> str:
|
||||
b = self.b
|
||||
title = ""
|
||||
for sel in ("h1", ".post-title", ".content-title", "title"):
|
||||
title = (await b.text(sel) or "").strip()
|
||||
if title:
|
||||
break
|
||||
summary = f'title="{title[:100]}"' if title else "no title"
|
||||
try:
|
||||
comments = await b.page.locator(".comment, .comment-item").count()
|
||||
if comments:
|
||||
summary += f", {comments} comments"
|
||||
except Exception as e:
|
||||
logger.debug("page summary count failed: %s", e)
|
||||
return summary
|
||||
|
||||
async def _page_state(
|
||||
self,
|
||||
curl: str,
|
||||
page_text: str,
|
||||
*,
|
||||
did_post: bool,
|
||||
follows: int,
|
||||
gists: int,
|
||||
can_project: bool,
|
||||
can_post: bool,
|
||||
can_message: bool,
|
||||
) -> dict:
|
||||
pages = [
|
||||
("post", "/posts/" in curl),
|
||||
("profile", "/profile/" in curl),
|
||||
("news_detail", "/news/" in curl),
|
||||
("news_list", bool(re.search(r"/news(\?|$)", curl))),
|
||||
("project_detail", "/projects/" in curl),
|
||||
("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
|
||||
("gist_detail", "/gists/" in curl),
|
||||
("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
|
||||
("notifications", "/notifications" in curl),
|
||||
]
|
||||
page = next((name for name, hit in pages if hit), "feed")
|
||||
own_post = page == "post" and await self._is_own_post()
|
||||
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
|
||||
return {
|
||||
"page": page,
|
||||
"url": curl,
|
||||
"visible": await self._page_summary(page),
|
||||
"own_post": own_post,
|
||||
"mentioned": mentioned,
|
||||
"did_post": did_post,
|
||||
"follows": follows,
|
||||
"gists": gists,
|
||||
"can_project": can_project,
|
||||
"can_post": can_post,
|
||||
"can_message": can_message,
|
||||
}
|
||||
|
||||
def _build_menu(self, page_state: dict) -> list[dict]:
|
||||
page = page_state["page"]
|
||||
menu: list[dict] = []
|
||||
|
||||
def add(action: str, desc: str) -> None:
|
||||
menu.append({"action": action, "desc": desc})
|
||||
|
||||
if page == "post":
|
||||
if not page_state["own_post"] or page_state["mentioned"]:
|
||||
add("comment", "write a comment on this post")
|
||||
add("reply", "reply to an existing comment in the thread")
|
||||
add("vote", "upvote this post")
|
||||
add("vote_comment", "upvote a good comment in the thread")
|
||||
add("vote_poll", "answer the attached poll if there is one")
|
||||
add("react", "add an emoji reaction to this post")
|
||||
elif page == "news_detail":
|
||||
add("comment", "write a comment reacting to this news article")
|
||||
elif page == "project_detail":
|
||||
add("vote", "star this project")
|
||||
add("react", "add an emoji reaction")
|
||||
add("comment", "write a comment on this project")
|
||||
elif page == "gist_detail":
|
||||
add("comment", "write a comment on this code snippet")
|
||||
add("vote", "star this gist")
|
||||
add("react", "add an emoji reaction")
|
||||
elif page == "profile":
|
||||
if not page_state["follows"]:
|
||||
add("follow", "follow this developer")
|
||||
if page_state["can_message"]:
|
||||
add("message", "send this developer a direct message")
|
||||
add("open_post", "open one of their posts to read it")
|
||||
elif page == "projects_list":
|
||||
add("vote", "star a project in the list")
|
||||
if page_state["can_project"]:
|
||||
add("create_project", "create a new project of your own")
|
||||
add("open_project", "open a project to read it")
|
||||
elif page == "gists_list":
|
||||
add("vote", "star a gist in the list")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_gist", "open a gist to read it")
|
||||
elif page == "news_list":
|
||||
add("open_news", "open a news article to read it")
|
||||
elif page == "notifications":
|
||||
add("check_notifications", "read and act on your notifications")
|
||||
else:
|
||||
add("open_post", "open a post from the feed to read it")
|
||||
add("vote", "upvote a post in the feed")
|
||||
add("react", "react to a post in the feed")
|
||||
add("vote_poll", "answer a poll in the feed")
|
||||
if page_state["can_post"] and not page_state["did_post"]:
|
||||
add("create_post", "write a new post reacting to a tech news article")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_profile", "open another developer's profile")
|
||||
add("search", "search the platform for a topic you care about")
|
||||
add("browse", "browse a category or section")
|
||||
add("navigate", "move to another section: feed, projects, gists, or news")
|
||||
return menu
|
||||
|
||||
def _record_history(self, action: str, rationale: str) -> None:
|
||||
note = action.replace("_", " ")
|
||||
if rationale:
|
||||
note = f"{note} ({rationale[:60]})"
|
||||
self._session_history.append(note)
|
||||
if len(self._session_history) > 20:
|
||||
self._session_history = self._session_history[-20:]
|
||||
|
||||
async def _vote_for_page(self, page: str) -> bool:
|
||||
if page in ("project_detail", "projects_list"):
|
||||
return await self._vote_on_project()
|
||||
if page in ("gist_detail", "gists_list"):
|
||||
return await self._vote_on_gist()
|
||||
return await self._vote_on_feed()
|
||||
|
||||
async def _navigate_to(self, target: str) -> bool:
|
||||
routes = {
|
||||
"feed": "/feed",
|
||||
"projects": "/projects",
|
||||
"gists": "/gists",
|
||||
"news": "/news",
|
||||
"notifications": "/notifications",
|
||||
}
|
||||
path = routes.get((target or "").lower())
|
||||
if path:
|
||||
await self.b.goto(f"{self.base_url}{path}")
|
||||
return True
|
||||
return await self._random_nav_click()
|
||||
|
||||
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
|
||||
if action == "navigate":
|
||||
return await self._navigate_to(target)
|
||||
if action == "vote":
|
||||
return await self._vote_for_page(page_state["page"])
|
||||
handlers = {
|
||||
"comment": self._comment_on_post,
|
||||
"reply": self._reply_to_random_comment,
|
||||
"vote_comment": self._vote_on_comments,
|
||||
"vote_poll": self._vote_on_poll,
|
||||
"react": self._react_to_object,
|
||||
"create_post": self._create_post,
|
||||
"create_gist": self._create_gist,
|
||||
"create_project": self._click_create_project,
|
||||
"follow": self._follow_user,
|
||||
"message": self._send_message,
|
||||
"open_post": self._click_post_link,
|
||||
"open_gist": self._click_gist_link,
|
||||
"open_profile": self._click_profile_link,
|
||||
"open_project": lambda: self._open_content("project"),
|
||||
"open_news": lambda: self._open_content("news"),
|
||||
"check_notifications": self._check_notifications,
|
||||
"search": lambda: self._search("feed"),
|
||||
"browse": self._browse_section,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return False
|
||||
try:
|
||||
result = await handler()
|
||||
except Exception as e:
|
||||
logger.debug("dispatch %s failed: %s", action, e)
|
||||
return False
|
||||
return True if result is None else bool(result)
|
||||
|
||||
async def _run_plan(
|
||||
self, plan: list[dict], page_state: dict
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
actions = 0
|
||||
did_post = False
|
||||
projs = 0
|
||||
follows = 0
|
||||
gists = 0
|
||||
if not plan:
|
||||
await self._navigate_to("feed")
|
||||
return 0, did_post, projs, follows, gists
|
||||
for entry in plan:
|
||||
action = entry["action"]
|
||||
if action == "create_post" and (did_post or page_state["did_post"]):
|
||||
continue
|
||||
if action == "create_gist" and (gists or page_state["gists"]):
|
||||
continue
|
||||
if action == "create_project" and projs:
|
||||
continue
|
||||
if await self._dispatch_action(action, entry.get("target", ""), page_state):
|
||||
actions += 1
|
||||
self._record_history(action, entry.get("rationale", ""))
|
||||
if action == "create_post":
|
||||
did_post = True
|
||||
elif action == "create_project":
|
||||
projs += 1
|
||||
elif action == "follow":
|
||||
follows += 1
|
||||
elif action == "create_gist":
|
||||
gists += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
return actions, did_post, projs, follows, gists
|
||||
|
||||
async def _cycle_ai(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
*,
|
||||
session_posted: bool = False,
|
||||
session_projects: int = 0,
|
||||
session_follows: int = 0,
|
||||
session_gists: int = 0,
|
||||
can_project: bool = False,
|
||||
can_post: bool = False,
|
||||
can_message: bool = False,
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
page_state = await self._page_state(
|
||||
curl,
|
||||
page_text,
|
||||
did_post=session_posted,
|
||||
follows=session_follows,
|
||||
gists=session_gists,
|
||||
can_project=can_project,
|
||||
can_post=can_post,
|
||||
can_message=can_message,
|
||||
)
|
||||
if page_state["page"] in (
|
||||
"post",
|
||||
"news_detail",
|
||||
"project_detail",
|
||||
"gist_detail",
|
||||
):
|
||||
await self._read_like_human(4, 90)
|
||||
menu = self._build_menu(page_state)
|
||||
decision = await self._generate(
|
||||
self.llm.decide,
|
||||
self.state.identity,
|
||||
page_state,
|
||||
menu,
|
||||
self._session_history,
|
||||
self._decision_temperature,
|
||||
)
|
||||
if not decision:
|
||||
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
|
||||
self._session_energy = decision.get("energy", self._session_energy)
|
||||
self._session_stop_after = decision.get("stop_after", 0)
|
||||
actions, did_post, projs, follows, gists = await self._run_plan(
|
||||
decision["plan"], page_state
|
||||
)
|
||||
return (
|
||||
actions,
|
||||
did_post or session_posted,
|
||||
session_projects + projs,
|
||||
session_follows + follows,
|
||||
session_gists + gists,
|
||||
)
|
||||
|
||||
def _scaled_pause(self) -> int:
|
||||
base = random.randint(self._pause_min, self._pause_max)
|
||||
factor = 1.4 - 0.8 * self._session_energy
|
||||
return max(1, int(base * factor))
|
||||
|
||||
async def _vote_on_feed(self) -> bool:
|
||||
b = self.b
|
||||
sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
|
||||
@@ -2245,6 +2538,10 @@ class DevPlaceBot:
|
||||
self._session_start_cost = self.llm.total_cost
|
||||
self._session_start_calls = self.llm.total_calls
|
||||
self._session_banner(mood, session_len)
|
||||
self._session_history = []
|
||||
self._session_energy = 0.5
|
||||
self._session_stop_after = 0
|
||||
use_ai = False
|
||||
|
||||
try:
|
||||
await b.launch()
|
||||
@@ -2263,6 +2560,10 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
if self._ai_decisions:
|
||||
await self._ensure_identity()
|
||||
use_ai = self._ai_decisions and bool(self.state.identity)
|
||||
|
||||
await self._warm_cache()
|
||||
self._log(f"Session active ({mood}) as {self._identity()}")
|
||||
|
||||
@@ -2305,7 +2606,7 @@ class DevPlaceBot:
|
||||
session_projects,
|
||||
session_follows,
|
||||
session_gists,
|
||||
) = await self._cycle(
|
||||
) = await (self._cycle_ai if use_ai else self._cycle)(
|
||||
mood=mood,
|
||||
session_posted=session_posted,
|
||||
session_projects=session_projects,
|
||||
@@ -2326,12 +2627,23 @@ class DevPlaceBot:
|
||||
self._save()
|
||||
crash_streak = 0
|
||||
|
||||
fatigue = await self._fatigue_check(session_actions)
|
||||
if fatigue:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
if use_ai:
|
||||
if (
|
||||
self._session_stop_after
|
||||
and session_actions >= self._session_stop_after
|
||||
):
|
||||
self._log(
|
||||
f"Energy spent (stop_after={self._session_stop_after}): ending session"
|
||||
)
|
||||
break
|
||||
intra_pause = self._scaled_pause()
|
||||
else:
|
||||
fatigue = await self._fatigue_check(session_actions)
|
||||
if fatigue:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
self._log(
|
||||
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
|
||||
)
|
||||
@@ -2368,6 +2680,9 @@ class DevPlaceBot:
|
||||
self._log(f"Recovery wait {break_s}s")
|
||||
else:
|
||||
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
|
||||
if use_ai:
|
||||
factor = 1.5 - 1.0 * self._session_energy
|
||||
break_s = max(60, int(break_s * factor))
|
||||
break_h = break_s / 3600
|
||||
self._log(f"Session done. Break {break_h:.1f}h")
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ ACTION_PAUSE_MIN_SECONDS = 5
|
||||
ACTION_PAUSE_MAX_SECONDS = 30
|
||||
BREAK_SCALE_DEFAULT = 1.0
|
||||
|
||||
AI_DECISIONS_DEFAULT = False
|
||||
DECISION_TEMPERATURE_DEFAULT = 0.4
|
||||
|
||||
PERSONAS = [
|
||||
"enthusiastic_junior",
|
||||
"grumpy_senior",
|
||||
|
||||
@@ -5,12 +5,14 @@ import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
SEARCH_TERMS,
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
|
||||
@@ -40,7 +42,7 @@ class LLMClient:
|
||||
self.total_in_tokens = 0
|
||||
self.total_out_tokens = 0
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
def _raw_call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
payload = {
|
||||
@@ -80,7 +82,7 @@ class LLMClient:
|
||||
self.total_calls += 1
|
||||
self.total_in_tokens += in_tokens
|
||||
self.total_out_tokens += out_tokens
|
||||
return self.clean(result["choices"][0]["message"]["content"])
|
||||
return result["choices"][0]["message"]["content"]
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
@@ -93,6 +95,9 @@ class LLMClient:
|
||||
time.sleep(2**attempt)
|
||||
return ""
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
return self.clean(self._raw_call(system, prompt, temperature))
|
||||
|
||||
@staticmethod
|
||||
def clean(text: str, preserve_md: bool = False) -> str:
|
||||
if not preserve_md:
|
||||
@@ -341,6 +346,183 @@ class LLMClient:
|
||||
return True, "ok"
|
||||
return False, verdict.strip()[:120] or "judge rejected"
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(text: str) -> dict:
|
||||
text = LLMClient.strip_code_fences(text or "").strip()
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
text = text[start : end + 1]
|
||||
parsed = json.loads(text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("expected a JSON object")
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _clamp01(value: Any, default: float) -> float:
|
||||
try:
|
||||
return max(0.0, min(1.0, float(value)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _as_terms(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()][:8]
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [part.strip() for part in value.split(",") if part.strip()][:8]
|
||||
return []
|
||||
|
||||
def _normalize_identity(self, data: dict, archetype: str) -> dict:
|
||||
interests = self._as_terms(data.get("interests")) or list(
|
||||
SEARCH_TERMS.get(archetype, [])
|
||||
)
|
||||
return {
|
||||
"archetype": archetype,
|
||||
"name": str(data.get("name", "")).strip()[:60],
|
||||
"backstory": str(data.get("backstory", "")).strip()[:300],
|
||||
"interests": interests,
|
||||
"dislikes": self._as_terms(data.get("dislikes")),
|
||||
"temperament": str(data.get("temperament", "")).strip()[:120],
|
||||
"verbosity": self._clamp01(data.get("verbosity"), 0.5),
|
||||
"contrarianness": self._clamp01(data.get("contrarianness"), 0.3),
|
||||
"generosity": self._clamp01(data.get("generosity"), 0.5),
|
||||
"curiosity": self._clamp01(data.get("curiosity"), 0.5),
|
||||
"rhythm": str(data.get("rhythm", "")).strip()[:120],
|
||||
}
|
||||
|
||||
def generate_identity(self, archetype: str) -> dict:
|
||||
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
|
||||
system = (
|
||||
"You invent a unique, believable individual developer for a social coding "
|
||||
"platform, seeded from a broad archetype but distinct from anyone else who "
|
||||
"shares it. Return ONLY a JSON object, no prose and no markdown fences, with "
|
||||
"these keys: name (a short handle-like display name), backstory (one concrete "
|
||||
"sentence), interests (array of 4 to 6 short topics), dislikes (array of 2 to 4 "
|
||||
"short topics), temperament (a few words), verbosity (number 0.0 to 1.0), "
|
||||
"contrarianness (number 0.0 to 1.0), generosity (number 0.0 to 1.0), curiosity "
|
||||
"(number 0.0 to 1.0), rhythm (a few words on when and how they show up). Make it "
|
||||
"specific and memorable, never generic. No em dashes."
|
||||
)
|
||||
prompt = (
|
||||
f"Archetype: {archetype}\n"
|
||||
f"Typical interests for this archetype: {interests}\n"
|
||||
"Create the persona JSON:"
|
||||
)
|
||||
data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
|
||||
return self._normalize_identity(data, archetype)
|
||||
|
||||
@staticmethod
|
||||
def _identity_card(identity: dict) -> str:
|
||||
lines = [
|
||||
f"name: {identity.get('name', '')}",
|
||||
f"archetype: {identity.get('archetype', '')}",
|
||||
f"backstory: {identity.get('backstory', '')}",
|
||||
f"interests: {', '.join(identity.get('interests', []))}",
|
||||
f"dislikes: {', '.join(identity.get('dislikes', []))}",
|
||||
f"temperament: {identity.get('temperament', '')}",
|
||||
f"verbosity: {identity.get('verbosity', 0.5)}",
|
||||
f"contrarianness: {identity.get('contrarianness', 0.3)}",
|
||||
f"generosity: {identity.get('generosity', 0.5)}",
|
||||
f"curiosity: {identity.get('curiosity', 0.5)}",
|
||||
f"rhythm: {identity.get('rhythm', '')}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _decision_system(self, identity: dict) -> str:
|
||||
return (
|
||||
"You are role-playing a single developer on a social coding platform. Stay in "
|
||||
"character at all times.\n\n"
|
||||
"IDENTITY\n"
|
||||
f"{self._identity_card(identity)}\n\n"
|
||||
"HOW TO DECIDE\n"
|
||||
"- Choose the actions that THIS person, with these interests and this "
|
||||
"temperament, would take on the page described. Behaviour must follow the "
|
||||
"identity, not chance.\n"
|
||||
"- A generous person votes and reacts readily; a contrarian one comments to push "
|
||||
"back; a terse, low-verbosity one acts rarely and briefly; a curious one explores "
|
||||
"and navigates. Let the numbers and interests drive the plan.\n"
|
||||
"- Only choose actions whose name appears in the MENU. Never invent an action or "
|
||||
"a target.\n"
|
||||
"- Order the plan the way this person would actually act, and include only what "
|
||||
"they would genuinely do (an empty plan is fine if nothing here interests them).\n"
|
||||
"- energy is how engaged this person is right now, 0.0 (barely present) to 1.0 "
|
||||
"(highly active), following their rhythm.\n"
|
||||
"- stop_after is roughly how many more actions this whole visit warrants before "
|
||||
"they would naturally drift away.\n"
|
||||
"- Output ONLY the JSON object below. No prose, no markdown fences.\n\n"
|
||||
"OUTPUT SCHEMA\n"
|
||||
'{"plan":[{"action":"<menu action>","target":"<id or empty>",'
|
||||
'"rationale":"<short>"}],"energy":<0.0-1.0>,"stop_after":<integer>}'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decision_prompt(page_state: dict, menu: list[dict], history: list[str]) -> str:
|
||||
menu_lines = "\n".join(f"- {item['action']}: {item['desc']}" for item in menu)
|
||||
recent = "; ".join(history[-8:]) if history else "nothing yet this visit"
|
||||
return (
|
||||
f"PAGE: {page_state.get('page', 'unknown')}\n"
|
||||
f"VISIBLE: {page_state.get('visible', '')}\n"
|
||||
f"MENU:\n{menu_lines}\n"
|
||||
f"RECENT (this visit): {recent}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _filter_plan(plan: Any, allowed: set) -> list[dict]:
|
||||
if not isinstance(plan, list):
|
||||
return []
|
||||
result = []
|
||||
for entry in plan:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
action = str(entry.get("action", "")).strip()
|
||||
if action not in allowed:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"action": action,
|
||||
"target": str(entry.get("target", "")).strip()[:120],
|
||||
"rationale": str(entry.get("rationale", "")).strip()[:200],
|
||||
}
|
||||
)
|
||||
return result[:12]
|
||||
|
||||
def decide(
|
||||
self,
|
||||
identity: dict,
|
||||
page_state: dict,
|
||||
menu: list[dict],
|
||||
history: list[str],
|
||||
temperature: float = 0.4,
|
||||
) -> dict:
|
||||
allowed = {item["action"] for item in menu}
|
||||
system = self._decision_system(identity)
|
||||
base = self._decision_prompt(page_state, menu, history)
|
||||
result = {"plan": [], "energy": 0.5, "stop_after": 0}
|
||||
for attempt in range(2):
|
||||
prompt = base
|
||||
if attempt:
|
||||
prompt += "\n\nReturn ONLY valid JSON matching the schema. No prose."
|
||||
try:
|
||||
data = self._parse_json(self._raw_call(system, prompt, temperature))
|
||||
except (ValueError, KeyError, json.JSONDecodeError):
|
||||
continue
|
||||
result = {
|
||||
"plan": self._filter_plan(data.get("plan"), allowed),
|
||||
"energy": self._clamp01(data.get("energy"), 0.5),
|
||||
"stop_after": self._as_int(data.get("stop_after"), 0),
|
||||
}
|
||||
if result["plan"]:
|
||||
break
|
||||
return result
|
||||
|
||||
def generate_bug(self, topic: str) -> tuple[str, str]:
|
||||
text = self._call(
|
||||
"Write a bug report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting a bug. No em dashes.",
|
||||
|
||||
@@ -22,3 +22,5 @@ class BotRuntimeConfig:
|
||||
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
|
||||
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
|
||||
break_scale: float = config.BREAK_SCALE_DEFAULT
|
||||
ai_decisions: bool = config.AI_DECISIONS_DEFAULT
|
||||
decision_temperature: float = config.DECISION_TEMPERATURE_DEFAULT
|
||||
|
||||
@@ -167,6 +167,24 @@ class BotsService(BaseService):
|
||||
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_ai_decisions",
|
||||
"AI-driven decisions",
|
||||
type="bool",
|
||||
default=config.AI_DECISIONS_DEFAULT,
|
||||
help="Let each bot's persona decide every action via the LLM instead of fixed probabilities. Adds one decision call per page; falls back to the procedural cascade when off.",
|
||||
group="Decisions",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_decision_temperature",
|
||||
"Decision temperature",
|
||||
type="float",
|
||||
default=config.DECISION_TEMPERATURE_DEFAULT,
|
||||
minimum=0.0,
|
||||
maximum=2.0,
|
||||
help="Sampling temperature for the per-page decision call. Low values keep structured output stable; variety comes from the identity, not noise.",
|
||||
group="Decisions",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -249,6 +267,8 @@ class BotsService(BaseService):
|
||||
action_pause_min=cfg["bot_action_pause_min_seconds"],
|
||||
action_pause_max=cfg["bot_action_pause_max_seconds"],
|
||||
break_scale=cfg["bot_break_scale"],
|
||||
ai_decisions=cfg["bot_ai_decisions"],
|
||||
decision_temperature=cfg["bot_decision_temperature"],
|
||||
)
|
||||
bot = DevPlaceBot(rc, on_event=self.log)
|
||||
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
|
||||
|
||||
@@ -14,6 +14,7 @@ class BotState:
|
||||
password: str = ""
|
||||
uid: str = ""
|
||||
persona: str = ""
|
||||
identity: dict = field(default_factory=dict)
|
||||
log: list[str] = field(default_factory=list)
|
||||
known_users: list[str] = field(default_factory=list)
|
||||
known_posts: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -734,6 +734,30 @@ ACTIONS: tuple[Action, ...] = (
|
||||
query("page", "Page number, 25 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_media",
|
||||
method="GET",
|
||||
path="/profile/{username}?tab=media",
|
||||
summary="List a user's uploaded media, newest first",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("username", "Username whose media to list."),
|
||||
query("page", "Page number, 24 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_media",
|
||||
method="POST",
|
||||
path="/media/{uid}/delete",
|
||||
summary="Delete one of your uploaded media attachments",
|
||||
description=(
|
||||
"Removes the attachment from the user's Media tab and from any post, project, or "
|
||||
"gist where it was attached. Show the user the exact media, get explicit "
|
||||
"confirmation, then call again with confirm=true."
|
||||
),
|
||||
requires_auth=True,
|
||||
params=(path("uid", "Attachment uid to delete."),),
|
||||
),
|
||||
Action(
|
||||
name="view_leaderboard",
|
||||
method="GET",
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIRM_REQUIRED = {
|
||||
"customize_reset",
|
||||
"project_delete_file",
|
||||
"delete_project",
|
||||
"delete_media",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_COMMAND = re.compile(
|
||||
@@ -98,6 +99,12 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
|
||||
"to confirm this explicitly first, then call again with confirm=true."
|
||||
)
|
||||
if name == "delete_media":
|
||||
return ToolInputError(
|
||||
"Deleting media removes it from every display surface, including its parent post or "
|
||||
"project. Show the user the exact media, get explicit confirmation, then call again "
|
||||
"with confirm=true."
|
||||
)
|
||||
if (
|
||||
name == "container_instance_action"
|
||||
and str(arguments.get("action", "")).strip().lower() == "delete"
|
||||
|
||||
@@ -82,61 +82,9 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.attachment-lightbox {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-lightbox.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attachment-lightbox img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.attachment-lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.attachment-gallery-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.attachment-lightbox-close {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +867,7 @@ button.comment-form-submit:hover {
|
||||
.context-menu,
|
||||
.dp-toast-host,
|
||||
.feed-fab,
|
||||
.attachment-lightbox {
|
||||
.lightbox-overlay {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
img[data-lightbox] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
cursor: zoom-out;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.lightbox-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lightbox-overlay .lightbox-image {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.lightbox-close {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.media-tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.media-tile .gallery-thumb,
|
||||
.media-tile .gallery-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-tile .gallery-video {
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.media-tile .non-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.media-tile .non-image .icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.media-tile .non-image .name {
|
||||
font-size: 0.6875rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-tile .non-image .size {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.media-tile-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-sm);
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 45%, transparent 70%, rgba(0, 0, 0, 0.55));
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-tile:hover .media-tile-overlay,
|
||||
.media-tile:focus-within .media-tile-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.media-tile-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.media-source-link {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.media-source-link:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.media-delete-btn {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: var(--white);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.media-delete-btn:hover {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.media-tile.media-removing {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.media-tile-meta {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
font-size: 0.6875rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.media-uploader {
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-uploader:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.media-deleted-at {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-tile-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.media-tile-actions .admin-inline-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-tile-actions .admin-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.media-tile-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-tile-overlay,
|
||||
.media-tile.media-removing {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import { MediaGallery } from "./MediaGallery.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||
|
||||
@@ -31,7 +32,8 @@ class Application {
|
||||
this.dialog = document.createElement("dp-dialog");
|
||||
this.contextMenu = document.createElement("dp-context-menu");
|
||||
this.toast = document.createElement("dp-toast");
|
||||
document.body.append(this.dialog, this.contextMenu, this.toast);
|
||||
this.lightbox = document.createElement("dp-lightbox");
|
||||
document.body.append(this.dialog, this.contextMenu, this.toast, this.lightbox);
|
||||
this.modals = new ModalManager();
|
||||
this.forms = new FormManager();
|
||||
this.votes = new VoteManager();
|
||||
@@ -58,6 +60,7 @@ class Application {
|
||||
this.devii = new DeviiTerminal();
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
this.mediaGallery = new MediaGallery();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ export class ContentEnhancer {
|
||||
initContentRenderer() {
|
||||
document.querySelectorAll("[data-render]").forEach((el) => {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
});
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class MediaGallery {
|
||||
constructor() {
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-media-delete]");
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
this.remove(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(btn) {
|
||||
const uid = btn.dataset.mediaDelete;
|
||||
const tile = btn.closest("[data-media-tile]");
|
||||
try {
|
||||
await Http.send(`/media/${uid}/delete`);
|
||||
if (tile) {
|
||||
tile.classList.add("media-removing");
|
||||
setTimeout(() => tile.remove(), 220);
|
||||
}
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show("Media deleted");
|
||||
}
|
||||
} catch (err) {
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(err.message, { type: "error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.MediaGallery = MediaGallery;
|
||||
@@ -0,0 +1,71 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppLightbox extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this.lastFocus = null;
|
||||
this.bodyOverflow = "";
|
||||
this.build();
|
||||
this.bind();
|
||||
}
|
||||
|
||||
build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox-overlay";
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.innerHTML =
|
||||
'<button type="button" class="lightbox-close" aria-label="Close">×</button>' +
|
||||
'<img class="lightbox-image" src="" alt="">';
|
||||
this.appendChild(overlay);
|
||||
|
||||
this.overlay = overlay;
|
||||
this.image = overlay.querySelector(".lightbox-image");
|
||||
this.closeBtn = overlay.querySelector(".lightbox-close");
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.overlay.addEventListener("click", (e) => {
|
||||
if (e.target !== this.image) this.close();
|
||||
});
|
||||
this.closeBtn.addEventListener("click", () => this.close());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.overlay.classList.contains("visible")) {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
const thumb = e.target.closest("img[data-lightbox]");
|
||||
if (!thumb || thumb.closest("a")) return;
|
||||
e.preventDefault();
|
||||
this.open(thumb.dataset.full || thumb.currentSrc || thumb.src, { alt: thumb.alt });
|
||||
});
|
||||
}
|
||||
|
||||
open(src, options) {
|
||||
const opts = options || {};
|
||||
if (!src) return;
|
||||
this.image.src = src;
|
||||
this.image.alt = opts.alt || "";
|
||||
this.lastFocus = document.activeElement;
|
||||
this.bodyOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
this.overlay.classList.add("visible");
|
||||
setTimeout(() => this.closeBtn.focus(), 20);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.overlay.classList.remove("visible");
|
||||
this.image.src = "";
|
||||
document.body.style.overflow = this.bodyOverflow;
|
||||
if (this.lastFocus && this.lastFocus.focus) this.lastFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-lightbox", AppLightbox);
|
||||
@@ -7,4 +7,5 @@ import "./AppUpload.js";
|
||||
import "./AppToast.js";
|
||||
import "./AppDialog.js";
|
||||
import "./AppContextMenu.js";
|
||||
import "./AppLightbox.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{% for att in attachments %}
|
||||
<div class="attachment-gallery-item">
|
||||
{% if att.get('is_image') and att.get('thumbnail_url') %}
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_image') %}
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
|
||||
{% elif att.get('is_video') %}
|
||||
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% else %}
|
||||
@@ -17,8 +17,3 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="attachment-lightbox" id="attachment-lightbox">
|
||||
<button type="button" class="attachment-lightbox-close">×</button>
|
||||
<img src="" alt="">
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="media-grid">
|
||||
{% for item in media %}
|
||||
<div class="media-tile" data-media-tile="{{ item['uid'] }}">
|
||||
{% if item.get('is_image') and item.get('thumbnail_url') %}
|
||||
<img src="{{ item['thumbnail_url'] }}" alt="{{ item.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ item['url'] }}">
|
||||
{% elif item.get('is_image') %}
|
||||
<img src="{{ item['url'] }}" alt="{{ item.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ item['url'] }}">
|
||||
{% elif item.get('is_video') %}
|
||||
<video src="{{ item['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% else %}
|
||||
<a href="{{ item['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ item.get('original_filename', 'file') }}">
|
||||
<span class="icon">{{ file_icon_emoji(item.get('original_filename', 'file')) }}</span>
|
||||
<span class="name">{{ item.get('original_filename', 'file') }}</span>
|
||||
<span class="size">{{ format_file_size(item.get('file_size', 0)) }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="media-tile-overlay">
|
||||
{% if item.get('target_url') %}
|
||||
<a href="{{ item['target_url'] }}" class="media-source-link">{{ item.get('target_type', 'source')|capitalize }}</a>
|
||||
{% endif %}
|
||||
{% if is_owner or viewer_is_admin %}
|
||||
<button type="button" class="media-delete-btn" data-media-delete="{{ item['uid'] }}" data-confirm="Delete this media?" data-confirm-danger aria-label="Delete media">🗑️</button>
|
||||
<noscript>
|
||||
<form method="POST" action="/media/{{ item['uid'] }}/delete" class="media-delete-form">
|
||||
<button type="submit" class="media-delete-btn" aria-label="Delete media">🗑️</button>
|
||||
</form>
|
||||
</noscript>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No media yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,9 +1,10 @@
|
||||
{% if pagination and pagination.total > 0 and pagination.total_pages > 1 %}
|
||||
{% set _base = pagination_base | default('?page=') %}
|
||||
<div class="pagination">
|
||||
<span class="pagination-info">{{ pagination.total }} items · Page {{ pagination.page }} of {{ pagination.total_pages }}</span>
|
||||
<div class="pagination-controls">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="?page={{ pagination.prev_page }}" class="pagination-btn"><span class="icon">←</span>Previous</a>
|
||||
<a href="{{ _base }}{{ pagination.prev_page }}" class="pagination-btn"><span class="icon">←</span>Previous</a>
|
||||
{% else %}
|
||||
<span class="pagination-btn pagination-btn-disabled"><span class="icon">←</span>Previous</span>
|
||||
{% endif %}
|
||||
@@ -11,7 +12,7 @@
|
||||
<div class="pagination-pages">
|
||||
{% for p in range(1, pagination.total_pages + 1) %}
|
||||
{% if p == 1 or p == pagination.total_pages or (p >= pagination.page - 2 and p <= pagination.page + 2) %}
|
||||
<a href="?page={{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}">{{ p }}</a>
|
||||
<a href="{{ _base }}{{ p }}" class="pagination-page {% if p == pagination.page %}pagination-page-active{% endif %}">{{ p }}</a>
|
||||
{% elif p == pagination.page - 3 or p == pagination.page + 3 %}
|
||||
<span class="pagination-ellipsis">…</span>
|
||||
{% endif %}
|
||||
@@ -19,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="?page={{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">→</span></a>
|
||||
<a href="{{ _base }}{{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">→</span></a>
|
||||
{% else %}
|
||||
<span class="pagination-btn pagination-btn-disabled">Next <span class="icon">→</span></span>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
<a href="/admin/news" class="sidebar-link {% if admin_section == 'news' %}active{% endif %}">
|
||||
<span class="sidebar-icon">📰</span> News
|
||||
</a>
|
||||
<a href="/admin/media" class="sidebar-link {% if admin_section == 'media' %}active{% endif %}">
|
||||
<span class="sidebar-icon">🖼️</span> Media
|
||||
</a>
|
||||
<a href="/admin/services" class="sidebar-link {% if admin_section == 'services' %}active{% endif %}">
|
||||
<span class="sidebar-icon">⚙️</span> Services
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "admin_base.html" %}
|
||||
{% block admin_content %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>Media trash</h2>
|
||||
<span class="admin-count">{{ pagination.total }} soft-deleted</span>
|
||||
</div>
|
||||
|
||||
<div class="media-grid">
|
||||
{% for item in media %}
|
||||
<div class="media-tile" data-media-tile="{{ item['uid'] }}">
|
||||
{% if item.get('is_image') and item.get('thumbnail_url') %}
|
||||
<img src="{{ item['thumbnail_url'] }}" alt="{{ item.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ item['url'] }}">
|
||||
{% elif item.get('is_image') %}
|
||||
<img src="{{ item['url'] }}" alt="{{ item.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ item['url'] }}">
|
||||
{% elif item.get('is_video') %}
|
||||
<video src="{{ item['url'] }}" controls preload="metadata" class="gallery-video"></video>
|
||||
{% else %}
|
||||
<a href="{{ item['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ item.get('original_filename', 'file') }}">
|
||||
<span class="icon">{{ file_icon_emoji(item.get('original_filename', 'file')) }}</span>
|
||||
<span class="name">{{ item.get('original_filename', 'file') }}</span>
|
||||
<span class="size">{{ format_file_size(item.get('file_size', 0)) }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="media-tile-meta">
|
||||
<a href="/profile/{{ item['uploader'] }}" class="media-uploader">{{ item['uploader'] }}</a>
|
||||
<span class="media-deleted-at">{{ format_date(item['deleted_at']) }}</span>
|
||||
</div>
|
||||
<div class="media-tile-actions">
|
||||
<form method="POST" action="/media/{{ item['uid'] }}/restore" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm"><span class="icon">♻️</span> Restore</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/media/{{ item['uid'] }}/purge" class="admin-inline-form">
|
||||
<button type="submit" class="admin-btn admin-btn-sm" data-confirm-danger data-confirm="Permanently delete this media? This cannot be undone."><span class="icon">🗑️</span> Purge</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">Trash is empty.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "_pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -39,6 +39,8 @@
|
||||
<link rel="stylesheet" href="/static/css/markdown.css">
|
||||
<link rel="stylesheet" href="/static/css/mention.css">
|
||||
<link rel="stylesheet" href="/static/css/attachments.css">
|
||||
<link rel="stylesheet" href="/static/css/lightbox.css">
|
||||
<link rel="stylesheet" href="/static/css/media.css">
|
||||
<link rel="stylesheet" href="/static/css/engagement.css">
|
||||
{% block extra_head %}{% endblock %}
|
||||
{{ custom_css_tag(request) }}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="docs-content" data-render>
|
||||
# dp-lightbox
|
||||
|
||||
A full-screen image viewer opened by clicking a thumbnail. A single instance is created by
|
||||
`Application.js` and reachable as `app.lightbox`. It is wired centrally: every image marked with
|
||||
the `data-lightbox` attribute opens in the same overlay, so thumbnails behave identically across
|
||||
the site.
|
||||
|
||||
Source: `static/js/components/AppLightbox.js`.
|
||||
|
||||
## The `data-lightbox` contract
|
||||
|
||||
Mark any thumbnail with `data-lightbox` to make it clickable. The viewer shows the value of
|
||||
`data-full` when present (a distinct full-resolution URL), otherwise the image's own source. The
|
||||
image `alt` is reused as the caption.
|
||||
|
||||
```html
|
||||
<img src="thumb.jpg" data-lightbox data-full="full.jpg" alt="A diagram">
|
||||
```
|
||||
|
||||
Images rendered from markdown content (`data-render`) are marked automatically, so embedded
|
||||
images are always clickable with no extra markup.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Behaviour |
|
||||
|---|---|
|
||||
| `open(src, { alt })` | Open the overlay showing `src`, with optional caption `alt`. |
|
||||
| `close()` | Close the overlay. |
|
||||
|
||||
The overlay also closes on the close button, a click outside the image, or the `Escape` key.
|
||||
|
||||
```javascript
|
||||
app.lightbox.open("/static/img/diagram.png", { alt: "Architecture" });
|
||||
```
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live example</div>
|
||||
<div class="component-demo-stage">
|
||||
<img src="/avatar/multiavatar/devplace?size=96" width="96" height="96" data-lightbox data-full="/avatar/multiavatar/devplace?size=512" alt="Sample thumbnail" style="border-radius: var(--radius); border: 1px solid var(--border);">
|
||||
<span class="text-muted">Click the thumbnail to open the lightbox.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,6 +30,7 @@ is available everywhere - including these documentation pages.
|
||||
| [dp-toast](/docs/component-dp-toast.html) | Transient corner notifications. |
|
||||
| [dp-dialog](/docs/component-dp-dialog.html) | Promise-based confirm, prompt, and alert modal. |
|
||||
| [dp-context-menu](/docs/component-dp-context-menu.html) | Right-click / long-press context menu. |
|
||||
| [dp-lightbox](/docs/component-dp-lightbox.html) | Full-screen image viewer opened by clicking a thumbnail. |
|
||||
| [devii-terminal](/docs/component-devii-terminal.html) | The Devii assistant terminal window. |
|
||||
| [devii-avatar](/docs/component-devii-avatar.html) | The Devii animated avatar character. |
|
||||
| [emoji-picker](/docs/component-emoji-picker.html) | Vendored emoji picker element. |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{% set uname = user.get('username') if user else 'YOUR_USERNAME' %}
|
||||
<div class="docs-content" data-render>
|
||||
# Media gallery
|
||||
|
||||
Every image, video, and file you share on DevPlace is collected in one place: the **Media** tab on
|
||||
your profile. Whatever you attach to a post, project, gist, comment, direct message, bug report, or
|
||||
news item shows up here automatically, newest first, so you never have to hunt through old posts to
|
||||
find something you uploaded.
|
||||
|
||||
## Where to find it
|
||||
|
||||
Open any profile and choose the **Media** tab, or go straight to
|
||||
`/profile/{{ uname }}?tab=media`. The gallery is public: just like the Posts, Projects, and Gists
|
||||
tabs, anyone can browse a member's media.
|
||||
|
||||
## Viewing media
|
||||
|
||||
The gallery is a responsive grid of thumbnails that adapts from a wide multi-column layout on the
|
||||
desktop down to a comfortable single column on a phone.
|
||||
|
||||
- **Images** show as thumbnails. Click one to open it full size in the lightbox; click the
|
||||
backdrop, press `Escape`, or use the close button to dismiss it.
|
||||
- **Videos** play inline with standard controls.
|
||||
- **Other files** (PDFs, archives, code, audio) appear as a labelled card showing the file name and
|
||||
size, and download when clicked.
|
||||
|
||||
Each tile links back to the post, project, or gist the media belongs to, so you can jump from a
|
||||
thumbnail to the place it was shared. Long galleries are split into numbered pages.
|
||||
|
||||
## Deleting your own media
|
||||
|
||||
Hover (or tap) a tile you uploaded and a delete button appears. Confirm, and the item is removed
|
||||
from your Media tab and from anywhere it was shown, including the original post or project. You can
|
||||
only delete media you uploaded yourself.
|
||||
|
||||
You can also ask the Devii assistant to do it for you, for example:
|
||||
|
||||
> Delete the screenshot I attached to my last post.
|
||||
|
||||
Devii shows you exactly which file it will remove and asks you to confirm before deleting.
|
||||
</div>
|
||||
|
||||
<div class="devii-doc-cta">
|
||||
{% if is_admin(user) %}
|
||||
<a href="/docs/media-moderation.html" class="sidebar-link">Media moderation (admin)</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Media moderation
|
||||
|
||||
> Audience: administrators. This page is hidden from members and guests in the sidebar, the search
|
||||
> index, and the documentation export. It describes the soft-delete mechanics and the moderation
|
||||
> tools behind the profile [Media gallery](/docs/media-gallery.html).
|
||||
|
||||
DevPlace never hard-deletes an attachment when a user removes it. Deletion is a **soft delete**: the
|
||||
database row and the file on disk are kept, and the attachment's link to its parent object is left
|
||||
completely intact. The item simply disappears from every place it was shown. This makes every
|
||||
deletion reversible and gives you a moderation trail.
|
||||
|
||||
## What a delete actually does
|
||||
|
||||
When a member deletes one of their own uploads (or an admin deletes anyone's), the attachment row
|
||||
gets a `deleted_at` timestamp. Nothing else changes:
|
||||
|
||||
- the file stays on disk,
|
||||
- the `target_type` / `target_uid` relation to the parent post, project, gist, comment, message,
|
||||
bug, or news item is preserved,
|
||||
- the item vanishes from the owner's Media tab **and** from the parent object it was attached to.
|
||||
|
||||
Because the relation is never cleared, restoring is a single step: clear `deleted_at` and the
|
||||
attachment reappears in the gallery and on its original parent object automatically, with no
|
||||
re-linking.
|
||||
|
||||
## Who can do what
|
||||
|
||||
| Action | Who | Effect |
|
||||
|--------|-----|--------|
|
||||
| Delete media | The uploader, or any admin | Soft delete (reversible) |
|
||||
| Restore media | Admin only | Brings a soft-deleted item back everywhere |
|
||||
| Purge media | Admin only | Permanent hard delete (row + file) |
|
||||
|
||||
Members and guests are never shown the soft-delete, restore, or purge concepts; from their point of
|
||||
view a delete simply removes the item. Moderation is an administrator surface.
|
||||
|
||||
## The Media trash
|
||||
|
||||
The **Media** entry in the admin sidebar opens `/admin/media`: every soft-deleted attachment across
|
||||
all users, newest first, with its uploader and the time it was deleted. From here you can:
|
||||
|
||||
- **Restore** an item (`POST /media/{uid}/restore`) - it returns to the owner's gallery and to its
|
||||
parent object immediately.
|
||||
- **Purge** an item (`POST /admin/media/{uid}/purge`) - a permanent hard delete that removes the
|
||||
database row and deletes the file from disk. This cannot be undone and is the only way media is
|
||||
hard-deleted from the UI.
|
||||
|
||||
## Invariant: parent deletion still cleans up files
|
||||
|
||||
Soft delete only affects the per-attachment delete path. When a whole parent object is permanently
|
||||
deleted (for example deleting a project), its attachments are still hard-deleted, including any that
|
||||
were already soft-deleted, so files are never orphaned. The display filters that hide soft-deleted
|
||||
media are deliberately not applied to that cascade.
|
||||
|
||||
## Reference
|
||||
|
||||
- API: [Media trash, restore, and purge](/docs/admin.html) on the Admin API page; the member-facing
|
||||
[delete endpoint](/docs/profiles.html) is on the Profiles page.
|
||||
- Devii operates the member-facing delete (`delete_media`); restore and purge are admin UI only.
|
||||
</div>
|
||||
@@ -256,6 +256,7 @@
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">📝</span> Gists</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=media" class="profile-tab {% if current_tab == 'media' %}active{% endif %}"><span class="icon">🖼️</span> Media</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">📊</span> Activity</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=followers" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}"><span class="icon">👥</span> Followers ({{ followers_count }})</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=following" class="profile-tab {% if current_tab == 'following' %}active{% endif %}"><span class="icon">👤</span> Following ({{ following_count }})</a>
|
||||
@@ -314,6 +315,11 @@
|
||||
{% else %}
|
||||
<div class="empty-state">No gists yet.</div>
|
||||
{% endfor %}
|
||||
{% elif current_tab == 'media' %}
|
||||
{% include "_media_gallery.html" %}
|
||||
{% set pagination = media_pagination %}
|
||||
{% set pagination_base = "?tab=media&page=" %}
|
||||
{% include "_pagination.html" %}
|
||||
{% elif current_tab == 'followers' or current_tab == 'following' %}
|
||||
{% for person in people %}
|
||||
<div class="follow-row">
|
||||
|
||||
Reference in New Issue
Block a user