@live-change/frontend-template 0.9.197 → 0.9.199
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/.claude/rules/live-change-backend-actions-views-triggers.md +184 -0
- package/.claude/rules/live-change-backend-architecture.md +126 -0
- package/.claude/rules/live-change-backend-models-and-relations.md +188 -0
- package/.claude/rules/live-change-frontend-vue-primevue.md +291 -0
- package/.claude/rules/live-change-service-structure.md +89 -0
- package/.claude/skills/create-skills-and-rules.md +196 -0
- package/.claude/skills/live-change-design-actions-views-triggers.md +190 -0
- package/.claude/skills/live-change-design-models-relations.md +173 -0
- package/.claude/skills/live-change-design-service.md +132 -0
- package/.claude/skills/live-change-frontend-action-buttons.md +128 -0
- package/.claude/skills/live-change-frontend-action-form.md +143 -0
- package/.claude/skills/live-change-frontend-analytics.md +146 -0
- package/.claude/skills/live-change-frontend-command-forms.md +215 -0
- package/.claude/skills/live-change-frontend-data-views.md +182 -0
- package/.claude/skills/live-change-frontend-editor-form.md +177 -0
- package/.claude/skills/live-change-frontend-locale-time.md +171 -0
- package/.claude/skills/live-change-frontend-page-list-detail.md +200 -0
- package/.claude/skills/live-change-frontend-range-list.md +128 -0
- package/.claude/skills/live-change-frontend-ssr-setup.md +118 -0
- package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +202 -0
- package/.cursor/rules/live-change-backend-architecture.mdc +131 -0
- package/.cursor/rules/live-change-backend-models-and-relations.mdc +194 -0
- package/.cursor/rules/live-change-frontend-vue-primevue.mdc +290 -0
- package/.cursor/rules/live-change-service-structure.mdc +107 -0
- package/.cursor/skills/live-change-design-actions-views-triggers.md +197 -0
- package/.cursor/skills/live-change-design-models-relations.md +168 -0
- package/.cursor/skills/live-change-design-service.md +75 -0
- package/.cursor/skills/live-change-frontend-action-buttons.md +128 -0
- package/.cursor/skills/live-change-frontend-action-form.md +143 -0
- package/.cursor/skills/live-change-frontend-analytics.md +146 -0
- package/.cursor/skills/live-change-frontend-command-forms.md +215 -0
- package/.cursor/skills/live-change-frontend-data-views.md +182 -0
- package/.cursor/skills/live-change-frontend-editor-form.md +177 -0
- package/.cursor/skills/live-change-frontend-locale-time.md +171 -0
- package/.cursor/skills/live-change-frontend-page-list-detail.md +200 -0
- package/.cursor/skills/live-change-frontend-range-list.md +128 -0
- package/.cursor/skills/live-change-frontend-ssr-setup.md +119 -0
- package/README.md +71 -0
- package/package.json +50 -50
- package/server/app.config.js +35 -0
- package/server/services.list.js +2 -0
- package/.nx/workspace-data/file-map.json +0 -195
- package/.nx/workspace-data/nx_files.nxt +0 -0
- package/.nx/workspace-data/project-graph.json +0 -8
- package/.nx/workspace-data/project-graph.lock +0 -0
- package/.nx/workspace-data/source-maps.json +0 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Build CRUD editing forms with editorData and AutoField components
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill: live-change-frontend-editor-form (Claude Code)
|
|
6
|
+
|
|
7
|
+
Use this skill when you build **CRUD editing forms** with `editorData` and `AutoField` in a LiveChange frontend.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- You need a create/edit form for a model record.
|
|
12
|
+
- You want draft auto-saving while the user types.
|
|
13
|
+
- You prefer individual `<AutoField>` components over `<AutoEditor>` for layout control.
|
|
14
|
+
|
|
15
|
+
**Not editing a model?** Use `actionData` instead (see `live-change-frontend-action-form` skill).
|
|
16
|
+
**No form fields, just a button?** Use `api.command` (see `live-change-frontend-command-forms` skill).
|
|
17
|
+
|
|
18
|
+
## Step 1 – Set up editorData
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import { ref, getCurrentInstance } from 'vue'
|
|
22
|
+
import { AutoField, editorData } from '@live-change/frontend-auto-form'
|
|
23
|
+
import { useToast } from 'primevue/usetoast'
|
|
24
|
+
import { useRouter } from 'vue-router'
|
|
25
|
+
|
|
26
|
+
const toast = useToast()
|
|
27
|
+
const router = useRouter()
|
|
28
|
+
const appContext = getCurrentInstance().appContext
|
|
29
|
+
|
|
30
|
+
const editor = ref(null)
|
|
31
|
+
|
|
32
|
+
async function loadEditor() {
|
|
33
|
+
editor.value = await editorData({
|
|
34
|
+
service: 'blog',
|
|
35
|
+
model: 'Article',
|
|
36
|
+
identifiers: { article: props.articleId },
|
|
37
|
+
draft: true,
|
|
38
|
+
appContext,
|
|
39
|
+
toast,
|
|
40
|
+
onSaved: () => {
|
|
41
|
+
toast.add({ severity: 'success', summary: 'Saved', life: 1500 })
|
|
42
|
+
},
|
|
43
|
+
onCreated: (result) => {
|
|
44
|
+
router.push({ name: 'article', params: { article: result } })
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
loadEditor()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For new records, pass an empty or missing identifier:
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
identifiers: { article: props.articleId } // props.articleId can be undefined for new
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Step 2 – Build the template with AutoField
|
|
59
|
+
|
|
60
|
+
Use `editor.model.properties.*` as definitions and `editor.value.*` as v-model:
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<template>
|
|
64
|
+
<div v-if="editor" class="space-y-4">
|
|
65
|
+
<AutoField
|
|
66
|
+
:definition="editor.model.properties.title"
|
|
67
|
+
v-model="editor.value.title"
|
|
68
|
+
:error="editor.propertiesErrors?.title"
|
|
69
|
+
label="Title"
|
|
70
|
+
/>
|
|
71
|
+
<AutoField
|
|
72
|
+
:definition="editor.model.properties.body"
|
|
73
|
+
v-model="editor.value.body"
|
|
74
|
+
:error="editor.propertiesErrors?.body"
|
|
75
|
+
label="Body"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<!-- Manual field alongside AutoField -->
|
|
79
|
+
<div>
|
|
80
|
+
<label class="block text-sm font-medium mb-1">Category</label>
|
|
81
|
+
<Dropdown
|
|
82
|
+
v-model="editor.value.category"
|
|
83
|
+
:options="categories"
|
|
84
|
+
optionLabel="name"
|
|
85
|
+
optionValue="id"
|
|
86
|
+
class="w-full"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Buttons -->
|
|
91
|
+
<div class="flex gap-2 justify-end">
|
|
92
|
+
<Button v-if="!editor.isNew" @click="editor.save()"
|
|
93
|
+
label="Save" icon="pi pi-save"
|
|
94
|
+
:disabled="!editor.changed" />
|
|
95
|
+
<Button v-else @click="editor.save()"
|
|
96
|
+
label="Create" icon="pi pi-plus" severity="success"
|
|
97
|
+
:disabled="!editor.changed" />
|
|
98
|
+
<Button @click="editor.reset()"
|
|
99
|
+
label="Reset" icon="pi pi-eraser"
|
|
100
|
+
:disabled="!editor.changed" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</template>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Step 3 – Use EditorButtons (alternative)
|
|
107
|
+
|
|
108
|
+
Instead of manual buttons, use the `EditorButtons` component:
|
|
109
|
+
|
|
110
|
+
```vue
|
|
111
|
+
<template>
|
|
112
|
+
<div v-if="editor">
|
|
113
|
+
<!-- fields... -->
|
|
114
|
+
<EditorButtons :editor="editor" :resetButton="true" />
|
|
115
|
+
</div>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<script setup>
|
|
119
|
+
import { EditorButtons } from '@live-change/frontend-auto-form'
|
|
120
|
+
</script>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`EditorButtons` automatically handles:
|
|
124
|
+
- "Saving draft..." spinner,
|
|
125
|
+
- "Draft changed" hint,
|
|
126
|
+
- validation error message,
|
|
127
|
+
- Save/Create button (disabled when nothing changed),
|
|
128
|
+
- optional Reset button.
|
|
129
|
+
|
|
130
|
+
## Step 4 – Reactive identifiers with computedAsync
|
|
131
|
+
|
|
132
|
+
When identifiers come from reactive sources (route params, props):
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
import { computedAsync } from '@vueuse/core'
|
|
136
|
+
|
|
137
|
+
const editor = computedAsync(() =>
|
|
138
|
+
editorData({
|
|
139
|
+
service: 'blog',
|
|
140
|
+
model: 'Article',
|
|
141
|
+
identifiers: { article: props.articleId },
|
|
142
|
+
draft: true,
|
|
143
|
+
appContext,
|
|
144
|
+
toast,
|
|
145
|
+
})
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Key options reference
|
|
150
|
+
|
|
151
|
+
| Option | Default | Description |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `service` | required | Service name |
|
|
154
|
+
| `model` | required | Model name |
|
|
155
|
+
| `identifiers` | required | e.g. `{ article: id }` |
|
|
156
|
+
| `draft` | `true` | Auto-save draft while editing |
|
|
157
|
+
| `autoSave` | `false` | Auto-save directly (when `draft: false`) |
|
|
158
|
+
| `debounce` | `600` | Debounce delay in ms |
|
|
159
|
+
| `initialData` | `{}` | Default values for new records |
|
|
160
|
+
| `parameters` | `{}` | Extra params sent with every action |
|
|
161
|
+
| `onSaved` | – | Callback after save |
|
|
162
|
+
| `onCreated` | – | Callback after create (receives result) |
|
|
163
|
+
|
|
164
|
+
## Key returned properties
|
|
165
|
+
|
|
166
|
+
| Property | Description |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `value` | Editable data (use with `v-model`) |
|
|
169
|
+
| `model` | Model definition (use `.properties.*` for `AutoField`) |
|
|
170
|
+
| `changed` | `true` when there are unsaved changes |
|
|
171
|
+
| `isNew` | `true` when creating (no existing record) |
|
|
172
|
+
| `save()` | Submit to backend |
|
|
173
|
+
| `reset()` | Discard draft, restore saved state |
|
|
174
|
+
| `propertiesErrors` | Server validation errors per property |
|
|
175
|
+
| `saving` | Save in progress |
|
|
176
|
+
| `draftChanged` | Draft auto-saved but not submitted |
|
|
177
|
+
| `savingDraft` | Draft auto-save in progress |
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Handle locale, timezone, currentTime, time synchronization and email locale
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill: live-change-frontend-locale-time (Claude Code)
|
|
6
|
+
|
|
7
|
+
Use this skill when you work with **locale, language, time, and timezone** in a LiveChange frontend.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- You need to display dates/times in the user's timezone.
|
|
12
|
+
- You are building a time-sensitive feature (countdown, deadline, scheduling).
|
|
13
|
+
- You need to sync locale with vue-i18n.
|
|
14
|
+
- You are writing an email template that must use the recipient's language.
|
|
15
|
+
|
|
16
|
+
## Step 1 – Set up locale in App.vue
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import { watch } from 'vue'
|
|
20
|
+
import { useI18n } from 'vue-i18n'
|
|
21
|
+
import { useLocale } from '@live-change/vue3-components'
|
|
22
|
+
|
|
23
|
+
const { locale: i18nLocale } = useI18n()
|
|
24
|
+
const locale = useLocale()
|
|
25
|
+
|
|
26
|
+
// Capture browser locale settings and save to backend
|
|
27
|
+
locale.captureLocale()
|
|
28
|
+
|
|
29
|
+
// Watch for locale changes and sync with vue-i18n
|
|
30
|
+
locale.getLocaleObservable()
|
|
31
|
+
watch(() => locale.localeRef.value, (newLocale) => {
|
|
32
|
+
if (newLocale?.language && i18nLocale.value !== newLocale.language) {
|
|
33
|
+
i18nLocale.value = newLocale.language
|
|
34
|
+
}
|
|
35
|
+
}, { immediate: true })
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Step 2 – Display dates in user timezone
|
|
39
|
+
|
|
40
|
+
Use vue-i18n's `d()` function for formatting and `locale.localTime()` for SSR timezone conversion:
|
|
41
|
+
|
|
42
|
+
```vue
|
|
43
|
+
<template>
|
|
44
|
+
<span>{{ d(locale.localTime(new Date(article.createdAt)), 'long') }}</span>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script setup>
|
|
48
|
+
import { useI18n } from 'vue-i18n'
|
|
49
|
+
import { useLocale } from '@live-change/vue3-components'
|
|
50
|
+
|
|
51
|
+
const { d } = useI18n()
|
|
52
|
+
const locale = useLocale()
|
|
53
|
+
</script>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`locale.localTime(date)` converts server timestamps to the user's timezone during SSR. On the client (browser), it returns the date unchanged since the browser handles timezone natively.
|
|
57
|
+
|
|
58
|
+
## Step 3 – Use `currentTime` for reactive clocks
|
|
59
|
+
|
|
60
|
+
`currentTime` is a global `Ref<number>` that ticks every 500ms:
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<template>
|
|
64
|
+
<span>{{ formattedTime }}</span>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup>
|
|
68
|
+
import { computed } from 'vue'
|
|
69
|
+
import { currentTime } from '@live-change/frontend-base'
|
|
70
|
+
import { useI18n } from 'vue-i18n'
|
|
71
|
+
|
|
72
|
+
const { d } = useI18n()
|
|
73
|
+
const formattedTime = computed(() =>
|
|
74
|
+
isNaN(currentTime.value) ? '' : d(new Date(currentTime.value), 'time')
|
|
75
|
+
)
|
|
76
|
+
</script>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Any component reading `currentTime` re-renders automatically every 500ms.
|
|
80
|
+
|
|
81
|
+
## Step 4 – Correct clock skew with `useTimeSynchronization`
|
|
82
|
+
|
|
83
|
+
When the client clock differs from the server clock (important for real-time features like quizzes, countdowns, auctions):
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
import { useTimeSynchronization } from '@live-change/vue3-ssr'
|
|
87
|
+
import { currentTime } from '@live-change/frontend-base'
|
|
88
|
+
|
|
89
|
+
const timeSync = useTimeSynchronization()
|
|
90
|
+
|
|
91
|
+
// Convert client time to server time (reactive computed)
|
|
92
|
+
const serverTime = timeSync.localToServerComputed(currentTime)
|
|
93
|
+
|
|
94
|
+
// Countdown to a server-side deadline
|
|
95
|
+
const timeRemaining = computed(() => deadline.value - serverTime.value)
|
|
96
|
+
const seconds = computed(() => Math.max(0, Math.floor(timeRemaining.value / 1000) % 60))
|
|
97
|
+
const minutes = computed(() => Math.max(0, Math.floor(timeRemaining.value / 1000 / 60)))
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Enable time synchronization in `config.js`:
|
|
101
|
+
|
|
102
|
+
```javascript
|
|
103
|
+
export default {
|
|
104
|
+
timeSynchronization: true,
|
|
105
|
+
// ...
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**When to use:** Only when clock skew matters – real-time events, countdown timers, time-limited actions. For simple date display, `currentTime` alone is sufficient.
|
|
110
|
+
|
|
111
|
+
### Returned object from `useTimeSynchronization()`:
|
|
112
|
+
|
|
113
|
+
| Property | Description |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `diff` | Server-client time offset in ms |
|
|
116
|
+
| `synchronized` | `true` once sync is complete |
|
|
117
|
+
| `serverToLocal(ts)` | Convert server timestamp to local |
|
|
118
|
+
| `localToServer(ts)` | Convert local timestamp to server |
|
|
119
|
+
| `serverToLocalComputed(ts)` | Reactive computed version |
|
|
120
|
+
| `localToServerComputed(ts)` | Reactive computed version |
|
|
121
|
+
|
|
122
|
+
## Step 5 – Smooth countdown with requestAnimationFrame
|
|
123
|
+
|
|
124
|
+
For sub-500ms precision (e.g. animated countdown knobs):
|
|
125
|
+
|
|
126
|
+
```javascript
|
|
127
|
+
import { ref } from 'vue'
|
|
128
|
+
import { useRafFn } from '@vueuse/core'
|
|
129
|
+
import { useTimeSynchronization } from '@live-change/vue3-ssr'
|
|
130
|
+
|
|
131
|
+
const rafNow = ref(Date.now())
|
|
132
|
+
useRafFn(() => { rafNow.value = Date.now() })
|
|
133
|
+
|
|
134
|
+
const timeSync = useTimeSynchronization()
|
|
135
|
+
const rafServerTime = timeSync.localToServerComputed(rafNow)
|
|
136
|
+
|
|
137
|
+
const countdown = computed(() =>
|
|
138
|
+
Math.max(0, deadline.value - rafServerTime.value)
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Step 6 – Locale in email templates
|
|
143
|
+
|
|
144
|
+
Email templates render server-side. They fetch the recipient's locale explicitly:
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
import { useI18n } from 'vue-i18n'
|
|
148
|
+
import { useLocale } from '@live-change/vue3-components'
|
|
149
|
+
|
|
150
|
+
const { locale: i18nLocale, t } = useI18n()
|
|
151
|
+
const locale = useLocale()
|
|
152
|
+
|
|
153
|
+
// props.json contains { user, client: { session } }
|
|
154
|
+
const data = JSON.parse(json)
|
|
155
|
+
|
|
156
|
+
// Fetch locale for the recipient (not current session)
|
|
157
|
+
await Promise.all([
|
|
158
|
+
locale.getOtherUserOrSessionLocale(data.user, data.client?.session)
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
// Apply to vue-i18n
|
|
162
|
+
if (locale.getLanguage()) i18nLocale.value = locale.getLanguage()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Key differences from regular pages:
|
|
166
|
+
|
|
167
|
+
| Aspect | Regular page | Email template |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| Locale source | `locale.getLocale()` (current user) | `locale.getOtherUserOrSessionLocale(user, session)` |
|
|
170
|
+
| Time conversion | `locale.localTime()` only on SSR | Always server-side |
|
|
171
|
+
| Reactive updates | Yes | No (one-shot SSR render) |
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Build list and detail pages with live data, computed paths, .with() and useClient
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill: live-change-frontend-page-list-detail (Claude Code)
|
|
6
|
+
|
|
7
|
+
Use this skill when you need to build a **list + detail** UI in Vue 3 / PrimeVue / Tailwind for a LiveChange backend.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- You are adding a new list page (devices, orders, etc.).
|
|
12
|
+
- You are adding a detail page for a single object.
|
|
13
|
+
- You want to follow the `live(path)` + `Promise.all` pattern compatible with SSR.
|
|
14
|
+
|
|
15
|
+
## Step 1 – List page (`src/pages/<resource>/index.vue`)
|
|
16
|
+
|
|
17
|
+
1. Create the file: `src/pages/<resource>/index.vue`.
|
|
18
|
+
2. In the `<template>`, follow this layout:
|
|
19
|
+
- container with padding (`container mx-auto p-4`),
|
|
20
|
+
- header with title and “add” button,
|
|
21
|
+
- empty-state card,
|
|
22
|
+
- grid of cards or rows.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
|
|
26
|
+
```vue
|
|
27
|
+
<template>
|
|
28
|
+
<div class="container mx-auto p-4">
|
|
29
|
+
<div class="flex items-center justify-between mb-6">
|
|
30
|
+
<h1 class="text-2xl font-bold">Devices</h1>
|
|
31
|
+
<Button label="Add" icon="pi pi-plus" @click="openDialog" />
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<Card v-if="devices.value?.length === 0">
|
|
35
|
+
<template #content>
|
|
36
|
+
<p class="text-center text-gray-500">
|
|
37
|
+
No devices yet
|
|
38
|
+
</p>
|
|
39
|
+
</template>
|
|
40
|
+
</Card>
|
|
41
|
+
|
|
42
|
+
<div class="grid gap-4">
|
|
43
|
+
<Card v-for="device in devices.value" :key="device.id">
|
|
44
|
+
<template #content>
|
|
45
|
+
<!-- device content -->
|
|
46
|
+
</template>
|
|
47
|
+
</Card>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup>
|
|
53
|
+
import { path, live, api as useApi } from '@live-change/vue3-ssr'
|
|
54
|
+
import Button from 'primevue/button'
|
|
55
|
+
import Card from 'primevue/card'
|
|
56
|
+
|
|
57
|
+
const api = useApi()
|
|
58
|
+
|
|
59
|
+
const [devices] = await Promise.all([
|
|
60
|
+
live(path().deviceManager.myUserDevices({}))
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
function openDialog() {
|
|
64
|
+
// open dialog for creating a new device
|
|
65
|
+
}
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<route>
|
|
69
|
+
{ "name": "devices", "meta": { "signedIn": true } }
|
|
70
|
+
</route>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Step 2 – Detail page (`src/pages/<resource>/[id].vue`)
|
|
74
|
+
|
|
75
|
+
1. Create `src/pages/<resource>/[id].vue`.
|
|
76
|
+
2. In `script setup`:
|
|
77
|
+
- use `useRoute()` to get the id (`route.params.id`),
|
|
78
|
+
- fetch the main object and related data using `Promise.all`.
|
|
79
|
+
|
|
80
|
+
Example skeleton:
|
|
81
|
+
|
|
82
|
+
```vue
|
|
83
|
+
<template>
|
|
84
|
+
<div class="container mx-auto p-4 space-y-4" v-if="item.value">
|
|
85
|
+
<Card>
|
|
86
|
+
<template #title>
|
|
87
|
+
{{ item.value.name }}
|
|
88
|
+
</template>
|
|
89
|
+
<template #content>
|
|
90
|
+
<!-- details -->
|
|
91
|
+
</template>
|
|
92
|
+
</Card>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<script setup>
|
|
97
|
+
import { path, live } from '@live-change/vue3-ssr'
|
|
98
|
+
import { useRoute } from 'vue-router'
|
|
99
|
+
import Card from 'primevue/card'
|
|
100
|
+
|
|
101
|
+
const route = useRoute()
|
|
102
|
+
const id = route.params.id
|
|
103
|
+
|
|
104
|
+
const [item] = await Promise.all([
|
|
105
|
+
live(path().myService.myUserItem({ item: id }))
|
|
106
|
+
])
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<route>
|
|
110
|
+
{ "name": "myItem", "meta": { "signedIn": true } }
|
|
111
|
+
</route>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Step 3 – Computed paths with reactive parameters
|
|
115
|
+
|
|
116
|
+
When the path depends on reactive values (route params, props), wrap it in `computed()`:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import { computed, unref } from 'vue'
|
|
120
|
+
import { usePath, live } from '@live-change/vue3-ssr'
|
|
121
|
+
import { useRoute } from 'vue-router'
|
|
122
|
+
|
|
123
|
+
const path = usePath()
|
|
124
|
+
const route = useRoute()
|
|
125
|
+
const deviceId = route.params.device
|
|
126
|
+
|
|
127
|
+
const devicePath = computed(() => path.deviceManager.myUserDevice({ device: unref(deviceId) }))
|
|
128
|
+
const connectionsPath = computed(() =>
|
|
129
|
+
path.deviceManager.deviceOwnedDeviceConnections({ device: unref(deviceId) })
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const [device, connections] = await Promise.all([
|
|
133
|
+
live(devicePath),
|
|
134
|
+
live(connectionsPath),
|
|
135
|
+
])
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Step 4 – Attach related objects with `.with()`
|
|
139
|
+
|
|
140
|
+
Use `.with()` to load related data alongside each item:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
const devicesPath = computed(() =>
|
|
144
|
+
path.deviceManager.myUserDevices({})
|
|
145
|
+
.with(device => path.deviceManager.deviceState({ device: device.id }).bind('state'))
|
|
146
|
+
.with(device => path.userIdentification.identification({
|
|
147
|
+
sessionOrUserType: device.ownerType,
|
|
148
|
+
sessionOrUser: device.owner
|
|
149
|
+
}).bind('ownerProfile'))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const [devices] = await Promise.all([live(devicesPath)])
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Access in template: `device.state?.online`, `device.ownerProfile?.firstName`.
|
|
156
|
+
|
|
157
|
+
## Step 5 – Auth guard with `useClient`
|
|
158
|
+
|
|
159
|
+
Use `useClient()` to conditionally show UI or load data based on authentication:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { useClient } from '@live-change/vue3-ssr'
|
|
163
|
+
|
|
164
|
+
const client = useClient()
|
|
165
|
+
|
|
166
|
+
// Conditional path (null = no fetch)
|
|
167
|
+
const adminDataPath = computed(() =>
|
|
168
|
+
client.value.roles.includes('admin') && path.deviceManager.allDevices({})
|
|
169
|
+
)
|
|
170
|
+
const [adminData] = await Promise.all([live(adminDataPath)])
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Template:
|
|
174
|
+
|
|
175
|
+
```vue
|
|
176
|
+
<Button v-if="client.roles.includes('admin')" label="Admin panel" />
|
|
177
|
+
<div v-if="!client.user">
|
|
178
|
+
<p>Please sign in</p>
|
|
179
|
+
<router-link :to="{ name: 'user:signIn' }">Sign in</router-link>
|
|
180
|
+
</div>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Step 6 – Status tags and simple indicators
|
|
184
|
+
|
|
185
|
+
1. Use PrimeVue `Tag` for status fields.
|
|
186
|
+
2. Map statuses to severities (`success`, `secondary`, etc.).
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
|
|
190
|
+
```vue
|
|
191
|
+
<Tag
|
|
192
|
+
:value="conn.status"
|
|
193
|
+
:severity="conn.status === 'online' ? 'success' : 'secondary'"
|
|
194
|
+
/>
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Step 7 – Large lists with RangeViewer
|
|
198
|
+
|
|
199
|
+
For large or infinite-scroll lists, use `<RangeViewer>` instead of loading everything at once. See the `live-change-frontend-range-list` skill for details.
|
|
200
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Build paginated scrollable lists with RangeViewer, rangeBuckets and .with()
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill: live-change-frontend-range-list (Claude Code)
|
|
6
|
+
|
|
7
|
+
Use this skill when you build **paginated, scrollable lists** backed by DAO ranges in a LiveChange frontend.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- You need a list that loads items in pages (infinite scroll).
|
|
12
|
+
- The list is backed by a DAO range view (e.g. `articlesByCreatedAt`).
|
|
13
|
+
- You want to attach related objects to each item via `.with()`.
|
|
14
|
+
|
|
15
|
+
## Step 1 – Define the path function
|
|
16
|
+
|
|
17
|
+
The path function receives a `range` object (with `gt`, `gte`, `lt`, `lte`, `limit`, `reverse`) and returns a DAO path.
|
|
18
|
+
|
|
19
|
+
Use `reverseRange()` to display items newest-first:
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
import { reverseRange } from '@live-change/vue3-ssr'
|
|
23
|
+
|
|
24
|
+
function articlesPathRange(range) {
|
|
25
|
+
return path.blog.articlesByCreatedAt({ ...reverseRange(range) })
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Step 2 – Attach related objects with `.with()`
|
|
30
|
+
|
|
31
|
+
Chain `.with()` calls to load related data for each item:
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
function articlesPathRange(range) {
|
|
35
|
+
return path.blog.articlesByCreatedAt({ ...reverseRange(range) })
|
|
36
|
+
.with(article => path.userIdentification.identification({
|
|
37
|
+
sessionOrUserType: article.authorType,
|
|
38
|
+
sessionOrUser: article.author
|
|
39
|
+
}).bind('authorProfile'))
|
|
40
|
+
.with(article => path.blog.articleStats({ article: article.id }).bind('stats'))
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Each `.with()` call:
|
|
45
|
+
- receives a proxy of the item,
|
|
46
|
+
- builds a path to the related data,
|
|
47
|
+
- calls `.bind('fieldName')` to attach the result under that field name.
|
|
48
|
+
|
|
49
|
+
Nested `.with()` is also supported:
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
function eventsPathRange(range) {
|
|
53
|
+
return path.myService.allEvents({ ...reverseRange(range) })
|
|
54
|
+
.with(event => path.myService.eventState({ event: event.id }).bind('state')
|
|
55
|
+
.with(state => path.myService.roundPairs({ event: event.id, round: state.round }).bind('roundPairs'))
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Step 3 – Use `<RangeViewer>` in the template
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<template>
|
|
64
|
+
<RangeViewer
|
|
65
|
+
:pathFunction="articlesPathRange"
|
|
66
|
+
:canLoadTop="false"
|
|
67
|
+
canDropBottom
|
|
68
|
+
loadBottomSensorSize="3000px"
|
|
69
|
+
dropBottomSensorSize="5000px"
|
|
70
|
+
>
|
|
71
|
+
<template #empty>
|
|
72
|
+
<p class="text-center text-surface-500 my-4">No articles yet.</p>
|
|
73
|
+
</template>
|
|
74
|
+
<template #default="{ item: article }">
|
|
75
|
+
<Card class="mb-2">
|
|
76
|
+
<template #content>
|
|
77
|
+
<h3>{{ article.title }}</h3>
|
|
78
|
+
<p class="text-sm text-surface-500">By {{ article.authorProfile?.firstName }}</p>
|
|
79
|
+
</template>
|
|
80
|
+
</Card>
|
|
81
|
+
</template>
|
|
82
|
+
</RangeViewer>
|
|
83
|
+
</template>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Key props:
|
|
87
|
+
|
|
88
|
+
| Prop | Default | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `pathFunction` | required | `(range) => path` – builds the DAO path for a given range |
|
|
91
|
+
| `bucketSize` | `20` | Items per page |
|
|
92
|
+
| `canLoadTop` / `canLoadBottom` | `true` | Whether loading in each direction is allowed |
|
|
93
|
+
| `canDropTop` / `canDropBottom` | `false` | Whether to drop pages that scrolled far out of view |
|
|
94
|
+
| `loadBottomSensorSize` | `'500px'` | How far before the bottom to trigger loading (increase for smoother UX) |
|
|
95
|
+
| `dropBottomSensorSize` | `'5000px'` | How far to keep before dropping |
|
|
96
|
+
| `frozen` | `false` | Pause live updates |
|
|
97
|
+
|
|
98
|
+
Slots:
|
|
99
|
+
|
|
100
|
+
| Slot | Props | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `default` | `{ item, bucket, itemIndex, bucketIndex }` | Render each item |
|
|
103
|
+
| `empty` | – | Shown when there are no items |
|
|
104
|
+
| `loadingTop` / `loadingBottom` | – | Loading spinners |
|
|
105
|
+
| `changedTop` / `changedBottom` | – | Indicators when data changed while frozen |
|
|
106
|
+
|
|
107
|
+
## Step 4 – Low-level `rangeBuckets` (optional)
|
|
108
|
+
|
|
109
|
+
For advanced control, use `rangeBuckets` directly:
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
import { rangeBuckets, reverseRange } from '@live-change/vue3-ssr'
|
|
113
|
+
|
|
114
|
+
const { buckets, loadBottom, dropTop, freeze, unfreeze } = await rangeBuckets(
|
|
115
|
+
(range) => path.blog.articlesByCreatedAt({ ...reverseRange(range) }),
|
|
116
|
+
{ bucketSize: 20 }
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Iterate in the template:
|
|
121
|
+
|
|
122
|
+
```vue
|
|
123
|
+
<template v-for="(bucket, bi) in buckets.value" :key="bi">
|
|
124
|
+
<div v-for="(item, ii) in bucket.data.value" :key="item.id">
|
|
125
|
+
<!-- render item -->
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|
|
128
|
+
```
|