qdadm 0.13.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/CHANGELOG.md +270 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/package.json +48 -0
- package/src/assets/logo.svg +6 -0
- package/src/components/BoolCell.vue +28 -0
- package/src/components/dialogs/BulkStatusDialog.vue +43 -0
- package/src/components/dialogs/MultiStepDialog.vue +321 -0
- package/src/components/dialogs/SimpleDialog.vue +108 -0
- package/src/components/dialogs/UnsavedChangesDialog.vue +87 -0
- package/src/components/display/CardsGrid.vue +155 -0
- package/src/components/display/CopyableId.vue +92 -0
- package/src/components/display/EmptyState.vue +114 -0
- package/src/components/display/IntensityBar.vue +171 -0
- package/src/components/display/RichCardsGrid.vue +220 -0
- package/src/components/editors/JsonEditorFoldable.vue +467 -0
- package/src/components/editors/JsonStructuredField.vue +218 -0
- package/src/components/editors/JsonViewer.vue +91 -0
- package/src/components/editors/KeyValueEditor.vue +314 -0
- package/src/components/editors/LanguageEditor.vue +245 -0
- package/src/components/editors/ScopeEditor.vue +341 -0
- package/src/components/editors/VanillaJsonEditor.vue +185 -0
- package/src/components/forms/FormActions.vue +104 -0
- package/src/components/forms/FormField.vue +64 -0
- package/src/components/forms/FormTab.vue +217 -0
- package/src/components/forms/FormTabs.vue +108 -0
- package/src/components/index.js +44 -0
- package/src/components/layout/AppLayout.vue +430 -0
- package/src/components/layout/Breadcrumb.vue +106 -0
- package/src/components/layout/PageHeader.vue +75 -0
- package/src/components/layout/PageLayout.vue +93 -0
- package/src/components/lists/ActionButtons.vue +41 -0
- package/src/components/lists/ActionColumn.vue +37 -0
- package/src/components/lists/FilterBar.vue +53 -0
- package/src/components/lists/ListPage.vue +319 -0
- package/src/composables/index.js +19 -0
- package/src/composables/useApp.js +43 -0
- package/src/composables/useAuth.js +49 -0
- package/src/composables/useBareForm.js +143 -0
- package/src/composables/useBreadcrumb.js +221 -0
- package/src/composables/useDirtyState.js +103 -0
- package/src/composables/useEntityTitle.js +121 -0
- package/src/composables/useForm.js +254 -0
- package/src/composables/useGuardStore.js +37 -0
- package/src/composables/useJsonSyntax.js +101 -0
- package/src/composables/useListPageBuilder.js +1176 -0
- package/src/composables/useNavigation.js +89 -0
- package/src/composables/usePageBuilder.js +334 -0
- package/src/composables/useStatus.js +146 -0
- package/src/composables/useSubEditor.js +165 -0
- package/src/composables/useTabSync.js +110 -0
- package/src/composables/useUnsavedChangesGuard.js +122 -0
- package/src/entity/EntityManager.js +540 -0
- package/src/entity/index.js +11 -0
- package/src/entity/storage/ApiStorage.js +146 -0
- package/src/entity/storage/LocalStorage.js +220 -0
- package/src/entity/storage/MemoryStorage.js +201 -0
- package/src/entity/storage/index.js +10 -0
- package/src/index.js +29 -0
- package/src/kernel/Kernel.js +234 -0
- package/src/kernel/index.js +7 -0
- package/src/module/index.js +16 -0
- package/src/module/moduleRegistry.js +222 -0
- package/src/orchestrator/Orchestrator.js +141 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/useOrchestrator.js +61 -0
- package/src/plugin.js +142 -0
- package/src/styles/_alerts.css +48 -0
- package/src/styles/_code.css +33 -0
- package/src/styles/_dialogs.css +17 -0
- package/src/styles/_markdown.css +82 -0
- package/src/styles/_show-pages.css +84 -0
- package/src/styles/index.css +16 -0
- package/src/styles/main.css +845 -0
- package/src/styles/theme/components.css +286 -0
- package/src/styles/theme/index.css +10 -0
- package/src/styles/theme/tokens.css +125 -0
- package/src/styles/theme/utilities.css +172 -0
- package/src/utils/debugInjector.js +261 -0
- package/src/utils/formatters.js +165 -0
- package/src/utils/index.js +35 -0
- package/src/utils/transformers.js +105 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* JsonViewer - Reusable JSON viewer component
|
|
4
|
+
*
|
|
5
|
+
* Simple formatted JSON display with syntax highlighting.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <JsonViewer :model-value="jsonData" height="300px" />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { computed } from 'vue'
|
|
12
|
+
import { highlightJson } from '../../composables/useJsonSyntax'
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
modelValue: {
|
|
16
|
+
type: [Object, Array, String, null],
|
|
17
|
+
default: () => ({})
|
|
18
|
+
},
|
|
19
|
+
height: {
|
|
20
|
+
type: String,
|
|
21
|
+
default: '300px'
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Parse value if string
|
|
26
|
+
const parsedValue = computed(() => {
|
|
27
|
+
if (typeof props.modelValue === 'string') {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(props.modelValue)
|
|
30
|
+
} catch {
|
|
31
|
+
return props.modelValue
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return props.modelValue
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Format JSON with syntax highlighting
|
|
38
|
+
const formattedJson = computed(() => {
|
|
39
|
+
if (parsedValue.value === null || parsedValue.value === undefined) {
|
|
40
|
+
return '<span class="json-null">null</span>'
|
|
41
|
+
}
|
|
42
|
+
return highlightJson(JSON.stringify(parsedValue.value, null, 2))
|
|
43
|
+
})
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<div class="json-viewer" :style="{ maxHeight: height }">
|
|
48
|
+
<pre class="json-content" v-html="formattedJson"></pre>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.json-viewer {
|
|
54
|
+
background: var(--p-surface-50);
|
|
55
|
+
border: 1px solid var(--p-surface-200);
|
|
56
|
+
border-radius: 0.375rem;
|
|
57
|
+
overflow: auto;
|
|
58
|
+
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
59
|
+
font-size: 0.8125rem;
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.json-content {
|
|
64
|
+
padding: 1rem;
|
|
65
|
+
margin: 0;
|
|
66
|
+
white-space: pre-wrap;
|
|
67
|
+
word-break: break-word;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:deep(.json-key) {
|
|
71
|
+
color: #881391;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
:deep(.json-string) {
|
|
75
|
+
color: #1a1aa6;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
:deep(.json-number) {
|
|
79
|
+
color: #1c00cf;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
:deep(.json-boolean) {
|
|
83
|
+
color: #0d22aa;
|
|
84
|
+
font-weight: 500;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
:deep(.json-null) {
|
|
88
|
+
color: #808080;
|
|
89
|
+
font-style: italic;
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* KeyValueEditor - Reusable component for editing key-value pairs
|
|
4
|
+
*
|
|
5
|
+
* Props:
|
|
6
|
+
* - modelValue: Array of {key, value} objects
|
|
7
|
+
* - label: Field label
|
|
8
|
+
* - help: Help text
|
|
9
|
+
* - keyPlaceholder: Placeholder for key input
|
|
10
|
+
* - valuePlaceholder: Placeholder for value input (text mode only)
|
|
11
|
+
* - keySuggestions: Array of strings for autocomplete on key input
|
|
12
|
+
* - valueType: 'number' (default) or 'text'
|
|
13
|
+
* - min: Minimum value (default: 0, number mode only)
|
|
14
|
+
* - max: Maximum value (default: 1, number mode only)
|
|
15
|
+
* - step: Slider step (default: 0.1, number mode only)
|
|
16
|
+
* - showSign: Show +/- sign for values (default: false, number mode only)
|
|
17
|
+
* - colorize: Color negative/positive values (default: false, number mode only)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ref, computed } from 'vue'
|
|
21
|
+
import Button from 'primevue/button'
|
|
22
|
+
import InputText from 'primevue/inputtext'
|
|
23
|
+
import AutoComplete from 'primevue/autocomplete'
|
|
24
|
+
import Slider from 'primevue/slider'
|
|
25
|
+
|
|
26
|
+
const props = defineProps({
|
|
27
|
+
modelValue: {
|
|
28
|
+
type: Array,
|
|
29
|
+
default: () => []
|
|
30
|
+
},
|
|
31
|
+
label: {
|
|
32
|
+
type: String,
|
|
33
|
+
default: 'Key-Value Pairs'
|
|
34
|
+
},
|
|
35
|
+
help: {
|
|
36
|
+
type: String,
|
|
37
|
+
default: ''
|
|
38
|
+
},
|
|
39
|
+
keyPlaceholder: {
|
|
40
|
+
type: String,
|
|
41
|
+
default: 'Key'
|
|
42
|
+
},
|
|
43
|
+
valuePlaceholder: {
|
|
44
|
+
type: String,
|
|
45
|
+
default: 'Value'
|
|
46
|
+
},
|
|
47
|
+
keySuggestions: {
|
|
48
|
+
type: Array,
|
|
49
|
+
default: () => []
|
|
50
|
+
},
|
|
51
|
+
valueType: {
|
|
52
|
+
type: String,
|
|
53
|
+
default: 'number',
|
|
54
|
+
validator: (v) => ['number', 'text'].includes(v)
|
|
55
|
+
},
|
|
56
|
+
min: {
|
|
57
|
+
type: Number,
|
|
58
|
+
default: 0
|
|
59
|
+
},
|
|
60
|
+
max: {
|
|
61
|
+
type: Number,
|
|
62
|
+
default: 1
|
|
63
|
+
},
|
|
64
|
+
step: {
|
|
65
|
+
type: Number,
|
|
66
|
+
default: 0.1
|
|
67
|
+
},
|
|
68
|
+
showSign: {
|
|
69
|
+
type: Boolean,
|
|
70
|
+
default: false
|
|
71
|
+
},
|
|
72
|
+
colorize: {
|
|
73
|
+
type: Boolean,
|
|
74
|
+
default: false
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const emit = defineEmits(['update:modelValue'])
|
|
79
|
+
|
|
80
|
+
const items = computed({
|
|
81
|
+
get: () => props.modelValue || [],
|
|
82
|
+
set: (value) => emit('update:modelValue', value)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const isTextMode = computed(() => props.valueType === 'text')
|
|
86
|
+
const hasKeySuggestions = computed(() => props.keySuggestions.length > 0)
|
|
87
|
+
const filteredKeySuggestions = ref([])
|
|
88
|
+
|
|
89
|
+
const newItem = ref({
|
|
90
|
+
key: '',
|
|
91
|
+
value: props.valueType === 'text' ? '' : (props.min + props.max) / 2
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Filter key suggestions for autocomplete (exclude already used keys)
|
|
95
|
+
function searchKeys(event) {
|
|
96
|
+
const query = event.query.toLowerCase()
|
|
97
|
+
const usedKeys = items.value.map(item => item.key)
|
|
98
|
+
filteredKeySuggestions.value = props.keySuggestions
|
|
99
|
+
.filter(k => !usedKeys.includes(k))
|
|
100
|
+
.filter(k => k.toLowerCase().includes(query))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatValue(value) {
|
|
104
|
+
if (isTextMode.value) {
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
const formatted = Number(value).toFixed(2)
|
|
108
|
+
if (props.showSign && value > 0) {
|
|
109
|
+
return '+' + formatted
|
|
110
|
+
}
|
|
111
|
+
return formatted
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function addItem() {
|
|
115
|
+
if (!newItem.value.key) return
|
|
116
|
+
if (isTextMode.value && !newItem.value.value) return
|
|
117
|
+
const updated = [...items.value, { ...newItem.value }]
|
|
118
|
+
emit('update:modelValue', updated)
|
|
119
|
+
newItem.value = {
|
|
120
|
+
key: '',
|
|
121
|
+
value: isTextMode.value ? '' : (props.min + props.max) / 2
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeItem(index) {
|
|
126
|
+
const updated = items.value.filter((_, i) => i !== index)
|
|
127
|
+
emit('update:modelValue', updated)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function updateItemValue(index, value) {
|
|
131
|
+
const updated = [...items.value]
|
|
132
|
+
updated[index] = { ...updated[index], value }
|
|
133
|
+
emit('update:modelValue', updated)
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<template>
|
|
138
|
+
<div class="form-field">
|
|
139
|
+
<label>{{ label }}</label>
|
|
140
|
+
<div class="kv-list">
|
|
141
|
+
<div v-if="items.length === 0" class="kv-empty">
|
|
142
|
+
No items added yet
|
|
143
|
+
</div>
|
|
144
|
+
<div v-for="(item, index) in items" :key="index" class="kv-item">
|
|
145
|
+
<span class="kv-key">{{ item.key }}</span>
|
|
146
|
+
<div class="kv-value-container">
|
|
147
|
+
<!-- Text mode: show text input -->
|
|
148
|
+
<template v-if="isTextMode">
|
|
149
|
+
<InputText
|
|
150
|
+
:modelValue="item.value"
|
|
151
|
+
@update:modelValue="(v) => updateItemValue(index, v)"
|
|
152
|
+
class="kv-text-input"
|
|
153
|
+
/>
|
|
154
|
+
</template>
|
|
155
|
+
<!-- Number mode: show slider -->
|
|
156
|
+
<template v-else>
|
|
157
|
+
<span
|
|
158
|
+
class="kv-value"
|
|
159
|
+
:class="{
|
|
160
|
+
negative: colorize && item.value < 0,
|
|
161
|
+
positive: colorize && item.value > 0
|
|
162
|
+
}"
|
|
163
|
+
>
|
|
164
|
+
{{ formatValue(item.value) }}
|
|
165
|
+
</span>
|
|
166
|
+
<Slider
|
|
167
|
+
:modelValue="item.value"
|
|
168
|
+
@update:modelValue="(v) => updateItemValue(index, v)"
|
|
169
|
+
:min="min"
|
|
170
|
+
:max="max"
|
|
171
|
+
:step="step"
|
|
172
|
+
style="width: 100px"
|
|
173
|
+
/>
|
|
174
|
+
</template>
|
|
175
|
+
</div>
|
|
176
|
+
<Button
|
|
177
|
+
icon="pi pi-times"
|
|
178
|
+
severity="danger"
|
|
179
|
+
text
|
|
180
|
+
rounded
|
|
181
|
+
size="small"
|
|
182
|
+
@click="removeItem(index)"
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="kv-add">
|
|
187
|
+
<Button
|
|
188
|
+
icon="pi pi-plus"
|
|
189
|
+
@click="addItem"
|
|
190
|
+
:disabled="!newItem.key || (isTextMode && !newItem.value)"
|
|
191
|
+
v-tooltip.top="'Add item'"
|
|
192
|
+
/>
|
|
193
|
+
<!-- With autocomplete suggestions -->
|
|
194
|
+
<AutoComplete
|
|
195
|
+
v-if="hasKeySuggestions"
|
|
196
|
+
v-model="newItem.key"
|
|
197
|
+
:suggestions="filteredKeySuggestions"
|
|
198
|
+
@complete="searchKeys"
|
|
199
|
+
:placeholder="keyPlaceholder"
|
|
200
|
+
class="kv-add-input"
|
|
201
|
+
@keyup.enter="addItem"
|
|
202
|
+
/>
|
|
203
|
+
<!-- Without suggestions: plain input -->
|
|
204
|
+
<InputText
|
|
205
|
+
v-else
|
|
206
|
+
v-model="newItem.key"
|
|
207
|
+
:placeholder="keyPlaceholder"
|
|
208
|
+
class="kv-add-input"
|
|
209
|
+
@keyup.enter="addItem"
|
|
210
|
+
/>
|
|
211
|
+
<!-- Text mode: show text input for value -->
|
|
212
|
+
<template v-if="isTextMode">
|
|
213
|
+
<InputText
|
|
214
|
+
v-model="newItem.value"
|
|
215
|
+
:placeholder="valuePlaceholder"
|
|
216
|
+
class="kv-add-value"
|
|
217
|
+
@keyup.enter="addItem"
|
|
218
|
+
/>
|
|
219
|
+
</template>
|
|
220
|
+
<!-- Number mode: show slider -->
|
|
221
|
+
<template v-else>
|
|
222
|
+
<div class="kv-slider">
|
|
223
|
+
<span>{{ formatValue(newItem.value) }}</span>
|
|
224
|
+
<Slider v-model="newItem.value" :min="min" :max="max" :step="step" style="width: 100px" />
|
|
225
|
+
</div>
|
|
226
|
+
</template>
|
|
227
|
+
</div>
|
|
228
|
+
<small v-if="help" class="form-field-help">{{ help }}</small>
|
|
229
|
+
</div>
|
|
230
|
+
</template>
|
|
231
|
+
|
|
232
|
+
<style scoped>
|
|
233
|
+
.kv-list {
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-direction: column;
|
|
236
|
+
gap: 0.5rem;
|
|
237
|
+
margin-bottom: 0.5rem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.kv-item {
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
gap: 0.5rem;
|
|
244
|
+
padding: 0.5rem;
|
|
245
|
+
background: var(--p-surface-50);
|
|
246
|
+
border-radius: 0.25rem;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.kv-key {
|
|
250
|
+
font-weight: 500;
|
|
251
|
+
min-width: 120px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.kv-value-container {
|
|
255
|
+
display: flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
gap: 0.5rem;
|
|
258
|
+
flex: 1;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.kv-value {
|
|
262
|
+
min-width: 50px;
|
|
263
|
+
text-align: right;
|
|
264
|
+
font-family: monospace;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.kv-value.negative {
|
|
268
|
+
color: var(--p-red-500);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.kv-value.positive {
|
|
272
|
+
color: var(--p-green-500);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.kv-add {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 0.5rem;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.kv-add-input {
|
|
282
|
+
flex: 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.kv-add-value {
|
|
286
|
+
flex: 1;
|
|
287
|
+
min-width: 150px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.kv-text-input {
|
|
291
|
+
flex: 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.kv-slider {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: 0.5rem;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.kv-slider span {
|
|
301
|
+
min-width: 50px;
|
|
302
|
+
text-align: right;
|
|
303
|
+
font-family: monospace;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.kv-empty {
|
|
307
|
+
padding: 0.75rem;
|
|
308
|
+
color: var(--p-text-secondary);
|
|
309
|
+
font-style: italic;
|
|
310
|
+
background: var(--p-surface-50);
|
|
311
|
+
border-radius: 0.25rem;
|
|
312
|
+
text-align: center;
|
|
313
|
+
}
|
|
314
|
+
</style>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* LanguageEditor - Reusable component for editing language capabilities
|
|
4
|
+
*
|
|
5
|
+
* Props:
|
|
6
|
+
* - modelValue: Array of {code, fluency, primary} objects
|
|
7
|
+
* - label: Field label
|
|
8
|
+
* - help: Help text
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ref, computed } from 'vue'
|
|
12
|
+
import Button from 'primevue/button'
|
|
13
|
+
import InputText from 'primevue/inputtext'
|
|
14
|
+
import Slider from 'primevue/slider'
|
|
15
|
+
import Checkbox from 'primevue/checkbox'
|
|
16
|
+
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
modelValue: {
|
|
19
|
+
type: Array,
|
|
20
|
+
default: () => []
|
|
21
|
+
},
|
|
22
|
+
label: {
|
|
23
|
+
type: String,
|
|
24
|
+
default: 'Languages'
|
|
25
|
+
},
|
|
26
|
+
help: {
|
|
27
|
+
type: String,
|
|
28
|
+
default: ''
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits(['update:modelValue'])
|
|
33
|
+
|
|
34
|
+
const languages = computed({
|
|
35
|
+
get: () => props.modelValue || [],
|
|
36
|
+
set: (value) => emit('update:modelValue', value)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const newLanguage = ref({ code: '', fluency: 0.8, primary: false })
|
|
40
|
+
|
|
41
|
+
function addLanguage() {
|
|
42
|
+
if (!newLanguage.value.code) return
|
|
43
|
+
let updated = [...languages.value]
|
|
44
|
+
|
|
45
|
+
// If this is the first language, make it primary
|
|
46
|
+
if (updated.length === 0) {
|
|
47
|
+
newLanguage.value.primary = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// If new language is primary, remove primary from others
|
|
51
|
+
if (newLanguage.value.primary) {
|
|
52
|
+
updated = updated.map(l => ({ ...l, primary: false }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
updated.push({ ...newLanguage.value })
|
|
56
|
+
emit('update:modelValue', updated)
|
|
57
|
+
newLanguage.value = { code: '', fluency: 0.8, primary: false }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function removeLanguage(index) {
|
|
61
|
+
const updated = [...languages.value]
|
|
62
|
+
const wasPrimary = updated[index].primary
|
|
63
|
+
updated.splice(index, 1)
|
|
64
|
+
// If we removed the primary, make the first one primary
|
|
65
|
+
if (wasPrimary && updated.length > 0) {
|
|
66
|
+
updated[0].primary = true
|
|
67
|
+
}
|
|
68
|
+
emit('update:modelValue', updated)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function setPrimaryLanguage(index) {
|
|
72
|
+
const updated = languages.value.map((l, i) => ({
|
|
73
|
+
...l,
|
|
74
|
+
primary: i === index
|
|
75
|
+
}))
|
|
76
|
+
emit('update:modelValue', updated)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function updateFluency(index, fluency) {
|
|
80
|
+
const updated = [...languages.value]
|
|
81
|
+
updated[index] = { ...updated[index], fluency }
|
|
82
|
+
emit('update:modelValue', updated)
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<div class="form-field">
|
|
88
|
+
<label>{{ label }}</label>
|
|
89
|
+
<div class="lang-list">
|
|
90
|
+
<div v-if="languages.length === 0" class="lang-empty">
|
|
91
|
+
No languages added yet
|
|
92
|
+
</div>
|
|
93
|
+
<div v-for="(lang, index) in languages" :key="index" class="lang-item">
|
|
94
|
+
<span class="lang-code">{{ lang.code.toUpperCase() }}</span>
|
|
95
|
+
<div class="lang-fluency-container">
|
|
96
|
+
<span class="lang-fluency">{{ (lang.fluency * 100).toFixed(0) }}%</span>
|
|
97
|
+
<Slider
|
|
98
|
+
:modelValue="lang.fluency"
|
|
99
|
+
@update:modelValue="(v) => updateFluency(index, v)"
|
|
100
|
+
:min="0.1"
|
|
101
|
+
:max="1"
|
|
102
|
+
:step="0.1"
|
|
103
|
+
style="width: 100px"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<Button
|
|
107
|
+
v-if="lang.primary"
|
|
108
|
+
icon="pi pi-star-fill"
|
|
109
|
+
severity="warning"
|
|
110
|
+
text
|
|
111
|
+
rounded
|
|
112
|
+
size="small"
|
|
113
|
+
v-tooltip.top="'Primary language'"
|
|
114
|
+
/>
|
|
115
|
+
<Button
|
|
116
|
+
v-else
|
|
117
|
+
icon="pi pi-star"
|
|
118
|
+
severity="secondary"
|
|
119
|
+
text
|
|
120
|
+
rounded
|
|
121
|
+
size="small"
|
|
122
|
+
@click="setPrimaryLanguage(index)"
|
|
123
|
+
v-tooltip.top="'Set as primary'"
|
|
124
|
+
/>
|
|
125
|
+
<Button
|
|
126
|
+
icon="pi pi-times"
|
|
127
|
+
severity="danger"
|
|
128
|
+
text
|
|
129
|
+
rounded
|
|
130
|
+
size="small"
|
|
131
|
+
@click="removeLanguage(index)"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="lang-add">
|
|
136
|
+
<Button
|
|
137
|
+
icon="pi pi-plus"
|
|
138
|
+
@click="addLanguage"
|
|
139
|
+
:disabled="!newLanguage.code"
|
|
140
|
+
v-tooltip.top="'Add language'"
|
|
141
|
+
/>
|
|
142
|
+
<InputText
|
|
143
|
+
v-model="newLanguage.code"
|
|
144
|
+
placeholder="Code (e.g., en, fr)"
|
|
145
|
+
class="lang-add-input"
|
|
146
|
+
@keyup.enter="addLanguage"
|
|
147
|
+
/>
|
|
148
|
+
<div class="lang-slider">
|
|
149
|
+
<span>{{ (newLanguage.fluency * 100).toFixed(0) }}%</span>
|
|
150
|
+
<Slider v-model="newLanguage.fluency" :min="0.1" :max="1" :step="0.1" style="width: 80px" />
|
|
151
|
+
</div>
|
|
152
|
+
<div class="lang-primary-toggle">
|
|
153
|
+
<Checkbox v-model="newLanguage.primary" :binary="true" inputId="newLangPrimary" />
|
|
154
|
+
<label for="newLangPrimary" class="lang-primary-label">Primary</label>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<small v-if="help" class="form-field-help">{{ help }}</small>
|
|
158
|
+
</div>
|
|
159
|
+
</template>
|
|
160
|
+
|
|
161
|
+
<style scoped>
|
|
162
|
+
.lang-list {
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-direction: column;
|
|
165
|
+
gap: 0.5rem;
|
|
166
|
+
margin-bottom: 0.5rem;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.lang-item {
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 0.5rem;
|
|
173
|
+
padding: 0.5rem;
|
|
174
|
+
background: var(--p-surface-50);
|
|
175
|
+
border-radius: 0.25rem;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.lang-code {
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
min-width: 40px;
|
|
181
|
+
padding: 0.25rem 0.5rem;
|
|
182
|
+
background: var(--p-primary-100);
|
|
183
|
+
color: var(--p-primary-700);
|
|
184
|
+
border-radius: 0.25rem;
|
|
185
|
+
text-align: center;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.lang-fluency-container {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 0.5rem;
|
|
192
|
+
flex: 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.lang-fluency {
|
|
196
|
+
min-width: 40px;
|
|
197
|
+
text-align: right;
|
|
198
|
+
font-family: monospace;
|
|
199
|
+
color: var(--p-text-secondary);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.lang-add {
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
gap: 0.5rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.lang-add-input {
|
|
209
|
+
flex: 1;
|
|
210
|
+
max-width: 150px;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.lang-slider {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 0.5rem;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.lang-slider span {
|
|
220
|
+
min-width: 40px;
|
|
221
|
+
text-align: right;
|
|
222
|
+
font-family: monospace;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.lang-empty {
|
|
226
|
+
padding: 0.75rem;
|
|
227
|
+
color: var(--p-text-secondary);
|
|
228
|
+
font-style: italic;
|
|
229
|
+
background: var(--p-surface-50);
|
|
230
|
+
border-radius: 0.25rem;
|
|
231
|
+
text-align: center;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.lang-primary-toggle {
|
|
235
|
+
display: flex;
|
|
236
|
+
align-items: center;
|
|
237
|
+
gap: 0.25rem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.lang-primary-label {
|
|
241
|
+
font-size: 0.875rem;
|
|
242
|
+
color: var(--p-text-secondary);
|
|
243
|
+
cursor: pointer;
|
|
244
|
+
}
|
|
245
|
+
</style>
|