@vue-skuilder/common-ui 0.1.18 → 0.1.21
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/dist/common-ui.es.js +1 -1
- package/dist/common-ui.es.js.map +1 -1
- package/dist/common-ui.umd.js +1 -1
- package/dist/common-ui.umd.js.map +1 -1
- package/dist/components/UserTagPreferences.vue.d.ts +128 -0
- package/dist/components/UserTagPreferences.vue.d.ts.map +1 -0
- package/dist/components/auth/UserRegistration.vue.d.ts +12 -0
- package/dist/components/auth/UserRegistration.vue.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/stores/useAuthStore.d.ts +12 -0
- package/dist/stores/useAuthStore.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/components/UserTagPreferences.vue +514 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card :loading="loading">
|
|
3
|
+
<v-card-title>
|
|
4
|
+
<v-icon start>mdi-tune</v-icon>
|
|
5
|
+
Learning Preferences
|
|
6
|
+
</v-card-title>
|
|
7
|
+
|
|
8
|
+
<v-card-subtitle v-if="courseId">
|
|
9
|
+
Customize how content is presented in this course
|
|
10
|
+
</v-card-subtitle>
|
|
11
|
+
|
|
12
|
+
<v-card-text>
|
|
13
|
+
<v-alert v-if="!courseId" type="info" variant="tonal" class="mb-4">
|
|
14
|
+
Select a course to configure your learning preferences.
|
|
15
|
+
</v-alert>
|
|
16
|
+
|
|
17
|
+
<div v-else-if="!loading">
|
|
18
|
+
<!-- Tag Preferences Section -->
|
|
19
|
+
<div class="mb-6">
|
|
20
|
+
<h3 class="text-subtitle-1 font-weight-bold mb-2">
|
|
21
|
+
<v-icon start size="small">mdi-tune-variant</v-icon>
|
|
22
|
+
Tag Preferences
|
|
23
|
+
</h3>
|
|
24
|
+
<p class="text-body-2 text-medium-emphasis mb-3">
|
|
25
|
+
Adjust how much you want to see cards with specific tags. 0 = exclude, 1 = neutral, higher = prefer more.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<!-- Tag autocomplete -->
|
|
29
|
+
<v-autocomplete
|
|
30
|
+
v-model="tagToAdd"
|
|
31
|
+
:items="availableTagsToAdd"
|
|
32
|
+
item-title="name"
|
|
33
|
+
item-value="name"
|
|
34
|
+
label="Add tag preference"
|
|
35
|
+
placeholder="Search tags..."
|
|
36
|
+
density="compact"
|
|
37
|
+
variant="outlined"
|
|
38
|
+
clearable
|
|
39
|
+
hide-details
|
|
40
|
+
class="mb-4"
|
|
41
|
+
@update:model-value="addTag"
|
|
42
|
+
>
|
|
43
|
+
<template #item="{ props, item }">
|
|
44
|
+
<v-list-item v-bind="props">
|
|
45
|
+
<template #subtitle>
|
|
46
|
+
{{ item.raw.snippet }}
|
|
47
|
+
</template>
|
|
48
|
+
</v-list-item>
|
|
49
|
+
</template>
|
|
50
|
+
</v-autocomplete>
|
|
51
|
+
|
|
52
|
+
<!-- Tag slider list -->
|
|
53
|
+
<v-list v-if="tagNames.length > 0" class="mt-4">
|
|
54
|
+
<v-list-item
|
|
55
|
+
v-for="tagName in tagNames"
|
|
56
|
+
:key="tagName"
|
|
57
|
+
class="px-0"
|
|
58
|
+
>
|
|
59
|
+
<template #default>
|
|
60
|
+
<div class="d-flex align-center ga-3 w-100">
|
|
61
|
+
<!-- Tag name -->
|
|
62
|
+
<v-chip
|
|
63
|
+
size="small"
|
|
64
|
+
variant="tonal"
|
|
65
|
+
class="flex-shrink-0"
|
|
66
|
+
style="min-width: 120px;"
|
|
67
|
+
>
|
|
68
|
+
{{ tagName }}
|
|
69
|
+
</v-chip>
|
|
70
|
+
|
|
71
|
+
<!-- Slider -->
|
|
72
|
+
<v-slider
|
|
73
|
+
:model-value="preferences.boost[tagName]"
|
|
74
|
+
:min="sliderConfigResolved.min"
|
|
75
|
+
:max="globalSliderMax"
|
|
76
|
+
:step="0.01"
|
|
77
|
+
hide-details
|
|
78
|
+
class="flex-grow-1"
|
|
79
|
+
thumb-label
|
|
80
|
+
@update:model-value="(val: number) => updateBoost(tagName, val)"
|
|
81
|
+
>
|
|
82
|
+
<template #thumb-label="{ modelValue }">
|
|
83
|
+
{{ formatMultiplier(modelValue) }}
|
|
84
|
+
</template>
|
|
85
|
+
</v-slider>
|
|
86
|
+
|
|
87
|
+
<!-- Current value display -->
|
|
88
|
+
<span class="text-body-2 flex-shrink-0" style="min-width: 60px; text-align: right;">
|
|
89
|
+
{{ formatMultiplier(preferences.boost[tagName]) }}
|
|
90
|
+
</span>
|
|
91
|
+
|
|
92
|
+
<!-- Expand range button (always visible) -->
|
|
93
|
+
<v-btn
|
|
94
|
+
icon="mdi-plus"
|
|
95
|
+
size="small"
|
|
96
|
+
variant="text"
|
|
97
|
+
density="compact"
|
|
98
|
+
:disabled="globalSliderMax >= sliderConfigResolved.absoluteMax"
|
|
99
|
+
@click="expandSliderRange(tagName)"
|
|
100
|
+
/>
|
|
101
|
+
|
|
102
|
+
<!-- Delete button -->
|
|
103
|
+
<v-btn
|
|
104
|
+
icon="mdi-delete"
|
|
105
|
+
size="small"
|
|
106
|
+
variant="text"
|
|
107
|
+
density="compact"
|
|
108
|
+
@click="removeTag(tagName)"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
</v-list-item>
|
|
113
|
+
</v-list>
|
|
114
|
+
|
|
115
|
+
<v-alert v-else type="info" variant="tonal" class="mt-4">
|
|
116
|
+
No tag preferences configured yet. Add tags above to get started.
|
|
117
|
+
</v-alert>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- Status / Feedback -->
|
|
121
|
+
<v-alert v-if="saveError" type="error" variant="tonal" class="mt-4" closable @click:close="saveError = ''">
|
|
122
|
+
{{ saveError }}
|
|
123
|
+
</v-alert>
|
|
124
|
+
|
|
125
|
+
<v-alert v-if="saveSuccess" type="success" variant="tonal" class="mt-4" closable @click:close="saveSuccess = false">
|
|
126
|
+
Preferences saved successfully
|
|
127
|
+
</v-alert>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div v-else class="d-flex justify-center py-8">
|
|
131
|
+
<v-progress-circular indeterminate color="primary" />
|
|
132
|
+
</div>
|
|
133
|
+
</v-card-text>
|
|
134
|
+
|
|
135
|
+
<v-card-actions v-if="courseId && !loading">
|
|
136
|
+
<v-spacer />
|
|
137
|
+
<v-btn
|
|
138
|
+
variant="text"
|
|
139
|
+
:disabled="!hasChanges"
|
|
140
|
+
@click="resetToSaved"
|
|
141
|
+
>
|
|
142
|
+
Reset
|
|
143
|
+
</v-btn>
|
|
144
|
+
<v-btn
|
|
145
|
+
color="primary"
|
|
146
|
+
variant="flat"
|
|
147
|
+
:loading="saving"
|
|
148
|
+
:disabled="!hasChanges"
|
|
149
|
+
@click="savePreferences"
|
|
150
|
+
>
|
|
151
|
+
Save Preferences
|
|
152
|
+
</v-btn>
|
|
153
|
+
</v-card-actions>
|
|
154
|
+
</v-card>
|
|
155
|
+
</template>
|
|
156
|
+
|
|
157
|
+
<script lang="ts">
|
|
158
|
+
import { defineComponent, PropType } from 'vue';
|
|
159
|
+
import { getDataLayer, Tag, CourseDBInterface, UserDBInterface } from '@vue-skuilder/db';
|
|
160
|
+
import { getCurrentUser } from '../stores/useAuthStore';
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* User's tag preference state, matching the backend schema.
|
|
164
|
+
*/
|
|
165
|
+
interface UserTagPreferenceState {
|
|
166
|
+
boost: Record<string, number>;
|
|
167
|
+
updatedAt: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Slider configuration for tag preferences.
|
|
172
|
+
*/
|
|
173
|
+
interface SliderConfig {
|
|
174
|
+
min?: number; // default: 0
|
|
175
|
+
startingMax?: number; // default: 2
|
|
176
|
+
absoluteMax?: number; // default: 10
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const DEFAULT_SLIDER_CONFIG: Required<SliderConfig> = {
|
|
180
|
+
min: 0,
|
|
181
|
+
startingMax: 2,
|
|
182
|
+
absoluteMax: 10,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const STRATEGY_KEY = 'UserTagPreferenceFilter';
|
|
186
|
+
|
|
187
|
+
export default defineComponent({
|
|
188
|
+
name: 'UserTagPreferences',
|
|
189
|
+
|
|
190
|
+
props: {
|
|
191
|
+
/**
|
|
192
|
+
* Course ID to configure preferences for.
|
|
193
|
+
* If not provided, component shows a prompt to select a course.
|
|
194
|
+
*/
|
|
195
|
+
courseId: {
|
|
196
|
+
type: String as PropType<string>,
|
|
197
|
+
required: false,
|
|
198
|
+
default: '',
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Slider configuration (min, startingMax, absoluteMax).
|
|
203
|
+
* All fields optional, defaults: { min: 0, startingMax: 2, absoluteMax: 10 }
|
|
204
|
+
*/
|
|
205
|
+
sliderConfig: {
|
|
206
|
+
type: Object as PropType<SliderConfig>,
|
|
207
|
+
required: false,
|
|
208
|
+
default: () => ({}),
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
emits: ['preferences-saved', 'preferences-changed'],
|
|
213
|
+
|
|
214
|
+
data() {
|
|
215
|
+
return {
|
|
216
|
+
loading: true,
|
|
217
|
+
saving: false,
|
|
218
|
+
saveError: '',
|
|
219
|
+
saveSuccess: false,
|
|
220
|
+
|
|
221
|
+
// Current working state
|
|
222
|
+
preferences: {
|
|
223
|
+
boost: {} as Record<string, number>,
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// Saved state for comparison
|
|
227
|
+
savedPreferences: {
|
|
228
|
+
boost: {} as Record<string, number>,
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Per-tag current max range (for dynamic expansion)
|
|
232
|
+
tagMaxRanges: {} as Record<string, number>,
|
|
233
|
+
|
|
234
|
+
// Available tags from course
|
|
235
|
+
availableTags: [] as Tag[],
|
|
236
|
+
|
|
237
|
+
// Autocomplete model
|
|
238
|
+
tagToAdd: null as string | null,
|
|
239
|
+
|
|
240
|
+
// DB references
|
|
241
|
+
courseDB: null as CourseDBInterface | null,
|
|
242
|
+
user: null as UserDBInterface | null,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
computed: {
|
|
247
|
+
/**
|
|
248
|
+
* Resolved slider config with defaults
|
|
249
|
+
*/
|
|
250
|
+
sliderConfigResolved(): Required<SliderConfig> {
|
|
251
|
+
return {
|
|
252
|
+
min: this.sliderConfig.min ?? DEFAULT_SLIDER_CONFIG.min,
|
|
253
|
+
startingMax: this.sliderConfig.startingMax ?? DEFAULT_SLIDER_CONFIG.startingMax,
|
|
254
|
+
absoluteMax: this.sliderConfig.absoluteMax ?? DEFAULT_SLIDER_CONFIG.absoluteMax,
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* List of tag names that have preferences (sorted alphabetically)
|
|
260
|
+
*/
|
|
261
|
+
tagNames(): string[] {
|
|
262
|
+
return Object.keys(this.preferences.boost).sort();
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Global max for all sliders (highest tagMaxRanges value)
|
|
267
|
+
* Ensures all sliders have the same visual scale
|
|
268
|
+
*/
|
|
269
|
+
globalSliderMax(): number {
|
|
270
|
+
const maxValues = Object.values(this.tagMaxRanges);
|
|
271
|
+
if (maxValues.length === 0) {
|
|
272
|
+
return this.sliderConfigResolved.startingMax;
|
|
273
|
+
}
|
|
274
|
+
return Math.max(...maxValues);
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Tags available to add (not already in preferences)
|
|
279
|
+
*/
|
|
280
|
+
availableTagsToAdd(): Tag[] {
|
|
281
|
+
const usedTags = new Set(Object.keys(this.preferences.boost));
|
|
282
|
+
return this.availableTags.filter((t) => !usedTags.has(t.name));
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check if current preferences differ from saved state
|
|
287
|
+
*/
|
|
288
|
+
hasChanges(): boolean {
|
|
289
|
+
const currentTags = Object.keys(this.preferences.boost).sort();
|
|
290
|
+
const savedTags = Object.keys(this.savedPreferences.boost).sort();
|
|
291
|
+
|
|
292
|
+
if (currentTags.length !== savedTags.length) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (currentTags.join(',') !== savedTags.join(',')) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return currentTags.some(
|
|
301
|
+
(tag) => this.preferences.boost[tag] !== this.savedPreferences.boost[tag]
|
|
302
|
+
);
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
watch: {
|
|
307
|
+
courseId: {
|
|
308
|
+
immediate: true,
|
|
309
|
+
async handler(newCourseId: string) {
|
|
310
|
+
if (newCourseId) {
|
|
311
|
+
await this.loadPreferences();
|
|
312
|
+
} else {
|
|
313
|
+
this.loading = false;
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
methods: {
|
|
320
|
+
/**
|
|
321
|
+
* Load preferences from strategy state and available tags from course
|
|
322
|
+
*/
|
|
323
|
+
async loadPreferences() {
|
|
324
|
+
this.loading = true;
|
|
325
|
+
this.saveError = '';
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
// Get user and course DB
|
|
329
|
+
this.user = (await getCurrentUser()) ?? null;
|
|
330
|
+
if (!this.user) {
|
|
331
|
+
throw new Error('User not available');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
this.courseDB = getDataLayer().getCourseDB(this.courseId);
|
|
335
|
+
|
|
336
|
+
// Load available tags
|
|
337
|
+
const tagStubs = await this.courseDB.getCourseTagStubs();
|
|
338
|
+
this.availableTags = tagStubs.rows.map((r) => r.doc!).filter(Boolean);
|
|
339
|
+
|
|
340
|
+
// Load saved preferences
|
|
341
|
+
const state = await this.user.getStrategyState<UserTagPreferenceState>(
|
|
342
|
+
this.courseId,
|
|
343
|
+
STRATEGY_KEY
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (state) {
|
|
347
|
+
this.preferences.boost = { ...state.boost };
|
|
348
|
+
} else {
|
|
349
|
+
// No preferences yet - start fresh
|
|
350
|
+
this.preferences.boost = {};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Store saved state for comparison
|
|
354
|
+
this.savedPreferences.boost = { ...this.preferences.boost };
|
|
355
|
+
|
|
356
|
+
// Initialize tag max ranges, auto-expanding if saved value exceeds startingMax
|
|
357
|
+
this.tagMaxRanges = {};
|
|
358
|
+
Object.keys(this.preferences.boost).forEach((tag) => {
|
|
359
|
+
const savedValue = this.preferences.boost[tag];
|
|
360
|
+
const startingMax = this.sliderConfigResolved.startingMax;
|
|
361
|
+
|
|
362
|
+
// If saved value exceeds startingMax, expand range to accommodate it
|
|
363
|
+
// (capped at absoluteMax)
|
|
364
|
+
if (savedValue > startingMax) {
|
|
365
|
+
this.tagMaxRanges[tag] = Math.min(
|
|
366
|
+
Math.ceil(savedValue),
|
|
367
|
+
this.sliderConfigResolved.absoluteMax
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
this.tagMaxRanges[tag] = startingMax;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.error('Failed to load preferences:', e);
|
|
375
|
+
this.saveError = 'Failed to load preferences. Please try again.';
|
|
376
|
+
} finally {
|
|
377
|
+
this.loading = false;
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Add a tag to preferences with default multiplier of 1.0
|
|
383
|
+
*/
|
|
384
|
+
addTag(tagName: string | null) {
|
|
385
|
+
if (tagName && !(tagName in this.preferences.boost)) {
|
|
386
|
+
this.preferences.boost[tagName] = 1.0;
|
|
387
|
+
this.tagMaxRanges[tagName] = this.sliderConfigResolved.startingMax;
|
|
388
|
+
this.$emit('preferences-changed', this.preferences);
|
|
389
|
+
}
|
|
390
|
+
// Clear the autocomplete
|
|
391
|
+
this.$nextTick(() => {
|
|
392
|
+
this.tagToAdd = null;
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Remove a tag from preferences
|
|
398
|
+
*/
|
|
399
|
+
removeTag(tagName: string) {
|
|
400
|
+
delete this.preferences.boost[tagName];
|
|
401
|
+
delete this.tagMaxRanges[tagName];
|
|
402
|
+
this.$emit('preferences-changed', this.preferences);
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Update boost multiplier for a tag
|
|
407
|
+
*/
|
|
408
|
+
updateBoost(tagName: string, value: number) {
|
|
409
|
+
this.preferences.boost[tagName] = value;
|
|
410
|
+
this.$emit('preferences-changed', this.preferences);
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Expand the global slider range by 1 and move the triggering tag's slider to new max
|
|
415
|
+
*/
|
|
416
|
+
expandSliderRange(tagName: string) {
|
|
417
|
+
const currentGlobalMax = this.globalSliderMax;
|
|
418
|
+
if (currentGlobalMax < this.sliderConfigResolved.absoluteMax) {
|
|
419
|
+
const newGlobalMax = currentGlobalMax + 1;
|
|
420
|
+
|
|
421
|
+
// Expand all tag ranges to the new global max
|
|
422
|
+
// (ensures all sliders stay synchronized)
|
|
423
|
+
Object.keys(this.tagMaxRanges).forEach((tag) => {
|
|
424
|
+
this.tagMaxRanges[tag] = Math.max(this.tagMaxRanges[tag], newGlobalMax);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Move the triggering tag's slider to new max
|
|
428
|
+
this.preferences.boost[tagName] = newGlobalMax;
|
|
429
|
+
this.$emit('preferences-changed', this.preferences);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Format multiplier for display
|
|
435
|
+
*/
|
|
436
|
+
formatMultiplier(value: number): string {
|
|
437
|
+
if (value === 0) {
|
|
438
|
+
return '0x';
|
|
439
|
+
}
|
|
440
|
+
if (value < 0.1) {
|
|
441
|
+
return value.toFixed(2) + 'x';
|
|
442
|
+
}
|
|
443
|
+
return value.toFixed(1) + 'x';
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Reset to last saved state
|
|
448
|
+
*/
|
|
449
|
+
resetToSaved() {
|
|
450
|
+
this.preferences.boost = { ...this.savedPreferences.boost };
|
|
451
|
+
this.saveSuccess = false;
|
|
452
|
+
this.saveError = '';
|
|
453
|
+
|
|
454
|
+
// Reset tag max ranges, auto-expanding if saved value exceeds startingMax
|
|
455
|
+
this.tagMaxRanges = {};
|
|
456
|
+
Object.keys(this.preferences.boost).forEach((tag) => {
|
|
457
|
+
const savedValue = this.preferences.boost[tag];
|
|
458
|
+
const startingMax = this.sliderConfigResolved.startingMax;
|
|
459
|
+
|
|
460
|
+
if (savedValue > startingMax) {
|
|
461
|
+
this.tagMaxRanges[tag] = Math.min(
|
|
462
|
+
Math.ceil(savedValue),
|
|
463
|
+
this.sliderConfigResolved.absoluteMax
|
|
464
|
+
);
|
|
465
|
+
} else {
|
|
466
|
+
this.tagMaxRanges[tag] = startingMax;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Save preferences to strategy state
|
|
473
|
+
*/
|
|
474
|
+
async savePreferences() {
|
|
475
|
+
if (!this.user || !this.courseId) {
|
|
476
|
+
this.saveError = 'Unable to save - user or course not available';
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
this.saving = true;
|
|
481
|
+
this.saveError = '';
|
|
482
|
+
this.saveSuccess = false;
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const state: UserTagPreferenceState = {
|
|
486
|
+
boost: { ...this.preferences.boost },
|
|
487
|
+
updatedAt: new Date().toISOString(),
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
await this.user.putStrategyState<UserTagPreferenceState>(
|
|
491
|
+
this.courseId,
|
|
492
|
+
STRATEGY_KEY,
|
|
493
|
+
state
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// Update saved state
|
|
497
|
+
this.savedPreferences.boost = { ...this.preferences.boost };
|
|
498
|
+
|
|
499
|
+
this.saveSuccess = true;
|
|
500
|
+
this.$emit('preferences-saved', state);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
console.error('Failed to save preferences:', e);
|
|
503
|
+
this.saveError = 'Failed to save preferences. Please try again.';
|
|
504
|
+
} finally {
|
|
505
|
+
this.saving = false;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
</script>
|
|
511
|
+
|
|
512
|
+
<style scoped>
|
|
513
|
+
/* Additional styles if needed */
|
|
514
|
+
</style>
|
package/src/index.ts
CHANGED
|
@@ -98,3 +98,4 @@ export { default as CardBrowser } from './components/CardBrowser.vue';
|
|
|
98
98
|
export { default as CourseCardBrowser } from './components/CourseCardBrowser.vue';
|
|
99
99
|
export { default as TagsInput } from './components/TagsInput.vue';
|
|
100
100
|
export { default as CourseTagFilterWidget } from './components/CourseTagFilterWidget.vue';
|
|
101
|
+
export { default as UserTagPreferences } from './components/UserTagPreferences.vue';
|