tangled
alpha
login
or
join now
aly.codes
/
atbbs
4
fork
atom
Retro Bulletin Board Systems on atproto. Web app and TUI.
atbbs.xyz
python
tui
atproto
bbs
4
fork
atom
overview
issues
pulls
pipelines
add quotes-in-replies
Aly Raffauf
1 week ago
df7d6aab
2c251c62
+102
-17
13 changed files
expand all
collapse all
unified
split
core
auth
session.py
models.py
records.py
lexicons
xyz.atboards.reply.json
tui
screens
compose.py
login.py
thread.py
widgets
post.py
web
routes.py
routes_auth.py
routes_write.py
static
style.css
templates
thread.html
+5
-4
core/auth/session.py
reviewed
···
24
24
refresh_token TEXT,
25
25
dpop_authserver_nonce TEXT NOT NULL,
26
26
dpop_pds_nonce TEXT,
27
27
-
dpop_private_jwk TEXT NOT NULL
27
27
+
dpop_private_jwk TEXT NOT NULL,
28
28
+
client_id TEXT
28
29
);
29
30
"""
30
31
···
82
83
con.execute(
83
84
"""INSERT OR REPLACE INTO oauth_session
84
85
(did, handle, pds_url, authserver_iss, access_token, refresh_token,
85
85
-
dpop_authserver_nonce, dpop_pds_nonce, dpop_private_jwk)
86
86
+
dpop_authserver_nonce, dpop_pds_nonce, dpop_private_jwk, client_id)
86
87
VALUES (:did, :handle, :pds_url, :authserver_iss, :access_token,
87
88
:refresh_token, :dpop_authserver_nonce, :dpop_pds_nonce,
88
88
-
:dpop_private_jwk)""",
89
89
+
:dpop_private_jwk, :client_id)""",
89
90
kwargs,
90
91
)
91
92
con.commit()
···
99
100
con.close()
100
101
return dict(row) if row else None
101
102
102
102
-
ALLOWED_FIELDS = {"dpop_pds_nonce", "dpop_authserver_nonce", "access_token", "refresh_token"}
103
103
+
ALLOWED_FIELDS = {"dpop_pds_nonce", "dpop_authserver_nonce", "access_token", "refresh_token", "client_id"}
103
104
104
105
def update_session_field(self, did: str, field: str, value: str):
105
106
if field not in self.ALLOWED_FIELDS:
+1
core/models.py
reviewed
···
122
122
author: MiniDoc
123
123
updated_at: str | None = None
124
124
attachments: list[dict] | None = None
125
125
+
quote: str | None = None
125
126
126
127
127
128
@dataclass
+12
-9
core/records.py
reviewed
···
68
68
author=authors[r.uri.split("/")[2]],
69
69
updated_at=r.value.get("updatedAt"),
70
70
attachments=r.value.get("attachments"),
71
71
+
quote=r.value.get("quote"),
71
72
)
72
73
for r in records
73
74
if r.uri.split("/")[2] in authors
···
84
85
from core.auth.oauth import refresh_tokens
85
86
from core.auth.config import load_secrets
86
87
import json, os
87
87
-
from platformdirs import user_data_dir
88
88
89
89
-
data_dir = os.environ.get("ATBOARDS_DATA_DIR", user_data_dir("atboards"))
89
89
+
data_dir = os.environ.get("ATBOARDS_DATA_DIR")
90
90
+
if not data_dir:
91
91
+
from platformdirs import user_data_dir
92
92
+
data_dir = user_data_dir("atboards")
90
93
secrets = load_secrets(data_dir)
91
94
client_secret_jwk = json.loads(secrets["client_secret_jwk"])
92
95
93
93
-
# Loopback client ID for TUI
94
94
-
from urllib.parse import urlencode
95
95
-
redirect_uri = "http://127.0.0.1:23847/oauth/callback"
96
96
-
scope = "atproto transition:generic"
97
97
-
client_id = "http://localhost?" + urlencode(
98
98
-
{"redirect_uri": redirect_uri, "scope": scope}
99
99
-
)
96
96
+
# Use stored client_id — required for token refresh
97
97
+
client_id = session.get("client_id")
98
98
+
if not client_id:
99
99
+
return False
100
100
101
101
token_resp, dpop_nonce = await refresh_tokens(
102
102
client=client,
···
221
221
thread_uri: str,
222
222
body: str,
223
223
attachments: list[dict] | None = None,
224
224
+
quote: str | None = None,
224
225
session_updater=None,
225
226
) -> httpx.Response:
226
227
"""Create a reply record in the user's repo."""
···
232
233
}
233
234
if attachments:
234
235
record["attachments"] = attachments
236
236
+
if quote:
237
237
+
record["quote"] = quote
235
238
return await _pds_post(client, session, "com.atproto.repo.createRecord", {
236
239
"repo": session["did"],
237
240
"collection": "xyz.atboards.reply",
+4
lexicons/xyz.atboards.reply.json
reviewed
···
21
21
"type": "string",
22
22
"maxLength": 10000
23
23
},
24
24
+
"quote": {
25
25
+
"type": "string",
26
26
+
"format": "at-uri"
27
27
+
},
24
28
"createdAt": {
25
29
"type": "string",
26
30
"format": "datetime"
+15
-1
tui/screens/compose.py
reviewed
···
103
103
class ComposeReplyScreen(Screen):
104
104
BINDINGS = [("escape", "app.pop_screen", "back")]
105
105
106
106
-
def __init__(self, bbs, handle: str, thread) -> None:
106
106
+
def __init__(self, bbs, handle: str, thread, quote=None) -> None:
107
107
super().__init__()
108
108
self.bbs = bbs
109
109
self.handle = handle
110
110
+
self.quote = quote # Reply object or None
110
111
self.thread = thread
111
112
112
113
def compose(self) -> ComposeResult:
···
119
120
)
120
121
with Vertical():
121
122
yield Static(f"reply to: {self.thread.title}", classes="title")
123
123
+
if self.quote:
124
124
+
body_preview = self.quote.body[:60] + ("..." if len(self.quote.body) > 60 else "")
125
125
+
yield Static(f"quoting {self.quote.author.handle}: {body_preview} [clear: ctrl+g]", classes="subtitle", id="quote-info")
122
126
yield TextArea(id="reply-body", language=None)
123
127
yield Input(placeholder="attach file (path, optional)", id="reply-file")
124
128
yield Static("ctrl+s to post", classes="subtitle")
···
127
131
def on_mount(self) -> None:
128
132
self.query_one("#reply-body", TextArea).focus()
129
133
134
134
+
def key_ctrl_g(self) -> None:
135
135
+
if self.quote:
136
136
+
self.quote = None
137
137
+
try:
138
138
+
self.query_one("#quote-info").remove()
139
139
+
except Exception:
140
140
+
pass
141
141
+
self.notify("Quote cleared.")
142
142
+
130
143
def key_ctrl_s(self) -> None:
131
144
self.post_reply()
132
145
···
157
170
resp = await create_reply_record(
158
171
self.app.http_client, session, self.thread.uri, body,
159
172
attachments=attachments or None,
173
173
+
quote=self.quote.uri if self.quote else None,
160
174
)
161
175
resp.raise_for_status()
162
176
except Exception as e:
+1
tui/screens/login.py
reviewed
···
153
153
"dpop_authserver_nonce": final_dpop_nonce,
154
154
"dpop_pds_nonce": "",
155
155
"dpop_private_jwk": dpop_private_jwk_json,
156
156
+
"client_id": client_id,
156
157
}
157
158
self.app.session_store.save_session(**session_data)
158
159
self.app.user_session = session_data
+31
-2
tui/screens/thread.py
reviewed
···
24
24
self.handle = handle
25
25
self.thread = thread
26
26
self.next_cursor: str | None = None
27
27
+
self._replies_map: dict[str, object] = {}
27
28
28
29
def compose(self) -> ComposeResult:
29
30
board_slug = self.thread.board_uri.split("/")[-1]
···
69
70
70
71
scroll = self.query_one("#thread-scroll")
71
72
73
73
+
# Store for quote lookup
72
74
for r in replies:
75
75
+
self._replies_map[r.uri] = r
76
76
+
77
77
+
for r in replies:
78
78
+
quote_text = None
79
79
+
if r.quote and r.quote in self._replies_map:
80
80
+
q = self._replies_map[r.quote]
81
81
+
body_preview = q.body[:200] + ("..." if len(q.body) > 200 else "")
82
82
+
quote_text = f"{q.author.handle}: {body_preview}"
83
83
+
73
84
await scroll.mount(
74
85
Post(
75
86
author=r.author.handle,
···
80
91
record_uri=r.uri,
81
92
collection="xyz.atboards.reply",
82
93
attachments=r.attachments,
94
94
+
quote_text=quote_text,
83
95
)
84
96
)
85
97
···
111
123
return
112
124
113
125
scroll = self.query_one("#thread-scroll")
126
126
+
self._replies_map.clear()
114
127
for r in replies:
128
128
+
self._replies_map[r.uri] = r
129
129
+
130
130
+
for r in replies:
131
131
+
quote_text = None
132
132
+
if r.quote and r.quote in self._replies_map:
133
133
+
q = self._replies_map[r.quote]
134
134
+
body_preview = q.body[:200] + ("..." if len(q.body) > 200 else "")
135
135
+
quote_text = f"{q.author.handle}: {body_preview}"
136
136
+
115
137
await scroll.mount(
116
138
Post(
117
139
author=r.author.handle,
···
122
144
record_uri=r.uri,
123
145
collection="xyz.atboards.reply",
124
146
attachments=r.attachments,
147
147
+
quote_text=quote_text,
125
148
)
126
149
)
127
150
···
140
163
if session["did"] in self.bbs.site.banned_dids:
141
164
self.notify("You have been banned from this BBS.", severity="error")
142
165
return
166
166
+
167
167
+
# If focused on a reply, quote it
168
168
+
quote = None
169
169
+
focused = self.focused
170
170
+
if isinstance(focused, Post) and focused.collection == "xyz.atboards.reply" and focused.record_uri:
171
171
+
quote = self._replies_map.get(focused.record_uri)
172
172
+
143
173
from tui.screens.compose import ComposeReplyScreen
144
144
-
self.app.push_screen(ComposeReplyScreen(self.bbs, self.handle, self.thread))
174
174
+
self.app.push_screen(ComposeReplyScreen(self.bbs, self.handle, self.thread, quote=quote))
145
175
146
176
def action_delete(self) -> None:
147
177
session = self.app.user_session
···
182
212
183
213
@work(exclusive=True)
184
214
async def _do_save(self, post: Post) -> None:
185
185
-
import os
186
215
from pathlib import Path
187
216
188
217
downloads = Path.home() / "Downloads"
+10
tui/widgets/post.py
reviewed
···
65
65
color: #8a8a8a;
66
66
margin-top: 1;
67
67
}
68
68
+
Post .post-quote {
69
69
+
color: #8a8a8a;
70
70
+
border-left: solid #525252;
71
71
+
padding-left: 2;
72
72
+
margin-bottom: 1;
73
73
+
}
68
74
"""
69
75
70
76
def __init__(
···
78
84
record_uri: str | None = None,
79
85
collection: str | None = None,
80
86
attachments: list[dict] | None = None,
87
87
+
quote_text: str | None = None,
81
88
**kwargs,
82
89
) -> None:
83
90
super().__init__(**kwargs)
···
90
97
self.record_uri = record_uri
91
98
self.collection = collection
92
99
self.attachments = attachments or []
100
100
+
self._quote_text = quote_text
93
101
94
102
@property
95
103
def rkey(self) -> str | None:
···
101
109
yield Static(f"{self._author} {self._date}", classes="post-meta", markup=False)
102
110
if self._title:
103
111
yield Static(self._title, classes="post-title", markup=False)
112
112
+
if self._quote_text:
113
113
+
yield Static(self._quote_text, classes="post-quote", markup=False)
104
114
yield Static(self._body, classes="post-body", markup=False)
105
115
for att in self.attachments:
106
116
name = att.get("name", "file")
+1
web/routes.py
reviewed
···
257
257
"body": r.body,
258
258
"created_at": r.created_at,
259
259
"attachments": r.attachments or [],
260
260
+
"quote": r.quote,
260
261
}
261
262
for r in replies
262
263
],
+1
web/routes_auth.py
reviewed
···
177
177
dpop_authserver_nonce=dpop_nonce,
178
178
dpop_pds_nonce="",
179
179
dpop_private_jwk=auth_req["dpop_private_jwk"],
180
180
+
client_id=client_id,
180
181
)
181
182
182
183
# Clean up auth request, set cookie
+3
web/routes_write.py
reviewed
···
87
87
88
88
form = await request.form
89
89
body = form.get("body", "").strip()
90
90
+
quote = form.get("quote", "").strip() or None
90
91
if not body:
91
92
return redirect(f"/bbs/{handle}/thread/{did}/{tid}")
92
93
···
110
111
}
111
112
if attachments:
112
113
record["attachments"] = attachments
114
114
+
if quote:
115
115
+
record["quote"] = quote
113
116
114
117
resp = await _authed_pds_post(user, "com.atproto.repo.createRecord", {
115
118
"repo": user["did"],
+1
-1
web/static/style.css
reviewed
···
1
1
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */
2
2
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-900:oklch(39.6% .141 25.723);--color-amber-500:oklch(76.9% .188 70.08);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.start{inset-inline-start:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-500{color:var(--color-amber-500)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:border-red-900:hover{border-color:var(--color-red-900)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
2
2
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-900:oklch(39.6% .141 25.723);--color-amber-500:oklch(76.9% .188 70.08);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-bold:700;--tracking-wide:.025em;--leading-snug:1.375;--leading-relaxed:1.625;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.start{inset-inline-start:var(--spacing)}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-24{margin-top:calc(var(--spacing) * 24)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-4{margin-left:calc(var(--spacing) * 4)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.min-h-screen{min-height:100vh}.w-1\/3{width:33.3333%}.w-1\/4{width:25%}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.resize-y{resize:vertical}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-neutral-800\/50{border-color:#26262680}@supports (color:color-mix(in lab, red, red)){.border-neutral-800\/50{border-color:color-mix(in oklab, var(--color-neutral-800) 50%, transparent)}}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-neutral-950{background-color:var(--color-neutral-950)}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-16{padding-block:calc(var(--spacing) * 16)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-500{color:var(--color-amber-500)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-red-500{color:var(--color-red-500)}.uppercase{text-transform:uppercase}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-neutral-500::placeholder{color:var(--color-neutral-500)}@media (hover:hover){.group-hover\:text-white:is(:where(.group):hover *){color:var(--color-white)}.hover\:border-neutral-700:hover{border-color:var(--color-neutral-700)}.hover\:border-red-900:hover{border-color:var(--color-red-900)}.hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.hover\:bg-neutral-900:hover{background-color:var(--color-neutral-900)}.hover\:text-neutral-200:hover{color:var(--color-neutral-200)}.hover\:text-neutral-300:hover{color:var(--color-neutral-300)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-80:hover{opacity:.8}}.focus\:border-neutral-600:focus{border-color:var(--color-neutral-600)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}
+17
web/templates/thread.html
reviewed
···
50
50
51
51
{% if g.user %}
52
52
<form method="post" action="/bbs/{{ handle }}/thread/{{ thread.author.did }}/{{ thread.uri.split('/')[-1] }}/reply" enctype="multipart/form-data" class="mt-6 border border-neutral-800 rounded p-4">
53
53
+
<input type="hidden" name="quote" id="quote-uri" value="">
54
54
+
<div id="quote-preview" class="text-xs text-neutral-500 mb-2 hidden">
55
55
+
<span id="quote-preview-text"></span>
56
56
+
<button type="button" onclick="document.getElementById('quote-uri').value=''; document.getElementById('quote-preview').classList.add('hidden');" class="text-neutral-500 hover:text-red-400 ml-2">x</button>
57
57
+
</div>
53
58
<textarea
54
59
name="body"
55
60
placeholder="Write a reply..."
61
61
+
id="reply-body"
56
62
required
57
63
rows="3"
58
64
class="w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-neutral-600 resize-y mb-3"
···
75
81
const userDid = "{{ g.user.did if g.user else '' }}";
76
82
const sysopDid = "{{ bbs.identity.did }}";
77
83
let nextCursor = null;
84
84
+
const allReplies = {};
78
85
79
86
function formatDate(iso) {
80
87
const d = new Date(iso);
···
101
108
if (userDid && userDid === sysopDid) {
102
109
hideBtn = `<form method="post" action="/bbs/${handle}/hide" class="inline" onsubmit="return confirm('Hide this post?')"><input type="hidden" name="uri" value="${r.uri}"><button type="submit" class="text-xs text-neutral-500 hover:text-red-400">hide</button></form>`;
103
110
}
111
111
+
let quoteBlock = '';
112
112
+
if (r.quote && allReplies[r.quote]) {
113
113
+
const q = allReplies[r.quote];
114
114
+
quoteBlock = `<div class="border-l-2 border-neutral-700 pl-3 mb-3 py-1 text-xs text-neutral-500"><span class="text-neutral-400">${escapeHtml(q.handle)}:</span> ${escapeHtml(q.body.substring(0, 200))}${q.body.length > 200 ? '...' : ''}</div>`;
115
115
+
}
104
116
return `<div class="border border-neutral-800/50 rounded p-4">
105
117
<div class="flex items-baseline justify-between mb-2">
106
118
<span class="text-neutral-300">${escapeHtml(r.handle)}</span>
107
119
<div class="flex items-center gap-3">
108
120
<span class="text-xs text-neutral-500">${formatDate(r.created_at)}</span>
121
121
+
${userDid ? `<button type="button" onclick="document.getElementById('quote-uri').value='${r.uri}'; document.getElementById('quote-preview-text').textContent='quoting ${escapeHtml(r.handle)}'; document.getElementById('quote-preview').classList.remove('hidden'); document.getElementById('reply-body').focus();" class="text-xs text-neutral-500 hover:text-neutral-300">quote</button>` : ''}
109
122
${deleteBtn}
110
123
${banBtn}
111
124
${hideBtn}
112
125
</div>
113
126
</div>
127
127
+
${quoteBlock}
114
128
<p class="text-neutral-400 whitespace-pre-wrap leading-relaxed">${escapeHtml(r.body)}</p>
115
129
${(r.attachments || []).map(a => `<a href="${r.pds_url}/xrpc/com.atproto.sync.getBlob?did=${r.did}&cid=${a.file.ref['$link']}" target="_blank" class="text-xs text-neutral-500 hover:text-neutral-300 block mt-1">[${escapeHtml(a.name)}]</a>`).join('')}
116
130
</div>`;
···
125
139
.then(data => {
126
140
const container = document.getElementById('replies');
127
141
const loading = document.getElementById('replies-loading');
142
142
+
143
143
+
// Store all replies for quote lookup
144
144
+
data.replies.forEach(r => { allReplies[r.uri] = r; });
128
145
if (loading) loading.remove();
129
146
130
147
data.replies.forEach(r => {