@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.
Files changed (36) hide show
  1. package/README.md +43 -10
  2. package/package.json +16 -4
  3. package/vue/components/BlockCanvas.vue +512 -0
  4. package/vue/components/BlockInsertZone.vue +86 -0
  5. package/vue/components/BlockPicker.vue +274 -0
  6. package/vue/components/BlockWrapper.vue +188 -0
  7. package/vue/components/EditorBlocks.vue +235 -0
  8. package/vue/components/EditorSection.vue +81 -0
  9. package/vue/components/EditorShell.vue +198 -0
  10. package/vue/components/EditorTagInput.vue +116 -0
  11. package/vue/components/EditorVisibility.vue +110 -0
  12. package/vue/components/blocks/BuildStepBlock.vue +103 -0
  13. package/vue/components/blocks/CalloutBlock.vue +123 -0
  14. package/vue/components/blocks/CheckpointBlock.vue +29 -0
  15. package/vue/components/blocks/CodeBlock.vue +178 -0
  16. package/vue/components/blocks/DividerBlock.vue +22 -0
  17. package/vue/components/blocks/DownloadsBlock.vue +43 -0
  18. package/vue/components/blocks/EmbedBlock.vue +22 -0
  19. package/vue/components/blocks/GalleryBlock.vue +235 -0
  20. package/vue/components/blocks/HeadingBlock.vue +97 -0
  21. package/vue/components/blocks/ImageBlock.vue +272 -0
  22. package/vue/components/blocks/MarkdownBlock.vue +259 -0
  23. package/vue/components/blocks/MathBlock.vue +39 -0
  24. package/vue/components/blocks/PartsListBlock.vue +354 -0
  25. package/vue/components/blocks/QuizBlock.vue +49 -0
  26. package/vue/components/blocks/QuoteBlock.vue +102 -0
  27. package/vue/components/blocks/SectionHeaderBlock.vue +132 -0
  28. package/vue/components/blocks/SliderBlock.vue +320 -0
  29. package/vue/components/blocks/TextBlock.vue +202 -0
  30. package/vue/components/blocks/ToolListBlock.vue +71 -0
  31. package/vue/components/blocks/VideoBlock.vue +24 -0
  32. package/vue/composables/useBlockEditor.ts +190 -0
  33. package/vue/index.ts +59 -0
  34. package/vue/provide.ts +28 -0
  35. package/vue/types.ts +40 -0
  36. package/vue/utils.ts +12 -0
@@ -0,0 +1,259 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onUnmounted } from 'vue';
3
+ import { markdownToBlockTuples } from '@commonpub/editor';
4
+ import type { BlockTuple } from '@commonpub/editor';
5
+
6
+ const props = defineProps<{
7
+ content: { source: string };
8
+ }>();
9
+
10
+ const emit = defineEmits<{
11
+ update: [content: { source: string }];
12
+ }>();
13
+
14
+ /** Type-safe block content accessors — cast content to record for dynamic key access */
15
+ function bStr(block: BlockTuple, key: string): string {
16
+ return String((block[1] as Record<string, unknown>)[key] ?? '');
17
+ }
18
+ function bNum(block: BlockTuple, key: string): number {
19
+ return Number((block[1] as Record<string, unknown>)[key]) || 0;
20
+ }
21
+
22
+ const viewMode = ref<'edit' | 'split' | 'preview'>('split');
23
+ const source = ref(props.content.source || '');
24
+
25
+ watch(() => props.content.source, (val) => {
26
+ if (val !== source.value) source.value = val;
27
+ });
28
+
29
+ const previewBlocks = computed(() => {
30
+ if (!source.value.trim()) return [];
31
+ try {
32
+ return markdownToBlockTuples(source.value);
33
+ } catch {
34
+ return [];
35
+ }
36
+ });
37
+
38
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
39
+ function handleInput(): void {
40
+ if (debounceTimer) clearTimeout(debounceTimer);
41
+ debounceTimer = setTimeout(() => {
42
+ emit('update', { source: source.value });
43
+ }, 300);
44
+ }
45
+
46
+ onUnmounted(() => {
47
+ if (debounceTimer) clearTimeout(debounceTimer);
48
+ });
49
+ </script>
50
+
51
+ <template>
52
+ <div class="md-block" :class="[`md-block--${viewMode}`]">
53
+ <div class="md-block-toolbar">
54
+ <span class="md-block-label"><i class="fa-brands fa-markdown"></i> Markdown</span>
55
+ <div class="md-block-modes">
56
+ <button :class="{ active: viewMode === 'edit' }" @click="viewMode = 'edit'" title="Edit">
57
+ <i class="fa-solid fa-code"></i>
58
+ </button>
59
+ <button :class="{ active: viewMode === 'split' }" @click="viewMode = 'split'" title="Split">
60
+ <i class="fa-solid fa-columns"></i>
61
+ </button>
62
+ <button :class="{ active: viewMode === 'preview' }" @click="viewMode = 'preview'" title="Preview">
63
+ <i class="fa-solid fa-eye"></i>
64
+ </button>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="md-block-content">
69
+ <div v-if="viewMode !== 'preview'" class="md-block-editor">
70
+ <textarea
71
+ v-model="source"
72
+ class="md-block-textarea"
73
+ placeholder="Write markdown here..."
74
+ spellcheck="false"
75
+ @input="handleInput"
76
+ />
77
+ </div>
78
+
79
+ <div v-if="viewMode !== 'edit'" class="md-block-preview">
80
+ <div v-if="!previewBlocks.length" class="md-block-empty">
81
+ <i class="fa-solid fa-file-lines"></i>
82
+ <span>Preview will appear here</span>
83
+ </div>
84
+ <div v-else class="md-block-preview-blocks">
85
+ <div v-for="(block, i) in previewBlocks" :key="i" class="md-preview-block">
86
+ <template v-if="block[0] === 'heading'">
87
+ <component :is="`h${bNum(block, 'level')}`" class="md-preview-heading">
88
+ {{ bStr(block, 'text') }}
89
+ </component>
90
+ </template>
91
+ <template v-else-if="block[0] === 'text'">
92
+ <div class="md-preview-text" v-html="bStr(block, 'html')" />
93
+ </template>
94
+ <template v-else-if="block[0] === 'code'">
95
+ <pre class="md-preview-code"><code>{{ bStr(block, 'code') }}</code></pre>
96
+ </template>
97
+ <template v-else-if="block[0] === 'image'">
98
+ <figure class="md-preview-figure">
99
+ <img :src="bStr(block, 'src')" :alt="bStr(block, 'alt')" />
100
+ </figure>
101
+ </template>
102
+ <template v-else-if="block[0] === 'callout'">
103
+ <div :class="['md-preview-callout', `md-callout--${bStr(block, 'variant')}`]">
104
+ <div v-html="bStr(block, 'html')" />
105
+ </div>
106
+ </template>
107
+ <template v-else-if="block[0] === 'quote'">
108
+ <blockquote class="md-preview-quote" v-html="bStr(block, 'html')" />
109
+ </template>
110
+ <template v-else-if="block[0] === 'divider'">
111
+ <hr class="md-preview-hr" />
112
+ </template>
113
+ <template v-else>
114
+ <div class="md-preview-unknown">{{ block[0] }} block</div>
115
+ </template>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .md-block {
125
+ border: var(--border-width-default) solid var(--border);
126
+ border-radius: 0;
127
+ background: var(--surface);
128
+ overflow: hidden;
129
+ }
130
+
131
+ .md-block-toolbar {
132
+ display: flex;
133
+ align-items: center;
134
+ justify-content: space-between;
135
+ padding: 6px 12px;
136
+ background: var(--surface2);
137
+ border-bottom: var(--border-width-default) solid var(--border);
138
+ }
139
+
140
+ .md-block-label {
141
+ font-size: 11px;
142
+ font-weight: 600;
143
+ color: var(--text-dim);
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 6px;
147
+ }
148
+
149
+ .md-block-modes {
150
+ display: flex;
151
+ gap: 2px;
152
+ background: var(--surface3);
153
+ border-radius: 0;
154
+ padding: 2px;
155
+ }
156
+
157
+ .md-block-modes button {
158
+ padding: 3px 8px;
159
+ border: none;
160
+ border-radius: 0;
161
+ background: none;
162
+ color: var(--text-faint);
163
+ cursor: pointer;
164
+ font-size: 11px;
165
+ }
166
+
167
+ .md-block-modes button.active {
168
+ background: var(--surface);
169
+ color: var(--text);
170
+ box-shadow: var(--shadow-sm);
171
+ }
172
+
173
+ .md-block-content {
174
+ display: flex;
175
+ min-height: 200px;
176
+ }
177
+
178
+ .md-block--edit .md-block-editor { flex: 1; }
179
+ .md-block--preview .md-block-preview { flex: 1; }
180
+ .md-block--split .md-block-editor { flex: 1; border-right: var(--border-width-default) solid var(--border); }
181
+ .md-block--split .md-block-preview { flex: 1; }
182
+
183
+ .md-block-textarea {
184
+ width: 100%;
185
+ height: 100%;
186
+ min-height: 200px;
187
+ resize: vertical;
188
+ border: none;
189
+ outline: none;
190
+ padding: 12px;
191
+ font-family: var(--font-mono);
192
+ font-size: 12px;
193
+ line-height: 1.6;
194
+ color: var(--text);
195
+ background: var(--bg);
196
+ tab-size: 2;
197
+ }
198
+
199
+ .md-block-preview {
200
+ padding: 12px;
201
+ font-size: 13px;
202
+ line-height: 1.6;
203
+ color: var(--text);
204
+ overflow-y: auto;
205
+ }
206
+
207
+ .md-block-empty {
208
+ display: flex;
209
+ flex-direction: column;
210
+ align-items: center;
211
+ justify-content: center;
212
+ height: 100%;
213
+ min-height: 200px;
214
+ gap: 8px;
215
+ color: var(--text-faint);
216
+ font-size: 12px;
217
+ }
218
+
219
+ .md-preview-heading { margin-bottom: 8px; }
220
+ .md-preview-text { margin-bottom: 8px; }
221
+ .md-preview-code {
222
+ background: var(--surface2);
223
+ border: var(--border-width-default) solid var(--border);
224
+ border-radius: 0;
225
+ padding: 10px;
226
+ font-family: var(--font-mono);
227
+ font-size: 11px;
228
+ overflow-x: auto;
229
+ margin-bottom: 8px;
230
+ }
231
+ .md-preview-figure img { max-width: 100%; border-radius: 0; }
232
+ .md-preview-callout {
233
+ padding: 10px 12px;
234
+ border-radius: 0;
235
+ border-left: 3px solid;
236
+ margin-bottom: 8px;
237
+ font-size: 12px;
238
+ }
239
+ .md-callout--info { background: var(--teal-bg); border-color: var(--teal); }
240
+ .md-callout--tip { background: var(--green-bg); border-color: var(--green); }
241
+ .md-callout--warning { background: var(--yellow-bg); border-color: var(--yellow); }
242
+ .md-callout--danger { background: var(--red-bg); border-color: var(--red); }
243
+ .md-preview-quote {
244
+ border-left: 3px solid var(--border);
245
+ padding-left: 12px;
246
+ color: var(--text-dim);
247
+ font-style: italic;
248
+ margin-bottom: 8px;
249
+ }
250
+ .md-preview-hr { border: none; border-top: var(--border-width-default) solid var(--border); margin: 12px 0; }
251
+ .md-preview-unknown {
252
+ padding: 6px 10px;
253
+ background: var(--surface2);
254
+ border-radius: 0;
255
+ font-size: 11px;
256
+ color: var(--text-faint);
257
+ margin-bottom: 8px;
258
+ }
259
+ </style>
@@ -0,0 +1,39 @@
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 expression = computed(() => (props.content.expression as string) ?? '');
8
+ const notation = computed(() => (props.content.notation as string) ?? 'latex');
9
+
10
+ function updateExpression(value: string): void {
11
+ emit('update', { ...props.content, expression: value });
12
+ }
13
+ </script>
14
+
15
+ <template>
16
+ <div class="cpub-math-edit">
17
+ <div class="cpub-math-edit-header"><i class="fa-solid fa-square-root-variable"></i> Math Notation</div>
18
+ <div class="cpub-math-edit-body">
19
+ <textarea
20
+ class="cpub-math-textarea"
21
+ :value="expression"
22
+ placeholder="e.g. f(x) = \sum_{i=1}^{n} w_i \cdot x_i + b"
23
+ rows="3"
24
+ @input="updateExpression(($event.target as HTMLTextAreaElement).value)"
25
+ />
26
+ <div class="cpub-math-hint">Enter LaTeX or plain math notation</div>
27
+ </div>
28
+ </div>
29
+ </template>
30
+
31
+ <style scoped>
32
+ .cpub-math-edit { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
33
+ .cpub-math-edit-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; }
34
+ .cpub-math-edit-header i { color: var(--purple); }
35
+ .cpub-math-edit-body { padding: 12px; }
36
+ .cpub-math-textarea { width: 100%; font-family: var(--font-mono); font-size: 13px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); padding: 8px; color: var(--text); outline: none; resize: vertical; }
37
+ .cpub-math-textarea:focus { border-color: var(--accent); }
38
+ .cpub-math-hint { font-size: 10px; color: var(--text-faint); margin-top: 4px; }
39
+ </style>
@@ -0,0 +1,354 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Parts list block — editable BOM table with product catalog autocomplete.
4
+ * Product search is abstracted via the onSearchProducts callback prop.
5
+ */
6
+ import { ref, computed } from 'vue';
7
+
8
+ interface Part {
9
+ name: string;
10
+ qty: number;
11
+ price?: number;
12
+ url?: string;
13
+ category?: string;
14
+ required?: boolean;
15
+ notes?: string;
16
+ productId?: string;
17
+ productSlug?: string;
18
+ }
19
+
20
+ interface ProductResult {
21
+ id: string;
22
+ name: string;
23
+ slug: string;
24
+ description: string | null;
25
+ category: string | null;
26
+ imageUrl: string | null;
27
+ purchaseUrl: string | null;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ content: Record<string, unknown>;
32
+ onSearchProducts?: (query: string) => Promise<ProductResult[]>;
33
+ }>();
34
+
35
+ const emit = defineEmits<{
36
+ update: [content: Record<string, unknown>];
37
+ }>();
38
+
39
+ const parts = computed(() => (props.content.parts as Part[]) ?? []);
40
+
41
+ function updatePart(index: number, field: string, value: unknown): void {
42
+ const updated = [...parts.value];
43
+ updated[index] = { ...updated[index]!, [field]: value };
44
+ emit('update', { parts: updated });
45
+ }
46
+
47
+ function addPart(): void {
48
+ emit('update', { parts: [...parts.value, { name: '', qty: 1, required: true }] });
49
+ }
50
+
51
+ function removePart(index: number): void {
52
+ const updated = parts.value.filter((_: Part, i: number) => i !== index);
53
+ emit('update', { parts: updated });
54
+ }
55
+
56
+ function selectProduct(index: number, product: ProductResult): void {
57
+ const updated = [...parts.value];
58
+ updated[index] = {
59
+ ...updated[index]!,
60
+ name: product.name,
61
+ url: product.purchaseUrl ?? undefined,
62
+ productId: product.id,
63
+ productSlug: product.slug,
64
+ category: product.category ?? undefined,
65
+ };
66
+ emit('update', { parts: updated });
67
+ activeAutocomplete.value = -1;
68
+ autocompleteResults.value = [];
69
+ }
70
+
71
+ function clearProductLink(index: number): void {
72
+ const updated = [...parts.value];
73
+ updated[index] = { ...updated[index]!, productId: undefined, productSlug: undefined };
74
+ emit('update', { parts: updated });
75
+ }
76
+
77
+ const totalPrice = computed(() => {
78
+ return parts.value.reduce((sum: number, p: Part) => sum + (p.price ?? 0) * p.qty, 0);
79
+ });
80
+
81
+ // --- Autocomplete ---
82
+ const activeAutocomplete = ref(-1);
83
+ const autocompleteResults = ref<ProductResult[]>([]);
84
+ const autocompleteLoading = ref(false);
85
+ const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null);
86
+
87
+ function onNameInput(index: number, value: string): void {
88
+ updatePart(index, 'name', value);
89
+
90
+ if (parts.value[index]?.productId) {
91
+ clearProductLink(index);
92
+ }
93
+
94
+ if (debounceTimer.value) clearTimeout(debounceTimer.value);
95
+
96
+ if (value.length < 2 || !props.onSearchProducts) {
97
+ autocompleteResults.value = [];
98
+ activeAutocomplete.value = -1;
99
+ return;
100
+ }
101
+
102
+ activeAutocomplete.value = index;
103
+ debounceTimer.value = setTimeout(async () => {
104
+ autocompleteLoading.value = true;
105
+ try {
106
+ autocompleteResults.value = await props.onSearchProducts!(value);
107
+ } catch {
108
+ autocompleteResults.value = [];
109
+ } finally {
110
+ autocompleteLoading.value = false;
111
+ }
112
+ }, 250);
113
+ }
114
+
115
+ function onNameBlur(): void {
116
+ setTimeout(() => {
117
+ activeAutocomplete.value = -1;
118
+ autocompleteResults.value = [];
119
+ }, 200);
120
+ }
121
+ </script>
122
+
123
+ <template>
124
+ <div class="cpub-parts-block">
125
+ <div class="cpub-parts-header">
126
+ <div class="cpub-parts-icon"><i class="fa-solid fa-list-check"></i></div>
127
+ <span class="cpub-parts-title">Parts List</span>
128
+ <span class="cpub-parts-count">{{ parts.length }} items<template v-if="totalPrice > 0"> · ${{ totalPrice.toFixed(2) }} est.</template></span>
129
+ <button class="cpub-parts-add-btn" @click="addPart">
130
+ <i class="fa-solid fa-plus"></i> Add part
131
+ </button>
132
+ </div>
133
+ <table v-if="parts.length > 0" class="cpub-parts-table">
134
+ <thead>
135
+ <tr>
136
+ <th>Part</th>
137
+ <th class="cpub-parts-qty">Qty</th>
138
+ <th>Notes</th>
139
+ <th class="cpub-parts-price">Price</th>
140
+ <th class="cpub-parts-actions"></th>
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ <tr v-for="(part, i) in parts" :key="i">
145
+ <td class="cpub-parts-name-cell">
146
+ <div class="cpub-parts-name-wrap">
147
+ <input
148
+ class="cpub-parts-input"
149
+ type="text"
150
+ :value="part.name"
151
+ :placeholder="onSearchProducts ? 'Search product catalog...' : 'Part name...'"
152
+ :aria-label="`Part ${i + 1} name`"
153
+ @input="onNameInput(i, ($event.target as HTMLInputElement).value)"
154
+ @blur="onNameBlur"
155
+ />
156
+ <span v-if="part.productId" class="cpub-parts-linked" title="Linked to product catalog">
157
+ <i class="fa-solid fa-link"></i>
158
+ </span>
159
+ </div>
160
+ <!-- Autocomplete dropdown -->
161
+ <div v-if="activeAutocomplete === i && autocompleteResults.length > 0" class="cpub-parts-ac">
162
+ <button
163
+ v-for="product in autocompleteResults"
164
+ :key="product.id"
165
+ class="cpub-parts-ac-item"
166
+ @mousedown.prevent="selectProduct(i, product)"
167
+ >
168
+ <div class="cpub-parts-ac-icon">
169
+ <img v-if="product.imageUrl" :src="product.imageUrl" :alt="product.name" />
170
+ <i v-else class="fa-solid fa-microchip"></i>
171
+ </div>
172
+ <div class="cpub-parts-ac-info">
173
+ <span class="cpub-parts-ac-name">{{ product.name }}</span>
174
+ <span v-if="product.category" class="cpub-parts-ac-cat">{{ product.category }}</span>
175
+ </div>
176
+ </button>
177
+ </div>
178
+ <div v-else-if="activeAutocomplete === i && autocompleteLoading" class="cpub-parts-ac">
179
+ <div class="cpub-parts-ac-loading">Searching...</div>
180
+ </div>
181
+ </td>
182
+ <td class="cpub-parts-qty">
183
+ <input class="cpub-parts-input cpub-parts-input-sm" type="number" :value="part.qty" min="1" :aria-label="`Part ${i + 1} quantity`" @input="updatePart(i, 'qty', Number(($event.target as HTMLInputElement).value))" />
184
+ </td>
185
+ <td>
186
+ <input class="cpub-parts-input" type="text" :value="part.notes ?? ''" placeholder="Notes..." :aria-label="`Part ${i + 1} notes`" @input="updatePart(i, 'notes', ($event.target as HTMLInputElement).value)" />
187
+ </td>
188
+ <td class="cpub-parts-price">
189
+ <input class="cpub-parts-input cpub-parts-input-sm" type="number" step="0.01" :value="part.price ?? ''" placeholder="0.00" :aria-label="`Part ${i + 1} price`" @input="updatePart(i, 'price', Number(($event.target as HTMLInputElement).value))" />
190
+ </td>
191
+ <td class="cpub-parts-actions">
192
+ <button class="cpub-parts-remove" title="Remove" @click="removePart(i)">
193
+ <i class="fa-solid fa-xmark"></i>
194
+ </button>
195
+ </td>
196
+ </tr>
197
+ </tbody>
198
+ </table>
199
+ <div v-else class="cpub-parts-empty" @click="addPart">
200
+ <i class="fa-solid fa-plus"></i> Click to add your first part
201
+ </div>
202
+ </div>
203
+ </template>
204
+
205
+ <style scoped>
206
+ .cpub-parts-block { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
207
+
208
+ .cpub-parts-header {
209
+ display: flex; align-items: center; gap: 8px;
210
+ padding: 10px 14px;
211
+ border-bottom: var(--border-width-default) solid var(--border2);
212
+ background: var(--surface2);
213
+ }
214
+
215
+ .cpub-parts-icon { font-size: 12px; color: var(--accent); }
216
+ .cpub-parts-title { font-size: 12px; font-weight: 600; }
217
+ .cpub-parts-count { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); margin-left: auto; }
218
+
219
+ .cpub-parts-add-btn {
220
+ font-family: var(--font-mono); font-size: 10px;
221
+ padding: 3px 8px; background: transparent;
222
+ border: var(--border-width-default) solid var(--border2); color: var(--text-dim);
223
+ cursor: pointer; display: flex; align-items: center; gap: 4px;
224
+ margin-left: 8px;
225
+ }
226
+ .cpub-parts-add-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-bg); }
227
+
228
+ .cpub-parts-table { width: 100%; border-collapse: collapse; }
229
+ .cpub-parts-table th {
230
+ font-family: var(--font-mono); font-size: 9px; font-weight: 700;
231
+ text-transform: uppercase; letter-spacing: 0.1em;
232
+ text-align: left; padding: 6px 10px;
233
+ background: var(--text); color: var(--surface);
234
+ }
235
+ .cpub-parts-table td { padding: 4px 6px; border-bottom: var(--border-width-default) solid var(--border2); }
236
+ .cpub-parts-qty { width: 50px; text-align: center; }
237
+ .cpub-parts-price { width: 80px; }
238
+ .cpub-parts-actions { width: 30px; text-align: center; }
239
+
240
+ .cpub-parts-name-cell {
241
+ position: relative;
242
+ }
243
+
244
+ .cpub-parts-name-wrap {
245
+ display: flex;
246
+ align-items: center;
247
+ gap: 4px;
248
+ }
249
+
250
+ .cpub-parts-linked {
251
+ font-size: 9px;
252
+ color: var(--accent);
253
+ flex-shrink: 0;
254
+ }
255
+
256
+ .cpub-parts-input {
257
+ width: 100%; padding: 4px 6px; font-size: 12px;
258
+ background: transparent; border: var(--border-width-default) solid transparent;
259
+ color: var(--text); outline: none;
260
+ }
261
+ .cpub-parts-input:focus { border-color: var(--accent); background: var(--accent-bg); }
262
+ .cpub-parts-input::placeholder { color: var(--text-faint); }
263
+ .cpub-parts-input-sm { width: 60px; text-align: center; }
264
+
265
+ .cpub-parts-remove {
266
+ background: none; border: none; color: var(--text-faint);
267
+ cursor: pointer; font-size: 10px; padding: 4px;
268
+ }
269
+ .cpub-parts-remove:hover { color: var(--red); }
270
+
271
+ .cpub-parts-empty {
272
+ padding: 24px; text-align: center;
273
+ font-size: 12px; color: var(--text-faint);
274
+ cursor: pointer;
275
+ }
276
+ .cpub-parts-empty:hover { color: var(--accent); background: var(--accent-bg); }
277
+
278
+ /* Autocomplete dropdown */
279
+ .cpub-parts-ac {
280
+ position: absolute;
281
+ top: 100%;
282
+ left: 0;
283
+ right: 0;
284
+ z-index: 50;
285
+ background: var(--surface);
286
+ border: var(--border-width-default) solid var(--border);
287
+ box-shadow: var(--shadow-md);
288
+ max-height: 200px;
289
+ overflow-y: auto;
290
+ }
291
+
292
+ .cpub-parts-ac-item {
293
+ display: flex;
294
+ align-items: center;
295
+ gap: 8px;
296
+ width: 100%;
297
+ padding: 6px 10px;
298
+ border: none;
299
+ background: transparent;
300
+ color: var(--text);
301
+ cursor: pointer;
302
+ text-align: left;
303
+ font-size: 12px;
304
+ }
305
+
306
+ .cpub-parts-ac-item:hover {
307
+ background: var(--accent-bg);
308
+ }
309
+
310
+ .cpub-parts-ac-icon {
311
+ width: 24px;
312
+ height: 24px;
313
+ background: var(--surface2);
314
+ border: var(--border-width-default) solid var(--border2);
315
+ display: flex;
316
+ align-items: center;
317
+ justify-content: center;
318
+ font-size: 10px;
319
+ color: var(--text-faint);
320
+ flex-shrink: 0;
321
+ }
322
+
323
+ .cpub-parts-ac-icon img {
324
+ width: 100%;
325
+ height: 100%;
326
+ object-fit: cover;
327
+ }
328
+
329
+ .cpub-parts-ac-info {
330
+ flex: 1;
331
+ min-width: 0;
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 6px;
335
+ }
336
+
337
+ .cpub-parts-ac-name {
338
+ font-weight: 500;
339
+ }
340
+
341
+ .cpub-parts-ac-cat {
342
+ font-size: 9px;
343
+ font-family: var(--font-mono);
344
+ color: var(--text-faint);
345
+ text-transform: uppercase;
346
+ }
347
+
348
+ .cpub-parts-ac-loading {
349
+ padding: 10px;
350
+ text-align: center;
351
+ font-size: 11px;
352
+ color: var(--text-faint);
353
+ }
354
+ </style>