@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.
@@ -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';