@commonpub/layer 0.3.17 → 0.3.19
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/components/MessageThread.vue +45 -8
- package/components/views/ArticleView.vue +4 -4
- package/components/views/BlogView.vue +4 -4
- package/components/views/ExplainerView.vue +24 -15
- package/package.json +6 -6
- package/pages/auth/login.vue +190 -10
- package/pages/dashboard.vue +87 -6
- package/pages/explore.vue +134 -9
- package/pages/messages/[conversationId].vue +2 -3
- package/pages/messages/index.vue +176 -21
- package/pages/mirror/[id].vue +1 -1
- package/pages/tags/[slug].vue +32 -8
- package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
- package/server/api/auth/federated/callback.get.ts +35 -13
- package/server/api/auth/federated/link.post.ts +82 -0
- package/server/api/messages/index.get.ts +9 -5
|
@@ -7,6 +7,7 @@ defineProps<{
|
|
|
7
7
|
senderAvatarUrl?: string | null;
|
|
8
8
|
body: string;
|
|
9
9
|
createdAt: string;
|
|
10
|
+
readAt?: string | null;
|
|
10
11
|
}>;
|
|
11
12
|
currentUserId: string;
|
|
12
13
|
}>();
|
|
@@ -39,9 +40,15 @@ function handleSend(): void {
|
|
|
39
40
|
<span v-if="msg.senderName" class="cpub-msg-name">{{ msg.senderName }}</span>
|
|
40
41
|
</div>
|
|
41
42
|
<div class="cpub-msg-bubble">{{ msg.body }}</div>
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
<div class="cpub-msg-meta">
|
|
44
|
+
<time class="cpub-msg-time">
|
|
45
|
+
{{ new Date(msg.createdAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) }}
|
|
46
|
+
</time>
|
|
47
|
+
<span v-if="msg.senderId === currentUserId" class="cpub-msg-receipt" :class="{ 'cpub-msg-read': msg.readAt }" :title="msg.readAt ? `Read ${new Date(msg.readAt).toLocaleString()}` : 'Sent'">
|
|
48
|
+
<i :class="msg.readAt ? 'fa-solid fa-check-double' : 'fa-solid fa-check'" aria-hidden="true"></i>
|
|
49
|
+
<span class="sr-only">{{ msg.readAt ? 'Read' : 'Sent' }}</span>
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
45
52
|
</div>
|
|
46
53
|
<p v-if="!messages.length" class="cpub-thread-empty">No messages yet. Say hello!</p>
|
|
47
54
|
</div>
|
|
@@ -54,7 +61,7 @@ function handleSend(): void {
|
|
|
54
61
|
aria-label="Message"
|
|
55
62
|
@keyup.enter="handleSend"
|
|
56
63
|
/>
|
|
57
|
-
<button class="cpub-btn cpub-btn-primary" :disabled="!newMessage.trim()" @click="handleSend">
|
|
64
|
+
<button class="cpub-btn cpub-btn-primary" :disabled="!newMessage.trim()" aria-label="Send message" @click="handleSend">
|
|
58
65
|
<i class="fa-solid fa-paper-plane"></i>
|
|
59
66
|
</button>
|
|
60
67
|
</div>
|
|
@@ -125,17 +132,47 @@ function handleSend(): void {
|
|
|
125
132
|
line-height: 1.5;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
|
-
.cpub-msg.own .cpub-msg-bubble {
|
|
135
|
+
.cpub-msg.cpub-msg-own .cpub-msg-bubble {
|
|
129
136
|
background: var(--accent-bg);
|
|
130
|
-
border-color: var(--accent-border);
|
|
137
|
+
border-color: var(--accent-border, var(--border));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.cpub-msg-meta {
|
|
141
|
+
display: flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
gap: 4px;
|
|
144
|
+
margin-top: 2px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.cpub-msg.cpub-msg-own .cpub-msg-meta {
|
|
148
|
+
justify-content: flex-end;
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
.cpub-msg-time {
|
|
134
152
|
font-size: 9px;
|
|
135
153
|
color: var(--text-faint);
|
|
136
154
|
font-family: var(--font-mono);
|
|
137
|
-
|
|
138
|
-
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.cpub-msg-receipt {
|
|
158
|
+
font-size: 9px;
|
|
159
|
+
color: var(--text-faint);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.cpub-msg-receipt.cpub-msg-read {
|
|
163
|
+
color: var(--accent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.sr-only {
|
|
167
|
+
position: absolute;
|
|
168
|
+
width: 1px;
|
|
169
|
+
height: 1px;
|
|
170
|
+
padding: 0;
|
|
171
|
+
margin: -1px;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
clip: rect(0, 0, 0, 0);
|
|
174
|
+
white-space: nowrap;
|
|
175
|
+
border-width: 0;
|
|
139
176
|
}
|
|
140
177
|
|
|
141
178
|
.cpub-thread-empty {
|
|
@@ -132,15 +132,15 @@ useJsonLd({
|
|
|
132
132
|
<div class="cpub-engagement-row">
|
|
133
133
|
<div class="cpub-eng-stat"><i class="fa-regular fa-eye"></i> {{ content.viewCount?.toLocaleString() || '0' }} views</div>
|
|
134
134
|
<div class="cpub-eng-sep"></div>
|
|
135
|
-
<button class="cpub-eng-btn" :class="{ liked }" @click="toggleLike">
|
|
135
|
+
<button class="cpub-eng-btn" :class="{ liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
|
|
136
136
|
<i class="fa-solid fa-heart"></i> {{ likeCount }}
|
|
137
137
|
</button>
|
|
138
|
-
<button class="cpub-eng-btn" :class="{ bookmarked }" @click="toggleBookmark">
|
|
138
|
+
<button class="cpub-eng-btn" :class="{ bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
|
|
139
139
|
<i class="fa-solid fa-bookmark"></i> Bookmark
|
|
140
140
|
</button>
|
|
141
141
|
<div class="cpub-eng-spacer"></div>
|
|
142
|
-
<button class="cpub-eng-btn" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
|
|
143
|
-
<button class="cpub-eng-btn"><i class="fa-solid fa-ellipsis"></i></button>
|
|
142
|
+
<button class="cpub-eng-btn" aria-label="Share" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
|
|
143
|
+
<button class="cpub-eng-btn" aria-label="More options"><i class="fa-solid fa-ellipsis"></i></button>
|
|
144
144
|
</div>
|
|
145
145
|
|
|
146
146
|
<!-- ARTICLE BODY WITH TOC SIDEBAR -->
|
|
@@ -85,15 +85,15 @@ const hasSeries = computed(() => !!seriesTitle.value && seriesTotalParts.value >
|
|
|
85
85
|
<div class="cpub-engagement-row">
|
|
86
86
|
<div class="cpub-eng-stat"><i class="fa-regular fa-eye"></i> {{ content.viewCount?.toLocaleString() || '0' }} views</div>
|
|
87
87
|
<div class="cpub-eng-sep"></div>
|
|
88
|
-
<button class="cpub-eng-btn" :class="{ liked }" @click="toggleLike">
|
|
88
|
+
<button class="cpub-eng-btn" :class="{ liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
|
|
89
89
|
<i class="fa-solid fa-heart"></i> {{ likeCount }}
|
|
90
90
|
</button>
|
|
91
|
-
<button class="cpub-eng-btn" :class="{ bookmarked }" @click="toggleBookmark">
|
|
91
|
+
<button class="cpub-eng-btn" :class="{ bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
|
|
92
92
|
<i class="fa-solid fa-bookmark"></i> Bookmark
|
|
93
93
|
</button>
|
|
94
94
|
<div class="cpub-eng-spacer"></div>
|
|
95
|
-
<button class="cpub-eng-btn" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
|
|
96
|
-
<button class="cpub-eng-btn"><i class="fa-solid fa-ellipsis"></i></button>
|
|
95
|
+
<button class="cpub-eng-btn" aria-label="Share" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
|
|
96
|
+
<button class="cpub-eng-btn" aria-label="More options"><i class="fa-solid fa-ellipsis"></i></button>
|
|
97
97
|
</div>
|
|
98
98
|
|
|
99
99
|
<!-- BLOG BODY (PROSE) -->
|
|
@@ -168,21 +168,21 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
168
168
|
<span class="cpub-progress-text">Section {{ activeSection + 1 }} of {{ totalSections }}</span>
|
|
169
169
|
<div class="cpub-topbar-divider"></div>
|
|
170
170
|
<div class="cpub-nav-btn-group">
|
|
171
|
-
<button class="cpub-icon-btn" :disabled="activeSection === 0"
|
|
171
|
+
<button class="cpub-icon-btn" :disabled="activeSection === 0" aria-label="Previous section" @click="prevSection">
|
|
172
172
|
<i class="fa-solid fa-arrow-left"></i>
|
|
173
173
|
</button>
|
|
174
|
-
<button class="cpub-icon-btn" :disabled="activeSection === totalSections - 1"
|
|
174
|
+
<button class="cpub-icon-btn" :disabled="activeSection === totalSections - 1" aria-label="Next section" @click="nextSection">
|
|
175
175
|
<i class="fa-solid fa-arrow-right"></i>
|
|
176
176
|
</button>
|
|
177
177
|
</div>
|
|
178
178
|
<div class="cpub-topbar-divider"></div>
|
|
179
|
-
<button class="cpub-icon-btn" :class="{ active: liked }"
|
|
179
|
+
<button class="cpub-icon-btn" :class="{ active: liked }" :aria-label="liked ? 'Unlike' : 'Like'" :aria-pressed="liked" @click="toggleLike">
|
|
180
180
|
<i :class="liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i>
|
|
181
181
|
</button>
|
|
182
|
-
<button class="cpub-icon-btn" :class="{ active: bookmarked }"
|
|
182
|
+
<button class="cpub-icon-btn" :class="{ active: bookmarked }" :aria-label="bookmarked ? 'Remove bookmark' : 'Bookmark'" :aria-pressed="bookmarked" @click="toggleBookmark">
|
|
183
183
|
<i :class="bookmarked ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'"></i>
|
|
184
184
|
</button>
|
|
185
|
-
<button class="cpub-icon-btn"
|
|
185
|
+
<button class="cpub-icon-btn" aria-label="Share" @click="share">
|
|
186
186
|
<i class="fa-solid fa-arrow-up-from-bracket"></i>
|
|
187
187
|
</button>
|
|
188
188
|
<NuxtLink
|
|
@@ -208,14 +208,14 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
208
208
|
class="cpub-toc-item"
|
|
209
209
|
:class="{ completed: completedSections.has(i), active: activeSection === i }"
|
|
210
210
|
>
|
|
211
|
-
<
|
|
211
|
+
<button type="button" :aria-label="`Go to section ${i + 1}: ${section.title}`" @click="goToSection(i)">
|
|
212
212
|
<span class="cpub-toc-icon">
|
|
213
213
|
<i v-if="completedSections.has(i)" class="fa-solid fa-check"></i>
|
|
214
214
|
<i v-else-if="activeSection === i" class="fa-solid fa-arrow-right"></i>
|
|
215
215
|
</span>
|
|
216
216
|
<span class="cpub-toc-num">{{ String(i + 1).padStart(2, '0') }}</span>
|
|
217
217
|
<span class="cpub-toc-label">{{ section.title }}</span>
|
|
218
|
-
</
|
|
218
|
+
</button>
|
|
219
219
|
</li>
|
|
220
220
|
</ul>
|
|
221
221
|
|
|
@@ -291,14 +291,17 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
291
291
|
</button>
|
|
292
292
|
<div v-else></div>
|
|
293
293
|
|
|
294
|
-
<div class="cpub-progress-dots">
|
|
295
|
-
<
|
|
294
|
+
<div class="cpub-progress-dots" role="group" aria-label="Section progress">
|
|
295
|
+
<button
|
|
296
296
|
v-for="(_, i) in totalSections"
|
|
297
297
|
:key="i"
|
|
298
|
+
type="button"
|
|
298
299
|
class="cpub-dot"
|
|
299
300
|
:class="{ done: completedSections.has(i), active: i === activeSection }"
|
|
301
|
+
:aria-label="`Section ${i + 1}`"
|
|
302
|
+
:aria-current="i === activeSection ? 'step' : undefined"
|
|
300
303
|
@click="goToSection(i)"
|
|
301
|
-
></
|
|
304
|
+
></button>
|
|
302
305
|
</div>
|
|
303
306
|
|
|
304
307
|
<button v-if="activeSection < totalSections - 1" class="cpub-next-btn" @click="nextSection">
|
|
@@ -430,22 +433,26 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
430
433
|
border-bottom: 1px solid var(--border);
|
|
431
434
|
}
|
|
432
435
|
.cpub-toc-list { list-style: none; padding: 6px 0; }
|
|
433
|
-
.cpub-toc-item
|
|
436
|
+
.cpub-toc-item button {
|
|
434
437
|
display: flex;
|
|
435
438
|
align-items: center;
|
|
436
439
|
gap: 8px;
|
|
437
440
|
padding: 8px 14px;
|
|
438
|
-
|
|
441
|
+
width: 100%;
|
|
442
|
+
background: none;
|
|
443
|
+
border: none;
|
|
444
|
+
text-align: left;
|
|
439
445
|
color: var(--text-dim);
|
|
440
446
|
font-size: 12px;
|
|
447
|
+
font-family: inherit;
|
|
441
448
|
line-height: 1.4;
|
|
442
449
|
border-left: 3px solid transparent;
|
|
443
450
|
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
|
444
451
|
cursor: pointer;
|
|
445
452
|
}
|
|
446
|
-
.cpub-toc-item
|
|
447
|
-
.cpub-toc-item.active
|
|
448
|
-
.cpub-toc-item.completed
|
|
453
|
+
.cpub-toc-item button:hover { background: var(--surface2); color: var(--text); }
|
|
454
|
+
.cpub-toc-item.active button { background: var(--accent-bg); border-left-color: var(--accent); color: var(--accent); font-weight: 500; }
|
|
455
|
+
.cpub-toc-item.completed button { color: var(--text-dim); }
|
|
449
456
|
.cpub-toc-icon { width: 14px; font-size: 10px; flex-shrink: 0; text-align: center; }
|
|
450
457
|
.cpub-toc-item.completed .cpub-toc-icon { color: var(--green); }
|
|
451
458
|
.cpub-toc-item.active .cpub-toc-icon { color: var(--accent); }
|
|
@@ -669,6 +676,8 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
669
676
|
.cpub-dot {
|
|
670
677
|
width: 7px;
|
|
671
678
|
height: 7px;
|
|
679
|
+
padding: 0;
|
|
680
|
+
border: none;
|
|
672
681
|
border-radius: 50%;
|
|
673
682
|
background: var(--border2);
|
|
674
683
|
transition: background 0.15s, transform 0.15s;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"vue-router": "^4.3.0",
|
|
46
46
|
"zod": "^4.3.6",
|
|
47
47
|
"@commonpub/auth": "0.5.0",
|
|
48
|
-
"@commonpub/config": "0.7.0",
|
|
49
48
|
"@commonpub/docs": "0.5.2",
|
|
49
|
+
"@commonpub/config": "0.7.0",
|
|
50
50
|
"@commonpub/editor": "0.5.0",
|
|
51
|
-
"@commonpub/protocol": "0.9.4",
|
|
52
|
-
"@commonpub/server": "2.12.1",
|
|
53
51
|
"@commonpub/learning": "0.5.0",
|
|
54
|
-
"@commonpub/
|
|
55
|
-
"@commonpub/
|
|
52
|
+
"@commonpub/protocol": "0.9.4",
|
|
53
|
+
"@commonpub/server": "2.13.0",
|
|
54
|
+
"@commonpub/schema": "0.8.12",
|
|
55
|
+
"@commonpub/ui": "0.7.1"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {}
|
|
58
58
|
}
|
package/pages/auth/login.vue
CHANGED
|
@@ -7,6 +7,7 @@ useSeoMeta({
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
const { signIn } = useAuth();
|
|
10
|
+
const { federation } = useFeatures();
|
|
10
11
|
const route = useRoute();
|
|
11
12
|
|
|
12
13
|
const identity = ref('');
|
|
@@ -14,6 +15,18 @@ const password = ref('');
|
|
|
14
15
|
const error = ref('');
|
|
15
16
|
const loading = ref(false);
|
|
16
17
|
|
|
18
|
+
// Federated login state
|
|
19
|
+
const federatedDomain = ref('');
|
|
20
|
+
const federatedLoading = ref(false);
|
|
21
|
+
const federatedError = ref('');
|
|
22
|
+
|
|
23
|
+
// Federated callback context — present when redirected back from OAuth callback.
|
|
24
|
+
// Only an opaque linkToken is passed; the verified identity stays server-side.
|
|
25
|
+
const federatedLinkToken = computed(() => {
|
|
26
|
+
if (route.query.federated !== 'true') return null;
|
|
27
|
+
return (route.query.linkToken as string) || null;
|
|
28
|
+
});
|
|
29
|
+
|
|
17
30
|
const redirectTo = computed(() => {
|
|
18
31
|
const raw = (route.query.redirect as string) || '/';
|
|
19
32
|
if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
|
|
@@ -25,15 +38,27 @@ async function handleSubmit(): Promise<void> {
|
|
|
25
38
|
loading.value = true;
|
|
26
39
|
|
|
27
40
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
if (federatedLinkToken.value) {
|
|
42
|
+
// Linking flow: authenticate + link federated account in one step
|
|
43
|
+
await $fetch('/api/auth/federated/link', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: {
|
|
46
|
+
identity: identity.value,
|
|
47
|
+
password: password.value,
|
|
48
|
+
linkToken: federatedLinkToken.value,
|
|
49
|
+
},
|
|
50
|
+
credentials: 'include',
|
|
51
|
+
});
|
|
52
|
+
await navigateTo('/dashboard');
|
|
53
|
+
} else {
|
|
54
|
+
// Normal login flow
|
|
55
|
+
const { email } = await $fetch<{ email: string }>('/api/resolve-identity', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
body: { identity: identity.value },
|
|
58
|
+
});
|
|
59
|
+
await signIn(email, password.value);
|
|
60
|
+
await navigateTo(redirectTo.value);
|
|
61
|
+
}
|
|
37
62
|
} catch (err: unknown) {
|
|
38
63
|
const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
|
|
39
64
|
if (fetchErr?.statusCode === 503) {
|
|
@@ -45,12 +70,48 @@ async function handleSubmit(): Promise<void> {
|
|
|
45
70
|
loading.value = false;
|
|
46
71
|
}
|
|
47
72
|
}
|
|
73
|
+
|
|
74
|
+
async function handleFederatedLogin(): Promise<void> {
|
|
75
|
+
federatedError.value = '';
|
|
76
|
+
const domain = federatedDomain.value.trim().toLowerCase();
|
|
77
|
+
if (!domain) return;
|
|
78
|
+
|
|
79
|
+
federatedLoading.value = true;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await $fetch<{ authorizationUrl: string }>('/api/auth/federated/login', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
body: { instanceDomain: domain },
|
|
85
|
+
});
|
|
86
|
+
// Redirect to the remote instance's OAuth consent page
|
|
87
|
+
window.location.href = result.authorizationUrl;
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
const fetchErr = err as { statusCode?: number; data?: { message?: string; statusMessage?: string } };
|
|
90
|
+
if (fetchErr?.statusCode === 403) {
|
|
91
|
+
federatedError.value = `${domain} is not a trusted instance.`;
|
|
92
|
+
} else if (fetchErr?.statusCode === 502) {
|
|
93
|
+
federatedError.value = `Could not connect to ${domain}. Check the domain and try again.`;
|
|
94
|
+
} else {
|
|
95
|
+
federatedError.value = fetchErr?.data?.statusMessage || fetchErr?.data?.message || 'Failed to initiate federated login.';
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
federatedLoading.value = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
48
101
|
</script>
|
|
49
102
|
|
|
50
103
|
<template>
|
|
51
104
|
<div class="login-page">
|
|
52
105
|
<h1 class="login-title">Log in</h1>
|
|
53
106
|
|
|
107
|
+
<!-- Federated context banner — shown when linking an account -->
|
|
108
|
+
<div v-if="federatedLinkToken" class="cpub-federated-banner" role="status">
|
|
109
|
+
<p class="cpub-federated-banner-text">
|
|
110
|
+
Link your federated identity to a local account.
|
|
111
|
+
Log in below to complete the link.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
54
115
|
<form class="login-form" @submit.prevent="handleSubmit" aria-label="Login form">
|
|
55
116
|
<div v-if="error" class="form-error" role="alert">{{ error }}</div>
|
|
56
117
|
|
|
@@ -81,12 +142,44 @@ async function handleSubmit(): Promise<void> {
|
|
|
81
142
|
</div>
|
|
82
143
|
|
|
83
144
|
<button type="submit" class="submit-btn" :disabled="loading">
|
|
84
|
-
{{ loading
|
|
145
|
+
{{ loading
|
|
146
|
+
? 'Logging in...'
|
|
147
|
+
: federatedLinkToken
|
|
148
|
+
? 'Log in & Link Account'
|
|
149
|
+
: 'Log in'
|
|
150
|
+
}}
|
|
85
151
|
</button>
|
|
86
152
|
|
|
87
153
|
<NuxtLink to="/auth/forgot-password" class="forgot-link">Forgot your password?</NuxtLink>
|
|
88
154
|
</form>
|
|
89
155
|
|
|
156
|
+
<!-- Federated login section — only shown when federation is enabled -->
|
|
157
|
+
<div v-if="federation && !federatedLinkToken" class="cpub-federated-section">
|
|
158
|
+
<div class="cpub-federated-divider">
|
|
159
|
+
<span class="cpub-federated-divider-text">or</span>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another instance">
|
|
163
|
+
<div v-if="federatedError" class="form-error" role="alert">{{ federatedError }}</div>
|
|
164
|
+
|
|
165
|
+
<label for="federated-domain" class="field-label">Sign in with another instance</label>
|
|
166
|
+
<div class="cpub-federated-input-group">
|
|
167
|
+
<input
|
|
168
|
+
id="federated-domain"
|
|
169
|
+
v-model="federatedDomain"
|
|
170
|
+
type="text"
|
|
171
|
+
class="field-input"
|
|
172
|
+
placeholder="instance.example.com"
|
|
173
|
+
required
|
|
174
|
+
autocomplete="off"
|
|
175
|
+
/>
|
|
176
|
+
<button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote instance">
|
|
177
|
+
{{ federatedLoading ? 'Connecting...' : 'Go' }}
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
90
183
|
<p class="login-footer">
|
|
91
184
|
Don't have an account?
|
|
92
185
|
<NuxtLink to="/auth/register">Register</NuxtLink>
|
|
@@ -213,4 +306,91 @@ async function handleSubmit(): Promise<void> {
|
|
|
213
306
|
color: var(--accent);
|
|
214
307
|
text-decoration: underline;
|
|
215
308
|
}
|
|
309
|
+
|
|
310
|
+
/* Federated login */
|
|
311
|
+
.cpub-federated-banner {
|
|
312
|
+
padding: var(--space-3);
|
|
313
|
+
background: var(--blue-bg, var(--surface-raised));
|
|
314
|
+
border: var(--border-width-default) solid var(--accent);
|
|
315
|
+
border-radius: var(--radius);
|
|
316
|
+
margin-bottom: var(--space-4);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.cpub-federated-banner-text {
|
|
320
|
+
font-size: 13px;
|
|
321
|
+
color: var(--text);
|
|
322
|
+
margin: 0;
|
|
323
|
+
line-height: 1.5;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.cpub-federated-banner-text code {
|
|
327
|
+
font-family: var(--font-mono);
|
|
328
|
+
font-size: 12px;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.cpub-federated-section {
|
|
332
|
+
margin-top: var(--space-5);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.cpub-federated-divider {
|
|
336
|
+
display: flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
gap: var(--space-3);
|
|
339
|
+
margin-bottom: var(--space-4);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.cpub-federated-divider::before,
|
|
343
|
+
.cpub-federated-divider::after {
|
|
344
|
+
content: '';
|
|
345
|
+
flex: 1;
|
|
346
|
+
height: 1px;
|
|
347
|
+
background: var(--border);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.cpub-federated-divider-text {
|
|
351
|
+
font-size: 11px;
|
|
352
|
+
font-family: var(--font-mono);
|
|
353
|
+
text-transform: uppercase;
|
|
354
|
+
letter-spacing: 0.06em;
|
|
355
|
+
color: var(--text-faint);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.cpub-federated-form {
|
|
359
|
+
display: flex;
|
|
360
|
+
flex-direction: column;
|
|
361
|
+
gap: var(--space-3);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.cpub-federated-input-group {
|
|
365
|
+
display: flex;
|
|
366
|
+
gap: var(--space-2);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.cpub-federated-input-group .field-input {
|
|
370
|
+
flex: 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.cpub-federated-btn {
|
|
374
|
+
padding: 7px 14px;
|
|
375
|
+
background: var(--surface-raised);
|
|
376
|
+
color: var(--text);
|
|
377
|
+
border: var(--border-width-default) solid var(--border);
|
|
378
|
+
border-radius: var(--radius);
|
|
379
|
+
font-size: 13px;
|
|
380
|
+
font-weight: 500;
|
|
381
|
+
font-family: var(--font-sans);
|
|
382
|
+
cursor: pointer;
|
|
383
|
+
white-space: nowrap;
|
|
384
|
+
transition: all 0.15s;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.cpub-federated-btn:hover:not(:disabled) {
|
|
388
|
+
border-color: var(--accent);
|
|
389
|
+
color: var(--accent);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.cpub-federated-btn:disabled {
|
|
393
|
+
opacity: 0.7;
|
|
394
|
+
cursor: not-allowed;
|
|
395
|
+
}
|
|
216
396
|
</style>
|
package/pages/dashboard.vue
CHANGED
|
@@ -8,9 +8,12 @@ useSeoMeta({
|
|
|
8
8
|
|
|
9
9
|
const { user } = useAuth();
|
|
10
10
|
const { learning: learningEnabled } = useFeatures();
|
|
11
|
+
const { enabledTypeMeta } = useContentTypes();
|
|
11
12
|
const reqHeaders = import.meta.server ? useRequestHeaders(['cookie']) : {};
|
|
12
13
|
|
|
13
14
|
const activeTab = ref<'content' | 'bookmarks' | 'learning'>('content');
|
|
15
|
+
const contentSort = ref<'newest' | 'oldest' | 'popular'>('newest');
|
|
16
|
+
const contentTypeFilter = ref('');
|
|
14
17
|
|
|
15
18
|
// My content (all statuses)
|
|
16
19
|
const { data: myContent, status: contentStatus } = await useFetch('/api/content', {
|
|
@@ -18,6 +21,11 @@ const { data: myContent, status: contentStatus } = await useFetch('/api/content'
|
|
|
18
21
|
headers: reqHeaders,
|
|
19
22
|
});
|
|
20
23
|
|
|
24
|
+
const contentTypeOptions = computed(() => [
|
|
25
|
+
{ value: '', label: 'All types' },
|
|
26
|
+
...enabledTypeMeta.value.map(m => ({ value: m.type, label: m.plural })),
|
|
27
|
+
]);
|
|
28
|
+
|
|
21
29
|
// Bookmarks
|
|
22
30
|
const { data: bookmarkData } = await useFetch('/api/social/bookmarks', {
|
|
23
31
|
query: { limit: 10 },
|
|
@@ -40,17 +48,42 @@ const { data: notifCount } = await useFetch('/api/notifications/count', {
|
|
|
40
48
|
|
|
41
49
|
const toast = useToast();
|
|
42
50
|
|
|
51
|
+
function filterByType<T extends { type: string }>(items: T[]): T[] {
|
|
52
|
+
if (!contentTypeFilter.value) return items;
|
|
53
|
+
return items.filter((i) => i.type === contentTypeFilter.value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sortItems<T extends { createdAt: string; viewCount?: number; likeCount?: number }>(items: T[]): T[] {
|
|
57
|
+
const sorted = [...items];
|
|
58
|
+
if (contentSort.value === 'oldest') {
|
|
59
|
+
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
60
|
+
} else if (contentSort.value === 'popular') {
|
|
61
|
+
sorted.sort((a, b) => ((b.viewCount ?? 0) + (b.likeCount ?? 0)) - ((a.viewCount ?? 0) + (a.likeCount ?? 0)));
|
|
62
|
+
} else {
|
|
63
|
+
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
64
|
+
}
|
|
65
|
+
return sorted;
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
const drafts = computed(() =>
|
|
44
|
-
(myContent.value?.items ?? []).filter((i) => i.status === 'draft'),
|
|
69
|
+
sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'))),
|
|
45
70
|
);
|
|
46
71
|
const published = computed(() =>
|
|
47
|
-
(myContent.value?.items ?? []).filter((i) => i.status === 'published'),
|
|
72
|
+
sortItems(filterByType((myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'))),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Stats use ALL items (unfiltered) so totals don't change with filter selection
|
|
76
|
+
const allPublished = computed(() =>
|
|
77
|
+
(myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'published'),
|
|
78
|
+
);
|
|
79
|
+
const allDrafts = computed(() =>
|
|
80
|
+
(myContent.value?.items ?? []).filter((i: { status: string }) => i.status === 'draft'),
|
|
48
81
|
);
|
|
49
82
|
const totalViews = computed(() =>
|
|
50
|
-
|
|
83
|
+
allPublished.value.reduce((sum, item) => sum + (item.viewCount ?? 0), 0),
|
|
51
84
|
);
|
|
52
85
|
const totalLikes = computed(() =>
|
|
53
|
-
|
|
86
|
+
allPublished.value.reduce((sum, item) => sum + (item.likeCount ?? 0), 0),
|
|
54
87
|
);
|
|
55
88
|
|
|
56
89
|
// Content actions
|
|
@@ -97,11 +130,11 @@ async function deleteItem(id: string, title: string): Promise<void> {
|
|
|
97
130
|
<!-- Stats row -->
|
|
98
131
|
<div class="cpub-dash-stats">
|
|
99
132
|
<div class="cpub-dash-stat">
|
|
100
|
-
<span class="cpub-dash-stat-n">{{
|
|
133
|
+
<span class="cpub-dash-stat-n">{{ allPublished.length }}</span>
|
|
101
134
|
<span class="cpub-dash-stat-l">Published</span>
|
|
102
135
|
</div>
|
|
103
136
|
<div class="cpub-dash-stat">
|
|
104
|
-
<span class="cpub-dash-stat-n">{{
|
|
137
|
+
<span class="cpub-dash-stat-n">{{ allDrafts.length }}</span>
|
|
105
138
|
<span class="cpub-dash-stat-l">Drafts</span>
|
|
106
139
|
</div>
|
|
107
140
|
<div class="cpub-dash-stat">
|
|
@@ -149,6 +182,21 @@ async function deleteItem(id: string, title: string): Promise<void> {
|
|
|
149
182
|
|
|
150
183
|
<!-- Content tab -->
|
|
151
184
|
<div v-else-if="activeTab === 'content'" class="cpub-dash-panel">
|
|
185
|
+
<!-- Sort & filter controls -->
|
|
186
|
+
<div class="cpub-dash-controls">
|
|
187
|
+
<div class="cpub-dash-controls-left">
|
|
188
|
+
<select v-model="contentTypeFilter" class="cpub-dash-select" aria-label="Filter by content type">
|
|
189
|
+
<option v-for="opt in contentTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="cpub-dash-controls-right">
|
|
193
|
+
<select v-model="contentSort" class="cpub-dash-select" aria-label="Sort order">
|
|
194
|
+
<option value="newest">Newest first</option>
|
|
195
|
+
<option value="oldest">Oldest first</option>
|
|
196
|
+
<option value="popular">Most popular</option>
|
|
197
|
+
</select>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
152
200
|
<!-- Drafts section -->
|
|
153
201
|
<div v-if="drafts.length" class="cpub-dash-section">
|
|
154
202
|
<h2 class="cpub-dash-section-title">Drafts</h2>
|
|
@@ -359,6 +407,39 @@ async function deleteItem(id: string, title: string): Promise<void> {
|
|
|
359
407
|
border-bottom-color: var(--accent);
|
|
360
408
|
}
|
|
361
409
|
|
|
410
|
+
/* Controls */
|
|
411
|
+
.cpub-dash-controls {
|
|
412
|
+
display: flex;
|
|
413
|
+
align-items: center;
|
|
414
|
+
justify-content: space-between;
|
|
415
|
+
padding: 10px 16px;
|
|
416
|
+
border-bottom: var(--border-width-default) solid var(--border);
|
|
417
|
+
gap: 8px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.cpub-dash-controls-left,
|
|
421
|
+
.cpub-dash-controls-right {
|
|
422
|
+
display: flex;
|
|
423
|
+
align-items: center;
|
|
424
|
+
gap: 8px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.cpub-dash-select {
|
|
428
|
+
font-family: var(--font-mono);
|
|
429
|
+
font-size: 10px;
|
|
430
|
+
padding: 4px 8px;
|
|
431
|
+
border: var(--border-width-default) solid var(--border);
|
|
432
|
+
background: var(--surface);
|
|
433
|
+
color: var(--text-dim);
|
|
434
|
+
cursor: pointer;
|
|
435
|
+
outline: none;
|
|
436
|
+
border-radius: var(--radius);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.cpub-dash-select:focus {
|
|
440
|
+
border-color: var(--accent);
|
|
441
|
+
}
|
|
442
|
+
|
|
362
443
|
/* Panel */
|
|
363
444
|
.cpub-dash-panel {
|
|
364
445
|
background: var(--surface);
|