@commonpub/layer 0.32.0 → 0.34.0
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.
|
@@ -21,6 +21,11 @@ const emit = defineEmits<{ read: [id: string] }>();
|
|
|
21
21
|
// work; otherwise it stays a plain div.
|
|
22
22
|
const destination = computed(() => props.notification.link || props.notification.targetUrl || null);
|
|
23
23
|
|
|
24
|
+
// Resolve NuxtLink to the actual component. Passing the string 'NuxtLink' to
|
|
25
|
+
// <component :is> can fail to resolve during SSR (renders a bogus <nuxtlink>
|
|
26
|
+
// element → hydration mismatch + a dead link); the resolved component is stable.
|
|
27
|
+
const NuxtLinkComponent = resolveComponent('NuxtLink');
|
|
28
|
+
|
|
24
29
|
function onActivate(): void {
|
|
25
30
|
if (!props.notification.read) emit('read', props.notification.id);
|
|
26
31
|
}
|
|
@@ -41,7 +46,7 @@ const iconMap: Record<string, string> = {
|
|
|
41
46
|
|
|
42
47
|
<template>
|
|
43
48
|
<component
|
|
44
|
-
:is="destination ?
|
|
49
|
+
:is="destination ? NuxtLinkComponent : 'div'"
|
|
45
50
|
:to="destination || undefined"
|
|
46
51
|
class="cpub-notif"
|
|
47
52
|
:class="{ 'cpub-notif-unread': !notification.read, 'cpub-notif-link-row': destination }"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
56
|
"@commonpub/auth": "0.7.0",
|
|
57
|
-
"@commonpub/docs": "0.6.3",
|
|
58
57
|
"@commonpub/config": "0.16.0",
|
|
59
|
-
"@commonpub/learning": "0.5.2",
|
|
60
58
|
"@commonpub/editor": "0.7.11",
|
|
61
|
-
"@commonpub/
|
|
59
|
+
"@commonpub/protocol": "0.12.0",
|
|
62
60
|
"@commonpub/schema": "0.24.0",
|
|
61
|
+
"@commonpub/docs": "0.6.3",
|
|
63
62
|
"@commonpub/server": "2.66.0",
|
|
64
63
|
"@commonpub/ui": "0.9.1",
|
|
65
|
-
"@commonpub/
|
|
64
|
+
"@commonpub/learning": "0.5.2",
|
|
65
|
+
"@commonpub/explainer": "0.7.15"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/[type]/index.vue
CHANGED
|
@@ -56,8 +56,13 @@ const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentLis
|
|
|
56
56
|
</template>
|
|
57
57
|
|
|
58
58
|
<style scoped>
|
|
59
|
+
/* Centered, padded content container (was left-aligned with no padding — content
|
|
60
|
+
hugged the left edge + cards stretched on wide screens). ~1200px reads like
|
|
61
|
+
deveco's gold-standard listing width. */
|
|
59
62
|
.cpub-listing {
|
|
60
|
-
max-width:
|
|
63
|
+
max-width: 1200px;
|
|
64
|
+
margin-inline: auto;
|
|
65
|
+
padding: 0 var(--space-6, 24px);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
.cpub-listing-header {
|
|
@@ -4,6 +4,7 @@ import type { Serialized, ContestEntryItem, ContestJudgeItem } from '@commonpub/
|
|
|
4
4
|
const route = useRoute();
|
|
5
5
|
const slug = route.params.slug as string;
|
|
6
6
|
const toast = useToast();
|
|
7
|
+
const { extract: extractError } = useApiError();
|
|
7
8
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
8
9
|
|
|
9
10
|
const { data: contest } = useLazyFetch(`/api/contests/${slug}`);
|
|
@@ -146,8 +147,8 @@ async function submitEntry(): Promise<void> {
|
|
|
146
147
|
submitContentId.value = '';
|
|
147
148
|
toast.success('Entry submitted!');
|
|
148
149
|
refreshNuxtData();
|
|
149
|
-
} catch {
|
|
150
|
-
toast.error(
|
|
150
|
+
} catch (err: unknown) {
|
|
151
|
+
toast.error(extractError(err));
|
|
151
152
|
} finally {
|
|
152
153
|
submitting.value = false;
|
|
153
154
|
}
|
|
@@ -278,6 +279,18 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
278
279
|
|
|
279
280
|
<!-- ENTRIES -->
|
|
280
281
|
<div v-show="activeTab === 'entries'" id="cpub-panel-entries" role="tabpanel" aria-labelledby="cpub-tab-entries" tabindex="0">
|
|
282
|
+
<div v-if="c?.status === 'active'" class="cpub-entries-cta">
|
|
283
|
+
<div class="cpub-entries-cta-text">
|
|
284
|
+
<p class="cpub-entries-cta-title"><i class="fa-solid fa-trophy"></i> Enter this contest</p>
|
|
285
|
+
<p class="cpub-entries-cta-sub">Submit one of your published projects — or start a new one.</p>
|
|
286
|
+
</div>
|
|
287
|
+
<button v-if="isAuthenticated" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="showSubmitDialog = true">
|
|
288
|
+
<i class="fa-solid fa-upload"></i> Submit Entry
|
|
289
|
+
</button>
|
|
290
|
+
<NuxtLink v-else :to="`/auth/login?redirect=/contests/${slug}`" class="cpub-btn cpub-btn-primary cpub-btn-lg">
|
|
291
|
+
<i class="fa-solid fa-right-to-bracket"></i> Log in to enter
|
|
292
|
+
</NuxtLink>
|
|
293
|
+
</div>
|
|
281
294
|
<ContestEntries
|
|
282
295
|
:entries="entries"
|
|
283
296
|
:contest-status="c?.status"
|
|
@@ -333,6 +346,11 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
333
346
|
|
|
334
347
|
/* LAYOUT */
|
|
335
348
|
.cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
|
349
|
+
|
|
350
|
+
.cpub-entries-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
|
|
351
|
+
.cpub-entries-cta-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
|
|
352
|
+
.cpub-entries-cta-title i { color: var(--accent); }
|
|
353
|
+
.cpub-entries-cta-sub { font-size: 12px; color: var(--text-dim); margin: 2px 0 0; }
|
|
336
354
|
.cpub-contest-layout { display: grid; grid-template-columns: 1fr 300px; gap: 28px; align-items: start; }
|
|
337
355
|
.cpub-contest-body { min-width: 0; }
|
|
338
356
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { submitContestEntry, getContestBySlug, canViewContest } from '@commonpub/server';
|
|
2
2
|
import type { ContestEntryItem } from '@commonpub/server';
|
|
3
|
+
import { contentItems } from '@commonpub/schema';
|
|
4
|
+
import { eq } from 'drizzle-orm';
|
|
3
5
|
import { z } from 'zod';
|
|
4
6
|
|
|
5
7
|
const submitEntrySchema = z.object({
|
|
@@ -19,9 +21,34 @@ export default defineEventHandler(async (event): Promise<ContestEntryItem> => {
|
|
|
19
21
|
}
|
|
20
22
|
const input = await parseBody(event, submitEntrySchema);
|
|
21
23
|
|
|
24
|
+
// Produce a SPECIFIC reason on failure (the old single catch-all 400 hid why
|
|
25
|
+
// submission failed — most commonly the contest isn't active yet).
|
|
26
|
+
if (contest.status !== 'active') {
|
|
27
|
+
const detail = contest.status === 'upcoming'
|
|
28
|
+
? 'Entries open once the contest is active.'
|
|
29
|
+
: contest.status === 'judging'
|
|
30
|
+
? 'Submissions are closed — the contest is being judged.'
|
|
31
|
+
: `The contest is ${contest.status}.`;
|
|
32
|
+
throw createError({ statusCode: 400, statusMessage: `This contest isn't accepting entries right now. ${detail}` });
|
|
33
|
+
}
|
|
34
|
+
const [content] = await db
|
|
35
|
+
.select({ authorId: contentItems.authorId, status: contentItems.status, type: contentItems.type })
|
|
36
|
+
.from(contentItems)
|
|
37
|
+
.where(eq(contentItems.id, input.contentId))
|
|
38
|
+
.limit(1);
|
|
39
|
+
if (!content) throw createError({ statusCode: 400, statusMessage: 'That content no longer exists.' });
|
|
40
|
+
if (content.authorId !== user.id) throw createError({ statusCode: 403, statusMessage: 'You can only submit your own content.' });
|
|
41
|
+
if (content.status !== 'published') throw createError({ statusCode: 400, statusMessage: 'That project isn’t published yet — publish it first, then submit.' });
|
|
42
|
+
const eligible = contest.eligibleContentTypes ?? null;
|
|
43
|
+
if (eligible && eligible.length > 0 && !eligible.includes(content.type)) {
|
|
44
|
+
throw createError({ statusCode: 400, statusMessage: `This contest only accepts: ${eligible.join(', ')}.` });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// submitContestEntry re-validates (defense in depth) + enforces the per-user
|
|
48
|
+
// cap + dedupes; a null here means already-entered or over the entry limit.
|
|
22
49
|
const entry = await submitContestEntry(db, contest.id, input.contentId, user.id);
|
|
23
50
|
if (!entry) {
|
|
24
|
-
throw createError({ statusCode: 400, statusMessage: '
|
|
51
|
+
throw createError({ statusCode: 400, statusMessage: 'Couldn’t submit — you may have already entered this project, or reached the contest’s entry limit.' });
|
|
25
52
|
}
|
|
26
53
|
return entry;
|
|
27
54
|
});
|