feat: add soft delete and media tab for user attachments with admin restore

This commit is contained in:
2026-06-11 18:52:56 +00:00
parent c74228bc6c
commit 3862958fae
40 changed files with 1549 additions and 81 deletions
+27 -2
View File
@@ -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", ""),
}
+60
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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")
+42
View File
@@ -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)
+20
View File
@@ -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",
+42
View File
@@ -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")
+8
View File
@@ -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,
+29
View File
@@ -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
+321 -6
View File
@@ -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")
+3
View File
@@ -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",
+184 -2
View File
@@ -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.",
+2
View File
@@ -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
+20
View File
@@ -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"]))
+1
View File
@@ -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"
-52
View File
@@ -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;
}
}
+1 -1
View File
@@ -867,7 +867,7 @@ button.comment-form-submit:hover {
.context-menu,
.dp-toast-host,
.feed-fab,
.attachment-lightbox {
.lightbox-overlay {
will-change: transform;
}
+58
View File
@@ -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;
}
}
+185
View File
@@ -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;
}
}
+4 -1
View File
@@ -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();
}
}
+3
View File
@@ -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();
}
+33
View File
@@ -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">&times;</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);
+1
View File
@@ -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">&times;</button>
<img src="" alt="">
</div>
+34
View File
@@ -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">&#x1F5D1;&#xFE0F;</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">&#x1F5D1;&#xFE0F;</button>
</form>
</noscript>
{% endif %}
</div>
</div>
{% else %}
<div class="empty-state">No media yet.</div>
{% endfor %}
</div>
+4 -3
View File
@@ -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 &middot; 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">&#x2190;</span>Previous</a>
<a href="{{ _base }}{{ pagination.prev_page }}" class="pagination-btn"><span class="icon">&#x2190;</span>Previous</a>
{% else %}
<span class="pagination-btn pagination-btn-disabled"><span class="icon">&#x2190;</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">&hellip;</span>
{% endif %}
@@ -19,7 +20,7 @@
</div>
{% if pagination.has_next %}
<a href="?page={{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">&#x2192;</span></a>
<a href="{{ _base }}{{ pagination.next_page }}" class="pagination-btn">Next <span class="icon">&#x2192;</span></a>
{% else %}
<span class="pagination-btn pagination-btn-disabled">Next <span class="icon">&#x2192;</span></span>
{% endif %}
+3
View File
@@ -14,6 +14,9 @@
<a href="/admin/news" class="sidebar-link {% if admin_section == 'news' %}active{% endif %}">
<span class="sidebar-icon">&#x1F4F0;</span> News
</a>
<a href="/admin/media" class="sidebar-link {% if admin_section == 'media' %}active{% endif %}">
<span class="sidebar-icon">&#x1F5BC;&#xFE0F;</span> Media
</a>
<a href="/admin/services" class="sidebar-link {% if admin_section == 'services' %}active{% endif %}">
<span class="sidebar-icon">&#x2699;&#xFE0F;</span> Services
</a>
+43
View File
@@ -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">&#x267B;&#xFE0F;</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">&#x1F5D1;&#xFE0F;</span> Purge</button>
</form>
</div>
</div>
{% else %}
<div class="empty-state">Trash is empty.</div>
{% endfor %}
</div>
{% include "_pagination.html" %}
{% endblock %}
+2
View File
@@ -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
&lt;img src="thumb.jpg" data-lightbox data-full="full.jpg" alt="A diagram"&gt;
```
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>
+6
View File
@@ -256,6 +256,7 @@
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=media" class="profile-tab {% if current_tab == 'media' %}active{% endif %}"><span class="icon">&#x1F5BC;&#xFE0F;</span> Media</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}"><span class="icon">&#x1F4CA;</span> Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=followers" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}"><span class="icon">&#x1F465;</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">&#x1F464;</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">