@rmdes/indiekit-endpoint-activitypub 2.7.1 → 2.8.1

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.
@@ -15,8 +15,8 @@
15
15
  .ap-link-preview {
16
16
  display: flex;
17
17
  overflow: hidden;
18
- border-radius: 8px;
19
- border: 1px solid var(--color-outline);
18
+ border-radius: var(--border-radius-small);
19
+ border: var(--border-width-thin) solid var(--color-outline);
20
20
  background-color: var(--color-offset);
21
21
  text-decoration: none;
22
22
  color: inherit;
@@ -121,7 +121,7 @@
121
121
  letter-spacing: 0.05em;
122
122
  margin: var(--space-l) 0 var(--space-s);
123
123
  padding-bottom: var(--space-xs);
124
- border-bottom: 1px solid var(--color-outline);
124
+ border-bottom: var(--border-width-thin) solid var(--color-outline);
125
125
  }
126
126
 
127
127
  .ap-post-detail__main {
package/assets/reader.css CHANGED
@@ -32,7 +32,7 @@
32
32
 
33
33
  .ap-breadcrumb__current {
34
34
  color: var(--color-on-background);
35
- font-weight: var(--font-weight-bold);
35
+ font-weight: 600;
36
36
  }
37
37
 
38
38
  /* ==========================================================================
@@ -151,10 +151,10 @@
151
151
  background: var(--color-offset);
152
152
  border: var(--border-width-thin) solid var(--color-outline);
153
153
  border-left: 3px solid var(--color-outline);
154
- border-radius: 8px;
154
+ border-radius: var(--border-radius-small);
155
155
  overflow: hidden;
156
156
  padding: var(--space-m);
157
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
157
+ box-shadow: 0 1px 2px hsl(var(--tint-neutral) 10% / 0.04);
158
158
  transition:
159
159
  box-shadow 0.2s ease,
160
160
  border-color 0.2s ease;
@@ -163,7 +163,7 @@
163
163
  .ap-card:hover {
164
164
  border-color: var(--color-outline-variant);
165
165
  border-left-color: var(--color-outline-variant);
166
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
166
+ box-shadow: 0 2px 8px hsl(var(--tint-neutral) 10% / 0.08);
167
167
  }
168
168
 
169
169
  /* ==========================================================================
@@ -261,15 +261,27 @@
261
261
  margin-bottom: var(--space-s);
262
262
  }
263
263
 
264
+ .ap-card__avatar-wrap {
265
+ flex-shrink: 0;
266
+ height: 44px;
267
+ position: relative;
268
+ width: 44px;
269
+ }
270
+
264
271
  .ap-card__avatar {
265
272
  border: var(--border-width-thin) solid var(--color-outline);
266
273
  border-radius: 50%;
267
- flex-shrink: 0;
268
274
  height: 44px;
269
275
  object-fit: cover;
270
276
  width: 44px;
271
277
  }
272
278
 
279
+ .ap-card__avatar-wrap > img {
280
+ position: absolute;
281
+ inset: 0;
282
+ z-index: 1;
283
+ }
284
+
273
285
  .ap-card__avatar--default {
274
286
  align-items: center;
275
287
  background: var(--color-offset-variant);
@@ -411,7 +423,7 @@
411
423
 
412
424
  .ap-card__content code {
413
425
  background: var(--color-offset-variant);
414
- border-radius: 3px;
426
+ border-radius: var(--border-radius-small);
415
427
  font-size: 0.9em;
416
428
  padding: 1px 4px;
417
429
  }
@@ -508,7 +520,7 @@
508
520
  ========================================================================== */
509
521
 
510
522
  .ap-card__gallery {
511
- border-radius: 6px;
523
+ border-radius: var(--border-radius-small);
512
524
  display: grid;
513
525
  gap: 2px;
514
526
  margin-bottom: var(--space-s);
@@ -528,12 +540,18 @@
528
540
  .ap-card__gallery img {
529
541
  background: var(--color-offset-variant);
530
542
  display: block;
531
- height: 220px;
543
+ height: 280px;
532
544
  object-fit: cover;
533
545
  width: 100%;
534
546
  transition: filter 0.2s ease;
535
547
  }
536
548
 
549
+ @media (max-width: 480px) {
550
+ .ap-card__gallery img {
551
+ height: 180px;
552
+ }
553
+ }
554
+
537
555
  .ap-card__gallery-link:hover img {
538
556
  filter: brightness(0.92);
539
557
  }
@@ -601,7 +619,7 @@
601
619
 
602
620
  .ap-lightbox {
603
621
  align-items: center;
604
- background: rgba(0, 0, 0, 0.92);
622
+ background: hsl(var(--tint-neutral) 10% / 0.92);
605
623
  display: flex;
606
624
  inset: 0;
607
625
  justify-content: center;
@@ -668,6 +686,83 @@
668
686
  transform: translateX(-50%);
669
687
  }
670
688
 
689
+ /* ==========================================================================
690
+ Link Preview Card
691
+ ========================================================================== */
692
+
693
+ .ap-link-previews {
694
+ margin-bottom: var(--space-s);
695
+ }
696
+
697
+ .ap-link-preview {
698
+ display: flex;
699
+ border: var(--border-width-thin) solid var(--color-outline);
700
+ border-radius: var(--border-radius-small);
701
+ overflow: hidden;
702
+ text-decoration: none;
703
+ color: inherit;
704
+ transition: border-color 0.2s ease;
705
+ }
706
+
707
+ .ap-link-preview:hover {
708
+ border-color: var(--color-primary);
709
+ }
710
+
711
+ .ap-link-preview__text {
712
+ flex: 1;
713
+ min-width: 0;
714
+ padding: var(--space-s) var(--space-m);
715
+ display: flex;
716
+ flex-direction: column;
717
+ justify-content: center;
718
+ gap: 0.2em;
719
+ }
720
+
721
+ .ap-link-preview__title {
722
+ font-weight: 600;
723
+ font-size: var(--font-size-s);
724
+ margin: 0;
725
+ overflow: hidden;
726
+ text-overflow: ellipsis;
727
+ white-space: nowrap;
728
+ }
729
+
730
+ .ap-link-preview__desc {
731
+ font-size: var(--font-size-s);
732
+ color: var(--color-on-offset);
733
+ margin: 0;
734
+ display: -webkit-box;
735
+ -webkit-line-clamp: 2;
736
+ -webkit-box-orient: vertical;
737
+ overflow: hidden;
738
+ }
739
+
740
+ .ap-link-preview__domain {
741
+ font-size: var(--font-size-xs);
742
+ color: var(--color-on-offset);
743
+ margin: 0;
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 0.3em;
747
+ }
748
+
749
+ .ap-link-preview__favicon {
750
+ width: 14px;
751
+ height: 14px;
752
+ }
753
+
754
+ .ap-link-preview__image {
755
+ flex-shrink: 0;
756
+ width: 120px;
757
+ }
758
+
759
+ .ap-link-preview__image img {
760
+ display: block;
761
+ width: 100%;
762
+ height: 100%;
763
+ object-fit: cover;
764
+ }
765
+
671
766
  /* ==========================================================================
672
767
  Video Embed
673
768
  ========================================================================== */
@@ -778,7 +873,7 @@
778
873
  align-items: center;
779
874
  background: transparent;
780
875
  border: 0;
781
- border-radius: 6px;
876
+ border-radius: var(--border-radius-small);
782
877
  color: var(--color-on-offset);
783
878
  cursor: pointer;
784
879
  display: inline-flex;
@@ -1105,6 +1200,11 @@
1105
1200
  position: relative;
1106
1201
  }
1107
1202
 
1203
+ .ap-notification__avatar-wrap {
1204
+ height: 40px;
1205
+ width: 40px;
1206
+ }
1207
+
1108
1208
  .ap-notification__avatar {
1109
1209
  border: var(--border-width-thin) solid var(--color-outline);
1110
1210
  border-radius: 50%;
@@ -1113,6 +1213,12 @@
1113
1213
  width: 40px;
1114
1214
  }
1115
1215
 
1216
+ .ap-notification__avatar-wrap > img {
1217
+ position: absolute;
1218
+ inset: 0;
1219
+ z-index: 1;
1220
+ }
1221
+
1116
1222
  .ap-notification__avatar--default {
1117
1223
  align-items: center;
1118
1224
  background: var(--color-offset-variant);
@@ -1236,7 +1342,16 @@
1236
1342
  }
1237
1343
 
1238
1344
  .ap-profile__avatar-wrap {
1345
+ height: 80px;
1239
1346
  margin-bottom: var(--space-s);
1347
+ position: relative;
1348
+ width: 80px;
1349
+ }
1350
+
1351
+ .ap-profile__avatar-wrap > img {
1352
+ position: absolute;
1353
+ inset: 0;
1354
+ z-index: 1;
1240
1355
  }
1241
1356
 
1242
1357
  .ap-profile__avatar {
@@ -1818,7 +1933,7 @@
1818
1933
 
1819
1934
  .ap-tag-header__title {
1820
1935
  font-size: var(--font-size-xl);
1821
- font-weight: var(--font-weight-bold);
1936
+ font-weight: 600;
1822
1937
  margin: 0 0 var(--space-xs);
1823
1938
  }
1824
1939
 
@@ -1945,7 +2060,7 @@
1945
2060
  border: var(--border-width-thin) solid var(--color-outline);
1946
2061
  border-radius: var(--border-radius-small);
1947
2062
  box-sizing: border-box;
1948
- font-size: var(--font-size-base);
2063
+ font-size: var(--font-size-m);
1949
2064
  min-width: 0;
1950
2065
  padding: var(--space-xs) var(--space-s);
1951
2066
  width: 100%;
@@ -2011,7 +2126,7 @@
2011
2126
  background: var(--color-background);
2012
2127
  border: var(--border-width-thin) solid var(--color-outline);
2013
2128
  border-radius: var(--border-radius-small);
2014
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
2129
+ box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
2015
2130
  left: 0;
2016
2131
  max-height: 320px;
2017
2132
  overflow-y: auto;
@@ -2088,7 +2203,7 @@
2088
2203
  background: var(--color-background);
2089
2204
  border: var(--border-width-thin) solid var(--color-outline);
2090
2205
  border-radius: var(--border-radius-small);
2091
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
2206
+ box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
2092
2207
  left: 0;
2093
2208
  max-height: 320px;
2094
2209
  overflow-y: auto;
@@ -2198,7 +2313,7 @@
2198
2313
  }
2199
2314
 
2200
2315
  .ap-explore-tabs-nav::after {
2201
- background: linear-gradient(to right, transparent, var(--color-background, #fff) 80%);
2316
+ background: linear-gradient(to right, transparent, var(--color-background) 80%);
2202
2317
  content: "";
2203
2318
  height: 100%;
2204
2319
  pointer-events: none;
@@ -2267,7 +2382,7 @@
2267
2382
 
2268
2383
  /* Scope badges on instance tabs */
2269
2384
  .ap-tab__badge {
2270
- border-radius: 3px;
2385
+ border-radius: var(--border-radius-small);
2271
2386
  font-size: 0.65em;
2272
2387
  font-weight: 700;
2273
2388
  letter-spacing: 0.02em;
@@ -2439,7 +2554,7 @@
2439
2554
 
2440
2555
  .ap-explore-tab-error__retry {
2441
2556
  background: none;
2442
- border: 1px solid var(--color-primary-on-background);
2557
+ border: var(--border-width-thin) solid var(--color-primary-on-background);
2443
2558
  border-radius: var(--border-radius-small);
2444
2559
  color: var(--color-primary-on-background);
2445
2560
  cursor: pointer;
@@ -2519,7 +2634,7 @@
2519
2634
 
2520
2635
  .ap-unread-toggle--active {
2521
2636
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
2522
- font-weight: var(--font-weight-bold);
2637
+ font-weight: 600;
2523
2638
  }
2524
2639
 
2525
2640
  /* ==========================================================================
@@ -2528,7 +2643,7 @@
2528
2643
 
2529
2644
  .ap-quote-embed {
2530
2645
  border: var(--border-width-thin) solid var(--color-outline);
2531
- border-radius: 8px;
2646
+ border-radius: var(--border-radius-small);
2532
2647
  margin-top: var(--space-s);
2533
2648
  overflow: hidden;
2534
2649
  transition: border-color 0.15s ease;
@@ -2574,7 +2689,7 @@
2574
2689
  color: var(--color-on-offset);
2575
2690
  display: inline-flex;
2576
2691
  font-size: var(--font-size-xs);
2577
- font-weight: var(--font-weight-bold);
2692
+ font-weight: 600;
2578
2693
  justify-content: center;
2579
2694
  }
2580
2695
 
@@ -2585,7 +2700,7 @@
2585
2700
 
2586
2701
  .ap-quote-embed__name {
2587
2702
  font-size: var(--font-size-s);
2588
- font-weight: var(--font-weight-bold);
2703
+ font-weight: 600;
2589
2704
  overflow: hidden;
2590
2705
  text-overflow: ellipsis;
2591
2706
  white-space: nowrap;
@@ -2608,7 +2723,7 @@
2608
2723
 
2609
2724
  .ap-quote-embed__title {
2610
2725
  font-size: var(--font-size-s);
2611
- font-weight: var(--font-weight-bold);
2726
+ font-weight: 600;
2612
2727
  margin: 0 0 var(--space-xs);
2613
2728
  }
2614
2729
 
@@ -2674,8 +2789,8 @@
2674
2789
  position: absolute;
2675
2790
  bottom: 0.5rem;
2676
2791
  left: 0.5rem;
2677
- background: rgba(0, 0, 0, 0.7);
2678
- color: white;
2792
+ background: hsl(var(--tint-neutral) 10% / 0.7);
2793
+ color: var(--color-neutral99);
2679
2794
  font-size: 0.65rem;
2680
2795
  font-weight: 700;
2681
2796
  padding: 0.15rem 0.35rem;
@@ -2688,7 +2803,7 @@
2688
2803
  }
2689
2804
 
2690
2805
  .ap-media__alt-badge:hover {
2691
- background: rgba(0, 0, 0, 0.9);
2806
+ background: hsl(var(--tint-neutral) 10% / 0.9);
2692
2807
  }
2693
2808
 
2694
2809
  .ap-media__alt-text {
@@ -2696,8 +2811,8 @@
2696
2811
  bottom: 2.2rem;
2697
2812
  left: 0.5rem;
2698
2813
  right: 0.5rem;
2699
- background: rgba(0, 0, 0, 0.85);
2700
- color: white;
2814
+ background: hsl(var(--tint-neutral) 10% / 0.85);
2815
+ color: var(--color-neutral99);
2701
2816
  font-size: var(--font-size-s);
2702
2817
  padding: 0.5rem;
2703
2818
  border-radius: var(--border-radius-small);
@@ -2787,11 +2902,11 @@
2787
2902
 
2788
2903
  /* --- Card shadows: use light tint instead of black --- */
2789
2904
  .ap-card {
2790
- box-shadow: 0 1px 2px rgba(255, 255, 255, 0.04);
2905
+ box-shadow: 0 1px 2px hsl(var(--tint-neutral) 90% / 0.04);
2791
2906
  }
2792
2907
 
2793
2908
  .ap-card:hover {
2794
- box-shadow: 0 2px 8px rgba(255, 255, 255, 0.06);
2909
+ box-shadow: 0 2px 8px hsl(var(--tint-neutral) 90% / 0.06);
2795
2910
  }
2796
2911
 
2797
2912
  /* --- Tab badge federated: soften purple --- */
package/lib/jf2-to-as2.js CHANGED
@@ -20,6 +20,22 @@ import {
20
20
  Video,
21
21
  } from "@fedify/fedify/vocab";
22
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Content helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Convert bare URLs in HTML content to clickable links.
29
+ * Skips URLs already inside href attributes or anchor tag text.
30
+ */
31
+ function linkifyUrls(html) {
32
+ if (!html) return html;
33
+ return html.replace(
34
+ /(?<![=">])(https?:\/\/[^\s<"]+)/g,
35
+ '<a href="$1">$1</a>',
36
+ );
37
+ }
38
+
23
39
  // ---------------------------------------------------------------------------
24
40
  // Plain JSON-LD (content negotiation on individual post URLs)
25
41
  // ---------------------------------------------------------------------------
@@ -68,7 +84,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
68
84
 
69
85
  if (postType === "bookmark") {
70
86
  const bookmarkUrl = properties["bookmark-of"];
71
- const commentary = properties.content?.html || properties.content || "";
87
+ const commentary = linkifyUrls(properties.content?.html || properties.content || "");
72
88
  object.content = commentary
73
89
  ? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
74
90
  : `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
@@ -80,7 +96,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
80
96
  },
81
97
  ];
82
98
  } else {
83
- object.content = properties.content?.html || properties.content || "";
99
+ object.content = linkifyUrls(properties.content?.html || properties.content || "");
84
100
  }
85
101
 
86
102
  // Append permalink to content so fediverse clients show a clickable link
@@ -193,12 +209,12 @@ export function jf2ToAS2Activity(properties, actorUrl, publicationUrl, options =
193
209
  // Content
194
210
  if (postType === "bookmark") {
195
211
  const bookmarkUrl = properties["bookmark-of"];
196
- const commentary = properties.content?.html || properties.content || "";
212
+ const commentary = linkifyUrls(properties.content?.html || properties.content || "");
197
213
  noteOptions.content = commentary
198
214
  ? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
199
215
  : `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
200
216
  } else {
201
- noteOptions.content = properties.content?.html || properties.content || "";
217
+ noteOptions.content = linkifyUrls(properties.content?.html || properties.content || "");
202
218
  }
203
219
 
204
220
  // Append permalink to content so fediverse clients show a clickable link
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -91,7 +91,7 @@
91
91
  <label class="label" for="link_value_{{ loop.index }}">{{ __("activitypub.profile.linkValueLabel") }}</label>
92
92
  <input class="input" type="url" id="link_value_{{ loop.index }}" name="link_value[]" value="{{ att.value }}" placeholder="https://example.com">
93
93
  </div>
94
- <button type="button" class="button button--small" onclick="this.closest('.profile-link-row').remove()" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button>
94
+ <button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button>
95
95
  </div>
96
96
  {% endfor %}
97
97
  {% endif %}
@@ -129,6 +129,11 @@
129
129
 
130
130
  <script>
131
131
  (function() {
132
+ document.getElementById('profile-links').addEventListener('click', function(e) {
133
+ var btn = e.target.closest('.profile-link-remove');
134
+ if (btn) btn.closest('.profile-link-row').remove();
135
+ });
136
+
132
137
  var linkCount = {{ (profile.attachments.length if profile.attachments) or 0 }};
133
138
  document.getElementById('add-link-btn').addEventListener('click', function() {
134
139
  linkCount++;
@@ -167,10 +172,9 @@
167
172
 
168
173
  var removeBtn = document.createElement('button');
169
174
  removeBtn.type = 'button';
170
- removeBtn.className = 'button button--small';
175
+ removeBtn.className = 'button button--small profile-link-remove';
171
176
  removeBtn.style.cssText = 'margin-block-end: 4px;';
172
177
  removeBtn.textContent = 'Remove';
173
- removeBtn.addEventListener('click', function() { row.remove(); });
174
178
 
175
179
  row.appendChild(nameDiv);
176
180
  row.appendChild(valueDiv);
@@ -47,8 +47,7 @@
47
47
  :class="{ 'ap-lookup-autocomplete__item--highlighted': index === highlighted }"
48
48
  @click="selectItem(item)"
49
49
  @mouseenter="highlighted = index">
50
- <img :src="item.avatar" :alt="item.name" class="ap-lookup-autocomplete__avatar"
51
- onerror="this.style.display='none'">
50
+ <img :src="item.avatar" :alt="item.name" class="ap-lookup-autocomplete__avatar">
52
51
  <span class="ap-lookup-autocomplete__info">
53
52
  <span class="ap-lookup-autocomplete__name" x-text="item.name"></span>
54
53
  <span class="ap-lookup-autocomplete__handle" x-text="item.handle"></span>
@@ -38,13 +38,11 @@
38
38
 
39
39
  {# Profile info #}
40
40
  <div class="ap-profile__info">
41
- <div class="ap-profile__avatar-wrap">
41
+ <div class="ap-profile__avatar-wrap" data-avatar-fallback>
42
42
  {% if icon %}
43
- <img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar"
44
- onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'ap-profile__avatar ap-profile__avatar--placeholder',textContent:'{{ name[0] }}'}))">
45
- {% else %}
46
- <div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
43
+ <img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar">
47
44
  {% endif %}
45
+ <div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
48
46
  </div>
49
47
 
50
48
  <div class="ap-profile__details">
@@ -10,8 +10,10 @@
10
10
  {# Relative timestamps — converts absolute dates to "5m", "3h", "2d" etc. #}
11
11
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-relative-time.js"></script>
12
12
 
13
- {# Alpine.js for client-side reactivity (CW toggles, interaction buttons, infinite scroll) #}
14
- <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
13
+ {# Avatar fallback remove broken images to reveal initials fallback underneath #}
14
+ <script>document.addEventListener("error",function(e){var t=e.target;if(t.tagName==="IMG"&&t.closest("[data-avatar-fallback]"))t.remove()},true)</script>
15
+
16
+ {# Alpine.js loaded by default.njk — AP scripts register via alpine:init before it initializes #}
15
17
 
16
18
  {# Reader stylesheet — loaded in body is fine for modern browsers #}
17
19
  <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">
@@ -39,13 +39,12 @@
39
39
 
40
40
  {# Author header #}
41
41
  <header class="ap-card__author">
42
- {% if item.author.photo %}
43
- <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous"
44
- onerror="this.style.display='none';this.nextElementSibling.style.display=''">
45
- <span class="ap-card__avatar ap-card__avatar--default" style="display:none" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
46
- {% else %}
42
+ <div class="ap-card__avatar-wrap" data-avatar-fallback>
43
+ {% if item.author.photo %}
44
+ <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar" loading="lazy" crossorigin="anonymous">
45
+ {% endif %}
47
46
  <span class="ap-card__avatar ap-card__avatar--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
48
- {% endif %}
47
+ </div>
49
48
  <div class="ap-card__author-info">
50
49
  <div class="ap-card__author-name">
51
50
  {% if item.author.url %}
@@ -5,7 +5,7 @@
5
5
  {% set displayCount = item.photo.length if item.photo.length < 4 else 4 %}
6
6
  {% set extraCount = item.photo.length - 4 %}
7
7
  {% set totalPhotos = item.photo.length %}
8
- <div x-data="{ lightbox: false, idx: 0 }" class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
8
+ <div x-data="{ lightbox: false, idx: 0, touchX: 0 }" class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
9
9
  {% for photo in item.photo %}
10
10
  {# Support both old string format and new object format #}
11
11
  {% set photoSrc = photo.url if photo.url else photo %}
@@ -36,7 +36,7 @@
36
36
 
37
37
  {# Lightbox modal — teleported to body to prevent overflow clipping #}
38
38
  <template x-teleport="body">
39
- <div x-show="lightbox" x-cloak @keydown.escape.window="lightbox = false" @click.self="lightbox = false" class="ap-lightbox" role="dialog" aria-modal="true">
39
+ <div x-show="lightbox" x-cloak @keydown.escape.window="lightbox = false" @click.self="lightbox = false" @touchstart="touchX = $event.changedTouches[0].clientX" @touchend="let dx = $event.changedTouches[0].clientX - touchX; if (dx < -50) idx = (idx + 1) % {{ totalPhotos }}; else if (dx > 50) idx = (idx - 1 + {{ totalPhotos }}) % {{ totalPhotos }}" class="ap-lightbox" role="dialog" aria-modal="true">
40
40
  <button type="button" @click="lightbox = false" class="ap-lightbox__close" aria-label="Close">&times;</button>
41
41
  {% if totalPhotos > 1 %}
42
42
  <button type="button" @click="idx = (idx - 1 + {{ totalPhotos }}) % {{ totalPhotos }}" class="ap-lightbox__prev" aria-label="Previous image">&lsaquo;</button>
@@ -9,14 +9,11 @@
9
9
  </form>
10
10
 
11
11
  {# Actor avatar with type badge #}
12
- <div class="ap-notification__avatar-wrap">
12
+ <div class="ap-notification__avatar-wrap" data-avatar-fallback>
13
13
  {% if item.actorPhoto %}
14
- <img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"
15
- onerror="this.style.display='none';this.nextElementSibling.style.display=''">
16
- <span class="ap-notification__avatar ap-notification__avatar--default" style="display:none" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
17
- {% else %}
18
- <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
14
+ <img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous">
19
15
  {% endif %}
16
+ <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
20
17
  <span class="ap-notification__type-badge">
21
18
  {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% endif %}
22
19
  </span>