@rmdes/indiekit-endpoint-activitypub 2.0.35 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,210 +9,381 @@
9
9
  <p class="ap-explore-header__desc">{{ __("activitypub.reader.explore.description") }}</p>
10
10
  </header>
11
11
 
12
- {# Tab navigation #}
13
- {% set exploreBase = mountPath + "/admin/reader/explore" %}
14
- <nav class="ap-tabs">
15
- <a href="{{ exploreBase }}" class="ap-tab{% if activeTab != 'decks' %} ap-tab--active{% endif %}">
16
- {{ __("activitypub.reader.explore.tabs.search") }}
17
- </a>
18
- <a href="{{ exploreBase }}?tab=decks" class="ap-tab{% if activeTab == 'decks' %} ap-tab--active{% endif %}">
19
- {{ __("activitypub.reader.explore.tabs.decks") }}
20
- {% if decks and decks.length > 0 %}
21
- <span class="ap-tab__count">{{ decks.length }}</span>
22
- {% endif %}
23
- </a>
24
- </nav>
25
-
26
- {# ── Search tab ────────────────────────────────────────────────── #}
27
- {% if activeTab != 'decks' %}
28
-
29
- {# Instance form with autocomplete #}
30
- <form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
31
- x-data="apInstanceSearch('{{ mountPath }}')"
32
- @submit="onSubmit">
33
- <div class="ap-explore-form__row">
34
- <div class="ap-explore-autocomplete">
35
- <input
36
- type="text"
37
- name="instance"
38
- value="{{ instance }}"
39
- class="ap-explore-form__input"
40
- placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
41
- aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
42
- autocomplete="off"
43
- required
44
- x-model="query"
45
- @input.debounce.300ms="search()"
46
- @keydown.arrow-down.prevent="highlightNext()"
47
- @keydown.arrow-up.prevent="highlightPrev()"
48
- @keydown.enter="selectHighlighted($event)"
49
- @keydown.escape="close()"
50
- @focus="showResults && suggestions.length > 0 ? showResults = true : null"
51
- @click.away="close()"
52
- x-ref="input">
53
-
54
- {# Autocomplete dropdown #}
55
- <div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
56
- <template x-for="(item, index) in suggestions" :key="item.domain">
57
- <button type="button"
58
- class="ap-explore-autocomplete__item"
59
- :class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
60
- @click="selectItem(item)"
61
- @mouseenter="highlighted = index">
62
- <span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
63
- <span class="ap-explore-autocomplete__meta">
64
- <span class="ap-explore-autocomplete__software" x-text="item.software"></span>
65
- <template x-if="item.mau > 0">
66
- <span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
67
- </template>
68
- </span>
69
- <span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
70
- <template x-if="item._timelineStatus === 'checking'">
71
- <span class="ap-explore-autocomplete__checking">⏳</span>
72
- </template>
73
- <template x-if="item._timelineStatus === true">
74
- <span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
75
- </template>
76
- <template x-if="item._timelineStatus === false">
77
- <span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
78
- </template>
79
- </span>
80
- </button>
81
- </template>
82
- </div>
83
- </div>
12
+ {# ── Tabbed explore container (Alpine.js component) ──────────────────── #}
13
+ <div class="ap-explore-tabs-container"
14
+ data-mount-path="{{ mountPath }}"
15
+ data-csrf="{{ csrfToken }}"
16
+ x-data="apExploreTabs()">
84
17
 
85
- <div class="ap-explore-form__scope">
86
- <label class="ap-explore-form__scope-label">
87
- <input type="radio" name="scope" value="local"
88
- {% if scope == "local" %}checked{% endif %}>
89
- {{ __("activitypub.reader.explore.local") }}
90
- </label>
91
- <label class="ap-explore-form__scope-label">
92
- <input type="radio" name="scope" value="federated"
93
- {% if scope == "federated" %}checked{% endif %}>
94
- {{ __("activitypub.reader.explore.federated") }}
95
- </label>
96
- </div>
97
- <button type="submit" class="ap-explore-form__btn">
98
- {{ __("activitypub.reader.explore.browse") }}
18
+ {# ── Tab bar ──────────────────────────────────────────────────────────── #}
19
+ <nav class="ap-tabs ap-explore-tabs-nav"
20
+ id="ap-explore-tab-bar"
21
+ role="tablist"
22
+ aria-label="{{ __('activitypub.reader.explore.tabs.label') }}">
23
+
24
+ {# Search tab — always first, not removable #}
25
+ <button
26
+ type="button"
27
+ class="ap-tab"
28
+ :class="{ 'ap-tab--active': activeTabId === null }"
29
+ role="tab"
30
+ :aria-selected="activeTabId === null ? 'true' : 'false'"
31
+ aria-controls="ap-tab-panel-search"
32
+ id="ap-tab-btn-search"
33
+ @click="switchToSearch()"
34
+ @keydown="handleTabKeydown($event, 0)">
35
+ {{ __("activitypub.reader.explore.tabs.search") }}
99
36
  </button>
100
- </div>
101
- </form>
102
-
103
- {# Error state #}
104
- {% if error %}
105
- <div class="ap-explore-error">{{ error }}</div>
106
- {% endif %}
107
-
108
- {# Results #}
109
- {% if instance and not error %}
110
- {# Add to deck toggle button (shown when browsing results) #}
111
- {% if items.length > 0 %}
112
- <div class="ap-explore-deck-toggle"
113
- x-data="apDeckToggle('{{ instance }}', '{{ scope }}', '{{ mountPath }}', '{{ csrfToken }}', {{ deckCount }}, {{ 'true' if isInDeck else 'false' }})">
37
+
38
+ {# User-created instance and hashtag tabs #}
39
+ <template x-for="(tab, index) in tabs" :key="tab._id">
40
+ <div class="ap-tab-wrapper">
41
+ {# Tab button #}
42
+ <button
43
+ type="button"
44
+ class="ap-tab ap-tab--user"
45
+ :class="{ 'ap-tab--active': activeTabId === tab._id }"
46
+ role="tab"
47
+ :aria-selected="activeTabId === tab._id ? 'true' : 'false'"
48
+ :aria-controls="'ap-tab-panel-' + tab._id"
49
+ :id="'ap-tab-btn-' + tab._id"
50
+ @click="switchTab(tab._id)"
51
+ @keydown="handleTabKeydown($event, index + 1)">
52
+ <span class="ap-tab__label" :title="tabLabel(tab)" x-text="tabLabel(tab)"></span>
53
+ <span
54
+ x-show="tab.type === 'instance'"
55
+ class="ap-tab__badge"
56
+ :class="tab.scope === 'local' ? 'ap-tab__badge--local' : 'ap-tab__badge--federated'"
57
+ x-text="tab.scope"></span>
58
+ </button>
59
+
60
+ {# Reorder and close controls #}
61
+ <div class="ap-tab-controls">
62
+ <button
63
+ type="button"
64
+ class="ap-tab-control ap-tab-control--up"
65
+ @click.stop="moveUp(tab)"
66
+ :disabled="index === 0"
67
+ :aria-label="'{{ __('activitypub.reader.explore.tabs.moveUp') }}: ' + tabLabel(tab)">↑</button>
68
+ <button
69
+ type="button"
70
+ class="ap-tab-control ap-tab-control--down"
71
+ @click.stop="moveDown(tab)"
72
+ :disabled="index === tabs.length - 1"
73
+ :aria-label="'{{ __('activitypub.reader.explore.tabs.moveDown') }}: ' + tabLabel(tab)">↓</button>
74
+ <button
75
+ type="button"
76
+ class="ap-tab-control ap-tab-control--remove"
77
+ @click.stop="removeTab(tab)"
78
+ :aria-label="'{{ __('activitypub.reader.explore.tabs.remove') }}: ' + tabLabel(tab)">×</button>
79
+ </div>
80
+ </div>
81
+ </template>
82
+
83
+ {# +# button — add a hashtag tab #}
84
+ <div class="ap-tab-add-hashtag">
114
85
  <button
115
86
  type="button"
116
- class="ap-explore-deck-toggle__btn"
117
- :class="{ 'ap-explore-deck-toggle__btn--active': inDeck }"
118
- @click="toggle()"
119
- :disabled="!inDeck && deckLimitReached"
120
- :title="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
121
- :aria-label="!inDeck && deckLimitReached ? '{{ __('activitypub.reader.explore.deck.deckLimitReached') }}' : (inDeck ? '{{ __('activitypub.reader.explore.deck.removeFromDeck') }}' : '{{ __('activitypub.reader.explore.deck.addToDeck') }}')"
122
- x-text="inDeck ? '★ {{ __('activitypub.reader.explore.deck.inDeck') }}' : '☆ {{ __('activitypub.reader.explore.deck.addToDeck') }}'">
123
- </button>
124
- </div>
125
- {% endif %}
126
-
127
- {% if items.length > 0 %}
128
- <div class="ap-timeline ap-explore-timeline"
129
- id="ap-explore-timeline"
130
- data-instance="{{ instance }}"
131
- data-scope="{{ scope }}"
132
- data-mount-path="{{ mountPath }}"
133
- data-max-id="{{ maxId if maxId else '' }}">
134
- {% for item in items %}
135
- {% include "partials/ap-item-card.njk" %}
136
- {% endfor %}
87
+ class="ap-tab ap-tab--add"
88
+ @click="showHashtagForm = !showHashtagForm"
89
+ :aria-expanded="showHashtagForm ? 'true' : 'false'"
90
+ title="{{ __('activitypub.reader.explore.tabs.addHashtag') }}">+#</button>
91
+ <form
92
+ x-show="showHashtagForm"
93
+ x-cloak
94
+ class="ap-tab-hashtag-form"
95
+ @submit.prevent="submitHashtagTab()"
96
+ @keydown.escape.prevent="showHashtagForm = false; hashtagInput = ''">
97
+ <span class="ap-tab-hashtag-form__prefix">#</span>
98
+ <input
99
+ type="text"
100
+ x-model="hashtagInput"
101
+ class="ap-tab-hashtag-form__input"
102
+ placeholder="{{ __('activitypub.reader.explore.tabs.hashtagTabPlaceholder') }}"
103
+ aria-label="{{ __('activitypub.reader.explore.tabs.addHashtag') }}"
104
+ autocomplete="off"
105
+ maxlength="100">
106
+ <button type="submit" class="ap-tab-hashtag-form__btn">
107
+ {{ __("activitypub.reader.explore.tabs.addTab") }}
108
+ </button>
109
+ </form>
137
110
  </div>
111
+ </nav>{# end tab bar nav #}
112
+
113
+ {# Error message (CSRF expiry, network errors) #}
114
+ <p x-show="error" x-cloak class="ap-explore-error" x-text="error"></p>
115
+
116
+ {# ── Search tab panel ─────────────────────────────────────────────────── #}
117
+ <div id="ap-tab-panel-search"
118
+ role="tabpanel"
119
+ aria-labelledby="ap-tab-btn-search"
120
+ x-show="activeTabId === null">
121
+
122
+ {# Instance form with autocomplete #}
123
+ <form action="{{ mountPath }}/admin/reader/explore" method="get" class="ap-explore-form"
124
+ x-data="apInstanceSearch('{{ mountPath }}')"
125
+ @submit="onSubmit">
126
+ <div class="ap-explore-form__row">
127
+ <div class="ap-explore-autocomplete">
128
+ <input
129
+ type="text"
130
+ name="instance"
131
+ value="{{ instance }}"
132
+ class="ap-explore-form__input"
133
+ placeholder="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
134
+ aria-label="{{ __('activitypub.reader.explore.instancePlaceholder') }}"
135
+ autocomplete="off"
136
+ required
137
+ x-model="query"
138
+ @input.debounce.300ms="search()"
139
+ @keydown.arrow-down.prevent="highlightNext()"
140
+ @keydown.arrow-up.prevent="highlightPrev()"
141
+ @keydown.enter="selectHighlighted($event)"
142
+ @keydown.escape="close()"
143
+ @focus="showResults && suggestions.length > 0 ? showResults = true : null"
144
+ @click.away="close()"
145
+ x-ref="input">
138
146
 
139
- {# Infinite scroll for explore page #}
140
- {% if maxId %}
141
- <div class="ap-load-more"
142
- id="ap-explore-load-more"
143
- data-max-id="{{ maxId }}"
144
- data-instance="{{ instance }}"
145
- data-scope="{{ scope }}"
146
- x-data="apExploreScroll()"
147
- x-init="init()">
148
- <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
149
- <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
150
- <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
151
- <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
147
+ {# Autocomplete dropdown #}
148
+ <div class="ap-explore-autocomplete__dropdown" x-show="showResults && suggestions.length > 0" x-cloak>
149
+ <template x-for="(item, index) in suggestions" :key="item.domain">
150
+ <button type="button"
151
+ class="ap-explore-autocomplete__item"
152
+ :class="{ 'ap-explore-autocomplete__item--highlighted': index === highlighted }"
153
+ @click="selectItem(item)"
154
+ @mouseenter="highlighted = index">
155
+ <span class="ap-explore-autocomplete__domain" x-text="item.domain"></span>
156
+ <span class="ap-explore-autocomplete__meta">
157
+ <span class="ap-explore-autocomplete__software" x-text="item.software"></span>
158
+ <template x-if="item.mau > 0">
159
+ <span class="ap-explore-autocomplete__mau" x-text="item.mau.toLocaleString() + ' {{ __("activitypub.reader.explore.mauLabel") }}'"></span>
160
+ </template>
161
+ </span>
162
+ <span class="ap-explore-autocomplete__status" x-show="item._timelineStatus !== undefined">
163
+ <template x-if="item._timelineStatus === 'checking'">
164
+ <span class="ap-explore-autocomplete__checking">⏳</span>
165
+ </template>
166
+ <template x-if="item._timelineStatus === true">
167
+ <span class="ap-explore-autocomplete__supported" title="{{ __('activitypub.reader.explore.timelineSupported') }}">✅</span>
168
+ </template>
169
+ <template x-if="item._timelineStatus === false">
170
+ <span class="ap-explore-autocomplete__unsupported" title="{{ __('activitypub.reader.explore.timelineUnsupported') }}">❌</span>
171
+ </template>
172
+ </span>
173
+ </button>
174
+ </template>
175
+ </div>
176
+ </div>
177
+
178
+ <div class="ap-explore-form__scope">
179
+ <label class="ap-explore-form__scope-label">
180
+ <input type="radio" name="scope" value="local"
181
+ {% if scope == "local" %}checked{% endif %}>
182
+ {{ __("activitypub.reader.explore.local") }}
183
+ </label>
184
+ <label class="ap-explore-form__scope-label">
185
+ <input type="radio" name="scope" value="federated"
186
+ {% if scope == "federated" %}checked{% endif %}>
187
+ {{ __("activitypub.reader.explore.federated") }}
188
+ </label>
189
+ </div>
190
+ <button type="submit" class="ap-explore-form__btn">
191
+ {{ __("activitypub.reader.explore.browse") }}
152
192
  </button>
153
- <p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
154
193
  </div>
194
+ <div class="ap-explore-form__hashtag-row">
195
+ <label class="ap-explore-form__hashtag-label" for="ap-explore-hashtag">
196
+ {{ __("activitypub.reader.explore.hashtagLabel") }}
197
+ </label>
198
+ <span class="ap-explore-form__hashtag-prefix">#</span>
199
+ <input
200
+ type="text"
201
+ id="ap-explore-hashtag"
202
+ name="hashtag"
203
+ value="{{ hashtag }}"
204
+ class="ap-explore-form__input ap-explore-form__input--hashtag"
205
+ placeholder="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
206
+ aria-label="{{ __('activitypub.reader.explore.hashtagPlaceholder') }}"
207
+ autocomplete="off"
208
+ pattern="[\w]+"
209
+ maxlength="100">
210
+ <span class="ap-explore-form__hashtag-hint">{{ __("activitypub.reader.explore.hashtagHint") }}</span>
211
+ </div>
212
+ </form>
213
+
214
+ {# Error state #}
215
+ {% if error %}
216
+ <div class="ap-explore-error">{{ error }}</div>
155
217
  {% endif %}
156
- {% elif instance %}
157
- {{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
158
- {% endif %}
159
- {% endif %}
160
-
161
- {% endif %}{# end Search tab #}
162
-
163
- {# ── Decks tab ──────────────────────────────────────────────────── #}
164
- {% if activeTab == 'decks' %}
165
- {% if decks and decks.length > 0 %}
166
- <div class="ap-deck-grid" data-csrf-token="{{ csrfToken }}">
167
- {% for deck in decks %}
168
- <div class="ap-deck-column"
169
- x-data="apDeckColumn('{{ deck.domain }}', '{{ deck.scope }}', '{{ mountPath }}', {{ loop.index0 }}, '{{ csrfToken }}')"
170
- x-init="init()">
171
- <header class="ap-deck-column__header">
172
- <span class="ap-deck-column__domain">{{ deck.domain }}</span>
173
- <span class="ap-deck-column__scope-badge ap-deck-column__scope-badge--{{ deck.scope }}">
174
- {{ __("activitypub.reader.explore.deck." + deck.scope + "Badge") }}
175
- </span>
176
- <button
177
- type="button"
178
- class="ap-deck-column__remove"
179
- @click="removeDeck()"
180
- title="{{ __('activitypub.reader.explore.deck.removeColumn') }}"
181
- aria-label="{{ __('activitypub.reader.explore.deck.removeColumn') }}">×</button>
182
- </header>
183
- <div class="ap-deck-column__body" x-ref="body">
184
- <div x-show="loading && itemCount === 0" class="ap-deck-column__loading">
185
- <span>{{ __("activitypub.reader.pagination.loading") }}</span>
186
- </div>
187
- <div x-show="error" class="ap-deck-column__error" x-cloak>
188
- <p x-text="error"></p>
189
- <button type="button" class="ap-deck-column__retry" @click="retryLoad()">
190
- {{ __("activitypub.reader.explore.deck.retry") }}
191
- </button>
192
- </div>
193
- <div x-show="!loading && !error && itemCount === 0" class="ap-deck-column__empty" x-cloak>
194
- {{ __("activitypub.reader.explore.noResults") }}
195
- </div>
196
- <div x-html="html" class="ap-deck-column__items"></div>
197
- <div class="ap-deck-column__sentinel" x-ref="sentinel"></div>
198
- <div x-show="loading && itemCount > 0" class="ap-deck-column__loading-more" x-cloak>
199
- <span>{{ __("activitypub.reader.pagination.loading") }}</span>
200
- </div>
201
- <p x-show="done && itemCount > 0" class="ap-deck-column__done" x-cloak>
202
- {{ __("activitypub.reader.pagination.noMore") }}
203
- </p>
218
+
219
+ {# Results #}
220
+ {% if instance and not error %}
221
+ {# Pin as tab button — outside form, inside apExploreTabs scope #}
222
+ <div class="ap-explore-pin-bar">
223
+ <button
224
+ type="button"
225
+ class="ap-explore-pin-btn"
226
+ @click="pinInstance('{{ instance }}', '{{ scope }}')"
227
+ :disabled="pinning">
228
+ <span x-show="!pinning">{{ __("activitypub.reader.explore.tabs.pinAsTab") }}</span>
229
+ <span x-show="pinning" x-cloak>…</span>
230
+ </button>
231
+ </div>
232
+
233
+ {% if items.length > 0 %}
234
+ <div class="ap-timeline ap-explore-timeline"
235
+ id="ap-explore-timeline"
236
+ data-instance="{{ instance }}"
237
+ data-scope="{{ scope }}"
238
+ data-hashtag="{{ hashtag }}"
239
+ data-mount-path="{{ mountPath }}"
240
+ data-max-id="{{ maxId if maxId else '' }}">
241
+ {% for item in items %}
242
+ {% include "partials/ap-item-card.njk" %}
243
+ {% endfor %}
244
+ </div>
245
+
246
+ {# Infinite scroll for explore search tab #}
247
+ {% if maxId %}
248
+ <div class="ap-load-more"
249
+ id="ap-explore-load-more"
250
+ data-max-id="{{ maxId }}"
251
+ data-instance="{{ instance }}"
252
+ data-scope="{{ scope }}"
253
+ data-hashtag="{{ hashtag }}"
254
+ x-data="apExploreScroll()"
255
+ x-init="init()">
256
+ <div class="ap-load-more__sentinel" x-ref="sentinel"></div>
257
+ <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done">
258
+ <span x-show="!loading">{{ __("activitypub.reader.pagination.loadMore") }}</span>
259
+ <span x-show="loading">{{ __("activitypub.reader.pagination.loading") }}</span>
260
+ </button>
261
+ <p class="ap-load-more__done" x-show="done" x-cloak>{{ __("activitypub.reader.pagination.noMore") }}</p>
262
+ </div>
263
+ {% endif %}
264
+ {% elif instance %}
265
+ {{ prose({ text: __("activitypub.reader.explore.noResults") }) }}
266
+ {% endif %}
267
+ {% endif %}
268
+
269
+ </div>{# end Search tab panel #}
270
+
271
+ {# ── Dynamic tab panels (instance + hashtag) ──────────────────────────── #}
272
+ <template x-for="tab in tabs" :key="tab._id + '-panel'">
273
+ <div
274
+ :id="'ap-tab-panel-' + tab._id"
275
+ role="tabpanel"
276
+ :aria-labelledby="'ap-tab-btn-' + tab._id"
277
+ x-show="activeTabId === tab._id"
278
+ x-cloak>
279
+
280
+ {# ── Instance tab panel ───────────────────────────────────────────── #}
281
+ <template x-if="tab.type === 'instance'">
282
+ <div class="ap-explore-instance-panel">
283
+
284
+ {# Loading spinner — first load, no content yet #}
285
+ <div class="ap-explore-tab-loading"
286
+ x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
287
+ <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
288
+ </div>
289
+
290
+ {# Error state with retry #}
291
+ <div class="ap-explore-tab-error"
292
+ x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
293
+ <p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
294
+ <button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
295
+ {{ __("activitypub.reader.explore.tabs.retry") }}
296
+ </button>
297
+ </div>
298
+
299
+ {# Timeline content — server-rendered cards injected via x-html #}
300
+ <div class="ap-timeline ap-explore-tab-timeline"
301
+ x-show="tabState[tab._id] && tabState[tab._id].html"
302
+ x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
204
303
  </div>
304
+
305
+ {# Inline loading spinner for subsequent pages #}
306
+ <div class="ap-explore-tab-loading ap-explore-tab-loading--more"
307
+ x-show="tabState[tab._id] && tabState[tab._id].loading && tabState[tab._id].html">
308
+ <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
309
+ </div>
310
+
311
+ {# Empty state — loaded successfully but no posts #}
312
+ <div class="ap-explore-tab-empty"
313
+ x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
314
+ <p>{{ __("activitypub.reader.explore.noResults") }}</p>
315
+ </div>
316
+
317
+ {# End of feed message #}
318
+ <p class="ap-load-more__done"
319
+ x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
320
+ x-cloak>
321
+ {{ __("activitypub.reader.pagination.noMore") }}
322
+ </p>
323
+
324
+ {# Infinite scroll sentinel — watched by IntersectionObserver #}
325
+ <div class="ap-tab-sentinel"></div>
205
326
  </div>
206
- {% endfor %}
207
- </div>
208
- {% else %}
209
- <div class="ap-deck-empty">
210
- <p>{{ __("activitypub.reader.explore.deck.emptyState") }}</p>
211
- <a href="{{ exploreBase }}" class="ap-deck-empty__link">
212
- {{ __("activitypub.reader.explore.deck.emptyStateLink") }}
213
- </a>
327
+ </template>
328
+
329
+ {# ── Hashtag tab panel ────────────────────────────────────────────── #}
330
+ <template x-if="tab.type === 'hashtag'">
331
+ <div class="ap-explore-hashtag-panel">
332
+
333
+ {# Source info line — shows which instances are being searched #}
334
+ <p class="ap-hashtag-sources"
335
+ x-show="tabState[tab._id] && tabState[tab._id].sourceMeta && tabState[tab._id].sourceMeta.instancesQueried > 0"
336
+ x-text="hashtagSourcesLine(tab)"
337
+ x-cloak></p>
338
+
339
+ {# Loading spinner — first load, no content yet #}
340
+ <div class="ap-explore-tab-loading"
341
+ x-show="tabState[tab._id] && tabState[tab._id].loading && !tabState[tab._id].html">
342
+ <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
343
+ </div>
344
+
345
+ {# Error state with retry #}
346
+ <div class="ap-explore-tab-error"
347
+ x-show="tabState[tab._id] && tabState[tab._id].error && !tabState[tab._id].html">
348
+ <p class="ap-explore-tab-error__message" x-text="tabState[tab._id] && tabState[tab._id].error"></p>
349
+ <button type="button" class="ap-explore-tab-error__retry" @click="retryTab(tab)">
350
+ {{ __("activitypub.reader.explore.tabs.retry") }}
351
+ </button>
352
+ </div>
353
+
354
+ {# Timeline content #}
355
+ <div class="ap-timeline ap-explore-tab-timeline"
356
+ x-show="tabState[tab._id] && tabState[tab._id].html"
357
+ x-html="tabState[tab._id] ? tabState[tab._id].html : ''">
358
+ </div>
359
+
360
+ {# Inline loading spinner for subsequent pages #}
361
+ <div class="ap-explore-tab-loading ap-explore-tab-loading--more"
362
+ x-show="tabState[tab._id] && tabState[tab._id].loading && tabState[tab._id].html">
363
+ <span class="ap-explore-tab-loading__text">{{ __("activitypub.reader.pagination.loading") }}</span>
364
+ </div>
365
+
366
+ {# Empty state — no instance tabs pinned yet #}
367
+ <div class="ap-explore-tab-empty"
368
+ x-show="tabState[tab._id] && tabState[tab._id].done && !tabState[tab._id].html && !tabState[tab._id].loading && !tabState[tab._id].error">
369
+ <p>{{ __("activitypub.reader.explore.tabs.noInstances") }}</p>
370
+ </div>
371
+
372
+ {# End of feed message #}
373
+ <p class="ap-load-more__done"
374
+ x-show="tabState[tab._id] && tabState[tab._id].done && tabState[tab._id].html"
375
+ x-cloak>
376
+ {{ __("activitypub.reader.pagination.noMore") }}
377
+ </p>
378
+
379
+ {# Infinite scroll sentinel #}
380
+ <div class="ap-tab-sentinel"></div>
381
+ </div>
382
+ </template>
383
+
214
384
  </div>
215
- {% endif %}
216
- {% endif %}{# end Decks tab #}
385
+ </template>
386
+
387
+ </div>{# end ap-explore-tabs-container #}
217
388
 
218
389
  {% endblock %}
@@ -5,8 +5,8 @@
5
5
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
6
6
  {# Autocomplete components for explore + popular accounts #}
7
7
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
8
- {# Deck components — apDeckToggle and apDeckColumn #}
9
- <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-decks.js"></script>
8
+ {# Tab components — apExploreTabs #}
9
+ <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-tabs.js"></script>
10
10
 
11
11
  {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
12
12
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>