@rmdes/indiekit-endpoint-activitypub 1.1.18 → 1.1.20
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.
- package/index.js +75 -7
- package/lib/controllers/public-profile.js +87 -0
- package/locales/en.json +12 -0
- package/package.json +1 -1
- package/views/activitypub-public-profile.njk +592 -0
package/index.js
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
featuredTagsRemoveController,
|
|
59
59
|
} from "./lib/controllers/featured-tags.js";
|
|
60
60
|
import { resolveController } from "./lib/controllers/resolve.js";
|
|
61
|
+
import { publicProfileController } from "./lib/controllers/public-profile.js";
|
|
61
62
|
import {
|
|
62
63
|
refollowPauseController,
|
|
63
64
|
refollowResumeController,
|
|
@@ -158,6 +159,10 @@ export default class ActivityPubEndpoint {
|
|
|
158
159
|
return self._fedifyMiddleware(req, res, next);
|
|
159
160
|
});
|
|
160
161
|
|
|
162
|
+
// HTML fallback for actor URL — serve a public profile page.
|
|
163
|
+
// Fedify only serves JSON-LD; browsers get 406 and fall through here.
|
|
164
|
+
router.get("/users/:identifier", publicProfileController(self));
|
|
165
|
+
|
|
161
166
|
// Catch-all for federation paths that Fedify didn't handle (e.g. GET
|
|
162
167
|
// on inbox). Without this, they fall through to Indiekit's auth
|
|
163
168
|
// middleware and redirect to the login page.
|
|
@@ -678,6 +683,10 @@ export default class ActivityPubEndpoint {
|
|
|
678
683
|
* Send an Update(Person) activity to all followers so remote servers
|
|
679
684
|
* re-fetch the actor object (picking up profile changes, new featured
|
|
680
685
|
* collections, attachments, etc.).
|
|
686
|
+
*
|
|
687
|
+
* Delivery is batched to avoid a thundering herd: hundreds of remote
|
|
688
|
+
* servers simultaneously re-fetching the actor, featured posts, and
|
|
689
|
+
* featured tags after receiving the Update all at once.
|
|
681
690
|
*/
|
|
682
691
|
async broadcastActorUpdate() {
|
|
683
692
|
if (!this._federation) return;
|
|
@@ -709,21 +718,80 @@ export default class ActivityPubEndpoint {
|
|
|
709
718
|
object: actor,
|
|
710
719
|
});
|
|
711
720
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
{
|
|
721
|
+
// Fetch followers and deduplicate by shared inbox so each remote
|
|
722
|
+
// server only gets one delivery (same as preferSharedInbox but
|
|
723
|
+
// gives us control over batching).
|
|
724
|
+
const followers = await this._collections.ap_followers
|
|
725
|
+
.find({})
|
|
726
|
+
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
|
|
727
|
+
.toArray();
|
|
728
|
+
|
|
729
|
+
// Group by shared inbox (or direct inbox if none)
|
|
730
|
+
const inboxMap = new Map();
|
|
731
|
+
for (const f of followers) {
|
|
732
|
+
const key = f.sharedInbox || f.inbox;
|
|
733
|
+
if (key && !inboxMap.has(key)) {
|
|
734
|
+
inboxMap.set(key, f);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const uniqueRecipients = [...inboxMap.values()];
|
|
739
|
+
const BATCH_SIZE = 25;
|
|
740
|
+
const BATCH_DELAY_MS = 5000;
|
|
741
|
+
let delivered = 0;
|
|
742
|
+
let failed = 0;
|
|
743
|
+
|
|
744
|
+
console.info(
|
|
745
|
+
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
|
|
746
|
+
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
|
|
717
747
|
);
|
|
718
748
|
|
|
719
|
-
|
|
749
|
+
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
|
|
750
|
+
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
|
|
751
|
+
|
|
752
|
+
// Build Fedify-compatible Recipient objects:
|
|
753
|
+
// extractInboxes() reads: recipient.id, recipient.inboxId,
|
|
754
|
+
// recipient.endpoints?.sharedInbox
|
|
755
|
+
const recipients = batch.map((f) => ({
|
|
756
|
+
id: new URL(f.actorUrl),
|
|
757
|
+
inboxId: new URL(f.inbox || f.sharedInbox),
|
|
758
|
+
endpoints: f.sharedInbox
|
|
759
|
+
? { sharedInbox: new URL(f.sharedInbox) }
|
|
760
|
+
: undefined,
|
|
761
|
+
}));
|
|
762
|
+
|
|
763
|
+
try {
|
|
764
|
+
await ctx.sendActivity(
|
|
765
|
+
{ identifier: handle },
|
|
766
|
+
recipients,
|
|
767
|
+
update,
|
|
768
|
+
{ preferSharedInbox: true },
|
|
769
|
+
);
|
|
770
|
+
delivered += batch.length;
|
|
771
|
+
} catch (error) {
|
|
772
|
+
failed += batch.length;
|
|
773
|
+
console.warn(
|
|
774
|
+
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Stagger batches so remote servers don't all re-fetch at once
|
|
779
|
+
if (i + BATCH_SIZE < uniqueRecipients.length) {
|
|
780
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
console.info(
|
|
785
|
+
`[ActivityPub] Update(Person) broadcast complete: ` +
|
|
786
|
+
`${delivered} delivered, ${failed} failed`,
|
|
787
|
+
);
|
|
720
788
|
|
|
721
789
|
await logActivity(this._collections.ap_activities, {
|
|
722
790
|
direction: "outbound",
|
|
723
791
|
type: "Update",
|
|
724
792
|
actorUrl: this._publicationUrl,
|
|
725
793
|
objectUrl: this._getActorUrl(),
|
|
726
|
-
summary:
|
|
794
|
+
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
|
|
727
795
|
}).catch(() => {});
|
|
728
796
|
} catch (error) {
|
|
729
797
|
console.error(
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public profile controller — renders a standalone HTML profile page
|
|
3
|
+
* for browsers visiting the actor URL (e.g. /activitypub/users/rick).
|
|
4
|
+
*
|
|
5
|
+
* Fedify handles ActivityPub clients via content negotiation; browsers
|
|
6
|
+
* that send Accept: text/html fall through to this controller.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function publicProfileController(plugin) {
|
|
10
|
+
return async (req, res, next) => {
|
|
11
|
+
const identifier = req.params.identifier;
|
|
12
|
+
|
|
13
|
+
// Only serve our own actor; unknown handles fall through to 404
|
|
14
|
+
if (identifier !== plugin.options.actor.handle) {
|
|
15
|
+
return next();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { application } = req.app.locals;
|
|
20
|
+
const collections = application.collections;
|
|
21
|
+
|
|
22
|
+
const apProfile = collections.get("ap_profile");
|
|
23
|
+
const apFollowers = collections.get("ap_followers");
|
|
24
|
+
const apFollowing = collections.get("ap_following");
|
|
25
|
+
const apFeatured = collections.get("ap_featured");
|
|
26
|
+
const postsCollection = collections.get("posts");
|
|
27
|
+
|
|
28
|
+
// Parallel queries for all profile data
|
|
29
|
+
const [profile, followerCount, followingCount, postCount, featuredDocs, recentPosts] =
|
|
30
|
+
await Promise.all([
|
|
31
|
+
apProfile ? apProfile.findOne({}) : null,
|
|
32
|
+
apFollowers ? apFollowers.countDocuments() : 0,
|
|
33
|
+
apFollowing ? apFollowing.countDocuments() : 0,
|
|
34
|
+
postsCollection ? postsCollection.countDocuments() : 0,
|
|
35
|
+
apFeatured
|
|
36
|
+
? apFeatured.find().sort({ pinnedAt: -1 }).toArray()
|
|
37
|
+
: [],
|
|
38
|
+
postsCollection
|
|
39
|
+
? postsCollection
|
|
40
|
+
.find()
|
|
41
|
+
.sort({ "properties.published": -1 })
|
|
42
|
+
.limit(20)
|
|
43
|
+
.toArray()
|
|
44
|
+
: [],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Enrich pinned posts with title/type from posts collection
|
|
48
|
+
const pinned = [];
|
|
49
|
+
for (const doc of featuredDocs) {
|
|
50
|
+
if (!postsCollection) break;
|
|
51
|
+
const post = await postsCollection.findOne({
|
|
52
|
+
"properties.url": doc.postUrl,
|
|
53
|
+
});
|
|
54
|
+
if (post?.properties) {
|
|
55
|
+
pinned.push({
|
|
56
|
+
url: doc.postUrl,
|
|
57
|
+
title:
|
|
58
|
+
post.properties.name ||
|
|
59
|
+
post.properties.content?.text?.slice(0, 120) ||
|
|
60
|
+
doc.postUrl,
|
|
61
|
+
type: post.properties["post-type"] || "note",
|
|
62
|
+
published: post.properties.published,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const domain = new URL(plugin._publicationUrl).hostname;
|
|
68
|
+
const handle = plugin.options.actor.handle;
|
|
69
|
+
|
|
70
|
+
res.render("activitypub-public-profile", {
|
|
71
|
+
profile: profile || {},
|
|
72
|
+
handle,
|
|
73
|
+
domain,
|
|
74
|
+
fullHandle: `@${handle}@${domain}`,
|
|
75
|
+
actorUrl: `${plugin._publicationUrl}activitypub/users/${handle}`,
|
|
76
|
+
siteUrl: plugin._publicationUrl,
|
|
77
|
+
followerCount,
|
|
78
|
+
followingCount,
|
|
79
|
+
postCount,
|
|
80
|
+
pinned,
|
|
81
|
+
recentPosts: recentPosts.map((p) => p.properties),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
next(error);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
package/locales/en.json
CHANGED
|
@@ -50,6 +50,18 @@
|
|
|
50
50
|
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
|
|
51
51
|
"save": "Save profile",
|
|
52
52
|
"saved": "Profile saved. Changes are now visible to the fediverse.",
|
|
53
|
+
"public": {
|
|
54
|
+
"followPrompt": "Follow me on the fediverse",
|
|
55
|
+
"copyHandle": "Copy handle",
|
|
56
|
+
"copied": "Copied!",
|
|
57
|
+
"pinnedPosts": "Pinned posts",
|
|
58
|
+
"recentPosts": "Recent posts",
|
|
59
|
+
"joinedDate": "Joined",
|
|
60
|
+
"posts": "Posts",
|
|
61
|
+
"followers": "Followers",
|
|
62
|
+
"following": "Following",
|
|
63
|
+
"viewOnSite": "View on site"
|
|
64
|
+
},
|
|
53
65
|
"remote": {
|
|
54
66
|
"follow": "Follow",
|
|
55
67
|
"unfollow": "Unfollow",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.20",
|
|
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",
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>{{ profile.name or handle }} (@{{ handle }}@{{ domain }})</title>
|
|
7
|
+
<meta name="description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
|
|
8
|
+
<meta property="og:title" content="{{ profile.name or handle }}">
|
|
9
|
+
<meta property="og:description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
|
|
10
|
+
{% if profile.icon %}
|
|
11
|
+
<meta property="og:image" content="{{ profile.icon }}">
|
|
12
|
+
{% endif %}
|
|
13
|
+
<meta property="og:type" content="profile">
|
|
14
|
+
<meta property="og:url" content="{{ actorUrl }}">
|
|
15
|
+
<link rel="me" href="{{ siteUrl }}">
|
|
16
|
+
<link rel="alternate" type="application/activity+json" href="{{ actorUrl }}">
|
|
17
|
+
<style>
|
|
18
|
+
/* ================================================================
|
|
19
|
+
CSS Custom Properties — light/dark mode
|
|
20
|
+
================================================================ */
|
|
21
|
+
:root {
|
|
22
|
+
--color-bg: #fff;
|
|
23
|
+
--color-surface: #f5f5f5;
|
|
24
|
+
--color-surface-raised: #fff;
|
|
25
|
+
--color-text: #1a1a1a;
|
|
26
|
+
--color-text-muted: #666;
|
|
27
|
+
--color-text-faint: #999;
|
|
28
|
+
--color-border: #e0e0e0;
|
|
29
|
+
--color-accent: #4f46e5;
|
|
30
|
+
--color-accent-text: #fff;
|
|
31
|
+
--color-purple: #7c3aed;
|
|
32
|
+
--color-green: #16a34a;
|
|
33
|
+
--color-yellow: #ca8a04;
|
|
34
|
+
--color-blue: #2563eb;
|
|
35
|
+
--radius-s: 6px;
|
|
36
|
+
--radius-m: 10px;
|
|
37
|
+
--radius-l: 16px;
|
|
38
|
+
--radius-full: 9999px;
|
|
39
|
+
--space-xs: 4px;
|
|
40
|
+
--space-s: 8px;
|
|
41
|
+
--space-m: 16px;
|
|
42
|
+
--space-l: 24px;
|
|
43
|
+
--space-xl: 32px;
|
|
44
|
+
--space-2xl: 48px;
|
|
45
|
+
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
46
|
+
--shadow-s: 0 1px 2px rgba(0,0,0,0.05);
|
|
47
|
+
--shadow-m: 0 2px 8px rgba(0,0,0,0.08);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@media (prefers-color-scheme: dark) {
|
|
51
|
+
:root {
|
|
52
|
+
--color-bg: #111;
|
|
53
|
+
--color-surface: #1a1a1a;
|
|
54
|
+
--color-surface-raised: #222;
|
|
55
|
+
--color-text: #e5e5e5;
|
|
56
|
+
--color-text-muted: #999;
|
|
57
|
+
--color-text-faint: #666;
|
|
58
|
+
--color-border: #333;
|
|
59
|
+
--color-accent: #818cf8;
|
|
60
|
+
--color-accent-text: #111;
|
|
61
|
+
--color-purple: #a78bfa;
|
|
62
|
+
--color-green: #4ade80;
|
|
63
|
+
--color-yellow: #facc15;
|
|
64
|
+
--color-blue: #60a5fa;
|
|
65
|
+
--shadow-s: 0 1px 2px rgba(0,0,0,0.2);
|
|
66
|
+
--shadow-m: 0 2px 8px rgba(0,0,0,0.3);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* ================================================================
|
|
71
|
+
Base
|
|
72
|
+
================================================================ */
|
|
73
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
74
|
+
|
|
75
|
+
body {
|
|
76
|
+
background: var(--color-bg);
|
|
77
|
+
color: var(--color-text);
|
|
78
|
+
font-family: var(--font-sans);
|
|
79
|
+
line-height: 1.5;
|
|
80
|
+
margin: 0;
|
|
81
|
+
-webkit-font-smoothing: antialiased;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
a { color: var(--color-accent); text-decoration: none; }
|
|
85
|
+
a:hover { text-decoration: underline; }
|
|
86
|
+
|
|
87
|
+
.ap-pub {
|
|
88
|
+
margin: 0 auto;
|
|
89
|
+
max-width: 640px;
|
|
90
|
+
padding: 0 var(--space-m);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ================================================================
|
|
94
|
+
Header image
|
|
95
|
+
================================================================ */
|
|
96
|
+
.ap-pub__header {
|
|
97
|
+
background: var(--color-surface);
|
|
98
|
+
height: 220px;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
position: relative;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ap-pub__header img {
|
|
104
|
+
display: block;
|
|
105
|
+
height: 100%;
|
|
106
|
+
object-fit: cover;
|
|
107
|
+
width: 100%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.ap-pub__header--empty {
|
|
111
|
+
background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
|
|
112
|
+
height: 160px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ================================================================
|
|
116
|
+
Identity — avatar, name, handle
|
|
117
|
+
================================================================ */
|
|
118
|
+
.ap-pub__identity {
|
|
119
|
+
padding: 0 var(--space-m);
|
|
120
|
+
position: relative;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ap-pub__avatar-wrap {
|
|
124
|
+
margin-top: -48px;
|
|
125
|
+
position: relative;
|
|
126
|
+
width: 96px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ap-pub__avatar {
|
|
130
|
+
background: var(--color-surface);
|
|
131
|
+
border: 4px solid var(--color-bg);
|
|
132
|
+
border-radius: var(--radius-full);
|
|
133
|
+
display: block;
|
|
134
|
+
height: 96px;
|
|
135
|
+
object-fit: cover;
|
|
136
|
+
width: 96px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.ap-pub__avatar--placeholder {
|
|
140
|
+
align-items: center;
|
|
141
|
+
background: var(--color-surface);
|
|
142
|
+
border: 4px solid var(--color-bg);
|
|
143
|
+
border-radius: var(--radius-full);
|
|
144
|
+
color: var(--color-text-muted);
|
|
145
|
+
display: flex;
|
|
146
|
+
font-size: 2.5em;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
height: 96px;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
width: 96px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.ap-pub__name {
|
|
154
|
+
font-size: 1.5em;
|
|
155
|
+
font-weight: 700;
|
|
156
|
+
line-height: 1.2;
|
|
157
|
+
margin: var(--space-s) 0 var(--space-xs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.ap-pub__handle {
|
|
161
|
+
color: var(--color-text-muted);
|
|
162
|
+
font-size: 0.95em;
|
|
163
|
+
margin-bottom: var(--space-m);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* ================================================================
|
|
167
|
+
Bio
|
|
168
|
+
================================================================ */
|
|
169
|
+
.ap-pub__bio {
|
|
170
|
+
line-height: 1.6;
|
|
171
|
+
margin-bottom: var(--space-l);
|
|
172
|
+
padding: 0 var(--space-m);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.ap-pub__bio a { color: var(--color-accent); }
|
|
176
|
+
|
|
177
|
+
.ap-pub__bio p { margin: 0 0 var(--space-s); }
|
|
178
|
+
.ap-pub__bio p:last-child { margin-bottom: 0; }
|
|
179
|
+
|
|
180
|
+
/* ================================================================
|
|
181
|
+
Profile fields
|
|
182
|
+
================================================================ */
|
|
183
|
+
.ap-pub__fields {
|
|
184
|
+
border: 1px solid var(--color-border);
|
|
185
|
+
border-radius: var(--radius-m);
|
|
186
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.ap-pub__field {
|
|
191
|
+
border-bottom: 1px solid var(--color-border);
|
|
192
|
+
display: grid;
|
|
193
|
+
grid-template-columns: 140px 1fr;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.ap-pub__field:last-child { border-bottom: 0; }
|
|
197
|
+
|
|
198
|
+
.ap-pub__field-name {
|
|
199
|
+
background: var(--color-surface);
|
|
200
|
+
color: var(--color-text-muted);
|
|
201
|
+
font-size: 0.85em;
|
|
202
|
+
font-weight: 600;
|
|
203
|
+
padding: var(--space-s) var(--space-m);
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
letter-spacing: 0.03em;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.ap-pub__field-value {
|
|
209
|
+
font-size: 0.95em;
|
|
210
|
+
overflow: hidden;
|
|
211
|
+
padding: var(--space-s) var(--space-m);
|
|
212
|
+
text-overflow: ellipsis;
|
|
213
|
+
white-space: nowrap;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.ap-pub__field-value a { color: var(--color-accent); }
|
|
217
|
+
|
|
218
|
+
/* ================================================================
|
|
219
|
+
Stats bar
|
|
220
|
+
================================================================ */
|
|
221
|
+
.ap-pub__stats {
|
|
222
|
+
border-bottom: 1px solid var(--color-border);
|
|
223
|
+
border-top: 1px solid var(--color-border);
|
|
224
|
+
display: flex;
|
|
225
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
226
|
+
padding: var(--space-m) 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.ap-pub__stat {
|
|
230
|
+
flex: 1;
|
|
231
|
+
text-align: center;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.ap-pub__stat-value {
|
|
235
|
+
display: block;
|
|
236
|
+
font-size: 1.2em;
|
|
237
|
+
font-weight: 700;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.ap-pub__stat-label {
|
|
241
|
+
color: var(--color-text-muted);
|
|
242
|
+
display: block;
|
|
243
|
+
font-size: 0.8em;
|
|
244
|
+
text-transform: uppercase;
|
|
245
|
+
letter-spacing: 0.05em;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ================================================================
|
|
249
|
+
Follow prompt
|
|
250
|
+
================================================================ */
|
|
251
|
+
.ap-pub__follow {
|
|
252
|
+
background: var(--color-surface);
|
|
253
|
+
border-radius: var(--radius-m);
|
|
254
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
255
|
+
padding: var(--space-l);
|
|
256
|
+
text-align: center;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.ap-pub__follow-title {
|
|
260
|
+
font-size: 1em;
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
margin: 0 0 var(--space-s);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.ap-pub__follow-handle {
|
|
266
|
+
background: var(--color-surface-raised);
|
|
267
|
+
border: 1px solid var(--color-border);
|
|
268
|
+
border-radius: var(--radius-s);
|
|
269
|
+
display: inline-flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
gap: var(--space-s);
|
|
272
|
+
padding: var(--space-s) var(--space-m);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.ap-pub__follow-text {
|
|
276
|
+
color: var(--color-text);
|
|
277
|
+
font-family: monospace;
|
|
278
|
+
font-size: 0.95em;
|
|
279
|
+
user-select: all;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.ap-pub__copy-btn {
|
|
283
|
+
background: var(--color-accent);
|
|
284
|
+
border: 0;
|
|
285
|
+
border-radius: var(--radius-s);
|
|
286
|
+
color: var(--color-accent-text);
|
|
287
|
+
cursor: pointer;
|
|
288
|
+
font-size: 0.8em;
|
|
289
|
+
font-weight: 600;
|
|
290
|
+
padding: var(--space-xs) var(--space-s);
|
|
291
|
+
transition: opacity 0.2s;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.ap-pub__copy-btn:hover { opacity: 0.85; }
|
|
295
|
+
|
|
296
|
+
/* ================================================================
|
|
297
|
+
Section headings
|
|
298
|
+
================================================================ */
|
|
299
|
+
.ap-pub__section-title {
|
|
300
|
+
border-bottom: 1px solid var(--color-border);
|
|
301
|
+
font-size: 1.1em;
|
|
302
|
+
font-weight: 600;
|
|
303
|
+
margin: 0 var(--space-m) var(--space-m);
|
|
304
|
+
padding-bottom: var(--space-s);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* ================================================================
|
|
308
|
+
Post cards (pinned + recent)
|
|
309
|
+
================================================================ */
|
|
310
|
+
.ap-pub__posts {
|
|
311
|
+
display: flex;
|
|
312
|
+
flex-direction: column;
|
|
313
|
+
gap: var(--space-s);
|
|
314
|
+
margin: 0 var(--space-m) var(--space-l);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.ap-pub__post {
|
|
318
|
+
background: var(--color-surface-raised);
|
|
319
|
+
border: 1px solid var(--color-border);
|
|
320
|
+
border-left: 3px solid var(--color-border);
|
|
321
|
+
border-radius: var(--radius-s);
|
|
322
|
+
display: block;
|
|
323
|
+
padding: var(--space-m);
|
|
324
|
+
text-decoration: none;
|
|
325
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.ap-pub__post:hover {
|
|
329
|
+
border-color: var(--color-accent);
|
|
330
|
+
box-shadow: var(--shadow-s);
|
|
331
|
+
text-decoration: none;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.ap-pub__post--article { border-left-color: var(--color-green); }
|
|
335
|
+
.ap-pub__post--note { border-left-color: var(--color-purple); }
|
|
336
|
+
.ap-pub__post--photo { border-left-color: var(--color-yellow); }
|
|
337
|
+
.ap-pub__post--bookmark { border-left-color: var(--color-blue); }
|
|
338
|
+
|
|
339
|
+
.ap-pub__post-meta {
|
|
340
|
+
align-items: center;
|
|
341
|
+
color: var(--color-text-muted);
|
|
342
|
+
display: flex;
|
|
343
|
+
font-size: 0.8em;
|
|
344
|
+
gap: var(--space-s);
|
|
345
|
+
margin-bottom: var(--space-xs);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.ap-pub__post-type {
|
|
349
|
+
background: var(--color-surface);
|
|
350
|
+
border-radius: var(--radius-s);
|
|
351
|
+
font-size: 0.85em;
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
padding: 1px 6px;
|
|
354
|
+
text-transform: capitalize;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.ap-pub__post-title {
|
|
358
|
+
color: var(--color-text);
|
|
359
|
+
font-weight: 600;
|
|
360
|
+
line-height: 1.4;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.ap-pub__post-excerpt {
|
|
364
|
+
color: var(--color-text-muted);
|
|
365
|
+
font-size: 0.9em;
|
|
366
|
+
line-height: 1.5;
|
|
367
|
+
margin-top: var(--space-xs);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.ap-pub__pinned-label {
|
|
371
|
+
color: var(--color-yellow);
|
|
372
|
+
font-size: 0.75em;
|
|
373
|
+
font-weight: 600;
|
|
374
|
+
text-transform: uppercase;
|
|
375
|
+
letter-spacing: 0.05em;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* ================================================================
|
|
379
|
+
Footer
|
|
380
|
+
================================================================ */
|
|
381
|
+
.ap-pub__footer {
|
|
382
|
+
border-top: 1px solid var(--color-border);
|
|
383
|
+
color: var(--color-text-faint);
|
|
384
|
+
font-size: 0.85em;
|
|
385
|
+
margin: var(--space-xl) var(--space-m) 0;
|
|
386
|
+
padding: var(--space-l) 0;
|
|
387
|
+
text-align: center;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.ap-pub__footer a { color: var(--color-text-muted); }
|
|
391
|
+
|
|
392
|
+
/* ================================================================
|
|
393
|
+
Empty state
|
|
394
|
+
================================================================ */
|
|
395
|
+
.ap-pub__empty {
|
|
396
|
+
color: var(--color-text-muted);
|
|
397
|
+
font-style: italic;
|
|
398
|
+
padding: var(--space-m) 0;
|
|
399
|
+
text-align: center;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ================================================================
|
|
403
|
+
Responsive
|
|
404
|
+
================================================================ */
|
|
405
|
+
@media (max-width: 480px) {
|
|
406
|
+
.ap-pub__header { height: 160px; }
|
|
407
|
+
.ap-pub__header--empty { height: 120px; }
|
|
408
|
+
|
|
409
|
+
.ap-pub__field {
|
|
410
|
+
grid-template-columns: 1fr;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.ap-pub__field-name {
|
|
414
|
+
border-bottom: 0;
|
|
415
|
+
padding-bottom: var(--space-xs);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.ap-pub__field-value {
|
|
419
|
+
padding-top: 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.ap-pub__stats { flex-wrap: wrap; }
|
|
423
|
+
|
|
424
|
+
.ap-pub__stat {
|
|
425
|
+
flex: 0 0 50%;
|
|
426
|
+
margin-bottom: var(--space-s);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.ap-pub__follow-handle {
|
|
430
|
+
flex-direction: column;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
</style>
|
|
434
|
+
</head>
|
|
435
|
+
<body>
|
|
436
|
+
{# ---- Header image ---- #}
|
|
437
|
+
{% if profile.image %}
|
|
438
|
+
<div class="ap-pub__header">
|
|
439
|
+
<img src="{{ profile.image }}" alt="">
|
|
440
|
+
</div>
|
|
441
|
+
{% else %}
|
|
442
|
+
<div class="ap-pub__header ap-pub__header--empty"></div>
|
|
443
|
+
{% endif %}
|
|
444
|
+
|
|
445
|
+
<div class="ap-pub">
|
|
446
|
+
{# ---- Avatar + identity ---- #}
|
|
447
|
+
<div class="ap-pub__identity">
|
|
448
|
+
<div class="ap-pub__avatar-wrap">
|
|
449
|
+
{% if profile.icon %}
|
|
450
|
+
<img src="{{ profile.icon }}" alt="{{ profile.name or handle }}" class="ap-pub__avatar">
|
|
451
|
+
{% else %}
|
|
452
|
+
<div class="ap-pub__avatar--placeholder">{{ (profile.name or handle)[0] | upper }}</div>
|
|
453
|
+
{% endif %}
|
|
454
|
+
</div>
|
|
455
|
+
<h1 class="ap-pub__name">{{ profile.name or handle }}</h1>
|
|
456
|
+
<div class="ap-pub__handle">{{ fullHandle }}</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
{# ---- Bio ---- #}
|
|
460
|
+
{% if profile.summary %}
|
|
461
|
+
<div class="ap-pub__bio">{{ profile.summary | safe }}</div>
|
|
462
|
+
{% endif %}
|
|
463
|
+
|
|
464
|
+
{# ---- Profile fields (attachments) ---- #}
|
|
465
|
+
{% if profile.attachments and profile.attachments.length > 0 %}
|
|
466
|
+
<dl class="ap-pub__fields">
|
|
467
|
+
{% for field in profile.attachments %}
|
|
468
|
+
<div class="ap-pub__field">
|
|
469
|
+
<dt class="ap-pub__field-name">{{ field.name }}</dt>
|
|
470
|
+
<dd class="ap-pub__field-value">
|
|
471
|
+
{% if field.value and (field.value.startsWith("http://") or field.value.startsWith("https://")) %}
|
|
472
|
+
<a href="{{ field.value }}" rel="noopener nofollow" target="_blank">{{ field.value | replace("https://", "") | replace("http://", "") }}</a>
|
|
473
|
+
{% else %}
|
|
474
|
+
{{ field.value }}
|
|
475
|
+
{% endif %}
|
|
476
|
+
</dd>
|
|
477
|
+
</div>
|
|
478
|
+
{% endfor %}
|
|
479
|
+
</dl>
|
|
480
|
+
{% endif %}
|
|
481
|
+
|
|
482
|
+
{# ---- Stats bar ---- #}
|
|
483
|
+
<div class="ap-pub__stats">
|
|
484
|
+
<div class="ap-pub__stat">
|
|
485
|
+
<span class="ap-pub__stat-value">{{ postCount }}</span>
|
|
486
|
+
<span class="ap-pub__stat-label">Posts</span>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="ap-pub__stat">
|
|
489
|
+
<span class="ap-pub__stat-value">{{ followingCount }}</span>
|
|
490
|
+
<span class="ap-pub__stat-label">Following</span>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="ap-pub__stat">
|
|
493
|
+
<span class="ap-pub__stat-value">{{ followerCount }}</span>
|
|
494
|
+
<span class="ap-pub__stat-label">Followers</span>
|
|
495
|
+
</div>
|
|
496
|
+
{% if profile.createdAt %}
|
|
497
|
+
<div class="ap-pub__stat">
|
|
498
|
+
<span class="ap-pub__stat-value" id="joined-date">—</span>
|
|
499
|
+
<span class="ap-pub__stat-label">Joined</span>
|
|
500
|
+
</div>
|
|
501
|
+
{% endif %}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
{# ---- Follow prompt ---- #}
|
|
505
|
+
<div class="ap-pub__follow">
|
|
506
|
+
<p class="ap-pub__follow-title">Follow me on the fediverse</p>
|
|
507
|
+
<div class="ap-pub__follow-handle">
|
|
508
|
+
<span class="ap-pub__follow-text" id="fedi-handle">{{ fullHandle }}</span>
|
|
509
|
+
<button class="ap-pub__copy-btn" id="copy-btn" type="button">Copy handle</button>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
{# ---- Pinned posts ---- #}
|
|
514
|
+
{% if pinned.length > 0 %}
|
|
515
|
+
<h2 class="ap-pub__section-title">Pinned posts</h2>
|
|
516
|
+
<div class="ap-pub__posts">
|
|
517
|
+
{% for post in pinned %}
|
|
518
|
+
<a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ post.type }}">
|
|
519
|
+
<div class="ap-pub__post-meta">
|
|
520
|
+
<span class="ap-pub__pinned-label">Pinned</span>
|
|
521
|
+
<span class="ap-pub__post-type">{{ post.type }}</span>
|
|
522
|
+
{% if post.published %}
|
|
523
|
+
<time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
|
|
524
|
+
{% endif %}
|
|
525
|
+
</div>
|
|
526
|
+
<div class="ap-pub__post-title">{{ post.title }}</div>
|
|
527
|
+
</a>
|
|
528
|
+
{% endfor %}
|
|
529
|
+
</div>
|
|
530
|
+
{% endif %}
|
|
531
|
+
|
|
532
|
+
{# ---- Recent posts ---- #}
|
|
533
|
+
{% if recentPosts.length > 0 %}
|
|
534
|
+
<h2 class="ap-pub__section-title">Recent posts</h2>
|
|
535
|
+
<div class="ap-pub__posts">
|
|
536
|
+
{% for post in recentPosts %}
|
|
537
|
+
{% set postType = post["post-type"] or "note" %}
|
|
538
|
+
<a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ postType }}">
|
|
539
|
+
<div class="ap-pub__post-meta">
|
|
540
|
+
<span class="ap-pub__post-type">{{ postType }}</span>
|
|
541
|
+
{% if post.published %}
|
|
542
|
+
<time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
|
|
543
|
+
{% endif %}
|
|
544
|
+
</div>
|
|
545
|
+
{% if post.name %}
|
|
546
|
+
<div class="ap-pub__post-title">{{ post.name }}</div>
|
|
547
|
+
{% endif %}
|
|
548
|
+
{% if post.content and post.content.text %}
|
|
549
|
+
<div class="ap-pub__post-excerpt">{{ post.content.text | truncate(150) }}</div>
|
|
550
|
+
{% endif %}
|
|
551
|
+
</a>
|
|
552
|
+
{% endfor %}
|
|
553
|
+
</div>
|
|
554
|
+
{% endif %}
|
|
555
|
+
|
|
556
|
+
{# ---- Empty state ---- #}
|
|
557
|
+
{% if pinned.length === 0 and recentPosts.length === 0 %}
|
|
558
|
+
<p class="ap-pub__empty">No posts yet.</p>
|
|
559
|
+
{% endif %}
|
|
560
|
+
|
|
561
|
+
{# ---- Footer ---- #}
|
|
562
|
+
<footer class="ap-pub__footer">
|
|
563
|
+
<a href="{{ siteUrl }}">{{ domain }}</a>
|
|
564
|
+
</footer>
|
|
565
|
+
</div>
|
|
566
|
+
|
|
567
|
+
<script>
|
|
568
|
+
// Copy handle to clipboard
|
|
569
|
+
document.getElementById("copy-btn").addEventListener("click", function() {
|
|
570
|
+
var handle = document.getElementById("fedi-handle").textContent;
|
|
571
|
+
navigator.clipboard.writeText(handle).then(function() {
|
|
572
|
+
var btn = document.getElementById("copy-btn");
|
|
573
|
+
btn.textContent = "Copied!";
|
|
574
|
+
setTimeout(function() { btn.textContent = "Copy handle"; }, 2000);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Format joined date
|
|
579
|
+
{% if profile.createdAt %}
|
|
580
|
+
(function() {
|
|
581
|
+
var el = document.getElementById("joined-date");
|
|
582
|
+
if (el) {
|
|
583
|
+
try {
|
|
584
|
+
var d = new Date("{{ profile.createdAt }}");
|
|
585
|
+
el.textContent = d.toLocaleDateString(undefined, { month: "short", year: "numeric" });
|
|
586
|
+
} catch(e) { el.textContent = "—"; }
|
|
587
|
+
}
|
|
588
|
+
})();
|
|
589
|
+
{% endif %}
|
|
590
|
+
</script>
|
|
591
|
+
</body>
|
|
592
|
+
</html>
|