Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

tui: add create bbs form

+317 -95
+31
tui/screens/home.py
··· 16 16 17 17 18 18 class HomeScreen(Screen): 19 + BINDINGS = [ 20 + ("ctrl+n", "create_bbs", "create bbs"), 21 + ] 22 + 19 23 DEFAULT_CSS = """ 20 24 HomeScreen ListView { 21 25 height: auto; ··· 62 66 self.query_one("#discover-label").display = False 63 67 self.query_one("#discover-list").display = False 64 68 self.load_discover() 69 + 70 + def action_create_bbs(self) -> None: 71 + if not self.app.user_session: 72 + self.notify("Log in to create a BBS.", severity="warning") 73 + return 74 + self._check_and_create_bbs() 75 + 76 + @work(exclusive=True) 77 + async def _check_and_create_bbs(self) -> None: 78 + """Check the user doesn't already have a BBS, then open the create screen.""" 79 + session = self.app.user_session 80 + try: 81 + await get_record( 82 + self.app.http_client, session["did"], lexicon.SITE, "self" 83 + ) 84 + # If we got here the record exists — they already have a BBS. 85 + self.notify( 86 + "You already have a BBS. Dial your handle to manage it.", 87 + severity="warning", 88 + ) 89 + return 90 + except Exception: 91 + pass 92 + 93 + from tui.screens.sysop.create import SysopCreateScreen 94 + 95 + self.app.push_screen(SysopCreateScreen()) 65 96 66 97 def refresh_data(self) -> None: 67 98 lv = self.query_one("#discover-list", ListView)
+8 -1
tui/screens/sysop/__init__.py
··· 1 1 from tui.screens.sysop.menu import SysopScreen 2 + from tui.screens.sysop.create import SysopCreateScreen 2 3 from tui.screens.sysop.edit import SysopEditScreen 3 4 from tui.screens.sysop.moderate import SysopModerateScreen 4 5 from tui.screens.sysop.delete import SysopDeleteScreen 5 6 6 - __all__ = ["SysopScreen", "SysopEditScreen", "SysopModerateScreen", "SysopDeleteScreen"] 7 + __all__ = [ 8 + "SysopScreen", 9 + "SysopCreateScreen", 10 + "SysopEditScreen", 11 + "SysopModerateScreen", 12 + "SysopDeleteScreen", 13 + ]
+160
tui/screens/sysop/bbs_form.py
··· 1 + """Shared form logic for creating and editing a BBS. 2 + 3 + Both the create and edit screens need the same form fields (name, 4 + description, intro, boards) and the same board add/remove behavior. 5 + This mixin provides all of that so neither screen has to duplicate it. 6 + """ 7 + 8 + from textual.containers import VerticalScroll 9 + from textual.widgets import Input, Static, TextArea 10 + 11 + from core import limits 12 + from core.util import now_iso 13 + 14 + DEFAULT_BOARD = { 15 + "slug": "general", 16 + "name": "General Discussion", 17 + "description": "Whatever's on your mind.", 18 + "created_at": "", 19 + } 20 + 21 + 22 + class BBSFormMixin: 23 + """Mixin that adds BBS form fields and board editing to a Screen. 24 + 25 + Subclasses must set ``self._boards`` (a list of board dicts) before 26 + ``compose()`` runs, and must include ``Screen`` in their MRO so that 27 + widget methods like ``query_one`` and ``notify`` are available. 28 + """ 29 + 30 + _boards: list[dict] 31 + 32 + # -- Widget composition helpers ------------------------------------------ 33 + 34 + def compose_site_fields( 35 + self, name: str = "", description: str = "", intro: str = "" 36 + ): 37 + """Yield the name, description, and intro form widgets.""" 38 + yield Static("NAME", classes="section-label") 39 + yield Input(value=name, id="edit-name", max_length=limits.SITE_NAME) 40 + yield Static("DESCRIPTION", classes="section-label") 41 + yield Input( 42 + value=description, id="edit-desc", max_length=limits.SITE_DESCRIPTION 43 + ) 44 + yield Static("INTRO", classes="section-label") 45 + yield TextArea(intro, id="edit-intro", language=None) 46 + 47 + def compose_board_widgets(self): 48 + """Yield the boards section header and a row of widgets per board.""" 49 + yield Static( 50 + "BOARDS (ctrl+n add, ctrl+d remove)", 51 + classes="section-label", 52 + id="boards-label", 53 + ) 54 + for board in self._boards: 55 + slug = board["slug"] 56 + yield Static( 57 + f" {slug}", classes="subtitle", id=f"board-label-{slug}" 58 + ) 59 + yield Input( 60 + value=board["name"], 61 + id=f"board-name-{slug}", 62 + max_length=limits.BOARD_NAME, 63 + ) 64 + yield Input( 65 + value=board["description"], 66 + id=f"board-desc-{slug}", 67 + max_length=limits.BOARD_DESCRIPTION, 68 + ) 69 + 70 + # -- Board add / remove actions ------------------------------------------ 71 + 72 + def action_add_board(self) -> None: 73 + """Add a new empty board to the form.""" 74 + index = len(self._boards) + 1 75 + while any(board["slug"] == f"board-{index}" for board in self._boards): 76 + index += 1 77 + slug = f"board-{index}" 78 + self._boards.append( 79 + {"slug": slug, "name": slug, "description": "", "created_at": now_iso()} 80 + ) 81 + 82 + scroll = self.query_one("#edit-scroll", VerticalScroll) 83 + label = Static(f" {slug}", classes="subtitle", id=f"board-label-{slug}") 84 + name_input = Input( 85 + value=slug, id=f"board-name-{slug}", max_length=limits.BOARD_NAME 86 + ) 87 + desc_input = Input( 88 + value="", id=f"board-desc-{slug}", max_length=limits.BOARD_DESCRIPTION 89 + ) 90 + scroll.mount(label) 91 + scroll.mount(name_input) 92 + scroll.mount(desc_input) 93 + name_input.focus() 94 + 95 + def action_remove_board(self) -> None: 96 + """Remove the last board from the form. At least one must remain.""" 97 + if len(self._boards) <= 1: 98 + self.notify("Must have at least one board.", severity="warning") 99 + return 100 + removed_board = self._boards.pop() 101 + slug = removed_board["slug"] 102 + for widget_id in ( 103 + f"board-label-{slug}", 104 + f"board-name-{slug}", 105 + f"board-desc-{slug}", 106 + ): 107 + try: 108 + self.query_one(f"#{widget_id}").remove() 109 + except Exception: 110 + pass 111 + 112 + # -- Reading values from the form ---------------------------------------- 113 + 114 + def get_site_field_values(self) -> tuple[str, str, str]: 115 + """Read the current name, description, and intro from the form. 116 + 117 + Returns (name, description, intro) with name and description 118 + stripped of leading/trailing whitespace. 119 + """ 120 + name = self.query_one("#edit-name", Input).value.strip() 121 + description = self.query_one("#edit-desc", Input).value.strip() 122 + intro = self.query_one("#edit-intro", TextArea).text 123 + return name, description, intro 124 + 125 + def get_board_values(self) -> list[dict]: 126 + """Read the current name and description of each board from the form. 127 + 128 + Returns a list of dicts with keys: slug, name, description, created_at. 129 + If a board's name field is blank, the slug is used as the name. 130 + """ 131 + boards = [] 132 + for board in self._boards: 133 + slug = board["slug"] 134 + name = self.query_one(f"#board-name-{slug}", Input).value.strip() 135 + description = self.query_one(f"#board-desc-{slug}", Input).value.strip() 136 + boards.append( 137 + { 138 + "slug": slug, 139 + "name": name or slug, 140 + "description": description, 141 + "created_at": board["created_at"], 142 + } 143 + ) 144 + return boards 145 + 146 + def validate_bbs_form(self, name: str, intro: str) -> bool: 147 + """Check that the form values are valid. 148 + 149 + Shows an error notification and returns False if anything is wrong. 150 + """ 151 + if not name: 152 + self.notify("Name cannot be empty.", severity="error") 153 + return False 154 + if len(intro) > limits.SITE_INTRO: 155 + self.notify( 156 + f"Intro too long ({len(intro)}/{limits.SITE_INTRO}).", 157 + severity="error", 158 + ) 159 + return False 160 + return True
+101
tui/screens/sysop/create.py
··· 1 + from textual import work 2 + from textual.app import ComposeResult 3 + from textual.containers import VerticalScroll 4 + from textual.screen import Screen 5 + from textual.widgets import Footer, Input 6 + 7 + from core import lexicon 8 + from core.models import AtUri, AuthError 9 + from core.records import put_board_record, put_site_record 10 + from core.resolver import invalidate_bbs_cache, resolve_bbs 11 + from core.util import now_iso 12 + from tui.screens.sysop.bbs_form import BBSFormMixin, DEFAULT_BOARD 13 + from tui.util import make_session_updater, require_session 14 + from tui.widgets.breadcrumb import Breadcrumb 15 + 16 + 17 + class SysopCreateScreen(BBSFormMixin, Screen): 18 + BINDINGS = [ 19 + ("escape", "app.pop_screen", "back"), 20 + ("ctrl+s", "save", "save"), 21 + ("ctrl+n", "add_board", "add board"), 22 + ("ctrl+d", "remove_board", "remove board"), 23 + ] 24 + 25 + def __init__(self) -> None: 26 + super().__init__() 27 + self._boards = [{**DEFAULT_BOARD, "created_at": now_iso()}] 28 + 29 + def compose(self) -> ComposeResult: 30 + yield Breadcrumb(("@bbs", 1), ("create bbs", 0)) 31 + with VerticalScroll(id="edit-scroll"): 32 + yield from self.compose_site_fields() 33 + yield from self.compose_board_widgets() 34 + yield Footer() 35 + 36 + def on_mount(self) -> None: 37 + self.query_one("#edit-name", Input).focus() 38 + 39 + def action_save(self) -> None: 40 + self._do_save() 41 + 42 + @work(exclusive=True) 43 + async def _do_save(self) -> None: 44 + session = require_session(self) 45 + if not session: 46 + return 47 + 48 + updater = make_session_updater(self.app.session_store) 49 + name, description, intro = self.get_site_field_values() 50 + 51 + if not self.validate_bbs_form(name, intro): 52 + return 53 + 54 + now = now_iso() 55 + 56 + try: 57 + # Create each board record (must exist before the site record 58 + # because the site record references them by AT-URI). 59 + for board in self.get_board_values(): 60 + await put_board_record( 61 + self.app.http_client, 62 + session, 63 + board["slug"], 64 + board["name"], 65 + board["description"], 66 + board["created_at"] or now, 67 + updater, 68 + ) 69 + 70 + # Create the site record, referencing all board AT-URIs. 71 + await put_site_record( 72 + self.app.http_client, 73 + session, 74 + { 75 + "$type": lexicon.SITE, 76 + "name": name, 77 + "description": description, 78 + "intro": intro, 79 + "boards": [ 80 + str(AtUri(session["did"], lexicon.BOARD, board["slug"])) 81 + for board in self._boards 82 + ], 83 + "createdAt": now, 84 + }, 85 + updater, 86 + ) 87 + invalidate_bbs_cache() 88 + self.notify("BBS created!") 89 + 90 + # Navigate to the new BBS so the user lands on it. 91 + handle = session.get("handle", "") 92 + self.app.pop_screen() 93 + bbs = await resolve_bbs(self.app.http_client, handle) 94 + 95 + from tui.screens.site import SiteScreen 96 + 97 + self.app.push_screen(SiteScreen(bbs, handle)) 98 + except AuthError: 99 + self.notify("Session expired. Please log in again.", severity="error") 100 + except Exception as e: 101 + self.notify(f"Could not create BBS: {e}", severity="error")
+17 -94
tui/screens/sysop/edit.py
··· 2 2 from textual.app import ComposeResult 3 3 from textual.containers import VerticalScroll 4 4 from textual.screen import Screen 5 - from textual.widgets import Footer, Input, Static, TextArea 5 + from textual.widgets import Footer, Input 6 6 7 - from core import lexicon, limits 7 + from core import lexicon 8 8 from core.models import AtUri, AuthError, BBS 9 9 from core.records import delete_record, put_board_record, put_site_record 10 10 from core.resolver import invalidate_bbs_cache 11 11 from core.util import now_iso 12 + from tui.screens.sysop.bbs_form import BBSFormMixin 12 13 from tui.util import make_session_updater, require_session 13 14 from tui.widgets.breadcrumb import Breadcrumb 14 15 15 16 16 - class SysopEditScreen(Screen): 17 + class SysopEditScreen(BBSFormMixin, Screen): 17 18 BINDINGS = [ 18 19 ("escape", "app.pop_screen", "back"), 19 20 ("ctrl+s", "save", "save"), ··· 43 44 ("edit", 0), 44 45 ) 45 46 with VerticalScroll(id="edit-scroll"): 46 - yield Static("NAME", classes="section-label") 47 - yield Input( 48 - value=self.bbs.site.name, id="edit-name", max_length=limits.SITE_NAME 49 - ) 50 - yield Static("DESCRIPTION", classes="section-label") 51 - yield Input( 52 - value=self.bbs.site.description, 53 - id="edit-desc", 54 - max_length=limits.SITE_DESCRIPTION, 55 - ) 56 - yield Static("INTRO", classes="section-label") 57 - yield TextArea(self.bbs.site.intro, id="edit-intro", language=None) 58 - yield Static( 59 - "BOARDS (ctrl+n add, ctrl+d remove)", 60 - classes="section-label", 61 - id="boards-label", 47 + yield from self.compose_site_fields( 48 + name=self.bbs.site.name, 49 + description=self.bbs.site.description, 50 + intro=self.bbs.site.intro, 62 51 ) 63 - for board in self._boards: 64 - yield Static( 65 - f" {board['slug']}", 66 - classes="subtitle", 67 - id=f"board-label-{board['slug']}", 68 - ) 69 - yield Input( 70 - value=board["name"], 71 - id=f"board-name-{board['slug']}", 72 - max_length=limits.BOARD_NAME, 73 - ) 74 - yield Input( 75 - value=board["description"], 76 - id=f"board-desc-{board['slug']}", 77 - max_length=limits.BOARD_DESCRIPTION, 78 - ) 52 + yield from self.compose_board_widgets() 79 53 yield Footer() 80 54 81 55 def on_mount(self) -> None: 82 56 self.query_one("#edit-name", Input).focus() 83 57 84 - def action_add_board(self) -> None: 85 - index = len(self._boards) + 1 86 - while any(board["slug"] == f"board-{index}" for board in self._boards): 87 - index += 1 88 - slug = f"board-{index}" 89 - self._boards.append( 90 - {"slug": slug, "name": slug, "description": "", "created_at": now_iso()} 91 - ) 92 - 93 - scroll = self.query_one("#edit-scroll", VerticalScroll) 94 - label = Static(f" {slug}", classes="subtitle", id=f"board-label-{slug}") 95 - name_input = Input( 96 - value=slug, id=f"board-name-{slug}", max_length=limits.BOARD_NAME 97 - ) 98 - desc_input = Input( 99 - value="", id=f"board-desc-{slug}", max_length=limits.BOARD_DESCRIPTION 100 - ) 101 - scroll.mount(label) 102 - scroll.mount(name_input) 103 - scroll.mount(desc_input) 104 - name_input.focus() 105 - 106 - def action_remove_board(self) -> None: 107 - if len(self._boards) <= 1: 108 - self.notify("Must have at least one board.", severity="warning") 109 - return 110 - board = self._boards.pop() 111 - slug = board["slug"] 112 - for widget_id in ( 113 - f"board-label-{slug}", 114 - f"board-name-{slug}", 115 - f"board-desc-{slug}", 116 - ): 117 - try: 118 - self.query_one(f"#{widget_id}").remove() 119 - except Exception: 120 - pass 121 - 122 58 def action_save(self) -> None: 123 59 self._do_save() 124 60 ··· 129 65 return 130 66 131 67 updater = make_session_updater(self.app.session_store) 68 + name, description, intro = self.get_site_field_values() 132 69 133 - name = self.query_one("#edit-name", Input).value.strip() 134 - description = self.query_one("#edit-desc", Input).value.strip() 135 - intro = self.query_one("#edit-intro", TextArea).text 136 - 137 - if not name: 138 - self.notify("Name cannot be empty.", severity="error") 139 - return 140 - if len(intro) > limits.SITE_INTRO: 141 - self.notify( 142 - f"Intro too long ({len(intro)}/{limits.SITE_INTRO}).", 143 - severity="error", 144 - ) 70 + if not self.validate_bbs_form(name, intro): 145 71 return 146 72 147 73 now = now_iso() 148 74 149 75 try: 150 - for board in self._boards: 151 - board_name = self.query_one( 152 - f"#board-name-{board['slug']}", Input 153 - ).value.strip() 154 - board_desc = self.query_one( 155 - f"#board-desc-{board['slug']}", Input 156 - ).value.strip() 76 + # Save each board record 77 + for board in self.get_board_values(): 157 78 await put_board_record( 158 79 self.app.http_client, 159 80 session, 160 81 board["slug"], 161 - board_name or board["slug"], 162 - board_desc, 82 + board["name"], 83 + board["description"], 163 84 board["created_at"], 164 85 updater, 165 86 ) 166 87 88 + # Delete any boards that were removed 167 89 current_slugs = {board["slug"] for board in self._boards} 168 90 for board in self.bbs.site.boards: 169 91 if board.slug not in current_slugs: ··· 175 97 updater, 176 98 ) 177 99 100 + # Update the site record 178 101 await put_site_record( 179 102 self.app.http_client, 180 103 session,