@commonpub/layer 0.14.1 → 0.15.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.
- package/components/DiscussionItem.vue +2 -2
- package/components/FeedItem.vue +1 -1
- package/components/PollDisplay.vue +1 -1
- package/components/contest/ContestJudgeManager.vue +95 -21
- package/components/hub/HubDiscussions.vue +46 -3
- package/components/hub/HubFeed.vue +70 -12
- package/composables/useFeatures.ts +8 -2
- package/package.json +4 -4
- package/pages/hubs/[slug]/index.vue +2 -2
|
@@ -32,7 +32,7 @@ const lastReplyFormatted = computed((): string | null => {
|
|
|
32
32
|
<button
|
|
33
33
|
class="cpub-vote-btn"
|
|
34
34
|
aria-label="Upvote"
|
|
35
|
-
@click="emit('upvote')"
|
|
35
|
+
@click.prevent.stop="emit('upvote')"
|
|
36
36
|
>
|
|
37
37
|
<i class="fa-solid fa-chevron-up"></i>
|
|
38
38
|
</button>
|
|
@@ -40,7 +40,7 @@ const lastReplyFormatted = computed((): string | null => {
|
|
|
40
40
|
<button
|
|
41
41
|
class="cpub-vote-btn"
|
|
42
42
|
aria-label="Downvote"
|
|
43
|
-
@click="emit('downvote')"
|
|
43
|
+
@click.prevent.stop="emit('downvote')"
|
|
44
44
|
>
|
|
45
45
|
<i class="fa-solid fa-chevron-down"></i>
|
|
46
46
|
</button>
|
package/components/FeedItem.vue
CHANGED
|
@@ -79,7 +79,7 @@ const formattedDate = computed((): string => {
|
|
|
79
79
|
</div>
|
|
80
80
|
|
|
81
81
|
<div class="cpub-feed-item-stats">
|
|
82
|
-
<button v-if="interactive" class="cpub-feed-stat cpub-feed-stat-btn" :class="{ 'cpub-feed-stat-voted': voted }" aria-label="Vote" @click.prevent.stop="emit('vote')">
|
|
82
|
+
<button v-if="interactive" type="button" class="cpub-feed-stat cpub-feed-stat-btn" :class="{ 'cpub-feed-stat-voted': voted }" aria-label="Vote" @click.prevent.stop="emit('vote')">
|
|
83
83
|
<i class="fa-solid fa-arrow-up"></i> {{ voteCount }}
|
|
84
84
|
</button>
|
|
85
85
|
<span v-else class="cpub-feed-stat" aria-label="Votes">
|
|
@@ -10,7 +10,7 @@ const { isAuthenticated } = useAuth();
|
|
|
10
10
|
const toast = useToast();
|
|
11
11
|
const loading = ref(false);
|
|
12
12
|
|
|
13
|
-
const { data, refresh } =
|
|
13
|
+
const { data, refresh } = useLazyFetch<{ options: PollOptionResult[]; userVote: string | null }>(
|
|
14
14
|
`/api/hubs/${props.hubSlug}/posts/${props.postId}/poll-options`,
|
|
15
15
|
);
|
|
16
16
|
|
|
@@ -7,24 +7,51 @@ const props = defineProps<{
|
|
|
7
7
|
}>();
|
|
8
8
|
|
|
9
9
|
const toast = useToast();
|
|
10
|
-
const { data: judges, refresh } =
|
|
10
|
+
const { data: judges, refresh } = useLazyFetch<ContestJudgeItem[]>(
|
|
11
11
|
`/api/contests/${props.contestSlug}/judges`,
|
|
12
12
|
);
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// User search for adding judges
|
|
15
|
+
const searchQuery = ref('');
|
|
16
|
+
const searchResults = ref<Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>>([]);
|
|
17
|
+
const searching = ref(false);
|
|
15
18
|
const newJudgeRole = ref<'lead' | 'judge' | 'guest'>('judge');
|
|
16
19
|
const adding = ref(false);
|
|
20
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
if (
|
|
22
|
+
function handleSearch(): void {
|
|
23
|
+
if (searchTimeout) clearTimeout(searchTimeout);
|
|
24
|
+
if (!searchQuery.value || searchQuery.value.length < 2) {
|
|
25
|
+
searchResults.value = [];
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
searchTimeout = setTimeout(async () => {
|
|
29
|
+
searching.value = true;
|
|
30
|
+
try {
|
|
31
|
+
const data = await ($fetch as Function)('/api/admin/users', {
|
|
32
|
+
query: { search: searchQuery.value, limit: 8 },
|
|
33
|
+
}) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
|
|
34
|
+
// Filter out users who are already judges
|
|
35
|
+
const judgeIds = new Set((judges.value ?? []).map((j: ContestJudgeItem) => j.userId));
|
|
36
|
+
searchResults.value = data.items.filter((u: { id: string }) => !judgeIds.has(u.id));
|
|
37
|
+
} catch {
|
|
38
|
+
searchResults.value = [];
|
|
39
|
+
} finally {
|
|
40
|
+
searching.value = false;
|
|
41
|
+
}
|
|
42
|
+
}, 300);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function addJudge(userId: string): Promise<void> {
|
|
20
46
|
adding.value = true;
|
|
21
47
|
try {
|
|
22
|
-
await $fetch(`/api/contests/${props.contestSlug}/judges`, {
|
|
48
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/judges`, {
|
|
23
49
|
method: 'POST',
|
|
24
|
-
body: { userId
|
|
50
|
+
body: { userId, role: newJudgeRole.value },
|
|
25
51
|
});
|
|
26
52
|
toast.success('Judge added');
|
|
27
|
-
|
|
53
|
+
searchQuery.value = '';
|
|
54
|
+
searchResults.value = [];
|
|
28
55
|
await refresh();
|
|
29
56
|
} catch {
|
|
30
57
|
toast.error('Failed to add judge');
|
|
@@ -36,7 +63,7 @@ async function addJudge(): Promise<void> {
|
|
|
36
63
|
async function removeJudge(userId: string): Promise<void> {
|
|
37
64
|
if (!confirm('Remove this judge?')) return;
|
|
38
65
|
try {
|
|
39
|
-
await $fetch(`/api/contests/${props.contestSlug}/judges/${userId}`, { method: 'DELETE' });
|
|
66
|
+
await ($fetch as Function)(`/api/contests/${props.contestSlug}/judges/${userId}`, { method: 'DELETE' });
|
|
40
67
|
toast.success('Judge removed');
|
|
41
68
|
await refresh();
|
|
42
69
|
} catch {
|
|
@@ -53,7 +80,7 @@ const roleLabels: Record<string, string> = {
|
|
|
53
80
|
|
|
54
81
|
<template>
|
|
55
82
|
<div class="cpub-contest-judges">
|
|
56
|
-
<h3 class="cpub-judges-title">
|
|
83
|
+
<h3 class="cpub-judges-title">Judge Management</h3>
|
|
57
84
|
|
|
58
85
|
<div v-if="judges?.length" class="cpub-judges-list">
|
|
59
86
|
<div v-for="judge in judges" :key="judge.id" class="cpub-judge-row">
|
|
@@ -74,15 +101,43 @@ const roleLabels: Record<string, string> = {
|
|
|
74
101
|
<p v-else class="cpub-judges-empty">No judges assigned yet.</p>
|
|
75
102
|
|
|
76
103
|
<div v-if="isOwner" class="cpub-judges-add">
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
<div class="cpub-judges-search-wrapper">
|
|
105
|
+
<div class="cpub-judges-search-row">
|
|
106
|
+
<input
|
|
107
|
+
v-model="searchQuery"
|
|
108
|
+
class="cpub-judges-input"
|
|
109
|
+
placeholder="Search by name or username..."
|
|
110
|
+
@input="handleSearch"
|
|
111
|
+
/>
|
|
112
|
+
<select v-model="newJudgeRole" class="cpub-judges-input cpub-judges-select">
|
|
113
|
+
<option value="lead">Lead</option>
|
|
114
|
+
<option value="judge">Judge</option>
|
|
115
|
+
<option value="guest">Guest</option>
|
|
116
|
+
</select>
|
|
117
|
+
</div>
|
|
118
|
+
<div v-if="searchResults.length" class="cpub-judges-dropdown">
|
|
119
|
+
<button
|
|
120
|
+
v-for="user in searchResults"
|
|
121
|
+
:key="user.id"
|
|
122
|
+
class="cpub-judges-dropdown-item"
|
|
123
|
+
:disabled="adding"
|
|
124
|
+
@click="addJudge(user.id)"
|
|
125
|
+
>
|
|
126
|
+
<span class="cpub-judge-avatar cpub-judge-avatar-sm">
|
|
127
|
+
<img v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.displayName || user.username" />
|
|
128
|
+
<span v-else>{{ (user.displayName || user.username).charAt(0) }}</span>
|
|
129
|
+
</span>
|
|
130
|
+
<span class="cpub-judges-dropdown-name">{{ user.displayName || user.username }}</span>
|
|
131
|
+
<span class="cpub-judges-dropdown-handle">@{{ user.username }}</span>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
<div v-else-if="searching" class="cpub-judges-dropdown">
|
|
135
|
+
<span class="cpub-judges-dropdown-empty">Searching...</span>
|
|
136
|
+
</div>
|
|
137
|
+
<div v-else-if="searchQuery.length >= 2 && !searchResults.length" class="cpub-judges-dropdown">
|
|
138
|
+
<span class="cpub-judges-dropdown-empty">No users found</span>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
86
141
|
</div>
|
|
87
142
|
</div>
|
|
88
143
|
</template>
|
|
@@ -95,6 +150,7 @@ const roleLabels: Record<string, string> = {
|
|
|
95
150
|
.cpub-judge-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); flex: 1; min-width: 0; }
|
|
96
151
|
.cpub-judge-avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--surface2); border: var(--border-width-default) solid var(--border); display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 700; overflow: hidden; flex-shrink: 0; }
|
|
97
152
|
.cpub-judge-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|
153
|
+
.cpub-judge-avatar-sm { width: 20px; height: 20px; font-size: 8px; }
|
|
98
154
|
.cpub-judge-name { font-size: 12px; font-weight: 600; }
|
|
99
155
|
.cpub-judge-role { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; color: var(--text-faint); }
|
|
100
156
|
.cpub-judge-pending { font-family: var(--font-mono); font-size: 9px; color: var(--yellow, var(--text-faint)); }
|
|
@@ -103,8 +159,26 @@ const roleLabels: Record<string, string> = {
|
|
|
103
159
|
|
|
104
160
|
.cpub-judges-empty { font-size: 12px; color: var(--text-faint); font-style: italic; margin-bottom: 12px; }
|
|
105
161
|
|
|
106
|
-
.cpub-judges-add {
|
|
107
|
-
.cpub-judges-
|
|
162
|
+
.cpub-judges-add { margin-top: 8px; }
|
|
163
|
+
.cpub-judges-search-wrapper { position: relative; }
|
|
164
|
+
.cpub-judges-search-row { display: flex; gap: 6px; }
|
|
165
|
+
.cpub-judges-input { font-size: 12px; padding: 6px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; flex: 1; }
|
|
108
166
|
.cpub-judges-input:focus { border-color: var(--accent); }
|
|
109
|
-
.cpub-judges-select { max-width: 100px; }
|
|
167
|
+
.cpub-judges-select { max-width: 100px; flex: none; }
|
|
168
|
+
|
|
169
|
+
.cpub-judges-dropdown {
|
|
170
|
+
position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
|
171
|
+
background: var(--surface); border: var(--border-width-default) solid var(--border);
|
|
172
|
+
box-shadow: var(--shadow-md); margin-top: 2px; max-height: 200px; overflow-y: auto;
|
|
173
|
+
}
|
|
174
|
+
.cpub-judges-dropdown-item {
|
|
175
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
|
176
|
+
background: none; border: none; width: 100%; text-align: left;
|
|
177
|
+
cursor: pointer; font-family: inherit; transition: background 0.1s;
|
|
178
|
+
}
|
|
179
|
+
.cpub-judges-dropdown-item:hover { background: var(--surface2); }
|
|
180
|
+
.cpub-judges-dropdown-item:disabled { opacity: 0.5; cursor: default; }
|
|
181
|
+
.cpub-judges-dropdown-name { font-size: 12px; font-weight: 600; color: var(--text); }
|
|
182
|
+
.cpub-judges-dropdown-handle { font-size: 11px; color: var(--text-faint); margin-left: auto; }
|
|
183
|
+
.cpub-judges-dropdown-empty { display: block; padding: 8px 12px; font-size: 11px; color: var(--text-faint); }
|
|
110
184
|
</style>
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HubPostViewModel } from '../../types/hub';
|
|
3
|
+
import type { VoteDirection } from '@commonpub/server';
|
|
3
4
|
|
|
4
5
|
const props = defineProps<{
|
|
5
|
-
posts: HubPostViewModel[]
|
|
6
|
+
posts: HubPostViewModel[];
|
|
7
|
+
hubSlug?: string;
|
|
6
8
|
}>();
|
|
7
9
|
|
|
10
|
+
const { isAuthenticated } = useAuth();
|
|
11
|
+
const toast = useToast();
|
|
12
|
+
|
|
8
13
|
function stripHtml(html: string): string {
|
|
9
14
|
return html.replace(/<[^>]*>/g, '').trim();
|
|
10
15
|
}
|
|
@@ -15,6 +20,40 @@ const discussionPosts = computed(() => {
|
|
|
15
20
|
&& !p.sharedContent,
|
|
16
21
|
);
|
|
17
22
|
});
|
|
23
|
+
|
|
24
|
+
// --- Voting ---
|
|
25
|
+
const scoreOverrides = reactive<Map<string, number>>(new Map());
|
|
26
|
+
const userVotes = reactive<Map<string, VoteDirection>>(new Map());
|
|
27
|
+
const voting = reactive<Set<string>>(new Set());
|
|
28
|
+
|
|
29
|
+
function getScore(post: HubPostViewModel): number {
|
|
30
|
+
return scoreOverrides.get(post.id) ?? post.voteScore;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getUserVote(postId: string): VoteDirection | undefined {
|
|
34
|
+
return userVotes.get(postId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function handleVote(postId: string, direction: VoteDirection): Promise<void> {
|
|
38
|
+
if (!isAuthenticated.value || !props.hubSlug || voting.has(postId)) return;
|
|
39
|
+
voting.add(postId);
|
|
40
|
+
try {
|
|
41
|
+
const result = await ($fetch as Function)(
|
|
42
|
+
`/api/hubs/${props.hubSlug}/posts/${postId}/vote`,
|
|
43
|
+
{ method: 'POST', body: { direction } },
|
|
44
|
+
) as { voted: boolean; direction: VoteDirection | null; voteScore: number };
|
|
45
|
+
scoreOverrides.set(postId, result.voteScore);
|
|
46
|
+
if (result.direction) {
|
|
47
|
+
userVotes.set(postId, result.direction);
|
|
48
|
+
} else {
|
|
49
|
+
userVotes.delete(postId);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
toast.error('Vote failed');
|
|
53
|
+
} finally {
|
|
54
|
+
voting.delete(postId);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
18
57
|
</script>
|
|
19
58
|
|
|
20
59
|
<template>
|
|
@@ -29,7 +68,9 @@ const discussionPosts = computed(() => {
|
|
|
29
68
|
:author="post.author.name"
|
|
30
69
|
:reply-count="post.replyCount"
|
|
31
70
|
:vote-count="post.likeCount"
|
|
32
|
-
:vote-score="post
|
|
71
|
+
:vote-score="getScore(post)"
|
|
72
|
+
@upvote.prevent.stop="handleVote(post.id, 'up')"
|
|
73
|
+
@downvote.prevent.stop="handleVote(post.id, 'down')"
|
|
33
74
|
/>
|
|
34
75
|
</NuxtLink>
|
|
35
76
|
<div v-else>
|
|
@@ -38,7 +79,9 @@ const discussionPosts = computed(() => {
|
|
|
38
79
|
:author="post.author.name"
|
|
39
80
|
:reply-count="post.replyCount"
|
|
40
81
|
:vote-count="post.likeCount"
|
|
41
|
-
:vote-score="post
|
|
82
|
+
:vote-score="getScore(post)"
|
|
83
|
+
@upvote="handleVote(post.id, 'up')"
|
|
84
|
+
@downvote="handleVote(post.id, 'down')"
|
|
42
85
|
/>
|
|
43
86
|
</div>
|
|
44
87
|
</template>
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { HubPostViewModel } from '../../types/hub';
|
|
3
|
+
import type { VoteDirection } from '@commonpub/server';
|
|
3
4
|
|
|
4
5
|
const props = defineProps<{
|
|
5
6
|
posts: HubPostViewModel[];
|
|
6
|
-
|
|
7
|
-
likedPostIds?: Set<string>;
|
|
7
|
+
hubSlug?: string;
|
|
8
8
|
}>();
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const { isAuthenticated } = useAuth();
|
|
11
|
+
const toast = useToast();
|
|
11
12
|
|
|
12
|
-
/** Strip HTML tags for plain-text display in feed items */
|
|
13
13
|
function stripHtml(html: string): string {
|
|
14
14
|
return html.replace(/<[^>]*>/g, '').trim();
|
|
15
15
|
}
|
|
@@ -28,6 +28,40 @@ const filteredPosts = computed(() => {
|
|
|
28
28
|
if (feedFilter.value === 'all') return props.posts;
|
|
29
29
|
return props.posts.filter((p) => p.type === feedFilter.value);
|
|
30
30
|
});
|
|
31
|
+
|
|
32
|
+
// --- Voting state ---
|
|
33
|
+
const votedPosts = reactive<Map<string, VoteDirection>>(new Map());
|
|
34
|
+
const scoreOverrides = reactive<Map<string, number>>(new Map());
|
|
35
|
+
const votingInProgress = reactive<Set<string>>(new Set());
|
|
36
|
+
|
|
37
|
+
function getScore(post: HubPostViewModel): number {
|
|
38
|
+
return scoreOverrides.get(post.id) ?? post.voteScore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isVoted(postId: string): boolean {
|
|
42
|
+
return votedPosts.has(postId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleVote(postId: string): Promise<void> {
|
|
46
|
+
if (!isAuthenticated.value || !props.hubSlug || votingInProgress.has(postId)) return;
|
|
47
|
+
votingInProgress.add(postId);
|
|
48
|
+
try {
|
|
49
|
+
const result = await ($fetch as Function)(
|
|
50
|
+
`/api/hubs/${props.hubSlug}/posts/${postId}/vote`,
|
|
51
|
+
{ method: 'POST', body: { direction: 'up' } },
|
|
52
|
+
) as { voted: boolean; direction: VoteDirection | null; voteScore: number };
|
|
53
|
+
scoreOverrides.set(postId, result.voteScore);
|
|
54
|
+
if (result.direction) {
|
|
55
|
+
votedPosts.set(postId, result.direction);
|
|
56
|
+
} else {
|
|
57
|
+
votedPosts.delete(postId);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
toast.error('Vote failed');
|
|
61
|
+
} finally {
|
|
62
|
+
votingInProgress.delete(postId);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
31
65
|
</script>
|
|
32
66
|
|
|
33
67
|
<template>
|
|
@@ -105,6 +139,26 @@ const filteredPosts = computed(() => {
|
|
|
105
139
|
</div>
|
|
106
140
|
</a>
|
|
107
141
|
|
|
142
|
+
<!-- Poll posts -->
|
|
143
|
+
<div v-else-if="post.type === 'poll' && hubSlug" class="cpub-feed-poll-wrapper">
|
|
144
|
+
<FeedItem
|
|
145
|
+
type="discussion"
|
|
146
|
+
:title="stripHtml(post.content || '').slice(0, 80) || 'Poll'"
|
|
147
|
+
:author="post.author.name"
|
|
148
|
+
:author-avatar="post.author.avatarUrl ?? undefined"
|
|
149
|
+
:author-handle="post.author.handle ?? undefined"
|
|
150
|
+
:body="post.content || ''"
|
|
151
|
+
:created-at="new Date(post.createdAt)"
|
|
152
|
+
:reply-count="post.replyCount"
|
|
153
|
+
:vote-count="getScore(post)"
|
|
154
|
+
:pinned="post.isPinned"
|
|
155
|
+
:locked="post.isLocked"
|
|
156
|
+
/>
|
|
157
|
+
<div class="cpub-feed-poll-body">
|
|
158
|
+
<PollDisplay :hub-slug="hubSlug" :post-id="post.id" />
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
108
162
|
<!-- Regular posts — linked or static -->
|
|
109
163
|
<template v-else>
|
|
110
164
|
<NuxtLink v-if="post.linkTo" :to="post.linkTo" class="cpub-feed-link">
|
|
@@ -117,12 +171,12 @@ const filteredPosts = computed(() => {
|
|
|
117
171
|
:body="post.content || ''"
|
|
118
172
|
:created-at="new Date(post.createdAt)"
|
|
119
173
|
:reply-count="post.replyCount"
|
|
120
|
-
:vote-count="post
|
|
174
|
+
:vote-count="getScore(post)"
|
|
121
175
|
:pinned="post.isPinned"
|
|
122
176
|
:locked="post.isLocked"
|
|
123
|
-
:interactive="
|
|
124
|
-
:voted="
|
|
125
|
-
@vote="
|
|
177
|
+
:interactive="!!hubSlug && isAuthenticated"
|
|
178
|
+
:voted="isVoted(post.id)"
|
|
179
|
+
@vote="handleVote(post.id)"
|
|
126
180
|
/>
|
|
127
181
|
</NuxtLink>
|
|
128
182
|
<div v-else>
|
|
@@ -135,12 +189,12 @@ const filteredPosts = computed(() => {
|
|
|
135
189
|
:body="post.content || ''"
|
|
136
190
|
:created-at="new Date(post.createdAt)"
|
|
137
191
|
:reply-count="post.replyCount"
|
|
138
|
-
:vote-count="post
|
|
192
|
+
:vote-count="getScore(post)"
|
|
139
193
|
:pinned="post.isPinned"
|
|
140
194
|
:locked="post.isLocked"
|
|
141
|
-
:interactive="
|
|
142
|
-
:voted="
|
|
143
|
-
@vote="
|
|
195
|
+
:interactive="!!hubSlug && isAuthenticated"
|
|
196
|
+
:voted="isVoted(post.id)"
|
|
197
|
+
@vote="handleVote(post.id)"
|
|
144
198
|
/>
|
|
145
199
|
</div>
|
|
146
200
|
</template>
|
|
@@ -198,6 +252,10 @@ const filteredPosts = computed(() => {
|
|
|
198
252
|
display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
|
|
199
253
|
}
|
|
200
254
|
|
|
255
|
+
/* Poll wrapper */
|
|
256
|
+
.cpub-feed-poll-wrapper { border: var(--border-width-default) solid var(--border); background: var(--surface); }
|
|
257
|
+
.cpub-feed-poll-body { padding: 0 16px 16px; }
|
|
258
|
+
|
|
201
259
|
@media (max-width: 640px) {
|
|
202
260
|
.cpub-share-card-thumb { width: 80px; }
|
|
203
261
|
}
|
|
@@ -20,12 +20,18 @@ export interface FeatureFlags {
|
|
|
20
20
|
|
|
21
21
|
let hydrated = false;
|
|
22
22
|
|
|
23
|
+
const DEFAULT_FLAGS: FeatureFlags = {
|
|
24
|
+
content: true, social: true, hubs: true, docs: true, video: true,
|
|
25
|
+
contests: false, events: false, learning: true, explainers: true,
|
|
26
|
+
editorial: true, federation: false, admin: false, emailNotifications: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
23
29
|
export function useFeatures() {
|
|
24
30
|
const config = useRuntimeConfig();
|
|
25
|
-
const buildFlags = config.public.features as unknown as FeatureFlags;
|
|
31
|
+
const buildFlags = (config.public.features as unknown as FeatureFlags) ?? DEFAULT_FLAGS;
|
|
26
32
|
|
|
27
33
|
// Shared reactive state — initialized from build-time config
|
|
28
|
-
const flags = useState<FeatureFlags>('feature-flags', () => ({ ...buildFlags }));
|
|
34
|
+
const flags = useState<FeatureFlags>('feature-flags', () => ({ ...DEFAULT_FLAGS, ...buildFlags }));
|
|
29
35
|
|
|
30
36
|
// On client, fetch dynamic features once to pick up DB overrides
|
|
31
37
|
if (import.meta.client && !hydrated) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
31
|
"@commonpub/explainer": "^0.7.11",
|
|
32
32
|
"@commonpub/schema": "^0.13.0",
|
|
33
|
-
"@commonpub/server": "^2.
|
|
33
|
+
"@commonpub/server": "^2.42.0",
|
|
34
34
|
"@tiptap/core": "^2.11.0",
|
|
35
35
|
"@tiptap/extension-bold": "^2.11.0",
|
|
36
36
|
"@tiptap/extension-bullet-list": "^2.11.0",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.5.1",
|
|
57
|
+
"@commonpub/docs": "0.6.2",
|
|
58
|
+
"@commonpub/config": "0.10.0",
|
|
57
59
|
"@commonpub/editor": "0.7.9",
|
|
58
60
|
"@commonpub/learning": "0.5.0",
|
|
59
61
|
"@commonpub/protocol": "0.9.9",
|
|
60
|
-
"@commonpub/docs": "0.6.2",
|
|
61
|
-
"@commonpub/config": "0.10.0",
|
|
62
62
|
"@commonpub/ui": "0.8.5"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -313,7 +313,7 @@ async function onRefreshGallery(): Promise<void> {
|
|
|
313
313
|
<!-- Main content area -->
|
|
314
314
|
|
|
315
315
|
<!-- Feed tab -->
|
|
316
|
-
<HubFeed v-if="activeTab === 'feed'" :posts="postsVM">
|
|
316
|
+
<HubFeed v-if="activeTab === 'feed'" :posts="postsVM" :hub-slug="slug">
|
|
317
317
|
<template v-if="isAuthenticated" #compose>
|
|
318
318
|
<div class="cpub-compose-bar">
|
|
319
319
|
<div class="cpub-compose-types">
|
|
@@ -348,7 +348,7 @@ async function onRefreshGallery(): Promise<void> {
|
|
|
348
348
|
</HubFeed>
|
|
349
349
|
|
|
350
350
|
<!-- Discussions tab -->
|
|
351
|
-
<HubDiscussions v-else-if="activeTab === 'discussions'" :posts="postsVM">
|
|
351
|
+
<HubDiscussions v-else-if="activeTab === 'discussions'" :posts="postsVM" :hub-slug="slug">
|
|
352
352
|
<template v-if="isAuthenticated" #compose>
|
|
353
353
|
<div class="cpub-compose-bar" style="margin-bottom: 16px">
|
|
354
354
|
<div class="cpub-compose-row">
|