@rmdes/indiekit-endpoint-activitypub 2.5.5 → 2.6.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.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Blurhash placeholder backgrounds for gallery images.
3
+ *
4
+ * Extracts the average (DC) color from a blurhash string and applies it
5
+ * as a background-color on images with a data-blurhash attribute.
6
+ * This provides a meaningful colored placeholder while images load.
7
+ *
8
+ * The DC component is encoded in the first 4 characters of the blurhash
9
+ * after the size byte, as a base-83 integer representing an sRGB color.
10
+ */
11
+
12
+ const BASE83_CHARS =
13
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
14
+
15
+ function decode83(str) {
16
+ let value = 0;
17
+ for (const c of str) {
18
+ const digit = BASE83_CHARS.indexOf(c);
19
+ if (digit === -1) return 0;
20
+ value = value * 83 + digit;
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function decodeDC(value) {
26
+ return {
27
+ r: (value >> 16) & 255,
28
+ g: (value >> 8) & 255,
29
+ b: value & 255,
30
+ };
31
+ }
32
+
33
+ function blurhashToColor(hash) {
34
+ if (!hash || hash.length < 6) return null;
35
+ const dcValue = decode83(hash.slice(1, 5));
36
+ const { r, g, b } = decodeDC(dcValue);
37
+ return `rgb(${r},${g},${b})`;
38
+ }
39
+
40
+ document.addEventListener("DOMContentLoaded", () => {
41
+ for (const img of document.querySelectorAll("img[data-blurhash]")) {
42
+ const color = blurhashToColor(img.dataset.blurhash);
43
+ if (color) {
44
+ img.style.backgroundColor = color;
45
+ }
46
+ }
47
+
48
+ // Handle dynamically loaded images (infinite scroll)
49
+ const observer = new MutationObserver((mutations) => {
50
+ for (const mutation of mutations) {
51
+ for (const node of mutation.addedNodes) {
52
+ if (node.nodeType !== 1) continue;
53
+ const imgs = node.querySelectorAll
54
+ ? node.querySelectorAll("img[data-blurhash]")
55
+ : [];
56
+ for (const img of imgs) {
57
+ const color = blurhashToColor(img.dataset.blurhash);
58
+ if (color) {
59
+ img.style.backgroundColor = color;
60
+ }
61
+ }
62
+ }
63
+ }
64
+ });
65
+ observer.observe(document.body, { childList: true, subtree: true });
66
+ });
@@ -15,16 +15,16 @@
15
15
  .ap-link-preview {
16
16
  display: flex;
17
17
  overflow: hidden;
18
- border-radius: var(--border-radius-small);
18
+ border-radius: 8px;
19
19
  border: 1px solid var(--color-neutral-lighter);
20
20
  background-color: var(--color-offset);
21
21
  text-decoration: none;
22
22
  color: inherit;
23
- transition: border-color 0.2s ease;
23
+ transition: border-color 0.15s ease;
24
24
  }
25
25
 
26
26
  .ap-link-preview:hover {
27
- border-color: var(--color-primary);
27
+ border-color: var(--color-outline-variant);
28
28
  }
29
29
 
30
30
  /* Text content area (left side) */
package/assets/reader.css CHANGED
@@ -150,10 +150,11 @@
150
150
  .ap-card {
151
151
  background: var(--color-offset);
152
152
  border: var(--border-width-thin) solid var(--color-outline);
153
- border-left: var(--border-width-thickest) solid var(--color-outline);
154
- border-radius: var(--border-radius-small);
153
+ border-left: 3px solid var(--color-outline);
154
+ border-radius: 8px;
155
155
  overflow: hidden;
156
156
  padding: var(--space-m);
157
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
157
158
  transition:
158
159
  box-shadow 0.2s ease,
159
160
  border-color 0.2s ease;
@@ -162,6 +163,7 @@
162
163
  .ap-card:hover {
163
164
  border-color: var(--color-outline-variant);
164
165
  border-left-color: var(--color-outline-variant);
166
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
165
167
  }
166
168
 
167
169
  /* ==========================================================================
@@ -263,9 +265,9 @@
263
265
  border: var(--border-width-thin) solid var(--color-outline);
264
266
  border-radius: 50%;
265
267
  flex-shrink: 0;
266
- height: 40px;
268
+ height: 44px;
267
269
  object-fit: cover;
268
- width: 40px;
270
+ width: 44px;
269
271
  }
270
272
 
271
273
  .ap-card__avatar--default {
@@ -282,10 +284,12 @@
282
284
  display: flex;
283
285
  flex-direction: column;
284
286
  flex: 1;
287
+ gap: 1px;
285
288
  min-width: 0;
286
289
  }
287
290
 
288
291
  .ap-card__author-name {
292
+ font-size: 0.95em;
289
293
  font-weight: 600;
290
294
  overflow: hidden;
291
295
  text-overflow: ellipsis;
@@ -301,6 +305,21 @@
301
305
  text-decoration: underline;
302
306
  }
303
307
 
308
+ .ap-card__bot-badge {
309
+ display: inline-block;
310
+ font-size: 0.6rem;
311
+ font-weight: 700;
312
+ line-height: 1;
313
+ padding: 0.15em 0.35em;
314
+ margin-left: 0.3em;
315
+ border: var(--border-width-thin) solid var(--color-on-offset);
316
+ border-radius: var(--border-radius-small);
317
+ color: var(--color-on-offset);
318
+ vertical-align: middle;
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.03em;
321
+ }
322
+
304
323
  .ap-card__author-handle {
305
324
  color: var(--color-on-offset);
306
325
  font-size: var(--font-size-s);
@@ -312,12 +331,20 @@
312
331
  .ap-card__timestamp {
313
332
  color: var(--color-on-offset);
314
333
  flex-shrink: 0;
334
+ font-size: var(--font-size-s);
335
+ }
336
+
337
+ .ap-card__edited {
315
338
  font-size: var(--font-size-xs);
339
+ margin-left: 0.2em;
316
340
  }
317
341
 
318
342
  .ap-card__timestamp-link {
319
343
  color: inherit;
320
344
  text-decoration: none;
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 0;
321
348
  }
322
349
 
323
350
  .ap-card__timestamp-link:hover {
@@ -351,7 +378,7 @@
351
378
 
352
379
  .ap-card__content {
353
380
  color: var(--color-on-background);
354
- line-height: var(--line-height-prose);
381
+ line-height: calc(4 / 3 * 1em);
355
382
  margin-bottom: var(--space-s);
356
383
  overflow-wrap: break-word;
357
384
  word-break: break-word;
@@ -481,7 +508,7 @@
481
508
  ========================================================================== */
482
509
 
483
510
  .ap-card__gallery {
484
- border-radius: var(--border-radius-small);
511
+ border-radius: 6px;
485
512
  display: grid;
486
513
  gap: 2px;
487
514
  margin-bottom: var(--space-s);
@@ -501,9 +528,14 @@
501
528
  .ap-card__gallery img {
502
529
  background: var(--color-offset-variant);
503
530
  display: block;
504
- height: 200px;
531
+ height: 220px;
505
532
  object-fit: cover;
506
533
  width: 100%;
534
+ transition: filter 0.2s ease;
535
+ }
536
+
537
+ .ap-card__gallery-link:hover img {
538
+ filter: brightness(0.92);
507
539
  }
508
540
 
509
541
  .ap-card__gallery-link--more::after {
@@ -534,7 +566,7 @@
534
566
 
535
567
  .ap-card__gallery--1 img {
536
568
  height: auto;
537
- max-height: 400px;
569
+ max-height: 500px;
538
570
  }
539
571
 
540
572
  /* 2 photos — side by side */
@@ -706,6 +738,30 @@
706
738
  opacity: 0.7;
707
739
  }
708
740
 
741
+ /* Hashtag stuffing collapse */
742
+ .ap-hashtag-overflow {
743
+ margin: var(--space-xs) 0;
744
+ font-size: var(--font-size-s);
745
+ }
746
+
747
+ .ap-hashtag-overflow summary {
748
+ cursor: pointer;
749
+ color: var(--color-on-offset);
750
+ list-style: none;
751
+ }
752
+
753
+ .ap-hashtag-overflow summary::before {
754
+ content: "▸ ";
755
+ }
756
+
757
+ .ap-hashtag-overflow[open] summary::before {
758
+ content: "▾ ";
759
+ }
760
+
761
+ .ap-hashtag-overflow p {
762
+ margin-top: var(--space-xs);
763
+ }
764
+
709
765
  /* ==========================================================================
710
766
  Interaction Buttons
711
767
  ========================================================================== */
@@ -714,60 +770,86 @@
714
770
  border-top: var(--border-width-thin) solid var(--color-outline);
715
771
  display: flex;
716
772
  flex-wrap: wrap;
717
- gap: var(--space-s);
773
+ gap: 2px;
718
774
  padding-top: var(--space-s);
719
775
  }
720
776
 
721
777
  .ap-card__action {
722
778
  align-items: center;
723
779
  background: transparent;
724
- border: var(--border-width-thin) solid var(--color-outline);
725
- border-radius: var(--border-radius-small);
780
+ border: 0;
781
+ border-radius: 6px;
726
782
  color: var(--color-on-offset);
727
783
  cursor: pointer;
728
784
  display: inline-flex;
729
785
  font-size: var(--font-size-s);
730
- gap: var(--space-xs);
731
- padding: var(--space-xs) var(--space-s);
786
+ gap: 0.3em;
787
+ min-height: 36px;
788
+ padding: 0.25em 0.6em;
732
789
  text-decoration: none;
733
- transition: all 0.2s ease;
790
+ transition:
791
+ background-color 0.15s ease,
792
+ color 0.15s ease;
734
793
  }
735
794
 
736
795
  .ap-card__action:hover {
737
796
  background: var(--color-offset-variant);
738
- border-color: var(--color-outline-variant);
739
797
  color: var(--color-on-background);
740
798
  }
741
799
 
742
- /* Active interaction states using Indiekit's color palette */
800
+ /* Color-coded hover states per action type */
801
+ .ap-card__action--reply:hover {
802
+ background: color-mix(in srgb, var(--color-primary) 12%, transparent);
803
+ color: var(--color-primary);
804
+ }
805
+
806
+ .ap-card__action--boost:hover {
807
+ background: color-mix(in srgb, var(--color-green50) 12%, transparent);
808
+ color: var(--color-green50);
809
+ }
810
+
811
+ .ap-card__action--like:hover {
812
+ background: color-mix(in srgb, var(--color-red45) 12%, transparent);
813
+ color: var(--color-red45);
814
+ }
815
+
816
+ .ap-card__action--link:hover {
817
+ background: var(--color-offset-variant);
818
+ color: var(--color-on-background);
819
+ }
820
+
821
+ .ap-card__action--save:hover {
822
+ background: color-mix(in srgb, var(--color-primary) 12%, transparent);
823
+ color: var(--color-primary);
824
+ }
825
+
826
+ /* Active interaction states */
743
827
  .ap-card__action--like.ap-card__action--active {
744
- background: var(--color-red90);
745
- border-color: var(--color-red45);
828
+ background: color-mix(in srgb, var(--color-red45) 12%, transparent);
746
829
  color: var(--color-red45);
747
830
  }
748
831
 
749
832
  .ap-card__action--boost.ap-card__action--active {
750
- background: var(--color-green90);
751
- border-color: var(--color-green50);
833
+ background: color-mix(in srgb, var(--color-green50) 12%, transparent);
752
834
  color: var(--color-green50);
753
835
  }
754
836
 
755
837
  .ap-card__action--save.ap-card__action--active {
756
- background: #4a9eff22;
757
- border-color: #4a9eff;
758
- color: #4a9eff;
838
+ background: color-mix(in srgb, var(--color-primary) 12%, transparent);
839
+ color: var(--color-primary);
759
840
  }
760
841
 
761
842
  .ap-card__action:disabled {
762
843
  cursor: wait;
763
- opacity: 0.6;
844
+ opacity: 0.5;
764
845
  }
765
846
 
766
847
  /* Interaction counts */
767
848
  .ap-card__count {
768
849
  font-size: var(--font-size-xs);
769
- color: var(--color-on-offset);
770
- margin-left: 0.25rem;
850
+ color: inherit;
851
+ opacity: 0.7;
852
+ margin-left: 0.1em;
771
853
  font-variant-numeric: tabular-nums;
772
854
  }
773
855
 
@@ -2489,9 +2571,14 @@
2489
2571
 
2490
2572
  .ap-quote-embed {
2491
2573
  border: var(--border-width-thin) solid var(--color-outline);
2492
- border-radius: var(--border-radius-small);
2574
+ border-radius: 8px;
2493
2575
  margin-top: var(--space-s);
2494
2576
  overflow: hidden;
2577
+ transition: border-color 0.15s ease;
2578
+ }
2579
+
2580
+ .ap-quote-embed:hover {
2581
+ border-color: var(--color-outline-variant);
2495
2582
  }
2496
2583
 
2497
2584
  .ap-quote-embed--pending {
@@ -2506,7 +2593,7 @@
2506
2593
  }
2507
2594
 
2508
2595
  .ap-quote-embed__link:hover {
2509
- background: var(--color-offset);
2596
+ background: color-mix(in srgb, var(--color-offset) 50%, transparent);
2510
2597
  }
2511
2598
 
2512
2599
  .ap-quote-embed__author {
@@ -2571,8 +2658,8 @@
2571
2658
  .ap-quote-embed__content {
2572
2659
  color: var(--color-on-background);
2573
2660
  font-size: var(--font-size-s);
2574
- line-height: 1.5;
2575
- max-height: calc(1.5em * 6);
2661
+ line-height: calc(4 / 3 * 1em);
2662
+ max-height: calc(1.333em * 6);
2576
2663
  overflow: hidden;
2577
2664
  }
2578
2665
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Content post-processing utilities.
3
+ * Applied after sanitization and emoji replacement in the item pipeline.
4
+ */
5
+
6
+ /**
7
+ * Shorten displayed URLs in <a> tags that exceed maxLength.
8
+ * Keeps the full URL in href, only truncates the visible text.
9
+ *
10
+ * Example: <a href="https://example.com/very/long/path">https://example.com/very/long/path</a>
11
+ * → <a href="https://example.com/very/long/path" title="https://example.com/very/long/path">example.com/very/lon…</a>
12
+ *
13
+ * @param {string} html - Sanitized HTML content
14
+ * @param {number} [maxLength=30] - Max visible URL length before truncation
15
+ * @returns {string} HTML with shortened display URLs
16
+ */
17
+ export function shortenDisplayUrls(html, maxLength = 30) {
18
+ if (!html) return html;
19
+
20
+ // Match <a ...>URL text</a> where the visible text looks like a URL
21
+ return html.replace(
22
+ /(<a\s[^>]*>)(https?:\/\/[^<]+)(<\/a>)/gi,
23
+ (match, openTag, urlText, closeTag) => {
24
+ if (urlText.length <= maxLength) return match;
25
+
26
+ // Strip protocol for display
27
+ const display = urlText.replace(/^https?:\/\//, "");
28
+ const truncated = display.slice(0, maxLength - 1) + "\u2026";
29
+
30
+ // Add title attribute with full URL for hover tooltip (if not already present)
31
+ let tag = openTag;
32
+ if (!tag.includes("title=")) {
33
+ tag = tag.replace(/>$/, ` title="${urlText}">`);
34
+ }
35
+
36
+ return `${tag}${truncated}${closeTag}`;
37
+ },
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Collapse paragraphs that are mostly hashtag links (hashtag stuffing).
43
+ * Detects <p> blocks where 80%+ of the text content is hashtag links
44
+ * and wraps them in a <details> element.
45
+ *
46
+ * @param {string} html - Sanitized HTML content
47
+ * @param {number} [minTags=3] - Minimum number of hashtag links to trigger collapse
48
+ * @returns {string} HTML with hashtag-heavy paragraphs collapsed
49
+ */
50
+ export function collapseHashtagStuffing(html, minTags = 3) {
51
+ if (!html) return html;
52
+
53
+ // Match <p> blocks
54
+ return html.replace(/<p>([^]*?)<\/p>/gi, (match, inner) => {
55
+ // Count hashtag links: <a ...>#something</a> or plain #word
56
+ const hashtagLinks = inner.match(/<a[^>]*>#[^<]+<\/a>/gi) || [];
57
+ if (hashtagLinks.length < minTags) return match;
58
+
59
+ // Calculate what fraction of text content is hashtags
60
+ const textOnly = inner.replace(/<[^>]*>/g, "").trim();
61
+ const hashtagText = hashtagLinks
62
+ .map((link) => link.replace(/<[^>]*>/g, "").trim())
63
+ .join(" ");
64
+
65
+ // If hashtags make up 80%+ of the text content, collapse
66
+ if (hashtagText.length / Math.max(textOnly.length, 1) >= 0.8) {
67
+ return `<details class="ap-hashtag-overflow"><summary>Show ${hashtagLinks.length} tags</summary><p>${inner}</p></details>`;
68
+ }
69
+
70
+ return match;
71
+ });
72
+ }
@@ -119,12 +119,14 @@ export function mapMastodonStatusToItem(status, instance) {
119
119
  summary: status.spoiler_text || "",
120
120
  sensitive: status.sensitive || false,
121
121
  published: status.created_at || new Date().toISOString(),
122
+ updated: status.edited_at || "",
122
123
  author: {
123
124
  name: sanitizeHtml(account.display_name || account.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
124
125
  url: account.url || "",
125
126
  photo: account.avatar || account.avatar_static || "",
126
127
  handle,
127
128
  emojis: authorEmojis,
129
+ bot: account.bot || false,
128
130
  },
129
131
  category,
130
132
  mentions,
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { stripQuoteReferenceHtml } from "./og-unfurl.js";
10
10
  import { replaceCustomEmoji } from "./emoji-utils.js";
11
+ import { shortenDisplayUrls, collapseHashtagStuffing } from "./content-utils.js";
11
12
 
12
13
  /**
13
14
  * Post-process timeline items for rendering.
@@ -31,7 +32,10 @@ export async function postProcessItems(items, options = {}) {
31
32
  // 3. Replace custom emoji shortcodes with <img> tags
32
33
  applyCustomEmoji(items);
33
34
 
34
- // 4. Build interaction map (likes/boosts) empty when no collection
35
+ // 4. Shorten long URLs and collapse hashtag stuffing in content
36
+ applyContentEnhancements(items);
37
+
38
+ // 5. Build interaction map (likes/boosts) — empty when no collection
35
39
  const interactionMap = options.interactionsCol
36
40
  ? await buildInteractionMap(items, options.interactionsCol)
37
41
  : {};
@@ -154,6 +158,24 @@ function applyCustomEmoji(items) {
154
158
  }
155
159
  }
156
160
 
161
+ /**
162
+ * Shorten long URLs and collapse hashtag-heavy paragraphs in content.
163
+ * Mutates items in place.
164
+ *
165
+ * @param {Array} items
166
+ */
167
+ function applyContentEnhancements(items) {
168
+ for (const item of items) {
169
+ if (item.content?.html) {
170
+ item.content.html = shortenDisplayUrls(item.content.html);
171
+ item.content.html = collapseHashtagStuffing(item.content.html);
172
+ }
173
+ if (item.quote?.content?.html) {
174
+ item.quote.content.html = shortenDisplayUrls(item.quote.content.html);
175
+ }
176
+ }
177
+ }
178
+
157
179
  /**
158
180
  * Build interaction map (likes/boosts) for template rendering.
159
181
  * Returns { [uid]: { like: true, boost: true } }.
@@ -3,7 +3,7 @@
3
3
  * @module timeline-store
4
4
  */
5
5
 
6
- import { Article, Emoji, Hashtag, Mention } from "@fedify/fedify/vocab";
6
+ import { Article, Application, Emoji, Hashtag, Mention, Service } from "@fedify/fedify/vocab";
7
7
  import sanitizeHtml from "sanitize-html";
8
8
 
9
9
  /**
@@ -101,7 +101,10 @@ export async function extractActorInfo(actor, options = {}) {
101
101
  // Emoji extraction failed — non-critical
102
102
  }
103
103
 
104
- return { name, url, photo, handle, emojis };
104
+ // Bot detection Service and Application actors are automated accounts
105
+ const bot = actor instanceof Service || actor instanceof Application;
106
+
107
+ return { name, url, photo, handle, emojis, bot };
105
108
  }
106
109
 
107
110
  /**
@@ -154,6 +157,9 @@ export async function extractObjectData(object, options = {}) {
154
157
  ? String(object.published)
155
158
  : new Date().toISOString();
156
159
 
160
+ // Edited date — non-null when the post has been updated after publishing
161
+ const updated = object.updated ? String(object.updated) : "";
162
+
157
163
  // Extract author — try multiple strategies in order of reliability
158
164
  const loaderOpts = options.documentLoader ? { documentLoader: options.documentLoader } : {};
159
165
  let authorObj = null;
@@ -304,6 +310,7 @@ export async function extractObjectData(object, options = {}) {
304
310
  summary,
305
311
  sensitive,
306
312
  published,
313
+ updated,
307
314
  author,
308
315
  category,
309
316
  mentions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.5.5",
3
+ "version": "2.6.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",
@@ -19,6 +19,8 @@
19
19
 
20
20
  {# AP link interception for internal navigation #}
21
21
  <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-links.js"></script>
22
+ {# Blurhash placeholder backgrounds for gallery images #}
23
+ <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-blurhash.js"></script>
22
24
 
23
25
  {% if readerParent %}
24
26
  <nav class="ap-breadcrumb" aria-label="Breadcrumb">
@@ -53,6 +53,7 @@
53
53
  {% else %}
54
54
  <span>{% if item.author.nameHtml %}{{ item.author.nameHtml | safe }}{% else %}{{ item.author.name or "Unknown" }}{% endif %}</span>
55
55
  {% endif %}
56
+ {% if item.author.bot %}<span class="ap-card__bot-badge" title="Bot account">BOT</span>{% endif %}
56
57
  </div>
57
58
  {% if item.author.handle %}
58
59
  <div class="ap-card__author-handle">{{ item.author.handle }}</div>
@@ -63,6 +64,7 @@
63
64
  <time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
64
65
  {{ item.published | date("PPp") }}
65
66
  </time>
67
+ {% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
66
68
  </a>
67
69
  {% endif %}
68
70
  </header>
@@ -10,10 +10,18 @@
10
10
  {# Support both old string format and new object format #}
11
11
  {% set photoSrc = photo.url if photo.url else photo %}
12
12
  {% set photoAlt = photo.alt if photo.alt else "" %}
13
+ {% set photoBlurhash = photo.blurhash if photo.blurhash else "" %}
14
+ {# Focus-point cropping: convert -1..1 range to CSS object-position percentages #}
15
+ {% set focusStyle = "" %}
16
+ {% if photo.focus and photo.focus.x != null and photo.focus.y != null %}
17
+ {% set fpX = ((photo.focus.x + 1) / 2 * 100) %}
18
+ {% set fpY = ((1 - (photo.focus.y + 1) / 2) * 100) %}
19
+ {% set focusStyle = "object-position:" + fpX + "% " + fpY + "%;" %}
20
+ {% endif %}
13
21
  {% if loop.index0 < 4 %}
14
22
  <div class="ap-card__gallery-item" x-data="{ showAlt: false }">
15
23
  <button type="button" @click="idx = {{ loop.index0 }}; lightbox = true" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
16
- <img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy">
24
+ <img src="{{ photoSrc }}" alt="{{ photoAlt }}" loading="lazy"{% if focusStyle %} style="{{ focusStyle }}"{% endif %}{% if photoBlurhash %} data-blurhash="{{ photoBlurhash }}"{% endif %}>
17
25
  {% if loop.index0 == 3 and extraCount > 0 %}
18
26
  <span class="ap-card__gallery-more">+{{ extraCount }}</span>
19
27
  {% endif %}
@@ -26,8 +26,14 @@
26
26
  {% endif %}
27
27
  {% if item.quote.photo and item.quote.photo.length > 0 %}
28
28
  {% set qPhoto = item.quote.photo[0] %}
29
+ {% set qFocusStyle = "" %}
30
+ {% if qPhoto.focus and qPhoto.focus.x != null and qPhoto.focus.y != null %}
31
+ {% set qFpX = ((qPhoto.focus.x + 1) / 2 * 100) %}
32
+ {% set qFpY = ((1 - (qPhoto.focus.y + 1) / 2) * 100) %}
33
+ {% set qFocusStyle = "object-position:" + qFpX + "% " + qFpY + "%;" %}
34
+ {% endif %}
29
35
  <div class="ap-quote-embed__media">
30
- <img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo">
36
+ <img src="{{ qPhoto.url if qPhoto.url else qPhoto }}" alt="{{ qPhoto.alt if qPhoto.alt else '' }}" loading="lazy" class="ap-quote-embed__photo"{% if qFocusStyle %} style="{{ qFocusStyle }}"{% endif %}>
31
37
  </div>
32
38
  {% endif %}
33
39
  </a>