@commonpub/layer 0.74.0 → 0.76.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.
- package/components/CpubMarkdown.vue +16 -2
- package/components/FormatToggle.vue +57 -0
- package/components/contest/ContestPrizes.vue +3 -2
- package/components/contest/ContestRules.vue +2 -1
- package/composables/useSanitize.ts +122 -2
- package/package.json +9 -9
- package/pages/contests/[slug]/edit.vue +28 -8
- package/pages/contests/[slug]/index.vue +6 -4
- package/pages/contests/create.vue +23 -7
|
@@ -11,16 +11,27 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { markdownToBlockTuples } from '@commonpub/editor';
|
|
13
13
|
import type { BlockTuple } from '@commonpub/editor';
|
|
14
|
+
import { sanitizeRichHtml } from '../composables/useSanitize';
|
|
14
15
|
|
|
15
16
|
const props = defineProps<{
|
|
16
17
|
/** Markdown source (may contain inline/block HTML — passed through). */
|
|
17
18
|
source?: string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Render mode. `markdown` (default) runs the Markdown pipeline; `html` renders
|
|
21
|
+
* the source as the author's raw HTML through the permissive (script-free)
|
|
22
|
+
* sanitizer. The HTML path is also cheaper, so large bodies render instantly
|
|
23
|
+
* (no synchronous Markdown parse) on both SSR and client.
|
|
24
|
+
*/
|
|
25
|
+
format?: 'markdown' | 'html' | null;
|
|
18
26
|
}>();
|
|
19
27
|
|
|
20
28
|
const trimmed = computed(() => (props.source ?? '').trim());
|
|
29
|
+
const isHtml = computed(() => props.format === 'html');
|
|
30
|
+
|
|
31
|
+
const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value) : ''));
|
|
21
32
|
|
|
22
33
|
const blocks = computed<BlockTuple[]>(() => {
|
|
23
|
-
if (!trimmed.value) return [];
|
|
34
|
+
if (isHtml.value || !trimmed.value) return [];
|
|
24
35
|
try {
|
|
25
36
|
return markdownToBlockTuples(trimmed.value);
|
|
26
37
|
} catch {
|
|
@@ -30,8 +41,11 @@ const blocks = computed<BlockTuple[]>(() => {
|
|
|
30
41
|
</script>
|
|
31
42
|
|
|
32
43
|
<template>
|
|
44
|
+
<!-- eslint-disable vue/no-v-html -- author HTML, sanitized via sanitizeRichHtml (script-free allowlist) -->
|
|
45
|
+
<div v-if="isHtml && richHtml" class="cpub-md cpub-md-html" v-html="richHtml" />
|
|
46
|
+
<!-- eslint-enable vue/no-v-html -->
|
|
33
47
|
<BlocksBlockContentRenderer
|
|
34
|
-
v-if="blocks.length"
|
|
48
|
+
v-else-if="blocks.length"
|
|
35
49
|
:blocks="(blocks as [string, Record<string, unknown>][])"
|
|
36
50
|
class="cpub-prose cpub-md"
|
|
37
51
|
/>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Compact Markdown / HTML segmented toggle for a single rich-text field.
|
|
4
|
+
* Used inline beside a field label so each field's render mode is set
|
|
5
|
+
* independently. `html` mode renders the author's raw HTML (script-free).
|
|
6
|
+
*/
|
|
7
|
+
const model = defineModel<'markdown' | 'html'>({ default: 'markdown' });
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div class="cpub-fmt-toggle" role="radiogroup" aria-label="Field format">
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="cpub-fmt-opt"
|
|
15
|
+
:class="{ 'cpub-fmt-active': model === 'markdown' }"
|
|
16
|
+
:aria-pressed="model === 'markdown'"
|
|
17
|
+
@click="model = 'markdown'"
|
|
18
|
+
>Markdown</button>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
class="cpub-fmt-opt"
|
|
22
|
+
:class="{ 'cpub-fmt-active': model === 'html' }"
|
|
23
|
+
:aria-pressed="model === 'html'"
|
|
24
|
+
@click="model = 'html'"
|
|
25
|
+
>HTML</button>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style scoped>
|
|
30
|
+
.cpub-fmt-toggle {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
border: var(--border-width-default) solid var(--border);
|
|
33
|
+
background: var(--surface2);
|
|
34
|
+
}
|
|
35
|
+
.cpub-fmt-opt {
|
|
36
|
+
font-family: var(--font-mono);
|
|
37
|
+
font-size: 10px;
|
|
38
|
+
text-transform: uppercase;
|
|
39
|
+
letter-spacing: 0.06em;
|
|
40
|
+
padding: 3px 10px;
|
|
41
|
+
background: transparent;
|
|
42
|
+
color: var(--text-faint);
|
|
43
|
+
border: none;
|
|
44
|
+
cursor: pointer;
|
|
45
|
+
}
|
|
46
|
+
.cpub-fmt-opt + .cpub-fmt-opt {
|
|
47
|
+
border-left: var(--border-width-default) solid var(--border);
|
|
48
|
+
}
|
|
49
|
+
.cpub-fmt-active {
|
|
50
|
+
background: var(--accent);
|
|
51
|
+
color: var(--accent-contrast, #fff);
|
|
52
|
+
}
|
|
53
|
+
.cpub-fmt-opt:focus-visible {
|
|
54
|
+
outline: 2px solid var(--accent);
|
|
55
|
+
outline-offset: -2px;
|
|
56
|
+
}
|
|
57
|
+
</style>
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
interface Prize { place?: number; category?: string; title?: string; description?: string; value?: string }
|
|
3
3
|
defineProps<{
|
|
4
4
|
prizes: Prize[];
|
|
5
|
-
/** Optional
|
|
5
|
+
/** Optional intro shown above the prize cards (section-level, not per-prize). */
|
|
6
6
|
description?: string | null;
|
|
7
|
+
format?: 'markdown' | 'html' | null;
|
|
7
8
|
}>();
|
|
8
9
|
|
|
9
10
|
function prizeLabel(prize: Prize): string {
|
|
@@ -37,7 +38,7 @@ function prizeIcon(prize: Prize): string {
|
|
|
37
38
|
<div class="cpub-sec-head">
|
|
38
39
|
<h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
|
|
39
40
|
</div>
|
|
40
|
-
<CpubMarkdown v-if="description" :source="description" class="cpub-prizes-intro" />
|
|
41
|
+
<CpubMarkdown v-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
|
|
41
42
|
<div v-if="prizes.length" class="cpub-prize-grid">
|
|
42
43
|
<div
|
|
43
44
|
v-for="(prize, i) in prizes"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
defineProps<{
|
|
10
10
|
rules: string;
|
|
11
|
+
format?: 'markdown' | 'html' | null;
|
|
11
12
|
}>();
|
|
12
13
|
</script>
|
|
13
14
|
|
|
@@ -17,7 +18,7 @@ defineProps<{
|
|
|
17
18
|
<h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
|
|
18
19
|
</div>
|
|
19
20
|
<div class="cpub-rules-card">
|
|
20
|
-
<CpubMarkdown :source="rules" />
|
|
21
|
+
<CpubMarkdown :source="rules" :format="format" />
|
|
21
22
|
</div>
|
|
22
23
|
</div>
|
|
23
24
|
</template>
|
|
@@ -115,7 +115,127 @@ export function sanitizeBlockHtml(html: string): string {
|
|
|
115
115
|
return result;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Rich HTML mode (the opt-in "Full HTML" content format, e.g. contest pages).
|
|
120
|
+
//
|
|
121
|
+
// A deliberately PERMISSIVE but still DEFAULT-DENY allowlist: it renders an
|
|
122
|
+
// author's presentational HTML verbatim — layout tags, inline CSS, SVG icons —
|
|
123
|
+
// while never permitting script execution. Anything not on the allowlist is
|
|
124
|
+
// dropped, so `<script>`, `<iframe>`, `<object>`, `<style>`, `on*` handlers and
|
|
125
|
+
// `javascript:` URLs cannot get through. Authoring is gated to trusted
|
|
126
|
+
// (staff/admin) contest creators and is opt-in per contest. Tag/attr NAME case
|
|
127
|
+
// is preserved on output so case-sensitive SVG names (viewBox, linearGradient)
|
|
128
|
+
// survive. The `style` attribute is allowed but its value is scrubbed of the
|
|
129
|
+
// exfil/script vectors (url(), expression(), javascript:, @import, behavior).
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
const RICH_ALLOWED_ELEMENTS = new Set([
|
|
133
|
+
'div', 'section', 'article', 'aside', 'header', 'footer', 'main', 'nav', 'figure', 'figcaption', 'address',
|
|
134
|
+
'p', 'span', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
135
|
+
'blockquote', 'pre', 'code', 'kbd', 'samp', 'var', 'cite', 'q', 'wbr', 'small', 'mark', 'abbr', 'time', 'sub', 'sup',
|
|
136
|
+
'em', 'strong', 'b', 'i', 'u', 's', 'del', 'ins',
|
|
137
|
+
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
|
|
138
|
+
'a', 'img', 'picture', 'source',
|
|
139
|
+
'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
140
|
+
'details', 'summary', 'button',
|
|
141
|
+
// SVG (lowercased for the allowlist check; original case preserved on output)
|
|
142
|
+
'svg', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'rect', 'defs',
|
|
143
|
+
'lineargradient', 'radialgradient', 'stop', 'text', 'tspan', 'use', 'symbol', 'clippath', 'mask', 'pattern', 'marker',
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
// Attributes allowed on any rich element (lowercased). `aria-*`/`data-*` are
|
|
147
|
+
// allowed by prefix; per-tag extras and `style` are handled separately.
|
|
148
|
+
const RICH_GLOBAL_ATTRS = new Set([
|
|
149
|
+
'class', 'id', 'title', 'role', 'lang', 'dir', 'slot', 'tabindex', 'hidden', 'datetime', 'open',
|
|
150
|
+
// SVG presentation
|
|
151
|
+
'viewbox', 'xmlns', 'version', 'preserveaspectratio', 'transform', 'opacity',
|
|
152
|
+
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
|
153
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit', 'clip-rule', 'clip-path', 'mask',
|
|
154
|
+
'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'd', 'points', 'width', 'height',
|
|
155
|
+
'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', 'spreadmethod', 'patternunits',
|
|
156
|
+
'text-anchor', 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'letter-spacing',
|
|
157
|
+
'marker-start', 'marker-mid', 'marker-end',
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
const RICH_PER_TAG_ATTRS: Record<string, Set<string>> = {
|
|
161
|
+
a: new Set(['href', 'rel', 'target', 'name']),
|
|
162
|
+
img: new Set(['src', 'alt', 'loading', 'decoding']),
|
|
163
|
+
source: new Set(['type', 'media', 'sizes']),
|
|
164
|
+
td: new Set(['colspan', 'rowspan', 'headers']),
|
|
165
|
+
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
|
|
166
|
+
ol: new Set(['start', 'type', 'reversed']),
|
|
167
|
+
col: new Set(['span']),
|
|
168
|
+
colgroup: new Set(['span']),
|
|
169
|
+
button: new Set(['type']),
|
|
170
|
+
use: new Set(['href']),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const RICH_URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
|
|
174
|
+
// CSS constructs that can fetch, exfiltrate, or execute — stripped per-declaration.
|
|
175
|
+
const STYLE_DECL_BLOCKLIST = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i;
|
|
176
|
+
|
|
177
|
+
function sanitizeStyleAttr(value: string): string {
|
|
178
|
+
return value
|
|
179
|
+
.split(';')
|
|
180
|
+
.filter((decl) => decl.trim() && !STYLE_DECL_BLOCKLIST.test(decl))
|
|
181
|
+
.join(';');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Sanitize author HTML for "full HTML" rendering — permissive but script-free. */
|
|
185
|
+
export function sanitizeRichHtml(html: string): string {
|
|
186
|
+
if (!html || typeof html !== 'string') return '';
|
|
187
|
+
|
|
188
|
+
let result = html.replace(/<!--[\s\S]*?-->/g, '');
|
|
189
|
+
|
|
190
|
+
// Remove raw-text / active elements WITH their contents, so script/style/embed
|
|
191
|
+
// bodies don't leak through as visible text or execute. The tag pass below
|
|
192
|
+
// also drops these by allowlist, but only the open/close tags — this strips
|
|
193
|
+
// the inner payload (e.g. `<script>code</script>` → '' rather than `code`).
|
|
194
|
+
result = result.replace(/<(script|style|noscript|template|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
|
195
|
+
|
|
196
|
+
result = result.replace(/<\/?([a-zA-Z][a-zA-Z0-9:-]*)\b([^>]*)?\/?>/g, (match, rawTag: string, attrs?: string) => {
|
|
197
|
+
const tag = rawTag.toLowerCase();
|
|
198
|
+
if (!RICH_ALLOWED_ELEMENTS.has(tag)) return '';
|
|
199
|
+
if (match.startsWith('</')) return `</${rawTag}>`;
|
|
200
|
+
|
|
201
|
+
const safeAttrs: string[] = [];
|
|
202
|
+
if (attrs) {
|
|
203
|
+
const attrRegex = /([a-zA-Z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
204
|
+
let attrMatch: RegExpExecArray | null;
|
|
205
|
+
while ((attrMatch = attrRegex.exec(attrs)) !== null) {
|
|
206
|
+
const rawName = attrMatch[1]!;
|
|
207
|
+
const name = rawName.toLowerCase();
|
|
208
|
+
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '';
|
|
209
|
+
|
|
210
|
+
if (name.startsWith('on')) continue; // event handlers never allowed
|
|
211
|
+
const allowed =
|
|
212
|
+
name === 'style' ||
|
|
213
|
+
RICH_GLOBAL_ATTRS.has(name) ||
|
|
214
|
+
name.startsWith('aria-') ||
|
|
215
|
+
name.startsWith('data-') ||
|
|
216
|
+
(RICH_PER_TAG_ATTRS[tag]?.has(name) ?? false);
|
|
217
|
+
if (!allowed) continue;
|
|
218
|
+
|
|
219
|
+
if (RICH_URL_ATTRS.has(name) && !isSafeUrl(value)) continue;
|
|
220
|
+
|
|
221
|
+
if (name === 'style') {
|
|
222
|
+
const cleaned = sanitizeStyleAttr(value);
|
|
223
|
+
if (cleaned) safeAttrs.push(`style="${escapeAttrValue(cleaned)}"`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
safeAttrs.push(`${rawName}="${escapeAttrValue(value)}"`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const attrsStr = safeAttrs.length > 0 ? ' ' + safeAttrs.join(' ') : '';
|
|
231
|
+
const selfClose = match.endsWith('/>') ? ' /' : '';
|
|
232
|
+
return `<${rawTag}${attrsStr}${selfClose}>`;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
118
238
|
/** Composable wrapper for template use */
|
|
119
|
-
export function useSanitize(): { sanitize: (html: string) => string } {
|
|
120
|
-
return { sanitize: sanitizeBlockHtml };
|
|
239
|
+
export function useSanitize(): { sanitize: (html: string) => string; sanitizeRich: (html: string) => string } {
|
|
240
|
+
return { sanitize: sanitizeBlockHtml, sanitizeRich: sanitizeRichHtml };
|
|
121
241
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -53,17 +53,17 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
+
"@commonpub/auth": "0.8.0",
|
|
56
57
|
"@commonpub/config": "0.22.1",
|
|
57
|
-
"@commonpub/
|
|
58
|
-
"@commonpub/
|
|
59
|
-
"@commonpub/schema": "0.41.0",
|
|
58
|
+
"@commonpub/docs": "0.6.3",
|
|
59
|
+
"@commonpub/editor": "0.7.12",
|
|
60
60
|
"@commonpub/learning": "0.5.2",
|
|
61
|
+
"@commonpub/schema": "0.43.0",
|
|
62
|
+
"@commonpub/protocol": "0.13.0",
|
|
63
|
+
"@commonpub/server": "2.86.0",
|
|
61
64
|
"@commonpub/ui": "0.13.1",
|
|
62
|
-
"@commonpub/
|
|
63
|
-
"@commonpub/
|
|
64
|
-
"@commonpub/docs": "0.6.3",
|
|
65
|
-
"@commonpub/theme-studio": "0.6.1",
|
|
66
|
-
"@commonpub/editor": "0.7.12"
|
|
65
|
+
"@commonpub/explainer": "0.7.15",
|
|
66
|
+
"@commonpub/theme-studio": "0.6.1"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -29,6 +29,10 @@ function slugify(s: string): string {
|
|
|
29
29
|
const subheading = ref('');
|
|
30
30
|
const description = ref('');
|
|
31
31
|
const rules = ref('');
|
|
32
|
+
// Per-field render mode: Markdown (default) or raw HTML, independent per field.
|
|
33
|
+
const descriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
34
|
+
const rulesFormat = ref<'markdown' | 'html'>('markdown');
|
|
35
|
+
const prizesDescriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
32
36
|
const bannerUrl = ref('');
|
|
33
37
|
const coverImageUrl = ref('');
|
|
34
38
|
const startDate = ref('');
|
|
@@ -86,6 +90,9 @@ watch(contest, (c) => {
|
|
|
86
90
|
subheading.value = c.subheading ?? '';
|
|
87
91
|
description.value = c.description ?? '';
|
|
88
92
|
rules.value = c.rules ?? '';
|
|
93
|
+
descriptionFormat.value = (c.descriptionFormat as 'markdown' | 'html') ?? 'markdown';
|
|
94
|
+
rulesFormat.value = (c.rulesFormat as 'markdown' | 'html') ?? 'markdown';
|
|
95
|
+
prizesDescriptionFormat.value = (c.prizesDescriptionFormat as 'markdown' | 'html') ?? 'markdown';
|
|
89
96
|
bannerUrl.value = c.bannerUrl ?? '';
|
|
90
97
|
coverImageUrl.value = c.coverImageUrl ?? '';
|
|
91
98
|
startDate.value = c.startDate ? new Date(c.startDate).toISOString().slice(0, 16) : '';
|
|
@@ -124,7 +131,7 @@ watch(contest, (c) => {
|
|
|
124
131
|
// Mark the form dirty on any post-hydration edit (gives the save bar its
|
|
125
132
|
// "unsaved changes" cue). Worst case (timing) is a harmless early "dirty".
|
|
126
133
|
watch(
|
|
127
|
-
[title, slugInput, subheading, description, rules, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
|
|
134
|
+
[title, slugInput, subheading, description, rules, descriptionFormat, rulesFormat, prizesDescriptionFormat, bannerUrl, coverImageUrl, startDate, endDate, judgingEndDate,
|
|
128
135
|
communityVotingEnabled, judgingVisibility, eligibleContentTypes, maxEntriesPerUser, visibility, visibleToRoles,
|
|
129
136
|
showPrizes, stages, currentStageIdRef, prizesDescription, prizes, criteria],
|
|
130
137
|
() => { if (!hydratingForm) formDirty.value = true; },
|
|
@@ -195,6 +202,9 @@ async function handleSave(): Promise<void> {
|
|
|
195
202
|
subheading: subheading.value || undefined,
|
|
196
203
|
description: description.value || undefined,
|
|
197
204
|
rules: rules.value || undefined,
|
|
205
|
+
descriptionFormat: descriptionFormat.value,
|
|
206
|
+
rulesFormat: rulesFormat.value,
|
|
207
|
+
prizesDescriptionFormat: prizesDescriptionFormat.value,
|
|
198
208
|
bannerUrl: bannerUrl.value || undefined,
|
|
199
209
|
coverImageUrl: coverImageUrl.value || undefined,
|
|
200
210
|
startDate: startDate.value ? new Date(startDate.value).toISOString() : undefined,
|
|
@@ -348,14 +358,20 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
348
358
|
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
349
359
|
</div>
|
|
350
360
|
<div class="cpub-form-field">
|
|
351
|
-
<
|
|
361
|
+
<div class="cpub-field-head">
|
|
362
|
+
<label class="cpub-form-label">Description</label>
|
|
363
|
+
<FormatToggle v-model="descriptionFormat" />
|
|
364
|
+
</div>
|
|
352
365
|
<textarea v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" />
|
|
353
|
-
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
366
|
+
<p class="cpub-form-hint">{{ descriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
|
|
354
367
|
</div>
|
|
355
368
|
<div class="cpub-form-field">
|
|
356
|
-
<
|
|
369
|
+
<div class="cpub-field-head">
|
|
370
|
+
<label class="cpub-form-label">Rules</label>
|
|
371
|
+
<FormatToggle v-model="rulesFormat" />
|
|
372
|
+
</div>
|
|
357
373
|
<textarea v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="One rule per line, or full Markdown" />
|
|
358
|
-
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text
|
|
374
|
+
<p class="cpub-form-hint">{{ rulesFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown. Plain one-rule-per-line text renders as a list.' }}</p>
|
|
359
375
|
</div>
|
|
360
376
|
<div class="cpub-form-field">
|
|
361
377
|
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
|
|
@@ -442,9 +458,12 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
442
458
|
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
|
|
443
459
|
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong>, whatever fits. Cash value is optional.</p>
|
|
444
460
|
<div class="cpub-form-field">
|
|
445
|
-
<
|
|
446
|
-
|
|
447
|
-
|
|
461
|
+
<div class="cpub-field-head">
|
|
462
|
+
<label class="cpub-form-label">Prizes overview (optional)</label>
|
|
463
|
+
<FormatToggle v-model="prizesDescriptionFormat" />
|
|
464
|
+
</div>
|
|
465
|
+
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards." />
|
|
466
|
+
<p class="cpub-form-hint">{{ prizesDescriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Markdown intro' }} displayed on the Prizes tab, above the individual prizes.</p>
|
|
448
467
|
</div>
|
|
449
468
|
<div v-for="(prize, i) in prizes" :key="i" class="cpub-prize-row">
|
|
450
469
|
<div class="cpub-prize-header">
|
|
@@ -660,6 +679,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
660
679
|
.cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
|
|
661
680
|
.cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 14px; }
|
|
662
681
|
.cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
|
|
682
|
+
.cpub-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
663
683
|
.cpub-form-field:last-child { margin-bottom: 0; }
|
|
664
684
|
.cpub-form-input, .cpub-form-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
665
685
|
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
@@ -7,7 +7,9 @@ const toast = useToast();
|
|
|
7
7
|
const { extract: extractError } = useApiError();
|
|
8
8
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Blocking fetch (not lazy) so the description/rules render server-side and are
|
|
11
|
+
// present on first paint — no empty flash while the client fetches + renders.
|
|
12
|
+
const { data: contest } = await useFetch(`/api/contests/${slug}`);
|
|
11
13
|
const { data: apiEntriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[]; total: number }>(`/api/contests/${slug}/entries`);
|
|
12
14
|
const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
|
|
13
15
|
|
|
@@ -310,7 +312,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
310
312
|
<div class="cpub-about-section">
|
|
311
313
|
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
312
314
|
<div class="cpub-about-card">
|
|
313
|
-
<CpubMarkdown v-if="c?.description" :source="c.description" />
|
|
315
|
+
<CpubMarkdown v-if="c?.description" :source="c.description" :format="c?.descriptionFormat" />
|
|
314
316
|
<p v-else>No description available for this contest.</p>
|
|
315
317
|
</div>
|
|
316
318
|
</div>
|
|
@@ -319,12 +321,12 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
319
321
|
|
|
320
322
|
<!-- RULES -->
|
|
321
323
|
<div v-show="activeTab === 'rules'" id="cpub-panel-rules" role="tabpanel" aria-labelledby="cpub-tab-rules" tabindex="0">
|
|
322
|
-
<ContestRules v-if="c?.rules" :rules="c.rules" />
|
|
324
|
+
<ContestRules v-if="c?.rules" :rules="c.rules" :format="c?.rulesFormat" />
|
|
323
325
|
</div>
|
|
324
326
|
|
|
325
327
|
<!-- PRIZES -->
|
|
326
328
|
<div v-show="activeTab === 'prizes'" id="cpub-panel-prizes" role="tabpanel" aria-labelledby="cpub-tab-prizes" tabindex="0">
|
|
327
|
-
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" />
|
|
329
|
+
<ContestPrizes v-if="c?.showPrizes !== false && (c?.prizes?.length || c?.prizesDescription)" :prizes="c?.prizes ?? []" :description="c?.prizesDescription" :format="c?.prizesDescriptionFormat" />
|
|
328
330
|
</div>
|
|
329
331
|
|
|
330
332
|
<!-- ENTRIES -->
|
|
@@ -20,6 +20,9 @@ watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
|
|
|
20
20
|
const subheading = ref('');
|
|
21
21
|
const description = ref('');
|
|
22
22
|
const rules = ref('');
|
|
23
|
+
const descriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
24
|
+
const rulesFormat = ref<'markdown' | 'html'>('markdown');
|
|
25
|
+
const prizesDescriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
23
26
|
const bannerUrl = ref('');
|
|
24
27
|
const coverImageUrl = ref('');
|
|
25
28
|
const startDate = ref('');
|
|
@@ -110,6 +113,9 @@ async function handleCreate(): Promise<void> {
|
|
|
110
113
|
subheading: subheading.value || undefined,
|
|
111
114
|
description: description.value || undefined,
|
|
112
115
|
rules: rules.value || undefined,
|
|
116
|
+
descriptionFormat: descriptionFormat.value,
|
|
117
|
+
rulesFormat: rulesFormat.value,
|
|
118
|
+
prizesDescriptionFormat: prizesDescriptionFormat.value,
|
|
113
119
|
bannerUrl: bannerUrl.value || undefined,
|
|
114
120
|
coverImageUrl: coverImageUrl.value || undefined,
|
|
115
121
|
startDate: new Date(startDate.value).toISOString(),
|
|
@@ -186,14 +192,20 @@ function prizeLabel(prize: Prize): string {
|
|
|
186
192
|
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
187
193
|
</div>
|
|
188
194
|
<div class="cpub-form-field">
|
|
189
|
-
<
|
|
195
|
+
<div class="cpub-field-head">
|
|
196
|
+
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
197
|
+
<FormatToggle v-model="descriptionFormat" />
|
|
198
|
+
</div>
|
|
190
199
|
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
|
|
191
|
-
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
200
|
+
<p class="cpub-form-hint">{{ descriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
|
|
192
201
|
</div>
|
|
193
202
|
<div class="cpub-form-field">
|
|
194
|
-
<
|
|
203
|
+
<div class="cpub-field-head">
|
|
204
|
+
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
205
|
+
<FormatToggle v-model="rulesFormat" />
|
|
206
|
+
</div>
|
|
195
207
|
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="Contest rules and requirements. Supports Markdown, one rule per line, or full Markdown." />
|
|
196
|
-
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text
|
|
208
|
+
<p class="cpub-form-hint">{{ rulesFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown. Plain one-rule-per-line text renders as a list.' }}</p>
|
|
197
209
|
</div>
|
|
198
210
|
<div class="cpub-form-field">
|
|
199
211
|
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
|
|
@@ -335,9 +347,12 @@ function prizeLabel(prize: Prize): string {
|
|
|
335
347
|
|
|
336
348
|
<p class="cpub-form-hint">Contests don't need prizes, leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
337
349
|
<div class="cpub-form-field">
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
350
|
+
<div class="cpub-field-head">
|
|
351
|
+
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
352
|
+
<FormatToggle v-model="prizesDescriptionFormat" />
|
|
353
|
+
</div>
|
|
354
|
+
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards." />
|
|
355
|
+
<p class="cpub-form-hint">{{ prizesDescriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Markdown intro' }} displayed on the Prizes tab, above the individual prizes.</p>
|
|
341
356
|
</div>
|
|
342
357
|
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
343
358
|
<div class="cpub-prize-header">
|
|
@@ -404,6 +419,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
404
419
|
.cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
|
|
405
420
|
|
|
406
421
|
.cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
|
|
422
|
+
.cpub-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
407
423
|
.cpub-form-field:last-child { margin-bottom: 0; }
|
|
408
424
|
.cpub-form-input, .cpub-form-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
409
425
|
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|