@commonpub/editor 0.5.0 → 0.6.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/README.md +43 -10
- package/package.json +16 -4
- package/vue/components/BlockCanvas.vue +512 -0
- package/vue/components/BlockInsertZone.vue +86 -0
- package/vue/components/BlockPicker.vue +274 -0
- package/vue/components/BlockWrapper.vue +188 -0
- package/vue/components/EditorBlocks.vue +235 -0
- package/vue/components/EditorSection.vue +81 -0
- package/vue/components/EditorShell.vue +198 -0
- package/vue/components/EditorTagInput.vue +116 -0
- package/vue/components/EditorVisibility.vue +110 -0
- package/vue/components/blocks/BuildStepBlock.vue +103 -0
- package/vue/components/blocks/CalloutBlock.vue +123 -0
- package/vue/components/blocks/CheckpointBlock.vue +29 -0
- package/vue/components/blocks/CodeBlock.vue +178 -0
- package/vue/components/blocks/DividerBlock.vue +22 -0
- package/vue/components/blocks/DownloadsBlock.vue +43 -0
- package/vue/components/blocks/EmbedBlock.vue +22 -0
- package/vue/components/blocks/GalleryBlock.vue +235 -0
- package/vue/components/blocks/HeadingBlock.vue +97 -0
- package/vue/components/blocks/ImageBlock.vue +272 -0
- package/vue/components/blocks/MarkdownBlock.vue +259 -0
- package/vue/components/blocks/MathBlock.vue +39 -0
- package/vue/components/blocks/PartsListBlock.vue +354 -0
- package/vue/components/blocks/QuizBlock.vue +49 -0
- package/vue/components/blocks/QuoteBlock.vue +102 -0
- package/vue/components/blocks/SectionHeaderBlock.vue +132 -0
- package/vue/components/blocks/SliderBlock.vue +320 -0
- package/vue/components/blocks/TextBlock.vue +202 -0
- package/vue/components/blocks/ToolListBlock.vue +71 -0
- package/vue/components/blocks/VideoBlock.vue +24 -0
- package/vue/composables/useBlockEditor.ts +190 -0
- package/vue/index.ts +59 -0
- package/vue/provide.ts +28 -0
- package/vue/types.ts +40 -0
- package/vue/utils.ts +12 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Visibility radio group for editor panels.
|
|
4
|
+
* Reused across Article, Project, Explainer editors.
|
|
5
|
+
*/
|
|
6
|
+
defineProps<{
|
|
7
|
+
modelValue: string;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
'update:modelValue': [value: string];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const options = [
|
|
15
|
+
{ value: 'public', label: 'Public', icon: 'fa-globe', desc: 'Visible to everyone' },
|
|
16
|
+
{ value: 'members', label: 'Members', icon: 'fa-users', desc: 'Hub members only' },
|
|
17
|
+
{ value: 'private', label: 'Private', icon: 'fa-lock', desc: 'Only you' },
|
|
18
|
+
];
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<div class="cpub-visibility-group" role="radiogroup" aria-label="Content visibility">
|
|
23
|
+
<label
|
|
24
|
+
v-for="opt in options"
|
|
25
|
+
:key="opt.value"
|
|
26
|
+
class="cpub-visibility-option"
|
|
27
|
+
:class="{ selected: modelValue === opt.value }"
|
|
28
|
+
>
|
|
29
|
+
<input
|
|
30
|
+
type="radio"
|
|
31
|
+
:value="opt.value"
|
|
32
|
+
:checked="modelValue === opt.value"
|
|
33
|
+
class="cpub-sr-only"
|
|
34
|
+
@change="emit('update:modelValue', opt.value)"
|
|
35
|
+
/>
|
|
36
|
+
<i :class="['fa-solid', opt.icon, 'cpub-vis-icon']"></i>
|
|
37
|
+
<div class="cpub-vis-text">
|
|
38
|
+
<span class="cpub-vis-label">{{ opt.label }}</span>
|
|
39
|
+
<span class="cpub-vis-desc">{{ opt.desc }}</span>
|
|
40
|
+
</div>
|
|
41
|
+
</label>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style scoped>
|
|
46
|
+
.cpub-visibility-group {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
gap: 4px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cpub-visibility-option {
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 10px;
|
|
56
|
+
padding: 8px 10px;
|
|
57
|
+
border: var(--border-width-default) solid var(--border2);
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
transition: border-color 0.1s, background 0.1s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.cpub-visibility-option:hover {
|
|
63
|
+
border-color: var(--border);
|
|
64
|
+
background: var(--surface2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.cpub-visibility-option.selected {
|
|
68
|
+
border-color: var(--accent);
|
|
69
|
+
background: var(--accent-bg);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.cpub-vis-icon {
|
|
73
|
+
font-size: 12px;
|
|
74
|
+
color: var(--text-faint);
|
|
75
|
+
width: 16px;
|
|
76
|
+
text-align: center;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.cpub-visibility-option.selected .cpub-vis-icon {
|
|
80
|
+
color: var(--accent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.cpub-vis-text {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
gap: 1px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.cpub-vis-label {
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
font-weight: 500;
|
|
92
|
+
color: var(--text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.cpub-vis-desc {
|
|
96
|
+
font-size: 10px;
|
|
97
|
+
color: var(--text-faint);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.cpub-sr-only {
|
|
101
|
+
position: absolute;
|
|
102
|
+
width: 1px;
|
|
103
|
+
height: 1px;
|
|
104
|
+
padding: 0;
|
|
105
|
+
margin: -1px;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
clip: rect(0, 0, 0, 0);
|
|
108
|
+
border: 0;
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Build step block — numbered step with title and instruction text.
|
|
4
|
+
*/
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
content: Record<string, unknown>;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const emit = defineEmits<{
|
|
11
|
+
update: [content: Record<string, unknown>];
|
|
12
|
+
}>();
|
|
13
|
+
|
|
14
|
+
const stepNumber = computed(() => (props.content.stepNumber as number) ?? 1);
|
|
15
|
+
const title = computed(() => (props.content.title as string) ?? '');
|
|
16
|
+
const instructions = computed(() => (props.content.instructions as string) ?? '');
|
|
17
|
+
const time = computed(() => (props.content.time as string) ?? '');
|
|
18
|
+
|
|
19
|
+
function updateField(field: string, value: unknown): void {
|
|
20
|
+
emit('update', { ...props.content, [field]: value });
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="cpub-step-block">
|
|
26
|
+
<div class="cpub-step-header">
|
|
27
|
+
<div class="cpub-step-num">{{ stepNumber }}</div>
|
|
28
|
+
<input
|
|
29
|
+
class="cpub-step-title"
|
|
30
|
+
type="text"
|
|
31
|
+
:value="title"
|
|
32
|
+
placeholder="Step title..."
|
|
33
|
+
@input="updateField('title', ($event.target as HTMLInputElement).value)"
|
|
34
|
+
/>
|
|
35
|
+
<input
|
|
36
|
+
class="cpub-step-time"
|
|
37
|
+
type="text"
|
|
38
|
+
:value="time"
|
|
39
|
+
placeholder="Time"
|
|
40
|
+
@input="updateField('time', ($event.target as HTMLInputElement).value)"
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="cpub-step-body">
|
|
44
|
+
<textarea
|
|
45
|
+
class="cpub-step-instructions"
|
|
46
|
+
:value="instructions"
|
|
47
|
+
placeholder="Describe this step..."
|
|
48
|
+
rows="4"
|
|
49
|
+
@input="updateField('instructions', ($event.target as HTMLTextAreaElement).value)"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.cpub-step-block {
|
|
57
|
+
border: var(--border-width-default) solid var(--accent-border);
|
|
58
|
+
border-left: 4px solid var(--accent);
|
|
59
|
+
background: var(--surface);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.cpub-step-header {
|
|
63
|
+
display: flex; align-items: center; gap: 12px;
|
|
64
|
+
padding: 12px 16px;
|
|
65
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
66
|
+
background: var(--accent-bg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.cpub-step-num {
|
|
70
|
+
width: 32px; height: 32px;
|
|
71
|
+
background: var(--accent); color: var(--color-text-inverse);
|
|
72
|
+
font-family: var(--font-mono); font-size: 14px; font-weight: 700;
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
display: flex; align-items: center; justify-content: center;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.cpub-step-title {
|
|
79
|
+
flex: 1; font-size: 14px; font-weight: 600;
|
|
80
|
+
background: transparent; border: none; outline: none;
|
|
81
|
+
color: var(--text);
|
|
82
|
+
}
|
|
83
|
+
.cpub-step-title::placeholder { color: var(--text-faint); }
|
|
84
|
+
|
|
85
|
+
.cpub-step-time {
|
|
86
|
+
width: 80px; font-family: var(--font-mono); font-size: 10px;
|
|
87
|
+
background: transparent; border: var(--border-width-default) solid var(--border2);
|
|
88
|
+
padding: 3px 6px; color: var(--text-dim); outline: none;
|
|
89
|
+
text-align: center;
|
|
90
|
+
}
|
|
91
|
+
.cpub-step-time:focus { border-color: var(--accent); }
|
|
92
|
+
.cpub-step-time::placeholder { color: var(--text-faint); }
|
|
93
|
+
|
|
94
|
+
.cpub-step-body { padding: 14px 16px; }
|
|
95
|
+
|
|
96
|
+
.cpub-step-instructions {
|
|
97
|
+
width: 100%; font-size: 13px; line-height: 1.65;
|
|
98
|
+
background: transparent; border: none; outline: none;
|
|
99
|
+
color: var(--text); resize: vertical;
|
|
100
|
+
font-family: var(--font-sans, system-ui);
|
|
101
|
+
}
|
|
102
|
+
.cpub-step-instructions::placeholder { color: var(--text-faint); }
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Callout block — variant picker (info/tip/warning/danger) + editable body.
|
|
4
|
+
*/
|
|
5
|
+
import { computed } from 'vue';
|
|
6
|
+
import { sanitizeBlockHtml } from '../../utils.js';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
content: Record<string, unknown>;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const emit = defineEmits<{
|
|
13
|
+
update: [content: Record<string, unknown>];
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const html = computed(() => (props.content.html as string) ?? '');
|
|
17
|
+
const variant = computed(() => (props.content.variant as string) ?? 'info');
|
|
18
|
+
|
|
19
|
+
const variants = [
|
|
20
|
+
{ value: 'info', icon: 'fa-circle-info', label: 'Info', color: 'var(--accent)' },
|
|
21
|
+
{ value: 'tip', icon: 'fa-lightbulb', label: 'Tip', color: 'var(--green)' },
|
|
22
|
+
{ value: 'warning', icon: 'fa-triangle-exclamation', label: 'Warning', color: 'var(--yellow)' },
|
|
23
|
+
{ value: 'danger', icon: 'fa-circle-exclamation', label: 'Danger', color: 'var(--red)' },
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
const currentVariant = computed(() => variants.find((v) => v.value === variant.value) ?? variants[0]);
|
|
27
|
+
|
|
28
|
+
function cycleVariant(): void {
|
|
29
|
+
const idx = variants.findIndex((v) => v.value === variant.value);
|
|
30
|
+
const next = variants[(idx + 1) % variants.length]!;
|
|
31
|
+
emit('update', { html: html.value, variant: next.value });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function onBodyInput(event: Event): void {
|
|
35
|
+
const el = event.target as HTMLElement;
|
|
36
|
+
emit('update', { html: sanitizeBlockHtml(el.innerHTML), variant: variant.value });
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div class="cpub-callout-block" :class="`cpub-callout--${variant}`">
|
|
42
|
+
<button
|
|
43
|
+
class="cpub-callout-icon-btn"
|
|
44
|
+
:title="`${currentVariant.label} — click to change`"
|
|
45
|
+
:style="{ color: currentVariant.color }"
|
|
46
|
+
@click="cycleVariant"
|
|
47
|
+
>
|
|
48
|
+
<i :class="['fa-solid', currentVariant.icon]"></i>
|
|
49
|
+
</button>
|
|
50
|
+
<div class="cpub-callout-body">
|
|
51
|
+
<div class="cpub-callout-label" :style="{ color: currentVariant.color }">{{ currentVariant.label }}</div>
|
|
52
|
+
<div
|
|
53
|
+
class="cpub-callout-text"
|
|
54
|
+
contenteditable="true"
|
|
55
|
+
data-placeholder="Callout text..."
|
|
56
|
+
@input="onBodyInput"
|
|
57
|
+
v-html="html"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<style scoped>
|
|
64
|
+
.cpub-callout-block {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 12px;
|
|
67
|
+
padding: 14px 16px;
|
|
68
|
+
border: var(--border-width-default) solid var(--border2);
|
|
69
|
+
border-left-width: 5px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.cpub-callout--info { border-left-color: var(--accent); background: var(--accent-bg); }
|
|
73
|
+
.cpub-callout--tip { border-left-color: var(--green); background: var(--green-bg); }
|
|
74
|
+
.cpub-callout--warning { border-left-color: var(--yellow); background: var(--yellow-bg); }
|
|
75
|
+
.cpub-callout--danger { border-left-color: var(--red); background: var(--red-bg); }
|
|
76
|
+
|
|
77
|
+
.cpub-callout-icon-btn {
|
|
78
|
+
width: 28px;
|
|
79
|
+
height: 28px;
|
|
80
|
+
background: transparent;
|
|
81
|
+
border: none;
|
|
82
|
+
font-size: 14px;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
flex-shrink: 0;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
padding: 0;
|
|
89
|
+
transition: transform 0.15s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.cpub-callout-icon-btn:hover {
|
|
93
|
+
transform: scale(1.15);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.cpub-callout-body {
|
|
97
|
+
flex: 1;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.cpub-callout-label {
|
|
102
|
+
font-family: var(--font-mono);
|
|
103
|
+
font-size: 10px;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
text-transform: uppercase;
|
|
106
|
+
letter-spacing: 0.1em;
|
|
107
|
+
margin-bottom: 4px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.cpub-callout-text {
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
line-height: 1.65;
|
|
113
|
+
color: var(--text);
|
|
114
|
+
outline: none;
|
|
115
|
+
min-height: 1.65em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.cpub-callout-text:empty::before {
|
|
119
|
+
content: attr(data-placeholder);
|
|
120
|
+
color: var(--text-faint);
|
|
121
|
+
pointer-events: none;
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
5
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
6
|
+
|
|
7
|
+
const label = computed(() => (props.content.label as string) ?? '');
|
|
8
|
+
|
|
9
|
+
function updateLabel(value: string): void {
|
|
10
|
+
emit('update', { ...props.content, label: value });
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="cpub-checkpoint-edit">
|
|
16
|
+
<div class="cpub-checkpoint-edit-header"><i class="fa-solid fa-circle-check"></i> Checkpoint</div>
|
|
17
|
+
<div class="cpub-checkpoint-edit-body">
|
|
18
|
+
<input class="cpub-checkpoint-input" :value="label" placeholder="e.g. Section 1 complete — you understand backpropagation!" @input="updateLabel(($event.target as HTMLInputElement).value)" />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.cpub-checkpoint-edit { border: var(--border-width-default) solid var(--green-border); background: var(--surface); }
|
|
25
|
+
.cpub-checkpoint-edit-header { padding: 8px 12px; font-size: 12px; font-weight: 600; background: var(--green-bg); border-bottom: var(--border-width-default) solid var(--green-border); display: flex; align-items: center; gap: 8px; color: var(--green); }
|
|
26
|
+
.cpub-checkpoint-edit-body { padding: 12px; }
|
|
27
|
+
.cpub-checkpoint-input { width: 100%; font-size: 13px; background: transparent; border: none; border-bottom: var(--border-width-default) solid var(--border2); padding: 6px 0; outline: none; color: var(--text); }
|
|
28
|
+
.cpub-checkpoint-input::placeholder { color: var(--text-faint); }
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Code block — textarea with language selector, filename, and copy button.
|
|
4
|
+
* Styled to match the mockup's dark terminal aesthetic.
|
|
5
|
+
*/
|
|
6
|
+
import { ref, computed } from 'vue';
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
content: Record<string, unknown>;
|
|
9
|
+
}>();
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<{
|
|
12
|
+
update: [content: Record<string, unknown>];
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const code = computed(() => (props.content.code as string) ?? '');
|
|
16
|
+
const language = computed(() => (props.content.language as string) ?? '');
|
|
17
|
+
const filename = computed(() => (props.content.filename as string) ?? '');
|
|
18
|
+
|
|
19
|
+
const languages = [
|
|
20
|
+
'', 'javascript', 'typescript', 'python', 'c', 'cpp', 'rust', 'go',
|
|
21
|
+
'java', 'bash', 'html', 'css', 'json', 'yaml', 'sql', 'markdown',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const copied = ref(false);
|
|
25
|
+
|
|
26
|
+
function updateField(field: string, value: string): void {
|
|
27
|
+
emit('update', { ...props.content, [field]: value });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function copyCode(): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(code.value);
|
|
33
|
+
copied.value = true;
|
|
34
|
+
setTimeout(() => { copied.value = false; }, 1500);
|
|
35
|
+
} catch {
|
|
36
|
+
// clipboard API not available
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<div class="cpub-code-block">
|
|
43
|
+
<div class="cpub-code-header">
|
|
44
|
+
<select
|
|
45
|
+
class="cpub-code-lang"
|
|
46
|
+
:value="language"
|
|
47
|
+
aria-label="Programming language"
|
|
48
|
+
@change="updateField('language', ($event.target as HTMLSelectElement).value)"
|
|
49
|
+
>
|
|
50
|
+
<option v-for="lang in languages" :key="lang" :value="lang">
|
|
51
|
+
{{ lang || 'Plain text' }}
|
|
52
|
+
</option>
|
|
53
|
+
</select>
|
|
54
|
+
<input
|
|
55
|
+
class="cpub-code-filename"
|
|
56
|
+
type="text"
|
|
57
|
+
:value="filename"
|
|
58
|
+
placeholder="filename.ext"
|
|
59
|
+
aria-label="Filename"
|
|
60
|
+
@input="updateField('filename', ($event.target as HTMLInputElement).value)"
|
|
61
|
+
/>
|
|
62
|
+
<button class="cpub-code-copy" @click="copyCode">
|
|
63
|
+
<i :class="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'"></i>
|
|
64
|
+
{{ copied ? 'Copied' : 'Copy' }}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<textarea
|
|
68
|
+
class="cpub-code-body"
|
|
69
|
+
:value="code"
|
|
70
|
+
placeholder="// Write your code here..."
|
|
71
|
+
spellcheck="false"
|
|
72
|
+
aria-label="Code content"
|
|
73
|
+
@input="updateField('code', ($event.target as HTMLTextAreaElement).value)"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style scoped>
|
|
79
|
+
/* Dark code editor context — scoped white-on-dark tokens */
|
|
80
|
+
.cpub-code-block {
|
|
81
|
+
--code-fg-faint: rgba(255, 255, 255, 0.2);
|
|
82
|
+
--code-fg-dim: rgba(255, 255, 255, 0.5);
|
|
83
|
+
--code-fg: rgba(255, 255, 255, 0.8);
|
|
84
|
+
--code-border: rgba(255, 255, 255, 0.15);
|
|
85
|
+
--code-surface: rgba(255, 255, 255, 0.08);
|
|
86
|
+
--code-surface-dim: rgba(255, 255, 255, 0.04);
|
|
87
|
+
|
|
88
|
+
background: var(--text);
|
|
89
|
+
border: var(--border-width-default) solid var(--border);
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.cpub-code-header {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 8px;
|
|
97
|
+
padding: 8px 12px;
|
|
98
|
+
background: var(--code-surface-dim);
|
|
99
|
+
border-bottom: var(--border-width-default) solid var(--code-surface);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.cpub-code-lang {
|
|
103
|
+
font-family: var(--font-mono);
|
|
104
|
+
font-size: 10px;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
letter-spacing: 0.06em;
|
|
108
|
+
color: var(--accent);
|
|
109
|
+
background: transparent;
|
|
110
|
+
border: none;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
outline: none;
|
|
113
|
+
-webkit-appearance: none;
|
|
114
|
+
appearance: none;
|
|
115
|
+
padding: 2px 4px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.cpub-code-lang option {
|
|
119
|
+
background: var(--text);
|
|
120
|
+
color: var(--surface);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.cpub-code-filename {
|
|
124
|
+
font-family: var(--font-mono);
|
|
125
|
+
font-size: 10px;
|
|
126
|
+
color: var(--text-faint);
|
|
127
|
+
background: transparent;
|
|
128
|
+
border: none;
|
|
129
|
+
outline: none;
|
|
130
|
+
flex: 1;
|
|
131
|
+
padding: 2px 4px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.cpub-code-filename::placeholder {
|
|
135
|
+
color: var(--code-fg-faint);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.cpub-code-copy {
|
|
139
|
+
font-family: var(--font-mono);
|
|
140
|
+
font-size: 10px;
|
|
141
|
+
color: var(--code-fg-dim);
|
|
142
|
+
background: transparent;
|
|
143
|
+
border: var(--border-width-default) solid var(--code-border);
|
|
144
|
+
padding: 3px 8px;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 4px;
|
|
149
|
+
flex-shrink: 0;
|
|
150
|
+
transition: all 0.1s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.cpub-code-copy:hover {
|
|
154
|
+
color: var(--code-fg);
|
|
155
|
+
background: var(--code-surface);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.cpub-code-body {
|
|
159
|
+
width: 100%;
|
|
160
|
+
min-height: 120px;
|
|
161
|
+
padding: 14px 16px;
|
|
162
|
+
font-family: var(--font-mono);
|
|
163
|
+
font-size: 13px;
|
|
164
|
+
line-height: 1.6;
|
|
165
|
+
color: var(--surface);
|
|
166
|
+
background: transparent;
|
|
167
|
+
border: none;
|
|
168
|
+
outline: none;
|
|
169
|
+
resize: vertical;
|
|
170
|
+
tab-size: 2;
|
|
171
|
+
white-space: pre;
|
|
172
|
+
overflow-x: auto;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.cpub-code-body::placeholder {
|
|
176
|
+
color: var(--code-fg-faint);
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Divider block — horizontal rule. No editing needed.
|
|
4
|
+
*/
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="cpub-divider-block">
|
|
9
|
+
<hr class="cpub-divider-line" />
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<style scoped>
|
|
14
|
+
.cpub-divider-block {
|
|
15
|
+
padding: 8px 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.cpub-divider-line {
|
|
19
|
+
border: none;
|
|
20
|
+
border-top: var(--border-width-default) solid var(--border2);
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
5
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
6
|
+
interface FileItem { name: string; url: string; size?: string; type?: string; }
|
|
7
|
+
const files = computed(() => (props.content.files as FileItem[]) ?? []);
|
|
8
|
+
function addFile(): void { emit('update', { files: [...files.value, { name: '', url: '' }] }); }
|
|
9
|
+
function removeFile(i: number): void { emit('update', { files: files.value.filter((_: FileItem, idx: number) => idx !== i) }); }
|
|
10
|
+
function updateFile(i: number, field: string, value: string): void {
|
|
11
|
+
const updated = [...files.value];
|
|
12
|
+
updated[i] = { ...updated[i]!, [field]: value };
|
|
13
|
+
emit('update', { files: updated });
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
<template>
|
|
17
|
+
<div class="cpub-downloads-block">
|
|
18
|
+
<div class="cpub-dl-header"><i class="fa-solid fa-download"></i> Downloads <button class="cpub-dl-add" @click="addFile"><i class="fa-solid fa-plus"></i></button></div>
|
|
19
|
+
<div v-for="(file, i) in files" :key="i" class="cpub-dl-item">
|
|
20
|
+
<i class="fa-solid fa-file cpub-dl-file-icon"></i>
|
|
21
|
+
<input class="cpub-dl-name" :value="file.name" placeholder="Filename..." @input="updateFile(i, 'name', ($event.target as HTMLInputElement).value)" />
|
|
22
|
+
<input class="cpub-dl-url" :value="file.url" placeholder="URL..." @input="updateFile(i, 'url', ($event.target as HTMLInputElement).value)" />
|
|
23
|
+
<button class="cpub-dl-remove" @click="removeFile(i)"><i class="fa-solid fa-xmark"></i></button>
|
|
24
|
+
</div>
|
|
25
|
+
<div v-if="files.length === 0" class="cpub-dl-empty" @click="addFile"><i class="fa-solid fa-plus"></i> Add downloadable file</div>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
<style scoped>
|
|
29
|
+
.cpub-downloads-block { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
30
|
+
.cpub-dl-header { padding: 8px 12px; font-size: 12px; font-weight: 600; background: var(--surface2); border-bottom: var(--border-width-default) solid var(--border2); display: flex; align-items: center; gap: 8px; }
|
|
31
|
+
.cpub-dl-header i { color: var(--accent); }
|
|
32
|
+
.cpub-dl-add { margin-left: auto; background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 11px; }
|
|
33
|
+
.cpub-dl-add:hover { color: var(--accent); }
|
|
34
|
+
.cpub-dl-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: var(--border-width-default) solid var(--border2); }
|
|
35
|
+
.cpub-dl-file-icon { color: var(--text-faint); font-size: 12px; }
|
|
36
|
+
.cpub-dl-name { flex: 1; font-size: 12px; font-weight: 500; background: transparent; border: none; outline: none; color: var(--text); }
|
|
37
|
+
.cpub-dl-url { flex: 2; font-size: 11px; background: transparent; border: none; outline: none; color: var(--text-dim); font-family: var(--font-mono); }
|
|
38
|
+
.cpub-dl-name::placeholder, .cpub-dl-url::placeholder { color: var(--text-faint); }
|
|
39
|
+
.cpub-dl-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; }
|
|
40
|
+
.cpub-dl-remove:hover { color: var(--red); }
|
|
41
|
+
.cpub-dl-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; }
|
|
42
|
+
.cpub-dl-empty:hover { color: var(--accent); }
|
|
43
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ content: Record<string, unknown> }>();
|
|
5
|
+
const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
|
|
6
|
+
const url = computed(() => (props.content.url as string) ?? '');
|
|
7
|
+
function updateField(field: string, value: string): void { emit('update', { ...props.content, [field]: value }); }
|
|
8
|
+
</script>
|
|
9
|
+
<template>
|
|
10
|
+
<div class="cpub-embed-block">
|
|
11
|
+
<div class="cpub-embed-header"><i class="fa-solid fa-globe"></i> Embed</div>
|
|
12
|
+
<input class="cpub-embed-url" type="url" :value="url" placeholder="Paste embed URL..." @input="updateField('url', ($event.target as HTMLInputElement).value)" />
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
<style scoped>
|
|
16
|
+
.cpub-embed-block { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
|
|
17
|
+
.cpub-embed-header { padding: 8px 12px; font-size: 12px; font-weight: 600; background: var(--surface2); border-bottom: var(--border-width-default) solid var(--border2); display: flex; align-items: center; gap: 8px; }
|
|
18
|
+
.cpub-embed-header i { color: var(--accent); }
|
|
19
|
+
.cpub-embed-url { width: 100%; padding: 8px 12px; font-size: 12px; background: transparent; border: none; color: var(--text); outline: none; }
|
|
20
|
+
.cpub-embed-url:focus { background: var(--accent-bg); }
|
|
21
|
+
.cpub-embed-url::placeholder { color: var(--text-faint); }
|
|
22
|
+
</style>
|