@rmdes/indiekit-endpoint-activitypub 1.0.29 → 1.1.2

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,117 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({
8
+ text: title,
9
+ level: 1,
10
+ parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
11
+ }) }}
12
+
13
+ <div class="ap-profile"
14
+ x-data="{
15
+ following: {{ 'true' if isFollowing else 'false' }},
16
+ muted: {{ 'true' if isMuted else 'false' }},
17
+ blocked: {{ 'true' if isBlocked else 'false' }},
18
+ loading: false,
19
+ async action(endpoint, body) {
20
+ if (this.loading) return;
21
+ this.loading = true;
22
+ try {
23
+ const res = await fetch('{{ mountPath }}/admin/reader/' + endpoint, {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'X-CSRF-Token': '{{ csrfToken }}'
28
+ },
29
+ body: JSON.stringify(body)
30
+ });
31
+ const data = await res.json();
32
+ return data.success;
33
+ } catch { return false; }
34
+ finally { this.loading = false; }
35
+ }
36
+ }">
37
+
38
+ {# Header image #}
39
+ {% if image %}
40
+ <div class="ap-profile__header">
41
+ <img src="{{ image }}" alt="" class="ap-profile__header-img">
42
+ </div>
43
+ {% endif %}
44
+
45
+ {# Profile info #}
46
+ <div class="ap-profile__info">
47
+ <div class="ap-profile__avatar-wrap">
48
+ {% if icon %}
49
+ <img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar">
50
+ {% else %}
51
+ <div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
52
+ {% endif %}
53
+ </div>
54
+
55
+ <div class="ap-profile__details">
56
+ <h2 class="ap-profile__name">{{ name }}</h2>
57
+ {% if actorHandle %}
58
+ <div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
59
+ {% endif %}
60
+ {% if summary %}
61
+ <div class="ap-profile__bio">{{ summary | safe }}</div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ {# Action buttons #}
66
+ <div class="ap-profile__actions">
67
+ <button class="ap-profile__action ap-profile__action--follow"
68
+ :class="{ 'ap-profile__action--active': following }"
69
+ :disabled="loading"
70
+ @click="
71
+ const ok = await action(following ? 'unfollow' : 'follow', { url: '{{ actorUrl }}' });
72
+ if (ok) following = !following;
73
+ ">
74
+ <span x-text="following ? '{{ __('activitypub.profile.remote.unfollow') }}' : '{{ __('activitypub.profile.remote.follow') }}'"></span>
75
+ </button>
76
+
77
+ <button class="ap-profile__action"
78
+ :disabled="loading"
79
+ @click="
80
+ const ok = await action(muted ? 'unmute' : 'mute', { url: '{{ actorUrl }}' });
81
+ if (ok) muted = !muted;
82
+ ">
83
+ <span x-text="muted ? '{{ __('activitypub.moderation.unmute') }}' : '{{ __('activitypub.moderation.muteActor') }}'"></span>
84
+ </button>
85
+
86
+ <button class="ap-profile__action ap-profile__action--danger"
87
+ :disabled="loading"
88
+ @click="
89
+ const ok = await action(blocked ? 'unblock' : 'block', { url: '{{ actorUrl }}' });
90
+ if (ok) blocked = !blocked;
91
+ ">
92
+ <span x-text="blocked ? '{{ __('activitypub.moderation.unblock') }}' : '{{ __('activitypub.moderation.blockActor') }}'"></span>
93
+ </button>
94
+
95
+ <a href="{{ actorUrl }}" class="ap-profile__action" target="_blank" rel="noopener">
96
+ {{ __("activitypub.profile.remote.viewOn") }} {{ instanceHost }}
97
+ </a>
98
+ </div>
99
+ </div>
100
+
101
+ {# Posts from this actor #}
102
+ <div class="ap-profile__posts">
103
+ <h3>{{ __("activitypub.profile.remote.postsTitle") }}</h3>
104
+ {% if posts.length > 0 %}
105
+ <div class="ap-timeline">
106
+ {% for item in posts %}
107
+ {% include "partials/ap-item-card.njk" %}
108
+ {% endfor %}
109
+ </div>
110
+ {% elif isFollowing %}
111
+ {{ prose({ text: __("activitypub.profile.remote.noPosts") }) }}
112
+ {% else %}
113
+ {{ prose({ text: __("activitypub.profile.remote.followToSee") }) }}
114
+ {% endif %}
115
+ </div>
116
+ </div>
117
+ {% endblock %}
@@ -0,0 +1,9 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% block head %}
4
+ {# Alpine.js for client-side reactivity #}
5
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14/dist/cdn.min.js"></script>
6
+
7
+ {# Reader stylesheet #}
8
+ <link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">
9
+ {% endblock %}
@@ -0,0 +1,157 @@
1
+ {# Timeline item card partial - reusable across timeline and profile views #}
2
+
3
+ <article class="ap-card">
4
+ {# Boost header if this is a boosted post #}
5
+ {% if item.type == "boost" and item.boostedBy %}
6
+ <div class="ap-card__boost">
7
+ 🔁 <a href="{{ item.boostedBy.url }}">{{ item.boostedBy.name }}</a> {{ __("activitypub.reader.boosted") }}
8
+ </div>
9
+ {% endif %}
10
+
11
+ {# Reply context if this is a reply #}
12
+ {% if item.inReplyTo %}
13
+ <div class="ap-card__reply-to">
14
+ ↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ item.inReplyTo }}">{{ item.inReplyTo }}</a>
15
+ </div>
16
+ {% endif %}
17
+
18
+ {# Author header #}
19
+ <header class="ap-card__author">
20
+ <img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar">
21
+ <div class="ap-card__author-info">
22
+ <div class="ap-card__author-name">
23
+ <a href="{{ item.author.url }}">{{ item.author.name }}</a>
24
+ </div>
25
+ <div class="ap-card__author-handle">{{ item.author.handle }}</div>
26
+ </div>
27
+ <time datetime="{{ item.published }}" class="ap-card__timestamp">
28
+ {{ item.published | date("PPp") }}
29
+ </time>
30
+ </header>
31
+
32
+ {# Post title (articles only) #}
33
+ {% if item.name %}
34
+ <h2 class="ap-card__title">
35
+ <a href="{{ item.url }}">{{ item.name }}</a>
36
+ </h2>
37
+ {% endif %}
38
+
39
+ {# Determine if content should be hidden behind CW #}
40
+ {% set hasCW = item.summary or item.sensitive %}
41
+ {% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %}
42
+
43
+ {% if hasCW %}
44
+ <div class="ap-card__cw" x-data="{ shown: false }">
45
+ <button @click="shown = !shown" class="ap-card__cw-toggle">
46
+ <span x-show="!shown">⚠️ {{ cwLabel }} — {{ __("activitypub.reader.showContent") }}</span>
47
+ <span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
48
+ </button>
49
+ <div x-show="shown" x-cloak>
50
+ {% if item.content and item.content.html %}
51
+ <div class="ap-card__content">
52
+ {{ item.content.html | safe }}
53
+ </div>
54
+ {% endif %}
55
+
56
+ {# Media hidden behind CW #}
57
+ {% include "partials/ap-item-media.njk" %}
58
+ </div>
59
+ </div>
60
+ {% else %}
61
+ {# Regular content (no CW) #}
62
+ {% if item.content and item.content.html %}
63
+ <div class="ap-card__content">
64
+ {{ item.content.html | safe }}
65
+ </div>
66
+ {% endif %}
67
+
68
+ {# Media visible directly #}
69
+ {% include "partials/ap-item-media.njk" %}
70
+ {% endif %}
71
+
72
+ {# Tags/categories #}
73
+ {% if item.category and item.category.length > 0 %}
74
+ <div class="ap-card__tags">
75
+ {% for tag in item.category %}
76
+ <a href="?tag={{ tag }}" class="ap-card__tag">#{{ tag }}</a>
77
+ {% endfor %}
78
+ </div>
79
+ {% endif %}
80
+
81
+ {# Interaction buttons — Alpine.js for optimistic updates #}
82
+ {# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
83
+ {% set itemUrl = item.url or item.originalUrl %}
84
+ {% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
85
+ {% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
86
+ <footer class="ap-card__actions"
87
+ data-item-url="{{ itemUrl }}"
88
+ data-csrf-token="{{ csrfToken }}"
89
+ data-mount-path="{{ mountPath }}"
90
+ x-data="{
91
+ liked: {{ 'true' if isLiked else 'false' }},
92
+ boosted: {{ 'true' if isBoosted else 'false' }},
93
+ loading: false,
94
+ error: '',
95
+ async interact(action) {
96
+ if (this.loading) return;
97
+ this.loading = true;
98
+ this.error = '';
99
+ const el = this.$root;
100
+ const itemUrl = el.dataset.itemUrl;
101
+ const csrfToken = el.dataset.csrfToken;
102
+ const basePath = el.dataset.mountPath;
103
+ const prev = { liked: this.liked, boosted: this.boosted };
104
+ if (action === 'like') this.liked = true;
105
+ else if (action === 'unlike') this.liked = false;
106
+ else if (action === 'boost') this.boosted = true;
107
+ else if (action === 'unboost') this.boosted = false;
108
+ try {
109
+ const res = await fetch(basePath + '/admin/reader/' + action, {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/json',
113
+ 'X-CSRF-Token': csrfToken
114
+ },
115
+ body: JSON.stringify({ url: itemUrl })
116
+ });
117
+ const data = await res.json();
118
+ if (!data.success) {
119
+ this.liked = prev.liked;
120
+ this.boosted = prev.boosted;
121
+ this.error = data.error || 'Failed';
122
+ }
123
+ } catch (e) {
124
+ this.liked = prev.liked;
125
+ this.boosted = prev.boosted;
126
+ this.error = e.message;
127
+ }
128
+ this.loading = false;
129
+ if (this.error) setTimeout(() => this.error = '', 3000);
130
+ }
131
+ }">
132
+ <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
133
+ class="ap-card__action ap-card__action--reply"
134
+ title="{{ __('activitypub.reader.actions.reply') }}">
135
+ ↩ {{ __("activitypub.reader.actions.reply") }}
136
+ </a>
137
+ <button class="ap-card__action ap-card__action--boost"
138
+ :class="{ 'ap-card__action--active': boosted }"
139
+ :title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
140
+ :disabled="loading"
141
+ @click="interact(boosted ? 'unboost' : 'boost')">
142
+ 🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span>
143
+ </button>
144
+ <button class="ap-card__action ap-card__action--like"
145
+ :class="{ 'ap-card__action--active': liked }"
146
+ :title="liked ? '{{ __('activitypub.reader.actions.unlike') }}' : '{{ __('activitypub.reader.actions.like') }}'"
147
+ :disabled="loading"
148
+ @click="interact(liked ? 'unlike' : 'like')">
149
+ <span x-text="liked ? '❤️' : '♥'"></span>
150
+ <span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span>
151
+ </button>
152
+ <a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
153
+ 🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
154
+ </a>
155
+ <div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
156
+ </footer>
157
+ </article>
@@ -0,0 +1,37 @@
1
+ {# Media attachments partial — included from ap-item-card.njk #}
2
+
3
+ {# Photo gallery #}
4
+ {% if item.photo and item.photo.length > 0 %}
5
+ {% set displayCount = [item.photo.length, 4] | min %}
6
+ {% set extraCount = item.photo.length - 4 %}
7
+ <div class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
8
+ {% for photoUrl in item.photo %}
9
+ {% if loop.index0 < 4 %}
10
+ <a href="{{ photoUrl }}" target="_blank" rel="noopener" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
11
+ <img src="{{ photoUrl }}" alt="" loading="lazy">
12
+ {% if loop.index0 == 3 and extraCount > 0 %}
13
+ <span class="ap-card__gallery-more">+{{ extraCount }}</span>
14
+ {% endif %}
15
+ </a>
16
+ {% endif %}
17
+ {% endfor %}
18
+ </div>
19
+ {% endif %}
20
+
21
+ {# Video embed #}
22
+ {% if item.video and item.video.length > 0 %}
23
+ <div class="ap-card__video">
24
+ <video controls preload="metadata" src="{{ item.video[0] }}">
25
+ {{ __("activitypub.reader.videoNotSupported") }}
26
+ </video>
27
+ </div>
28
+ {% endif %}
29
+
30
+ {# Audio player #}
31
+ {% if item.audio and item.audio.length > 0 %}
32
+ <div class="ap-card__audio">
33
+ <audio controls preload="metadata" src="{{ item.audio[0] }}">
34
+ {{ __("activitypub.reader.audioNotSupported") }}
35
+ </audio>
36
+ </div>
37
+ {% endif %}
@@ -0,0 +1,58 @@
1
+ {# Notification card partial #}
2
+
3
+ <div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
4
+ {# Type icon #}
5
+ <div class="ap-notification__icon">
6
+ {% if item.type == "like" %}
7
+
8
+ {% elif item.type == "boost" %}
9
+ 🔁
10
+ {% elif item.type == "follow" %}
11
+ 👤
12
+ {% elif item.type == "reply" %}
13
+ 💬
14
+ {% elif item.type == "mention" %}
15
+ @
16
+ {% endif %}
17
+ </div>
18
+
19
+ {# Notification body #}
20
+ <div class="ap-notification__body">
21
+ <span class="ap-notification__actor">
22
+ <a href="{{ item.actorUrl }}">{{ item.actorName }}</a>
23
+ </span>
24
+
25
+ <span class="ap-notification__action">
26
+ {% if item.type == "like" %}
27
+ {{ __("activitypub.notifications.liked") }}
28
+ {% elif item.type == "boost" %}
29
+ {{ __("activitypub.notifications.boostedPost") }}
30
+ {% elif item.type == "follow" %}
31
+ {{ __("activitypub.notifications.followedYou") }}
32
+ {% elif item.type == "reply" %}
33
+ {{ __("activitypub.notifications.repliedTo") }}
34
+ {% elif item.type == "mention" %}
35
+ {{ __("activitypub.notifications.mentionedYou") }}
36
+ {% endif %}
37
+ </span>
38
+
39
+ {% if item.targetUrl %}
40
+ <a href="{{ item.targetUrl }}" class="ap-notification__target">
41
+ {{ item.targetName or item.targetUrl }}
42
+ </a>
43
+ {% endif %}
44
+
45
+ {% if item.content and item.content.text %}
46
+ <div class="ap-notification__excerpt">
47
+ {{ item.content.text | truncate(200) }}
48
+ </div>
49
+ {% endif %}
50
+ </div>
51
+
52
+ {# Timestamp #}
53
+ {% if item.published %}
54
+ <time datetime="{{ item.published }}" class="ap-notification__time">
55
+ {{ item.published | date("PPp") }}
56
+ </time>
57
+ {% endif %}
58
+ </div>