@commonpub/layer 0.34.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.34.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",
57
+ "@commonpub/docs": "0.6.3",
58
58
  "@commonpub/editor": "0.7.11",
59
+ "@commonpub/config": "0.16.0",
60
+ "@commonpub/explainer": "0.7.15",
59
61
  "@commonpub/protocol": "0.12.0",
62
+ "@commonpub/learning": "0.5.2",
60
63
  "@commonpub/schema": "0.24.0",
61
- "@commonpub/docs": "0.6.3",
62
64
  "@commonpub/server": "2.66.0",
63
- "@commonpub/ui": "0.9.1",
64
- "@commonpub/learning": "0.5.2",
65
- "@commonpub/explainer": "0.7.15"
65
+ "@commonpub/ui": "0.9.1"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -126,11 +126,20 @@ const enteredContentIds = computed(() => new Set(entries.value.map((e) => e.cont
126
126
  // Restrict the submit picker to the contest's eligible content types (if set).
127
127
  const eligibleTypes = computed<string[]>(() => (c.value?.eligibleContentTypes as string[] | undefined) ?? []);
128
128
  const submittableContent = computed(() => {
129
- 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 }>;
130
130
  if (eligibleTypes.value.length === 0) return items;
131
131
  return items.filter((i) => eligibleTypes.value.includes(i.type));
132
132
  });
133
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
+
134
143
  function copyLink(): void {
135
144
  if (typeof window !== 'undefined' && window.navigator?.clipboard) {
136
145
  window.navigator.clipboard.writeText(window.location.href);
@@ -186,22 +195,38 @@ async function withdrawEntry(entryId: string): Promise<void> {
186
195
  </div>
187
196
  <div class="cpub-submit-body">
188
197
  <p class="cpub-submit-hint">
189
- Select one of your published projects to submit as an entry.
198
+ Pick one of your published projects to enter or start a new one.
190
199
  <template v-if="eligibleTypes.length"> This contest accepts: {{ eligibleTypes.join(', ') }}.</template>
191
200
  </p>
192
- <select v-model="submitContentId" class="cpub-submit-select" aria-label="Select a project to submit">
193
- <option value="">Select a project...</option>
194
- <option
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
195
208
  v-for="item in submittableContent"
196
209
  :key="item.id"
197
- :value="item.id"
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) }"
198
215
  :disabled="enteredContentIds.has(item.id)"
216
+ @click="submitContentId = item.id"
199
217
  >
200
- {{ item.title }} ({{ item.type }}){{ enteredContentIds.has(item.id) ? ' — already entered' : '' }}
201
- </option>
202
- </select>
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>
203
228
  <p v-if="submittableContent.length === 0" class="cpub-submit-hint" style="margin-top: 10px; margin-bottom: 0;">
204
- No eligible published content found.
229
+ No eligible published content yet — use “Create a new {{ newProjectType }}” above to start one.
205
230
  </p>
206
231
  </div>
207
232
  <div class="cpub-submit-footer">
@@ -334,14 +359,29 @@ async function withdrawEntry(entryId: string): Promise<void> {
334
359
  <style scoped>
335
360
  /* SUBMIT DIALOG */
336
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; }
337
- .cpub-submit-dialog { background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-xl); width: 420px; max-width: 90vw; }
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; }
338
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); }
339
364
  .cpub-submit-header h2 { font-size: 14px; font-weight: 700; }
340
365
  .cpub-submit-close { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
341
366
  .cpub-submit-body { padding: 16px; }
342
367
  .cpub-submit-hint { font-size: 12px; color: var(--text-dim); margin-bottom: 12px; }
343
- .cpub-submit-select { width: 100%; padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: 13px; }
344
- .cpub-submit-select:focus { border-color: var(--accent); outline: none; }
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; }
345
385
  .cpub-submit-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: var(--border-width-default) solid var(--border); }
346
386
 
347
387
  /* LAYOUT */
@@ -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 ---