Coffee journaling on ATProto (alpha)
alpha.arabica.social
coffee
1package components
2
3import (
4 "fmt"
5 "strings"
6)
7
8// ActionBarProps defines properties for the action bar below feed/profile cards
9type ActionBarProps struct {
10 // Subject reference for likes
11 SubjectURI string
12 SubjectCID string
13
14 // Like state
15 IsLiked bool
16 LikeCount int
17
18 // Comment state
19 CommentCount int
20 ViewURL string // URL to view the item (for comment link)
21 ShowComments bool // Whether to show the comment button
22
23 // Share props
24 ShareURL string
25 ShareTitle string
26 ShareText string
27
28 // Ownership and actions
29 IsOwner bool
30 EditURL string
31 EditModalURL string // If set, loads edit modal via HTMX (for entities that use modal editing)
32 DeleteURL string
33 DeleteTarget string // HTMX target selector for delete (defaults to "closest .feed-card")
34 DeleteRedirect string // If set, redirect to this URL after delete instead of swapping
35
36 // Auth state
37 IsAuthenticated bool
38
39 // Moderation state
40 IsModerator bool // User has moderator role
41 CanHideRecord bool // User has hide_record permission
42 CanBlockUser bool // User has blacklist_user permission
43 IsRecordHidden bool // This record is currently hidden
44 AuthorDID string // DID of the content author (for block action)
45}
46
47// getCommentHref returns the href for the comment button.
48func (p ActionBarProps) getCommentHref() string {
49 if p.ViewURL != "" {
50 return p.ViewURL + "#comment-section"
51 }
52 return "#comment-section"
53}
54
55// getDeleteTarget returns the HTMX target for the delete button.
56func (p ActionBarProps) getDeleteTarget() string {
57 if p.DeleteTarget != "" {
58 return p.DeleteTarget
59 }
60 return "closest .feed-card"
61}
62
63// hasModActions returns true if any moderation menu items will render.
64func (p ActionBarProps) hasModActions() bool {
65 return (p.CanHideRecord && p.SubjectURI != "") ||
66 (p.CanBlockUser && p.AuthorDID != "" && !p.IsOwner)
67}
68
69// hasReportAction returns true if the report menu item will render.
70func (p ActionBarProps) hasReportAction() bool {
71 return p.IsAuthenticated && !p.IsOwner
72}
73
74// ActionBar renders the action bar with Comments, Like, Share, and More menu
75// Order: [💬 Comments] [♡ Like] [↗ Share] [⋯ More]
76templ ActionBar(props ActionBarProps) {
77 <div class="action-bar" x-data="{ moreOpen: false, openUp: true }">
78 <!-- Comments -->
79 if props.ShowComments {
80 <a
81 href={ templ.SafeURL(props.getCommentHref()) }
82 class="action-btn"
83 title="View comments"
84 >
85 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
86 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path>
87 </svg>
88 <span>{ fmt.Sprintf("%d", props.CommentCount) }</span>
89 </a>
90 }
91 <!-- Hidden indicator (visible to moderators) -->
92 if props.IsModerator && props.IsRecordHidden {
93 <span class="hidden-badge" title="This record is hidden from the public feed">
94 <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
95 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path>
96 </svg>
97 Hidden
98 </span>
99 }
100 <!-- Like -->
101 if props.SubjectURI != "" && props.SubjectCID != "" {
102 @LikeButton(LikeButtonProps{
103 SubjectURI: props.SubjectURI,
104 SubjectCID: props.SubjectCID,
105 IsLiked: props.IsLiked,
106 LikeCount: props.LikeCount,
107 IsAuthenticated: props.IsAuthenticated,
108 })
109 }
110 <!-- Share -->
111 if props.ShareURL != "" {
112 @ActionBarShareButton(props)
113 }
114 <!-- More menu -->
115 <div class="relative z-10">
116 <button
117 type="button"
118 @click="if (!moreOpen) { openUp = $el.getBoundingClientRect().top > window.innerHeight * 0.25 }; moreOpen = !moreOpen"
119 @click.away="moreOpen = false"
120 class="action-btn"
121 aria-label="More options"
122 >
123 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
124 <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path>
125 </svg>
126 </button>
127 <!-- Dropdown menu -->
128 <div
129 x-show="moreOpen"
130 x-transition:enter="transition ease-out duration-100"
131 x-transition:enter-start="transform opacity-0 scale-95"
132 x-transition:enter-end="transform opacity-100 scale-100"
133 x-transition:leave="transition ease-in duration-75"
134 x-transition:leave-start="transform opacity-100 scale-100"
135 x-transition:leave-end="transform opacity-0 scale-95"
136 class="action-menu"
137 :class="openUp ? 'bottom-full mb-1' : 'top-full mt-1'"
138 x-cloak
139 >
140 if props.IsOwner {
141 if props.EditURL != "" {
142 <a href={ templ.SafeURL(props.EditURL) } class="action-menu-item">
143 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
144 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path>
145 </svg>
146 Edit
147 </a>
148 }
149 if props.EditModalURL != "" {
150 @ActionBarEditModal(props)
151 }
152 if props.DeleteURL != "" && props.DeleteRedirect != "" {
153 @ActionBarDeleteRedirect(props)
154 } else if props.DeleteURL != "" {
155 @ActionBarDeleteSwap(props)
156 }
157 if props.hasModActions() || props.hasReportAction() {
158 <div class="action-menu-divider"></div>
159 }
160 }
161 <!-- Moderation actions (for moderators/admins) -->
162 if props.CanHideRecord && props.SubjectURI != "" {
163 if props.IsRecordHidden {
164 <button
165 type="button"
166 hx-post="/_mod/unhide"
167 hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) }
168 hx-swap="none"
169 @click="moreOpen = false; $dispatch('notify', {message: 'Record unhidden'})"
170 class="action-menu-item"
171 >
172 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
173 <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"></path>
174 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"></path>
175 </svg>
176 Unhide from feed
177 </button>
178 } else {
179 <button
180 type="button"
181 hx-post="/_mod/hide"
182 hx-vals={ fmt.Sprintf(`{"uri": "%s"}`, props.SubjectURI) }
183 hx-swap="none"
184 hx-confirm="Hide this record from the public feed?"
185 @click="moreOpen = false; $dispatch('notify', {message: 'Record hidden from feed'})"
186 class="action-menu-item action-menu-item-warning"
187 >
188 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
189 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path>
190 </svg>
191 Hide from feed
192 </button>
193 }
194 if (props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner) || props.hasReportAction() {
195 <div class="action-menu-divider"></div>
196 }
197 }
198 if props.CanBlockUser && props.AuthorDID != "" && !props.IsOwner {
199 <button
200 type="button"
201 hx-post="/_mod/block"
202 hx-vals={ fmt.Sprintf(`{"did": "%s"}`, props.AuthorDID) }
203 hx-swap="none"
204 hx-confirm="Block this user? All their content will be hidden from the feed."
205 @click="moreOpen = false; $dispatch('notify', {message: 'User blocked'})"
206 class="action-menu-item action-menu-item-danger"
207 >
208 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
209 <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"></path>
210 </svg>
211 Block user
212 </button>
213 if props.hasReportAction() {
214 <div class="action-menu-divider"></div>
215 }
216 }
217 <!-- Report (available to authenticated users) -->
218 if props.IsAuthenticated && !props.IsOwner {
219 <button
220 type="button"
221 @click={ fmt.Sprintf("moreOpen = false; document.getElementById('report-modal-%s').showModal()", escapeForAlpine(props.SubjectURI)) }
222 class="action-menu-item"
223 >
224 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
225 <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path>
226 </svg>
227 Report
228 </button>
229 }
230 </div>
231 </div>
232 <!-- Report modal (only for authenticated non-owners) -->
233 if props.IsAuthenticated && !props.IsOwner && props.SubjectURI != "" {
234 @ReportModal(ReportModalProps{
235 ID: fmt.Sprintf("report-modal-%s", escapeForAlpine(props.SubjectURI)),
236 SubjectURI: props.SubjectURI,
237 SubjectCID: props.SubjectCID,
238 })
239 }
240 </div>
241}
242
243// ActionBarShareButton renders the share button for the action bar
244templ ActionBarShareButton(props ActionBarProps) {
245 <button
246 type="button"
247 x-data={ fmt.Sprintf("{ copied: false, share() { const fullUrl = window.location.origin + '%s'; if (navigator.share) { navigator.share({ title: '%s', text: '%s', url: fullUrl }).catch(() => {}); } else { navigator.clipboard.writeText(fullUrl).then(() => { this.copied = true; setTimeout(() => this.copied = false, 2000); }); } } }", props.ShareURL, props.ShareTitle, props.ShareText) }
248 @click="share()"
249 class="action-btn"
250 aria-label="Share"
251 >
252 <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
253 <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path>
254 </svg>
255 <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
256 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path>
257 </svg>
258 <span x-show="copied" x-cloak>Copied!</span>
259 </button>
260}
261
262// ReportModalProps defines properties for the report modal
263type ReportModalProps struct {
264 ID string // Unique ID for the dialog
265 SubjectURI string
266 SubjectCID string
267}
268
269// ReportModal renders an inline report modal for the action bar
270templ ReportModal(props ReportModalProps) {
271 <dialog id={ props.ID } class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }">
272 <div class="modal-content">
273 <h3 class="modal-title">Report Content</h3>
274 <template x-if="!success">
275 <form
276 @submit.prevent={ fmt.Sprintf(`
277 submitting = true;
278 error = '';
279 const dialog = document.getElementById('%s');
280 const body = new URLSearchParams({
281 subject_uri: '%s',
282 subject_cid: '%s',
283 reason: reason
284 });
285 fetch('/api/report', {
286 method: 'POST',
287 headers: {'Content-Type': 'application/x-www-form-urlencoded'},
288 body: body
289 })
290 .then(r => r.json().then(data => ({ok: r.ok, data})))
291 .then(({ok, data}) => {
292 submitting = false;
293 if (ok) {
294 success = true;
295 setTimeout(() => dialog.close(), 2000);
296 } else {
297 error = data.message || 'Failed to submit report';
298 }
299 })
300 .catch(() => {
301 submitting = false;
302 error = 'Network error. Please try again.';
303 });
304 `, props.ID, escapeForJS(props.SubjectURI), escapeForJS(props.SubjectCID)) }
305 class="space-y-4"
306 >
307 <p class="text-sm text-brown-700">
308 Please describe why you're reporting this content. Reports are reviewed by moderators.
309 </p>
310 <div>
311 <textarea
312 x-model="reason"
313 @input="charCount = reason.length"
314 name="reason"
315 placeholder="Describe the issue (optional)"
316 rows="4"
317 maxlength="500"
318 class="w-full form-textarea"
319 ></textarea>
320 <div class="flex justify-between text-xs text-brown-500 mt-1">
321 <span>Optional, but helpful for moderators</span>
322 <span x-text="charCount + '/500'"></span>
323 </div>
324 </div>
325 <template x-if="error">
326 <div class="bg-red-100 border border-red-300 text-red-800 px-3 py-2 rounded-lg text-sm" x-text="error"></div>
327 </template>
328 <div class="flex gap-2">
329 <button
330 type="submit"
331 class="flex-1 btn-primary"
332 x-bind:disabled="submitting"
333 >
334 <span x-show="!submitting">Submit Report</span>
335 <span x-show="submitting">Submitting...</span>
336 </button>
337 <button
338 type="button"
339 @click="$el.closest('dialog').close()"
340 class="flex-1 btn-secondary"
341 x-bind:disabled="submitting"
342 >
343 Cancel
344 </button>
345 </div>
346 </form>
347 </template>
348 <template x-if="success">
349 <div class="text-center py-4">
350 <div class="text-green-600 mb-2">
351 <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
352 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path>
353 </svg>
354 </div>
355 <p class="font-medium text-brown-900">Report Submitted</p>
356 <p class="text-sm text-brown-600 mt-1">Thank you for helping keep our community safe.</p>
357 </div>
358 </template>
359 </div>
360 </dialog>
361}
362
363// deleteRedirectScript returns an inline script for redirecting after a successful HTMX delete
364func deleteRedirectScript(url string) string {
365 return fmt.Sprintf("if(event.detail.successful) window.location.href='%s'", url)
366}
367
368// editModalAfterSwapScript returns an inline script for opening a modal after HTMX swap and reloading on save
369func editModalAfterSwapScript() string {
370 return "var d=document.querySelector('#modal-container dialog');if(d){d.showModal();var h=function(){window.location.reload()};document.body.addEventListener('refreshManage',h,{once:true});d.addEventListener('close',function(){document.body.removeEventListener('refreshManage',h)},{once:true})}"
371}
372
373// ActionBarDeleteRedirect renders a delete button that redirects after success (for view pages)
374templ ActionBarDeleteRedirect(props ActionBarProps) {
375 <button
376 type="button"
377 hx-delete={ props.DeleteURL }
378 hx-confirm="Are you sure you want to delete this?"
379 hx-swap="none"
380 hx-on--after-request={ deleteRedirectScript(props.DeleteRedirect) }
381 class="action-menu-item action-menu-item-danger"
382 >
383 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
384 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path>
385 </svg>
386 Delete
387 </button>
388}
389
390// ActionBarDeleteSwap renders a delete button that swaps out the target element (for feed cards)
391templ ActionBarDeleteSwap(props ActionBarProps) {
392 <button
393 type="button"
394 hx-delete={ props.DeleteURL }
395 hx-confirm="Are you sure you want to delete this?"
396 hx-target={ props.getDeleteTarget() }
397 hx-swap="outerHTML"
398 class="action-menu-item action-menu-item-danger"
399 >
400 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
401 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path>
402 </svg>
403 Delete
404 </button>
405}
406
407// ActionBarEditModal renders an edit button that loads an edit modal via HTMX (for entity view pages)
408templ ActionBarEditModal(props ActionBarProps) {
409 <button
410 type="button"
411 hx-get={ props.EditModalURL }
412 hx-target="#modal-container"
413 hx-swap="innerHTML"
414 class="action-menu-item"
415 @click="moreOpen = false"
416 hx-on--after-swap={ editModalAfterSwapScript() }
417 >
418 <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
419 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path>
420 </svg>
421 Edit
422 </button>
423}
424
425// escapeForAlpine escapes special characters in a string for use in Alpine.js expressions.
426// This creates a safe ID by replacing problematic characters.
427func escapeForAlpine(s string) string {
428 // Replace characters that are problematic in HTML IDs and Alpine expressions
429 s = strings.ReplaceAll(s, ":", "_")
430 s = strings.ReplaceAll(s, "/", "_")
431 s = strings.ReplaceAll(s, ".", "_")
432 return s
433}
434
435// escapeForJS escapes special characters in a string for use in JavaScript string literals.
436func escapeForJS(s string) string {
437 s = strings.ReplaceAll(s, "\\", "\\\\")
438 s = strings.ReplaceAll(s, "'", "\\'")
439 s = strings.ReplaceAll(s, "\n", "\\n")
440 s = strings.ReplaceAll(s, "\r", "\\r")
441 return s
442}