@commonpub/layer 0.33.0 → 0.35.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.
|
@@ -62,6 +62,14 @@ const heroCtas = computed<HeroCta[]>(() => {
|
|
|
62
62
|
];
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
// Optional hero logo/image (config-driven, per-site). When set, renders in a
|
|
66
|
+
// side column next to the hero copy — e.g. deveco's hexagon logo — without
|
|
67
|
+
// affecting sites that don't configure one. Additive: no logo unless set.
|
|
68
|
+
const heroLogo = computed(() => {
|
|
69
|
+
const cfg = props.config as { logoImageUrl?: string; logoAlt?: string };
|
|
70
|
+
return cfg.logoImageUrl?.trim() ? { src: cfg.logoImageUrl.trim(), alt: cfg.logoAlt?.trim() || '' } : null;
|
|
71
|
+
});
|
|
72
|
+
|
|
65
73
|
// Shared via useState so the dismiss sticks across component remounts.
|
|
66
74
|
// HomepageSectionRenderer's v-if wrappers can remount HeroSection when the
|
|
67
75
|
// `sections` useFetch revalidates on hydration or when feature flags flip
|
|
@@ -124,6 +132,9 @@ function dismissHero(): void {
|
|
|
124
132
|
</div>
|
|
125
133
|
</template>
|
|
126
134
|
</div>
|
|
135
|
+
<div v-if="heroLogo" class="cpub-hero-visual">
|
|
136
|
+
<img :src="heroLogo.src" :alt="heroLogo.alt" class="cpub-hero-logo-img" />
|
|
137
|
+
</div>
|
|
127
138
|
</div>
|
|
128
139
|
</section>
|
|
129
140
|
</template>
|
|
@@ -145,7 +156,12 @@ function dismissHero(): void {
|
|
|
145
156
|
.cpub-hero-title span { color: var(--accent); }
|
|
146
157
|
.cpub-hero-excerpt { font-size: 13px; color: var(--text-dim); line-height: 1.65; margin-bottom: 20px; max-width: 560px; }
|
|
147
158
|
.cpub-hero-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
159
|
+
.cpub-hero-visual { flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
|
160
|
+
.cpub-hero-logo-img { max-height: 240px; max-width: 320px; width: 100%; object-fit: contain; }
|
|
148
161
|
|
|
162
|
+
@media (max-width: 900px) {
|
|
163
|
+
.cpub-hero-visual { display: none; }
|
|
164
|
+
}
|
|
149
165
|
@media (max-width: 640px) {
|
|
150
166
|
.cpub-hero-inner { flex-direction: column; align-items: flex-start; gap: 20px; padding: 24px 16px; }
|
|
151
167
|
.cpub-hero-title { font-size: 19px; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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/config": "0.16.0",
|
|
58
|
-
"@commonpub/editor": "0.7.11",
|
|
59
|
-
"@commonpub/learning": "0.5.2",
|
|
60
57
|
"@commonpub/docs": "0.6.3",
|
|
58
|
+
"@commonpub/editor": "0.7.11",
|
|
59
|
+
"@commonpub/config": "0.16.0",
|
|
60
|
+
"@commonpub/explainer": "0.7.15",
|
|
61
61
|
"@commonpub/protocol": "0.12.0",
|
|
62
|
+
"@commonpub/learning": "0.5.2",
|
|
62
63
|
"@commonpub/schema": "0.24.0",
|
|
63
|
-
"@commonpub/ui": "0.9.1",
|
|
64
64
|
"@commonpub/server": "2.66.0",
|
|
65
|
-
"@commonpub/
|
|
65
|
+
"@commonpub/ui": "0.9.1"
|
|
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}`);
|
|
@@ -125,11 +126,20 @@ const enteredContentIds = computed(() => new Set(entries.value.map((e) => e.cont
|
|
|
125
126
|
// Restrict the submit picker to the contest's eligible content types (if set).
|
|
126
127
|
const eligibleTypes = computed<string[]>(() => (c.value?.eligibleContentTypes as string[] | undefined) ?? []);
|
|
127
128
|
const submittableContent = computed(() => {
|
|
128
|
-
const items = (userContent.value?.items ?? []) as Array<{ id: string; title: string; type: string }>;
|
|
129
|
+
const items = (userContent.value?.items ?? []) as Array<{ id: string; title: string; type: string; coverImageUrl?: string | null }>;
|
|
129
130
|
if (eligibleTypes.value.length === 0) return items;
|
|
130
131
|
return items.filter((i) => eligibleTypes.value.includes(i.type));
|
|
131
132
|
});
|
|
132
133
|
|
|
134
|
+
// "Create a new project for this contest" — lands in the editor with the contest
|
|
135
|
+
// association in the URL; on publish it auto-enters (server pending-entry hook).
|
|
136
|
+
const newProjectType = computed(() => (eligibleTypes.value[0] ?? 'project'));
|
|
137
|
+
const createForContestLink = computed(() =>
|
|
138
|
+
user.value?.username
|
|
139
|
+
? `/u/${user.value.username}/${newProjectType.value}/new/edit?contest=${slug}`
|
|
140
|
+
: `/auth/login?redirect=/contests/${slug}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
133
143
|
function copyLink(): void {
|
|
134
144
|
if (typeof window !== 'undefined' && window.navigator?.clipboard) {
|
|
135
145
|
window.navigator.clipboard.writeText(window.location.href);
|
|
@@ -146,8 +156,8 @@ async function submitEntry(): Promise<void> {
|
|
|
146
156
|
submitContentId.value = '';
|
|
147
157
|
toast.success('Entry submitted!');
|
|
148
158
|
refreshNuxtData();
|
|
149
|
-
} catch {
|
|
150
|
-
toast.error(
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
toast.error(extractError(err));
|
|
151
161
|
} finally {
|
|
152
162
|
submitting.value = false;
|
|
153
163
|
}
|
|
@@ -185,22 +195,38 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
185
195
|
</div>
|
|
186
196
|
<div class="cpub-submit-body">
|
|
187
197
|
<p class="cpub-submit-hint">
|
|
188
|
-
|
|
198
|
+
Pick one of your published projects to enter — or start a new one.
|
|
189
199
|
<template v-if="eligibleTypes.length"> This contest accepts: {{ eligibleTypes.join(', ') }}.</template>
|
|
190
200
|
</p>
|
|
191
|
-
<
|
|
192
|
-
<
|
|
193
|
-
|
|
201
|
+
<div class="cpub-submit-gallery" role="radiogroup" aria-label="Select a project to submit">
|
|
202
|
+
<NuxtLink :to="createForContestLink" class="cpub-submit-new" @click="showSubmitDialog = false">
|
|
203
|
+
<div class="cpub-submit-new-icon"><i class="fa-solid fa-plus"></i></div>
|
|
204
|
+
<span>Create a new {{ newProjectType }}</span>
|
|
205
|
+
<small>Enters automatically when you publish it</small>
|
|
206
|
+
</NuxtLink>
|
|
207
|
+
<button
|
|
194
208
|
v-for="item in submittableContent"
|
|
195
209
|
:key="item.id"
|
|
196
|
-
|
|
210
|
+
type="button"
|
|
211
|
+
role="radio"
|
|
212
|
+
:aria-checked="submitContentId === item.id"
|
|
213
|
+
class="cpub-submit-tile"
|
|
214
|
+
:class="{ selected: submitContentId === item.id, entered: enteredContentIds.has(item.id) }"
|
|
197
215
|
:disabled="enteredContentIds.has(item.id)"
|
|
216
|
+
@click="submitContentId = item.id"
|
|
198
217
|
>
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
218
|
+
<span class="cpub-submit-tile-thumb">
|
|
219
|
+
<img v-if="item.coverImageUrl" :src="item.coverImageUrl" :alt="item.title" />
|
|
220
|
+
<i v-else class="fa-solid fa-cube"></i>
|
|
221
|
+
<span v-if="enteredContentIds.has(item.id)" class="cpub-submit-tile-badge">Entered</span>
|
|
222
|
+
<span v-else-if="submitContentId === item.id" class="cpub-submit-tile-check"><i class="fa-solid fa-check"></i></span>
|
|
223
|
+
</span>
|
|
224
|
+
<span class="cpub-submit-tile-title">{{ item.title }}</span>
|
|
225
|
+
<span class="cpub-submit-tile-type">{{ item.type }}</span>
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
202
228
|
<p v-if="submittableContent.length === 0" class="cpub-submit-hint" style="margin-top: 10px; margin-bottom: 0;">
|
|
203
|
-
No eligible published content
|
|
229
|
+
No eligible published content yet — use “Create a new {{ newProjectType }}” above to start one.
|
|
204
230
|
</p>
|
|
205
231
|
</div>
|
|
206
232
|
<div class="cpub-submit-footer">
|
|
@@ -278,6 +304,18 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
278
304
|
|
|
279
305
|
<!-- ENTRIES -->
|
|
280
306
|
<div v-show="activeTab === 'entries'" id="cpub-panel-entries" role="tabpanel" aria-labelledby="cpub-tab-entries" tabindex="0">
|
|
307
|
+
<div v-if="c?.status === 'active'" class="cpub-entries-cta">
|
|
308
|
+
<div class="cpub-entries-cta-text">
|
|
309
|
+
<p class="cpub-entries-cta-title"><i class="fa-solid fa-trophy"></i> Enter this contest</p>
|
|
310
|
+
<p class="cpub-entries-cta-sub">Submit one of your published projects — or start a new one.</p>
|
|
311
|
+
</div>
|
|
312
|
+
<button v-if="isAuthenticated" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="showSubmitDialog = true">
|
|
313
|
+
<i class="fa-solid fa-upload"></i> Submit Entry
|
|
314
|
+
</button>
|
|
315
|
+
<NuxtLink v-else :to="`/auth/login?redirect=/contests/${slug}`" class="cpub-btn cpub-btn-primary cpub-btn-lg">
|
|
316
|
+
<i class="fa-solid fa-right-to-bracket"></i> Log in to enter
|
|
317
|
+
</NuxtLink>
|
|
318
|
+
</div>
|
|
281
319
|
<ContestEntries
|
|
282
320
|
:entries="entries"
|
|
283
321
|
:contest-status="c?.status"
|
|
@@ -321,18 +359,38 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
321
359
|
<style scoped>
|
|
322
360
|
/* SUBMIT DIALOG */
|
|
323
361
|
.cpub-submit-overlay { position: fixed; inset: 0; z-index: 200; background: var(--color-surface-overlay-light); display: flex; align-items: center; justify-content: center; }
|
|
324
|
-
.cpub-submit-dialog { background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-xl); width:
|
|
362
|
+
.cpub-submit-dialog { background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-xl); width: 560px; max-width: 92vw; }
|
|
325
363
|
.cpub-submit-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: var(--border-width-default) solid var(--border); }
|
|
326
364
|
.cpub-submit-header h2 { font-size: 14px; font-weight: 700; }
|
|
327
365
|
.cpub-submit-close { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
328
366
|
.cpub-submit-body { padding: 16px; }
|
|
329
367
|
.cpub-submit-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 12px; }
|
|
330
|
-
|
|
331
|
-
.cpub-submit-
|
|
368
|
+
/* Gallery picker (replaces the old <select>): scrollable grid of content tiles. */
|
|
369
|
+
.cpub-submit-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; max-height: 50vh; overflow-y: auto; padding: 2px; }
|
|
370
|
+
.cpub-submit-new { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; text-align: center; min-height: 150px; border: var(--border-width-default) dashed var(--accent-border); background: var(--accent-bg); color: var(--accent); text-decoration: none; padding: 10px; }
|
|
371
|
+
.cpub-submit-new:hover { border-color: var(--accent); }
|
|
372
|
+
.cpub-submit-new-icon { font-size: 20px; }
|
|
373
|
+
.cpub-submit-new span { font-size: 12px; font-weight: 600; }
|
|
374
|
+
.cpub-submit-new small { font-size: 10px; color: var(--text-dim); line-height: 1.3; }
|
|
375
|
+
.cpub-submit-tile { display: flex; flex-direction: column; text-align: left; padding: 0; border: var(--border-width-default) solid var(--border); background: var(--surface); cursor: pointer; overflow: hidden; }
|
|
376
|
+
.cpub-submit-tile:hover:not(:disabled) { border-color: var(--accent); }
|
|
377
|
+
.cpub-submit-tile.selected { border-color: var(--accent); box-shadow: var(--shadow-accent); }
|
|
378
|
+
.cpub-submit-tile.entered { opacity: 0.5; cursor: not-allowed; }
|
|
379
|
+
.cpub-submit-tile-thumb { position: relative; aspect-ratio: 4 / 3; background: var(--surface2); display: flex; align-items: center; justify-content: center; color: var(--text-faint); overflow: hidden; }
|
|
380
|
+
.cpub-submit-tile-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
|
381
|
+
.cpub-submit-tile-badge { position: absolute; top: 4px; right: 4px; font-size: 9px; font-family: var(--font-mono); text-transform: uppercase; background: var(--surface); border: var(--border-width-default) solid var(--border); padding: 1px 5px; color: var(--text-dim); }
|
|
382
|
+
.cpub-submit-tile-check { position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: var(--accent); color: var(--color-text-inverse); font-size: 10px; }
|
|
383
|
+
.cpub-submit-tile-title { font-size: 12px; font-weight: 600; padding: 6px 8px 2px; line-height: 1.3; }
|
|
384
|
+
.cpub-submit-tile-type { font-size: 9px; font-family: var(--font-mono); text-transform: uppercase; color: var(--text-faint); padding: 0 8px 8px; }
|
|
332
385
|
.cpub-submit-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: var(--border-width-default) solid var(--border); }
|
|
333
386
|
|
|
334
387
|
/* LAYOUT */
|
|
335
388
|
.cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 32px; }
|
|
389
|
+
|
|
390
|
+
.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); }
|
|
391
|
+
.cpub-entries-cta-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
|
|
392
|
+
.cpub-entries-cta-title i { color: var(--accent); }
|
|
393
|
+
.cpub-entries-cta-sub { font-size: 12px; color: var(--text-dim); margin: 2px 0 0; }
|
|
336
394
|
.cpub-contest-layout { display: grid; grid-template-columns: 1fr 300px; gap: 28px; align-items: start; }
|
|
337
395
|
.cpub-contest-body { min-width: 0; }
|
|
338
396
|
|
|
@@ -35,6 +35,11 @@ useSeoMeta({
|
|
|
35
35
|
|
|
36
36
|
const title = ref('');
|
|
37
37
|
const hubFromQuery = (route.query.hub as string) || '';
|
|
38
|
+
// Captured at setup because handleStarterSubmit's history.replaceState drops the
|
|
39
|
+
// query. If set (?contest=slug from a contest's "create new project" button),
|
|
40
|
+
// the project auto-enters that contest on publish.
|
|
41
|
+
const contestFromQuery = (route.query.contest as string) || '';
|
|
42
|
+
const submitToast = useToast();
|
|
38
43
|
const metadata = ref<Record<string, unknown>>({
|
|
39
44
|
description: '',
|
|
40
45
|
slug: '',
|
|
@@ -278,6 +283,15 @@ async function handleStarterSubmit(): Promise<void> {
|
|
|
278
283
|
// --- Publish with validation ---
|
|
279
284
|
async function handlePublish(): Promise<void> {
|
|
280
285
|
await doPublish(validate);
|
|
286
|
+
// Auto-enter into the contest this project was created for (if any). The
|
|
287
|
+
// submit endpoint re-validates published status, so a premature call (publish
|
|
288
|
+
// blocked by validation) just fails harmlessly and is swallowed.
|
|
289
|
+
if (contestFromQuery && contentId.value && !isNew.value) {
|
|
290
|
+
try {
|
|
291
|
+
await $fetch(`/api/contests/${contestFromQuery}/entries`, { method: 'POST', body: { contentId: contentId.value } });
|
|
292
|
+
submitToast.success('Entered into the contest!');
|
|
293
|
+
} catch { /* non-blocking — user can submit manually from the contest page */ }
|
|
294
|
+
}
|
|
281
295
|
}
|
|
282
296
|
|
|
283
297
|
// --- Preview mode ---
|
|
@@ -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
|
});
|