@commonpub/layer 0.83.2 → 0.85.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/contest/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBannerAdjust.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +94 -132
- package/components/contest/ContestHero.vue +5 -1
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +207 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +374 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +40 -4
- package/package.json +9 -9
- package/pages/contests/[slug]/index.vue +4 -1
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +35 -0
- package/utils/contestStages.ts +80 -51
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +119 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestStageTemplateEditor — the per-stage SUBMISSION FORM builder, extracted
|
|
4
|
+
* from ContestStagesEditor so the (heaviest, flag-gated) part of the stage card is
|
|
5
|
+
* its own cohesive unit. Operates on ONE stage's `submissionTemplate` array and
|
|
6
|
+
* emits the whole new array (`update:template`); the pure array ops live in
|
|
7
|
+
* utils/contestStages.ts. P2 added (a) one-click field PRESETS, (b) whole-form
|
|
8
|
+
* TEMPLATES, and (c) a block INTRO rendered above the fields on the public form.
|
|
9
|
+
*
|
|
10
|
+
* The intro is authored as markdown but STORED as `instructionsBlocks` (BlockTuple[]
|
|
11
|
+
* — the same shape the contest bodies use), so it renders through BlockContentRenderer
|
|
12
|
+
* identically in this preview and on the public submission form. Full drag-drop block
|
|
13
|
+
* editing of the intro is deferred (markdown + live preview is enough, same call the
|
|
14
|
+
* plan made for agreement terms); the storage already supports upgrading later.
|
|
15
|
+
*
|
|
16
|
+
* The agreement/address field types + the per-field PII toggle are gated behind
|
|
17
|
+
* `features.contestPii` (rule #2); PII *access* is always gated server-side by the
|
|
18
|
+
* `contest.pii` permission regardless.
|
|
19
|
+
*/
|
|
20
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
21
|
+
import { markdownToBlockTuples, blockTuplesToMarkdown, type BlockTuple } from '@commonpub/editor';
|
|
22
|
+
import {
|
|
23
|
+
availableFieldPresets,
|
|
24
|
+
availableFormTemplates,
|
|
25
|
+
templatePresetAdded,
|
|
26
|
+
type FieldPreset,
|
|
27
|
+
type SubmissionFormTemplate,
|
|
28
|
+
} from '../../utils/contestSubmissionTemplates';
|
|
29
|
+
|
|
30
|
+
const props = defineProps<{
|
|
31
|
+
template: ContestSubmissionTemplateField[];
|
|
32
|
+
/** This stage's block intro (rendered above the fields on the public form). */
|
|
33
|
+
instructions?: BlockTuple[];
|
|
34
|
+
}>();
|
|
35
|
+
const emit = defineEmits<{
|
|
36
|
+
'update:template': [template: ContestSubmissionTemplateField[]];
|
|
37
|
+
'update:instructions': [blocks: BlockTuple[]];
|
|
38
|
+
}>();
|
|
39
|
+
|
|
40
|
+
const { features } = useFeatures();
|
|
41
|
+
const piiEnabled = computed(() => features.value.contestPii === true);
|
|
42
|
+
const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
|
|
43
|
+
const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
|
|
44
|
+
if (piiEnabled.value) base.push('agreement', 'address');
|
|
45
|
+
return base;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const fieldPresets = computed(() => availableFieldPresets(piiEnabled.value));
|
|
49
|
+
const formTemplates = computed(() => availableFormTemplates(piiEnabled.value));
|
|
50
|
+
|
|
51
|
+
// ─── Two small dropdown menus (Add field · Use a template) ───
|
|
52
|
+
const menuWrap = ref<HTMLElement | null>(null);
|
|
53
|
+
const openMenu = ref<'add' | 'template' | null>(null);
|
|
54
|
+
function toggleMenu(which: 'add' | 'template'): void {
|
|
55
|
+
openMenu.value = openMenu.value === which ? null : which;
|
|
56
|
+
}
|
|
57
|
+
function closeMenu(): void {
|
|
58
|
+
openMenu.value = null;
|
|
59
|
+
}
|
|
60
|
+
function onDocPointer(e: PointerEvent): void {
|
|
61
|
+
if (openMenu.value && menuWrap.value && !menuWrap.value.contains(e.target as Node)) closeMenu();
|
|
62
|
+
}
|
|
63
|
+
function onDocKey(e: KeyboardEvent): void {
|
|
64
|
+
if (e.key === 'Escape' && openMenu.value) closeMenu();
|
|
65
|
+
}
|
|
66
|
+
onMounted(() => {
|
|
67
|
+
document.addEventListener('pointerdown', onDocPointer);
|
|
68
|
+
document.addEventListener('keydown', onDocKey);
|
|
69
|
+
});
|
|
70
|
+
onUnmounted(() => {
|
|
71
|
+
document.removeEventListener('pointerdown', onDocPointer);
|
|
72
|
+
document.removeEventListener('keydown', onDocKey);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function addPreset(preset: FieldPreset): void {
|
|
76
|
+
emit('update:template', templatePresetAdded(props.template, preset));
|
|
77
|
+
closeMenu();
|
|
78
|
+
}
|
|
79
|
+
function applyFormTemplate(tpl: SubmissionFormTemplate): void {
|
|
80
|
+
closeMenu();
|
|
81
|
+
// Replacing a non-empty form is destructive — confirm before clobbering.
|
|
82
|
+
if (props.template.length && typeof window !== 'undefined' && !window.confirm(`Replace the current ${props.template.length} field(s) with the "${tpl.label}" template?`)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
emit('update:template', tpl.build({ pii: piiEnabled.value }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Per-field edits (delegate to the pure array ops) ───
|
|
89
|
+
function labelInput(fi: number, e: Event): void {
|
|
90
|
+
emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
|
|
91
|
+
}
|
|
92
|
+
function setField(fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
|
|
93
|
+
emit('update:template', templateFieldSet(props.template, fi, patch));
|
|
94
|
+
}
|
|
95
|
+
function changeType(fi: number, type: ContestSubmissionTemplateField['type']): void {
|
|
96
|
+
emit('update:template', templateFieldTypeChanged(props.template, fi, type));
|
|
97
|
+
}
|
|
98
|
+
function removeField(fi: number): void {
|
|
99
|
+
emit('update:template', templateFieldRemoved(props.template, fi));
|
|
100
|
+
}
|
|
101
|
+
function addOption(fi: number): void {
|
|
102
|
+
emit('update:template', templateOptionAdded(props.template, fi));
|
|
103
|
+
}
|
|
104
|
+
function setOption(fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
|
|
105
|
+
emit('update:template', templateOptionSet(props.template, fi, oi, patch));
|
|
106
|
+
}
|
|
107
|
+
function removeOption(fi: number, oi: number): void {
|
|
108
|
+
emit('update:template', templateOptionRemoved(props.template, fi, oi));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Block intro (markdown ⇄ BlockTuple[]) ───
|
|
112
|
+
const showIntro = ref((props.instructions?.length ?? 0) > 0);
|
|
113
|
+
const introText = ref(blockTuplesToMarkdown(props.instructions ?? []));
|
|
114
|
+
const introPreview = computed<BlockTuple[]>(() => markdownToBlockTuples(introText.value));
|
|
115
|
+
// Re-sync only on a GENUINELY external change (a form-template reset, a reorder
|
|
116
|
+
// reusing this instance). Our own keystroke emits the same blocks straight back;
|
|
117
|
+
// re-deriving markdown from them isn't char-exact (round-trip normalisation), so a
|
|
118
|
+
// naive `md !== introText` resync would fight the caret. Compare BLOCKS instead:
|
|
119
|
+
// if the incoming blocks already match what our current text produces, it's our
|
|
120
|
+
// echo — skip.
|
|
121
|
+
watch(
|
|
122
|
+
() => props.instructions,
|
|
123
|
+
(b) => {
|
|
124
|
+
const incoming = JSON.stringify(b ?? []);
|
|
125
|
+
if (incoming === JSON.stringify(introPreview.value)) return; // our own echo
|
|
126
|
+
introText.value = blockTuplesToMarkdown(b ?? []);
|
|
127
|
+
if ((b?.length ?? 0) > 0) showIntro.value = true;
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
function onIntroInput(e: Event): void {
|
|
131
|
+
introText.value = (e.target as HTMLTextAreaElement).value;
|
|
132
|
+
emit('update:instructions', introText.value.trim() ? introPreview.value : []);
|
|
133
|
+
}
|
|
134
|
+
function toggleIntro(): void {
|
|
135
|
+
showIntro.value = !showIntro.value;
|
|
136
|
+
if (!showIntro.value && introText.value.trim()) {
|
|
137
|
+
introText.value = '';
|
|
138
|
+
emit('update:instructions', []);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<template>
|
|
144
|
+
<div class="cpub-stage-criteria">
|
|
145
|
+
<div class="cpub-stage-criteria-head">
|
|
146
|
+
<span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
|
|
147
|
+
<div ref="menuWrap" class="cpub-stf-menus">
|
|
148
|
+
<!-- Use a template -->
|
|
149
|
+
<div class="cpub-stf-menu">
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
class="cpub-btn cpub-btn-sm"
|
|
153
|
+
aria-haspopup="menu"
|
|
154
|
+
:aria-expanded="openMenu === 'template'"
|
|
155
|
+
@click="toggleMenu('template')"
|
|
156
|
+
>
|
|
157
|
+
<i class="fa-solid fa-wand-magic-sparkles"></i> Use a template <i class="fa-solid fa-chevron-down"></i>
|
|
158
|
+
</button>
|
|
159
|
+
<div v-if="openMenu === 'template'" class="cpub-stf-dropdown" role="menu" aria-label="Submission form templates">
|
|
160
|
+
<button
|
|
161
|
+
v-for="tpl in formTemplates"
|
|
162
|
+
:key="tpl.id"
|
|
163
|
+
type="button"
|
|
164
|
+
role="menuitem"
|
|
165
|
+
class="cpub-stf-item"
|
|
166
|
+
@click="applyFormTemplate(tpl)"
|
|
167
|
+
>
|
|
168
|
+
<span class="cpub-stf-item-label">{{ tpl.label }}</span>
|
|
169
|
+
<span class="cpub-stf-item-desc">{{ tpl.description }}</span>
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<!-- Add a field (presets) -->
|
|
174
|
+
<div class="cpub-stf-menu">
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
class="cpub-btn cpub-btn-sm"
|
|
178
|
+
aria-haspopup="menu"
|
|
179
|
+
:aria-expanded="openMenu === 'add'"
|
|
180
|
+
@click="toggleMenu('add')"
|
|
181
|
+
>
|
|
182
|
+
<i class="fa-solid fa-plus"></i> Add field <i class="fa-solid fa-chevron-down"></i>
|
|
183
|
+
</button>
|
|
184
|
+
<div v-if="openMenu === 'add'" class="cpub-stf-dropdown" role="menu" aria-label="Field presets">
|
|
185
|
+
<button
|
|
186
|
+
v-for="preset in fieldPresets"
|
|
187
|
+
:key="preset.id"
|
|
188
|
+
type="button"
|
|
189
|
+
role="menuitem"
|
|
190
|
+
class="cpub-stf-item cpub-stf-item-row"
|
|
191
|
+
@click="addPreset(preset)"
|
|
192
|
+
>
|
|
193
|
+
<i class="fa-solid cpub-stf-item-icon" :class="preset.icon"></i>
|
|
194
|
+
<span class="cpub-stf-item-label">{{ preset.label }}</span>
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
<p class="cpub-form-hint" style="margin: 4px 0;">Optional. Add fields entrants must fill for this stage (e.g. a proposal summary, or a repository link for a prototype round). Leave empty if entering a project is enough.</p>
|
|
201
|
+
|
|
202
|
+
<!-- Block intro: rich instructions shown above the fields on the public form. -->
|
|
203
|
+
<div class="cpub-stf-intro">
|
|
204
|
+
<label class="cpub-stage-tfield-req">
|
|
205
|
+
<input type="checkbox" :checked="showIntro" aria-label="Add instructions above the form" @change="toggleIntro" />
|
|
206
|
+
<span>Add instructions above the form</span>
|
|
207
|
+
</label>
|
|
208
|
+
<div v-if="showIntro" class="cpub-stf-intro-edit">
|
|
209
|
+
<textarea
|
|
210
|
+
:value="introText"
|
|
211
|
+
class="cpub-form-input cpub-form-textarea"
|
|
212
|
+
rows="3"
|
|
213
|
+
placeholder="Markdown instructions shown above the form (what to submit, tips, links)."
|
|
214
|
+
aria-label="Form instructions (markdown)"
|
|
215
|
+
@input="onIntroInput"
|
|
216
|
+
></textarea>
|
|
217
|
+
<div v-if="introPreview.length" class="cpub-stf-intro-preview">
|
|
218
|
+
<span class="cpub-form-hint" style="margin: 0 0 4px;">Preview</span>
|
|
219
|
+
<BlocksBlockContentRenderer :blocks="introPreview" class="cpub-prose cpub-md" />
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
|
|
225
|
+
<div class="cpub-stage-tfield-main">
|
|
226
|
+
<input
|
|
227
|
+
:value="tf.label"
|
|
228
|
+
type="text"
|
|
229
|
+
class="cpub-form-input"
|
|
230
|
+
placeholder="Field label (e.g. Repository URL)"
|
|
231
|
+
:aria-label="`Field ${fi + 1} label`"
|
|
232
|
+
@input="labelInput(fi, $event)"
|
|
233
|
+
/>
|
|
234
|
+
<select
|
|
235
|
+
:value="tf.type"
|
|
236
|
+
class="cpub-form-input cpub-stage-tfield-type"
|
|
237
|
+
:aria-label="`Field ${fi + 1} type`"
|
|
238
|
+
@change="changeType(fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
|
|
239
|
+
>
|
|
240
|
+
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
|
|
241
|
+
</select>
|
|
242
|
+
<label class="cpub-stage-tfield-req">
|
|
243
|
+
<input
|
|
244
|
+
type="checkbox"
|
|
245
|
+
:checked="tf.required"
|
|
246
|
+
:aria-label="`Field ${fi + 1} required`"
|
|
247
|
+
@change="setField(fi, { required: ($event.target as HTMLInputElement).checked })"
|
|
248
|
+
/>
|
|
249
|
+
<span>Required</span>
|
|
250
|
+
</label>
|
|
251
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove field" @click="removeField(fi)"><i class="fa-solid fa-xmark"></i></button>
|
|
252
|
+
</div>
|
|
253
|
+
<input
|
|
254
|
+
:value="tf.help ?? ''"
|
|
255
|
+
type="text"
|
|
256
|
+
class="cpub-form-input cpub-stage-tfield-help"
|
|
257
|
+
placeholder="Hint shown under the input (optional)"
|
|
258
|
+
:aria-label="`Field ${fi + 1} hint`"
|
|
259
|
+
@input="setField(fi, { help: ($event.target as HTMLInputElement).value || undefined })"
|
|
260
|
+
/>
|
|
261
|
+
|
|
262
|
+
<!-- select: the allowed options -->
|
|
263
|
+
<div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
|
|
264
|
+
<span class="cpub-form-hint" style="margin: 0;">Choices</span>
|
|
265
|
+
<div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
|
|
266
|
+
<input
|
|
267
|
+
:value="opt.label"
|
|
268
|
+
type="text"
|
|
269
|
+
class="cpub-form-input"
|
|
270
|
+
placeholder="Label (shown to entrants)"
|
|
271
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} label`"
|
|
272
|
+
@input="setOption(fi, oi, { label: ($event.target as HTMLInputElement).value })"
|
|
273
|
+
/>
|
|
274
|
+
<input
|
|
275
|
+
:value="opt.value"
|
|
276
|
+
type="text"
|
|
277
|
+
class="cpub-form-input"
|
|
278
|
+
placeholder="Value (stored)"
|
|
279
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} value`"
|
|
280
|
+
@input="setOption(fi, oi, { value: ($event.target as HTMLInputElement).value })"
|
|
281
|
+
/>
|
|
282
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(fi, oi)"><i class="fa-solid fa-xmark"></i></button>
|
|
283
|
+
</div>
|
|
284
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<!-- agreement: terms the entrant must accept -->
|
|
288
|
+
<div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
|
|
289
|
+
<textarea
|
|
290
|
+
:value="tf.terms ?? ''"
|
|
291
|
+
class="cpub-form-input cpub-form-textarea"
|
|
292
|
+
rows="3"
|
|
293
|
+
placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
|
|
294
|
+
:aria-label="`Field ${fi + 1} agreement terms`"
|
|
295
|
+
@input="setField(fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
|
|
296
|
+
></textarea>
|
|
297
|
+
<label class="cpub-stage-tfield-req">
|
|
298
|
+
<input
|
|
299
|
+
type="checkbox"
|
|
300
|
+
:checked="tf.mustAccept !== false"
|
|
301
|
+
:aria-label="`Field ${fi + 1} must accept`"
|
|
302
|
+
@change="setField(fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
|
|
303
|
+
/>
|
|
304
|
+
<span>Must accept to submit</span>
|
|
305
|
+
</label>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<!-- address: structured + always personal data -->
|
|
309
|
+
<p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
|
|
310
|
+
Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
|
|
311
|
+
</p>
|
|
312
|
+
|
|
313
|
+
<!-- PII toggle (non-address, non-agreement scalar fields) -->
|
|
314
|
+
<label
|
|
315
|
+
v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
|
|
316
|
+
class="cpub-stage-tfield-req cpub-stage-tfield-pii"
|
|
317
|
+
>
|
|
318
|
+
<input
|
|
319
|
+
type="checkbox"
|
|
320
|
+
:checked="tf.pii === true"
|
|
321
|
+
:aria-label="`Field ${fi + 1} is personal data`"
|
|
322
|
+
@change="setField(fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
|
|
323
|
+
/>
|
|
324
|
+
<span>Personal data (store privately, hide from the public listing)</span>
|
|
325
|
+
</label>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</template>
|
|
329
|
+
|
|
330
|
+
<style scoped>
|
|
331
|
+
/* Scoped CSS doesn't cross component boundaries — the form-control + template-field
|
|
332
|
+
styles travel with this extracted markup (the global theme only provides
|
|
333
|
+
.cpub-form-label/.cpub-form-hint/.cpub-btn). */
|
|
334
|
+
.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); }
|
|
335
|
+
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
336
|
+
.cpub-form-textarea { resize: vertical; }
|
|
337
|
+
|
|
338
|
+
.cpub-stage-iconbtn { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }
|
|
339
|
+
.cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
340
|
+
.cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
341
|
+
|
|
342
|
+
.cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
|
|
343
|
+
.cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
|
344
|
+
|
|
345
|
+
/* Add-field / template dropdown menus */
|
|
346
|
+
.cpub-stf-menus { display: flex; gap: 6px; }
|
|
347
|
+
.cpub-stf-menu { position: relative; }
|
|
348
|
+
.cpub-stf-dropdown { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 220px; max-height: 320px; overflow-y: auto; background: var(--surface); border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); display: flex; flex-direction: column; }
|
|
349
|
+
.cpub-stf-item { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 8px 10px; background: transparent; border: none; border-bottom: var(--border-width-default) solid var(--border2); cursor: pointer; color: var(--text); }
|
|
350
|
+
.cpub-stf-item:last-child { border-bottom: none; }
|
|
351
|
+
.cpub-stf-item:hover { background: var(--accent-bg); }
|
|
352
|
+
.cpub-stf-item-row { flex-direction: row; align-items: center; gap: 8px; }
|
|
353
|
+
.cpub-stf-item-icon { color: var(--accent); width: 16px; text-align: center; }
|
|
354
|
+
.cpub-stf-item-label { font-size: var(--text-sm); font-weight: 600; }
|
|
355
|
+
.cpub-stf-item-desc { font-size: var(--text-xs); color: var(--text-faint); line-height: 1.4; }
|
|
356
|
+
|
|
357
|
+
/* Block intro */
|
|
358
|
+
.cpub-stf-intro { margin: 8px 0; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); }
|
|
359
|
+
.cpub-stf-intro-edit { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
|
|
360
|
+
.cpub-stf-intro-preview { border-top: var(--border-width-default) dashed var(--border2); padding-top: 8px; }
|
|
361
|
+
|
|
362
|
+
.cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
|
|
363
|
+
.cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
|
|
364
|
+
.cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
365
|
+
.cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
|
|
366
|
+
.cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
|
|
367
|
+
.cpub-stage-tfield-req { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; flex-shrink: 0; }
|
|
368
|
+
.cpub-stage-tfield-req input { width: 13px; height: 13px; }
|
|
369
|
+
.cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
|
|
370
|
+
.cpub-stage-tfield-extra { margin-top: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); display: flex; flex-direction: column; gap: 6px; }
|
|
371
|
+
.cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
|
|
372
|
+
.cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
|
|
373
|
+
.cpub-stage-tfield-pii { margin-top: 6px; }
|
|
374
|
+
</style>
|